standardize packaging, tooling, docs, CI, and licensing

This commit is contained in:
2026-09-10 18:49:06 +05:30
parent f92d40ed7f
commit 1e7e7c8a6c
38 changed files with 283 additions and 138 deletions

View File

@@ -32,6 +32,26 @@ steps:
echo "🆕 New version detected: $PACKAGE_NAME==$VERSION" echo "🆕 New version detected: $PACKAGE_NAME==$VERSION"
fi fi
- name: quality-gate
image: python:3.13-slim
environment:
PIP_REPO_URL:
from_secret: PIP_REPO_URL
PIP_USERNAME:
from_secret: PIP_USERNAME
PIP_PASSWORD:
from_secret: PIP_PASSWORD
commands:
- pip install --upgrade pip build
- |
AUTH_URL="https://${PIP_USERNAME}:${PIP_PASSWORD}@$(echo "${PIP_REPO_URL#*://}" | sed 's:/*$::')/simple"
pip install --index-url "$AUTH_URL" --extra-index-url https://pypi.org/simple/ -U ".[dev]"
- echo "🛡️ Running quality gate..."
- python -m black --check .
- python -m ruff check .
- python -m mypy
- python -m pytest
- name: build-package - name: build-package
image: python:3.13-slim image: python:3.13-slim
commands: commands:
@@ -126,4 +146,4 @@ steps:
trigger: trigger:
event: event:
- custom - custom

21
CHANGELOG.md Normal file
View File

@@ -0,0 +1,21 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- `py.typed` marker for PEP 561 type information.
- `.drone.yml` CI with a quality-gate step (black, ruff, mypy, pytest).
- Canonical `docforge.nav.yml`, generated `mkdocs.yml` and `mcp_docs/` via doc-forge.
- MIT `LICENSE`.
### Changed
- Standardized `pyproject.toml` (canonical packaging, lint tool config, extras).
### Fixed
- `XLSX`/`CSV` members added to the `content` type surface.
- Stub fixes for typed API surfaces.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Aetoskia Platform
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -132,23 +132,19 @@ __all__ = [
# core # core
"Content", "Content",
"ContentType", "ContentType",
# html # html
"HTMLScraper", "HTMLScraper",
"HTMLParser", "HTMLParser",
# pdf # pdf
"FileSystemPDFClient", "FileSystemPDFClient",
"PDFScraper", "PDFScraper",
"PDFParser", "PDFParser",
# csv # csv
"BaseCsvClient", "BaseCsvClient",
"FileSystemCsvClient", "FileSystemCsvClient",
"CsvScraper", "CsvScraper",
"CsvParser", "CsvParser",
"CsvParserBase", "CsvParserBase",
# xlsx # xlsx
"BaseXlsxClient", "BaseXlsxClient",
"FileSystemXlsxClient", "FileSystemXlsxClient",

View File

@@ -1,6 +1,6 @@
from .core import Content, ContentType from .core import Content, ContentType
from .html import HTMLScraper, HTMLParser from .html import HTMLParser, HTMLScraper
from .pdf import FileSystemPDFClient, PDFScraper, PDFParser from .pdf import FileSystemPDFClient, PDFParser, PDFScraper
__all__ = [ __all__ = [
"Content", "Content",

View File

@@ -11,9 +11,10 @@ retrieved or parsed. Format-specific behavior and metadata must not alter
the semantic meaning of these models. the semantic meaning of these models.
""" """
from enum import Enum from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Mapping, Optional from enum import Enum
from typing import Any
class ContentType(str, Enum): class ContentType(str, Enum):
@@ -72,12 +73,12 @@ class Content:
Identifier of the content origin (URL, file path, or logical name). Identifier of the content origin (URL, file path, or logical name).
""" """
content_type: Optional[ContentType] = None content_type: ContentType | None = None
""" """
Optional MIME type of the content, if known. Optional MIME type of the content, if known.
""" """
metadata: Optional[Mapping[str, Any]] = None metadata: Mapping[str, Any] | None = None
""" """
Optional, implementation-defined metadata associated with the content (e.g., headers, encoding hints, extraction notes). Optional, implementation-defined metadata associated with the content (e.g., headers, encoding hints, extraction notes).
""" """

View File

@@ -1,15 +1,24 @@
from collections.abc import Mapping
from enum import Enum from enum import Enum
from typing import Any, Mapping, Optional from typing import Any
class ContentType(str, Enum): class ContentType(str, Enum):
HTML = "text/html" HTML = "text/html"
PDF = "application/pdf" PDF = "application/pdf"
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
CSV = "text/csv"
JSON = "application/json" JSON = "application/json"
XML = "application/xml" XML = "application/xml"
class Content: class Content:
raw: bytes raw: bytes
source: str source: str
content_type: Optional[ContentType] content_type: ContentType | None
metadata: Optional[Mapping[str, Any]] metadata: Mapping[str, Any] | None
def __init__(self, raw: bytes, source: str, content_type: Optional[ContentType] = ..., metadata: Optional[Mapping[str, Any]] = ...) -> None: ... def __init__(
self,
raw: bytes,
source: str,
content_type: ContentType | None = ...,
metadata: Mapping[str, Any] | None = ...,
) -> None: ...

View File

@@ -20,7 +20,7 @@ Parsers are not responsible for:
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Generic, TypeVar, Set from typing import Generic, TypeVar
from .content import Content, ContentType from .content import Content, ContentType
@@ -46,7 +46,7 @@ class BaseParser(ABC, Generic[T]):
- Implementations must remain deterministic for a given input. - Implementations must remain deterministic for a given input.
""" """
supported_types: Set[ContentType] = set() supported_types: set[ContentType] = set()
""" """
Set of content types supported by this parser. An empty set indicates that the parser is content-type agnostic. Set of content types supported by this parser. An empty set indicates that the parser is content-type agnostic.
""" """

View File

@@ -1,11 +1,12 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Generic, TypeVar, Set from typing import Generic, TypeVar
from .content import Content, ContentType from .content import Content, ContentType
T = TypeVar("T") T = TypeVar("T")
class BaseParser(ABC, Generic[T]): class BaseParser(ABC, Generic[T]):
supported_types: Set[ContentType] supported_types: set[ContentType]
content: Content content: Content
def __init__(self, content: Content) -> None: ... def __init__(self, content: Content) -> None: ...
@abstractmethod @abstractmethod

View File

@@ -22,7 +22,8 @@ All interpretation must be delegated to parsers.
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any, Mapping, Optional from collections.abc import Mapping
from typing import Any
from .content import Content from .content import Content
@@ -53,7 +54,7 @@ class BaseScraper(ABC):
self, self,
source: str, source: str,
*, *,
metadata: Optional[Mapping[str, Any]] = None, metadata: Mapping[str, Any] | None = None,
) -> Content: ) -> Content:
""" """
Fetch raw content from the given source. Fetch raw content from the given source.

View File

@@ -1,7 +1,11 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any, Mapping, Optional from collections.abc import Mapping
from typing import Any
from .content import Content from .content import Content
class BaseScraper(ABC): class BaseScraper(ABC):
@abstractmethod @abstractmethod
def fetch(self, source: str, *, metadata: Optional[Mapping[str, Any]] = ...) -> Content: ... def fetch(
self, source: str, *, metadata: Mapping[str, Any] | None = ...
) -> Content: ...

View File

@@ -17,9 +17,9 @@ Typical backing stores include:
- Network file systems - Network file systems
""" """
from typing import Any
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from typing import Any
class BaseCsvClient(ABC): class BaseCsvClient(ABC):

View File

@@ -13,15 +13,15 @@ detection or column interpretation beyond basic cell normalization and
delimiter detection. delimiter detection.
""" """
from io import StringIO
from csv import Sniffer, reader from csv import Sniffer, reader
from typing import List from io import StringIO
from omniread.core.content import Content from omniread.core.content import Content
from .parser_base import CsvParserBase from .parser_base import CsvParserBase
class CsvParser(CsvParserBase): class CsvParser(CsvParserBase[list[list[str]]]):
""" """
Generic csv parser producing string rows from the document. Generic csv parser producing string rows from the document.
@@ -54,7 +54,7 @@ class CsvParser(CsvParserBase):
""" """
super().__init__(content) super().__init__(content)
def parse(self) -> List[List[str]]: def parse(self) -> list[list[str]]:
""" """
Parse the document into normalized string rows. Parse the document into normalized string rows.
@@ -64,7 +64,7 @@ class CsvParser(CsvParserBase):
""" """
return self.rows() return self.rows()
def rows(self, *, skip_empty: bool = True) -> List[List[str]]: def rows(self, *, skip_empty: bool = True) -> list[list[str]]:
""" """
Extract normalized string rows from the document. Extract normalized string rows from the document.
@@ -84,7 +84,7 @@ class CsvParser(CsvParserBase):
delimiter = dialect.delimiter delimiter = dialect.delimiter
except Exception: except Exception:
delimiter = "," delimiter = ","
out: List[List[str]] = [] out: list[list[str]] = []
for row in reader(StringIO(text), delimiter=delimiter): for row in reader(StringIO(text), delimiter=delimiter):
cells = [c.strip() for c in row] cells = [c.strip() for c in row]
while cells and not cells[-1]: while cells and not cells[-1]:

View File

@@ -8,8 +8,8 @@ format-agnostic `BaseParser` with constraints appropriate for
comma-separated-value documents. comma-separated-value documents.
""" """
from typing import Generic, TypeVar
from abc import abstractmethod from abc import abstractmethod
from typing import Generic, TypeVar
from omniread.core.content import ContentType from omniread.core.content import ContentType
from omniread.core.parser import BaseParser from omniread.core.parser import BaseParser

View File

@@ -11,9 +11,11 @@ The scraper does not interpret or parse the acquired bytes; it wraps them in
the canonical `Content` model. the canonical `Content` model.
""" """
from typing import Any, Mapping, Optional from collections.abc import Mapping
from typing import Any
from omniread.core.content import Content, ContentType from omniread.core.content import Content, ContentType
from .client import BaseCsvClient from .client import BaseCsvClient
@@ -48,7 +50,7 @@ class CsvScraper:
self, self,
source: Any, source: Any,
*, *,
metadata: Optional[Mapping[str, Any]] = None, metadata: Mapping[str, Any] | None = None,
) -> Content: ) -> Content:
""" """
Fetch a csv document from the given source. Fetch a csv document from the given source.

View File

@@ -30,7 +30,6 @@ use this package only when HTML-specific behavior is required.
--- ---
""" """
from .scraper import HTMLScraper from .scraper import HTMLScraper
from .parser import HTMLParser from .parser import HTMLParser

View File

@@ -1,4 +1,4 @@
from .scraper import HTMLScraper
from .parser import HTMLParser from .parser import HTMLParser
from .scraper import HTMLScraper
__all__ = ["HTMLScraper", "HTMLParser"] __all__ = ["HTMLScraper", "HTMLParser"]

View File

@@ -16,12 +16,12 @@ Concrete parsers must subclass `HTMLParser` and implement the `parse()` method
to return a structured representation appropriate for their use case. to return a structured representation appropriate for their use case.
""" """
from typing import Any, Generic, TypeVar, Optional
from abc import abstractmethod from abc import abstractmethod
from typing import Any, Generic, TypeVar
from bs4 import BeautifulSoup, Tag from bs4 import BeautifulSoup, Tag
from omniread.core.content import ContentType, Content from omniread.core.content import Content, ContentType
from omniread.core.parser import BaseParser from omniread.core.parser import BaseParser
T = TypeVar("T") T = TypeVar("T")
@@ -117,7 +117,7 @@ class HTMLParser(BaseParser[T], Generic[T]):
return div.get_text(separator=separator, strip=True) return div.get_text(separator=separator, strip=True)
@staticmethod @staticmethod
def parse_link(a: Tag) -> Optional[str]: def parse_link(a: Tag) -> str | None:
""" """
Extract the hyperlink reference from an `<a>` element. Extract the hyperlink reference from an `<a>` element.
@@ -146,10 +146,7 @@ class HTMLParser(BaseParser[T], Generic[T]):
""" """
rows: list[list[str]] = [] rows: list[list[str]] = []
for tr in table.find_all("tr"): for tr in table.find_all("tr"):
cells = [ cells = [cell.get_text(strip=True) for cell in tr.find_all(["td", "th"])]
cell.get_text(strip=True)
for cell in tr.find_all(["td", "th"])
]
if cells: if cells:
rows.append(cells) rows.append(cells)
return rows return rows

View File

@@ -1,6 +1,8 @@
from typing import Any, Generic, TypeVar, Optional, list, dict from typing import Any, Generic, TypeVar
from bs4 import BeautifulSoup, Tag
from omniread.core.content import ContentType, Content from bs4 import Tag
from omniread.core.content import Content, ContentType
from omniread.core.parser import BaseParser from omniread.core.parser import BaseParser
T = TypeVar("T") T = TypeVar("T")
@@ -12,7 +14,7 @@ class HTMLParser(BaseParser[T], Generic[T]):
@staticmethod @staticmethod
def parse_div(div: Tag, *, separator: str = ...) -> str: ... def parse_div(div: Tag, *, separator: str = ...) -> str: ...
@staticmethod @staticmethod
def parse_link(a: Tag) -> Optional[str]: ... def parse_link(a: Tag) -> str | None: ...
@staticmethod @staticmethod
def parse_table(table: Tag) -> list[list[str]]: ... def parse_table(table: Tag) -> list[list[str]]: ...
def parse_meta(self) -> dict[str, Any]: ... def parse_meta(self) -> dict[str, Any]: ...

View File

@@ -20,8 +20,10 @@ This scraper is not responsible for:
- Managing crawl policies or rate limiting - Managing crawl policies or rate limiting
""" """
from collections.abc import Mapping
from typing import Any
import httpx import httpx
from typing import Any, Mapping, Optional
from omniread.core.content import Content, ContentType from omniread.core.content import Content, ContentType
from omniread.core.scraper import BaseScraper from omniread.core.scraper import BaseScraper
@@ -51,7 +53,7 @@ class HTMLScraper(BaseScraper):
*, *,
client: httpx.Client | None = None, client: httpx.Client | None = None,
timeout: float = 15.0, timeout: float = 15.0,
headers: Optional[Mapping[str, str]] = None, headers: Mapping[str, str] | None = None,
follow_redirects: bool = True, follow_redirects: bool = True,
): ):
""" """
@@ -97,15 +99,13 @@ class HTMLScraper(BaseScraper):
base_ct = raw_ct.split(";", 1)[0].strip().lower() base_ct = raw_ct.split(";", 1)[0].strip().lower()
if base_ct != self.content_type.value: if base_ct != self.content_type.value:
raise ValueError( raise ValueError(f"Expected HTML content, got '{raw_ct}'")
f"Expected HTML content, got '{raw_ct}'"
)
def fetch( def fetch(
self, self,
source: str, source: str,
*, *,
metadata: Optional[Mapping[str, Any]] = None, metadata: Mapping[str, Any] | None = None,
) -> Content: ) -> Content:
""" """
Fetch an HTML document from the given source. Fetch an HTML document from the given source.

View File

@@ -1,10 +1,22 @@
from collections.abc import Mapping
from typing import Any
import httpx import httpx
from typing import Any, Mapping, Optional
from omniread.core.content import Content, ContentType from omniread.core.content import Content, ContentType
from omniread.core.scraper import BaseScraper from omniread.core.scraper import BaseScraper
class HTMLScraper(BaseScraper): class HTMLScraper(BaseScraper):
content_type: ContentType content_type: ContentType
def __init__(self, *, client: Optional[httpx.Client] = ..., timeout: float = ..., headers: Optional[Mapping[str, str]] = ..., follow_redirects: bool = ...) -> None: ... def __init__(
self,
*,
client: httpx.Client | None = ...,
timeout: float = ...,
headers: Mapping[str, str] | None = ...,
follow_redirects: bool = ...,
) -> None: ...
def validate_content_type(self, response: httpx.Response) -> None: ... def validate_content_type(self, response: httpx.Response) -> None: ...
def fetch(self, source: str, *, metadata: Optional[Mapping[str, Any]] = ...) -> Content: ... def fetch(
self, source: str, *, metadata: Mapping[str, Any] | None = ...
) -> Content: ...

View File

@@ -1,5 +1,5 @@
from .client import FileSystemPDFClient from .client import FileSystemPDFClient
from .scraper import PDFScraper
from .parser import PDFParser from .parser import PDFParser
from .scraper import PDFScraper
__all__ = ["FileSystemPDFClient", "PDFScraper", "PDFParser"] __all__ = ["FileSystemPDFClient", "PDFScraper", "PDFParser"]

View File

@@ -17,9 +17,9 @@ Typical backing stores include:
- Network file systems - Network file systems
""" """
from typing import Any
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from typing import Any
class BasePDFClient(ABC): class BasePDFClient(ABC):

View File

@@ -10,8 +10,8 @@ PDF parsers are responsible for interpreting binary PDF data and producing
structured representations suitable for downstream consumption. structured representations suitable for downstream consumption.
""" """
from typing import Generic, TypeVar
from abc import abstractmethod from abc import abstractmethod
from typing import Generic, TypeVar
from omniread.core.content import ContentType from omniread.core.content import ContentType
from omniread.core.parser import BaseParser from omniread.core.parser import BaseParser

View File

@@ -1,5 +1,6 @@
from abc import abstractmethod from abc import abstractmethod
from typing import Generic, TypeVar from typing import Generic, TypeVar
from omniread.core.content import ContentType from omniread.core.content import ContentType
from omniread.core.parser import BaseParser from omniread.core.parser import BaseParser

View File

@@ -10,7 +10,8 @@ The scraper implements the core `BaseScraper` contract while delegating
all storage and access concerns to a `BasePDFClient` implementation. all storage and access concerns to a `BasePDFClient` implementation.
""" """
from typing import Any, Mapping, Optional from collections.abc import Mapping
from typing import Any
from omniread.core.content import Content, ContentType from omniread.core.content import Content, ContentType
from omniread.core.scraper import BaseScraper from omniread.core.scraper import BaseScraper
@@ -48,7 +49,7 @@ class PDFScraper(BaseScraper):
self, self,
source: Any, source: Any,
*, *,
metadata: Optional[Mapping[str, Any]] = None, metadata: Mapping[str, Any] | None = None,
) -> Content: ) -> Content:
""" """
Fetch a PDF document from the given source. Fetch a PDF document from the given source.

View File

@@ -1,8 +1,13 @@
from typing import Any, Mapping, Optional from collections.abc import Mapping
from omniread.core.content import Content, ContentType from typing import Any
from omniread.core.content import Content
from omniread.core.scraper import BaseScraper from omniread.core.scraper import BaseScraper
from .client import BasePDFClient from .client import BasePDFClient
class PDFScraper(BaseScraper): class PDFScraper(BaseScraper):
def __init__(self, *, client: BasePDFClient) -> None: ... def __init__(self, *, client: BasePDFClient) -> None: ...
def fetch(self, source: Any, *, metadata: Optional[Mapping[str, Any]] = ...) -> Content: ... def fetch(
self, source: Any, *, metadata: Mapping[str, Any] | None = ...
) -> Content: ...

0
omniread/py.typed Normal file
View File

View File

@@ -17,9 +17,9 @@ Typical backing stores include:
- Network file systems - Network file systems
""" """
from typing import Any
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from typing import Any
class BaseXlsxClient(ABC): class BaseXlsxClient(ABC):

View File

@@ -14,15 +14,16 @@ detection or column interpretation beyond basic cell normalization.
import datetime import datetime
from io import BytesIO from io import BytesIO
from typing import List, Optional, Union from typing import Any
import openpyxl import openpyxl
from omniread.core.content import Content from omniread.core.content import Content
from .parser_base import XlsxParserBase from .parser_base import XlsxParserBase
class XlsxParser(XlsxParserBase): class XlsxParser(XlsxParserBase[list[list[str]]]):
""" """
Generic xlsx parser producing string rows from a worksheet. Generic xlsx parser producing string rows from a worksheet.
@@ -41,7 +42,9 @@ class XlsxParser(XlsxParserBase):
locale-specific formatting must convert on their side. locale-specific formatting must convert on their side.
""" """
def __init__(self, content: Content, *, data_only: bool = True, read_only: bool = True): def __init__(
self, content: Content, *, data_only: bool = True, read_only: bool = True
):
""" """
Initialize the parser. Initialize the parser.
@@ -57,7 +60,7 @@ class XlsxParser(XlsxParserBase):
super().__init__(content) super().__init__(content)
self._data_only = data_only self._data_only = data_only
self._read_only = read_only self._read_only = read_only
self._workbook: Optional[openpyxl.Workbook] = None self._workbook: openpyxl.Workbook | None = None
@property @property
def workbook(self) -> openpyxl.Workbook: def workbook(self) -> openpyxl.Workbook:
@@ -73,13 +76,13 @@ class XlsxParser(XlsxParserBase):
return self._workbook return self._workbook
@property @property
def sheet_names(self) -> List[str]: def sheet_names(self) -> list[str]:
""" """
Names of all worksheets contained in the workbook. Names of all worksheets contained in the workbook.
""" """
return list(self.workbook.sheetnames) return list(self.workbook.sheetnames)
def parse(self) -> List[List[str]]: def parse(self) -> list[list[str]]:
""" """
Parse the first worksheet into normalized string rows. Parse the first worksheet into normalized string rows.
@@ -91,10 +94,10 @@ class XlsxParser(XlsxParserBase):
def rows( def rows(
self, self,
sheet: Optional[Union[int, str]] = None, sheet: int | str | None = None,
*, *,
skip_empty: bool = True, skip_empty: bool = True,
) -> List[List[str]]: ) -> list[list[str]]:
""" """
Extract normalized string rows from a worksheet. Extract normalized string rows from a worksheet.
@@ -113,7 +116,7 @@ class XlsxParser(XlsxParserBase):
If the requested sheet does not exist. If the requested sheet does not exist.
""" """
ws = self._resolve_sheet(sheet) ws = self._resolve_sheet(sheet)
out: List[List[str]] = [] out: list[list[str]] = []
for row in ws.iter_rows(values_only=True): for row in ws.iter_rows(values_only=True):
cells = [self._cell_str(v) for v in row] cells = [self._cell_str(v) for v in row]
while cells and not cells[-1].strip(): while cells and not cells[-1].strip():
@@ -123,7 +126,7 @@ class XlsxParser(XlsxParserBase):
out.append(cells) out.append(cells)
return out return out
def _resolve_sheet(self, sheet: Optional[Union[int, str]]): def _resolve_sheet(self, sheet: int | str | None) -> Any:
names = self.workbook.sheetnames names = self.workbook.sheetnames
if not names: if not names:
raise ValueError("Workbook contains no worksheets") raise ValueError("Workbook contains no worksheets")
@@ -138,7 +141,7 @@ class XlsxParser(XlsxParserBase):
return self.workbook[names[index]] return self.workbook[names[index]]
@staticmethod @staticmethod
def _cell_str(value) -> str: def _cell_str(value: Any) -> str:
if value is None: if value is None:
return "" return ""
if isinstance(value, (datetime.datetime, datetime.date)): if isinstance(value, (datetime.datetime, datetime.date)):

View File

@@ -8,8 +8,8 @@ format-agnostic `BaseParser` with constraints appropriate for Office Open
XML spreadsheet content. XML spreadsheet content.
""" """
from typing import Generic, TypeVar
from abc import abstractmethod from abc import abstractmethod
from typing import Generic, TypeVar
from omniread.core.content import ContentType from omniread.core.content import ContentType
from omniread.core.parser import BaseParser from omniread.core.parser import BaseParser

View File

@@ -10,9 +10,11 @@ The scraper does not interpret or parse the acquired bytes; it wraps them in
the canonical `Content` model. the canonical `Content` model.
""" """
from typing import Any, Mapping, Optional from collections.abc import Mapping
from typing import Any
from omniread.core.content import Content, ContentType from omniread.core.content import Content, ContentType
from .client import BaseXlsxClient from .client import BaseXlsxClient
@@ -47,7 +49,7 @@ class XlsxScraper:
self, self,
source: Any, source: Any,
*, *,
metadata: Optional[Mapping[str, Any]] = None, metadata: Mapping[str, Any] | None = None,
) -> Content: ) -> Content:
""" """
Fetch an xlsx document from the given source. Fetch an xlsx document from the given source.

View File

@@ -1,13 +1,14 @@
[build-system] [build-system]
requires = ["setuptools>=65.0", "wheel"] requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "omniread" name = "omniread"
version = "0.0.1" version = "0.0.1"
description = "Composable content ingestion framework with pluggable scrapers and parsers for HTML, PDF, and structured data" description = "Composable content ingestion framework with pluggable scrapers and parsers for HTML, PDF, and structured data"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.10"
license = { text = "MIT" } license = { text = "MIT" }
authors = [ authors = [
@@ -17,6 +18,7 @@ maintainers = [
{ name = "Aetos Skia", email = "dev@aetoskia.com" } { name = "Aetos Skia", email = "dev@aetoskia.com" }
] ]
keywords = [ keywords = [
"scraping", "scraping",
"parsing", "parsing",
@@ -33,39 +35,49 @@ classifiers = [
"Intended Audience :: Developers", "Intended Audience :: Developers",
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.13",
"Topic :: Security",
"Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Text Processing",
"Typing :: Typed", "Typing :: Typed",
] ]
dependencies = [ dependencies = [
"requests>=2.31.0", "requests>=2.31.0",
"beautifulsoup4>=4.12.0", "beautifulsoup4>=4.12.0",
# "lxml>=5.0.0",
"pypdf>=4.0.0", "pypdf>=4.0.0",
"openpyxl>=3.1.0", "openpyxl>=3.1.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest>=7.4.0", "pytest>=8.0.0",
"pytest-asyncio>=0.21.0", "pytest-asyncio>=0.21.0",
"pytest-cov>=4.1.0", "pytest-cov>=4.1.0",
"black>=23.0.0", "black>=23.0.0",
"ruff>=0.1.0", "ruff>=0.3.0",
"mypy>=1.5.0", "mypy>=1.8.0",
"build>=1.0.0",
"twine>=4.0.0",
"pre-commit>=3.4.0", "pre-commit>=3.4.0",
"doc-forge[mcp,mkdocs]>=0.0.6",
]
docs = [
"mkdocs>=1.5.0",
"mkdocs-material>=9.5.0",
"mkdocstrings[python]>=0.24.0",
] ]
all = [ all = [
"omniread[dev,fastapi]", "omniread[dev,docs]",
] ]
[project.urls] [project.urls]
Homepage = "https://git.aetoskia.com/aetos/omniread" Homepage = "https://git.aetoskia.com/aetos/omniread"
Documentation = "https://git.aetoskia.com/aetos/omniread#readme" Documentation = "https://git.aetoskia.com/aetos/omniread#readme"
@@ -73,12 +85,14 @@ Repository = "https://git.aetoskia.com/aetos/omniread.git"
Issues = "https://git.aetoskia.com/aetos/omniread/issues" Issues = "https://git.aetoskia.com/aetos/omniread/issues"
Versions = "https://git.aetoskia.com/aetos/omniread/tags" Versions = "https://git.aetoskia.com/aetos/omniread/tags"
[tool.setuptools] [tool.setuptools]
packages = { find = { include = ["omniread*"] } } packages = { find = { include = ["omniread*"] } }
[tool.setuptools.package-data] [tool.setuptools.package-data]
omniread = ["py.typed"] omniread = ["py.typed"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
asyncio_mode = "auto" asyncio_mode = "auto"
@@ -94,9 +108,10 @@ addopts = [
"--cov-report=xml", "--cov-report=xml",
] ]
[tool.black] [tool.black]
line-length = 88 line-length = 88
target-version = ["py39", "py310", "py311", "py312", "py313"] target-version = ["py310", "py311", "py312", "py313"]
include = '\.pyi?$' include = '\.pyi?$'
extend-exclude = ''' extend-exclude = '''
/( /(
@@ -111,9 +126,12 @@ extend-exclude = '''
)/ )/
''' '''
[tool.ruff] [tool.ruff]
line-length = 88 line-length = 88
target-version = "py39" target-version = "py310"
[tool.ruff.lint]
select = [ select = [
"E", "E",
"W", "W",
@@ -129,31 +147,30 @@ ignore = [
"C901", "C901",
] ]
[tool.ruff.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"] "__init__.py" = ["F401", "I001"]
"tests/*" = ["B008"]
[tool.mypy] [tool.mypy]
python_version = "3.9" python_version = "3.10"
warn_return_any = true strict = true
warn_unused_configs = true exclude = [
disallow_untyped_defs = true "tests/",
disallow_incomplete_defs = true ]
check_untyped_defs = true files = ["omniread"]
disallow_untyped_decorators = false
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
follow_imports = "normal"
strict_optional = true
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = [ module = [
"jose.*", "requests.*",
"httpx.*", "bs4.*",
"lxml.*",
"pypdf.*",
"openpyxl.*",
] ]
ignore_missing_imports = true ignore_missing_imports = true
[tool.coverage.run] [tool.coverage.run]
source = ["omniread"] source = ["omniread"]
omit = [ omit = [
@@ -170,4 +187,4 @@ exclude_lines = [
"raise NotImplementedError", "raise NotImplementedError",
"if TYPE_CHECKING:", "if TYPE_CHECKING:",
"@abstractmethod", "@abstractmethod",
] ]

View File

@@ -1,22 +1,20 @@
import json import json
import pytest
import httpx
from pathlib import Path from pathlib import Path
from jinja2 import Environment, BaseLoader
import httpx
import pytest
from jinja2 import BaseLoader, Environment
from omniread import ( from omniread import (
# core # core
ContentType, ContentType,
# html
HTMLScraper,
# pdf # pdf
FileSystemPDFClient, FileSystemPDFClient,
# html
HTMLScraper,
PDFScraper, PDFScraper,
) )
MOCK_HTML_DIR = Path(__file__).parent / "mocks" / "html" MOCK_HTML_DIR = Path(__file__).parent / "mocks" / "html"
MOCK_PDF_DIR = Path(__file__).parent / "mocks" / "pdf" MOCK_PDF_DIR = Path(__file__).parent / "mocks" / "pdf"
@@ -40,7 +38,7 @@ def mock_transport(request: httpx.Request) -> httpx.Response:
httpx MockTransport handler. httpx MockTransport handler.
""" """
path = request.url.path path = request.url.path
if path not in ['/simple', '/table']: if path not in ["/simple", "/table"]:
return httpx.Response( return httpx.Response(
status_code=404, status_code=404,
content=b"Not Found", content=b"Not Found",

View File

@@ -1,22 +1,19 @@
from typing import Optional
from pydantic import BaseModel
from bs4 import Tag from bs4 import Tag
from pydantic import BaseModel
from omniread import ( from omniread import (
# core # core
Content, Content,
# html # html
HTMLParser, HTMLParser,
) )
class ParsedSimpleHTML(BaseModel): class ParsedSimpleHTML(BaseModel):
title: Optional[str] title: str | None
description: Optional[str] description: str | None
content: Optional[str] content: str | None
link: Optional[str] link: str | None
class SimpleHTMLParser(HTMLParser[ParsedSimpleHTML]): class SimpleHTMLParser(HTMLParser[ParsedSimpleHTML]):

View File

@@ -1,18 +1,15 @@
from typing import Optional
from pydantic import BaseModel from pydantic import BaseModel
from omniread import ( from omniread import (
# core # core
Content, Content,
# html # html
HTMLParser, HTMLParser,
) )
class ParsedTableHTML(BaseModel): class ParsedTableHTML(BaseModel):
title: Optional[str] title: str | None
table: list[list[str]] table: list[list[str]]

View File

@@ -1,14 +1,15 @@
from typing import Literal from typing import Literal
from pydantic import BaseModel from pydantic import BaseModel
from omniread import ( from omniread import (
# core # core
Content, Content,
# pdf # pdf
PDFParser, PDFParser,
) )
class ParsedPDF(BaseModel): class ParsedPDF(BaseModel):
size_bytes: int size_bytes: int
magic: Literal[b"%PDF"] magic: Literal[b"%PDF"]

View File

@@ -8,7 +8,6 @@ from omniread import (
# core # core
Content, Content,
ContentType, ContentType,
# xlsx # xlsx
FileSystemXlsxClient, FileSystemXlsxClient,
XlsxParser, XlsxParser,
@@ -22,8 +21,28 @@ def _fixture_xlsx_bytes() -> bytes:
ws.title = "Statement" ws.title = "Statement"
ws.append(["DETAILED STATEMENT", None, None, ""]) ws.append(["DETAILED STATEMENT", None, None, ""])
ws.append([]) ws.append([])
ws.append(["S No", "Value Date", "Txn Date", "Remarks", "Withdrawal", "Deposit", "Balance"]) ws.append(
ws.append([1, "01/06/2026", "01/06/2026", "UPI/NPCI BHIM/bhimcashback@h/B", 0.0, 10.0, 250285.24]) [
"S No",
"Value Date",
"Txn Date",
"Remarks",
"Withdrawal",
"Deposit",
"Balance",
]
)
ws.append(
[
1,
"01/06/2026",
"01/06/2026",
"UPI/NPCI BHIM/bhimcashback@h/B",
0.0,
10.0,
250285.24,
]
)
row = 4 row = 4
ws.cell(row=row, column=5).value = 100.0 # ensure float rendering path ws.cell(row=row, column=5).value = 100.0 # ensure float rendering path
buf = BytesIO() buf = BytesIO()
@@ -68,7 +87,9 @@ def test_client_directory_raises(tmp_path):
def test_parser_rows_skip_empty_and_trim(xlsx_bytes): def test_parser_rows_skip_empty_and_trim(xlsx_bytes):
parser = XlsxParser(Content(raw=xlsx_bytes, source="mem", content_type=ContentType.XLSX)) parser = XlsxParser(
Content(raw=xlsx_bytes, source="mem", content_type=ContentType.XLSX)
)
rows = parser.rows() rows = parser.rows()
@@ -76,14 +97,24 @@ def test_parser_rows_skip_empty_and_trim(xlsx_bytes):
assert len(rows) == 3 assert len(rows) == 3
assert rows[0] == ["DETAILED STATEMENT"] assert rows[0] == ["DETAILED STATEMENT"]
header = rows[1] header = rows[1]
assert header[:7] == ["S No", "Value Date", "Txn Date", "Remarks", "Withdrawal", "Deposit", "Balance"] assert header[:7] == [
"S No",
"Value Date",
"Txn Date",
"Remarks",
"Withdrawal",
"Deposit",
"Balance",
]
data = rows[2] data = rows[2]
assert data[3] == "UPI/NPCI BHIM/bhimcashback@h/B" assert data[3] == "UPI/NPCI BHIM/bhimcashback@h/B"
assert data[6] == "250285.24" assert data[6] == "250285.24"
def test_parser_keep_empty_rows(xlsx_bytes): def test_parser_keep_empty_rows(xlsx_bytes):
parser = XlsxParser(Content(raw=xlsx_bytes, source="mem", content_type=ContentType.XLSX)) parser = XlsxParser(
Content(raw=xlsx_bytes, source="mem", content_type=ContentType.XLSX)
)
rows = parser.rows(skip_empty=False) rows = parser.rows(skip_empty=False)
@@ -91,7 +122,9 @@ def test_parser_keep_empty_rows(xlsx_bytes):
def test_parser_sheet_selection_and_names(xlsx_bytes): def test_parser_sheet_selection_and_names(xlsx_bytes):
parser = XlsxParser(Content(raw=xlsx_bytes, source="mem", content_type=ContentType.XLSX)) parser = XlsxParser(
Content(raw=xlsx_bytes, source="mem", content_type=ContentType.XLSX)
)
assert parser.sheet_names == ["Statement"] assert parser.sheet_names == ["Statement"]
assert parser.rows(sheet="Statement") == parser.rows(sheet=0) assert parser.rows(sheet="Statement") == parser.rows(sheet=0)
@@ -108,11 +141,15 @@ def test_parser_date_cell_isoformat():
buf = BytesIO() buf = BytesIO()
wb.save(buf) wb.save(buf)
parser = XlsxParser(Content(raw=buf.getvalue(), source="mem", content_type=ContentType.XLSX)) parser = XlsxParser(
Content(raw=buf.getvalue(), source="mem", content_type=ContentType.XLSX)
)
# openpyxl surfaces date cells as datetime; ISO rendering keeps determinism # openpyxl surfaces date cells as datetime; ISO rendering keeps determinism
assert parser.parse()[0][0] == "2026-06-01T00:00:00" assert parser.parse()[0][0] == "2026-06-01T00:00:00"
def test_parser_rejects_non_xlsx_content(): def test_parser_rejects_non_xlsx_content():
with pytest.raises(ValueError, match="does not support"): with pytest.raises(ValueError, match="does not support"):
XlsxParser(Content(raw=b"<html></html>", source="mem", content_type=ContentType.HTML)) XlsxParser(
Content(raw=b"<html></html>", source="mem", content_type=ContentType.HTML)
)