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"
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
image: python:3.13-slim
commands:
@@ -126,4 +146,4 @@ steps:
trigger:
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
"Content",
"ContentType",
# html
"HTMLScraper",
"HTMLParser",
# pdf
"FileSystemPDFClient",
"PDFScraper",
"PDFParser",
# csv
"BaseCsvClient",
"FileSystemCsvClient",
"CsvScraper",
"CsvParser",
"CsvParserBase",
# xlsx
"BaseXlsxClient",
"FileSystemXlsxClient",

View File

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

View File

@@ -11,9 +11,10 @@ retrieved or parsed. Format-specific behavior and metadata must not alter
the semantic meaning of these models.
"""
from enum import Enum
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Mapping, Optional
from enum import Enum
from typing import Any
class ContentType(str, Enum):
@@ -72,12 +73,12 @@ class Content:
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.
"""
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).
"""

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,11 @@
from abc import ABC, abstractmethod
from typing import Any, Mapping, Optional
from collections.abc import Mapping
from typing import Any
from .content import Content
class BaseScraper(ABC):
@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
"""
from typing import Any
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any
class BaseCsvClient(ABC):

View File

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

View File

@@ -8,8 +8,8 @@ format-agnostic `BaseParser` with constraints appropriate for
comma-separated-value documents.
"""
from typing import Generic, TypeVar
from abc import abstractmethod
from typing import Generic, TypeVar
from omniread.core.content import ContentType
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.
"""
from typing import Any, Mapping, Optional
from collections.abc import Mapping
from typing import Any
from omniread.core.content import Content, ContentType
from .client import BaseCsvClient
@@ -48,7 +50,7 @@ class CsvScraper:
self,
source: Any,
*,
metadata: Optional[Mapping[str, Any]] = None,
metadata: Mapping[str, Any] | None = None,
) -> Content:
"""
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 .parser import HTMLParser

View File

@@ -1,4 +1,4 @@
from .scraper import HTMLScraper
from .parser import HTMLParser
from .scraper import HTMLScraper
__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.
"""
from typing import Any, Generic, TypeVar, Optional
from abc import abstractmethod
from typing import Any, Generic, TypeVar
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
T = TypeVar("T")
@@ -117,7 +117,7 @@ class HTMLParser(BaseParser[T], Generic[T]):
return div.get_text(separator=separator, strip=True)
@staticmethod
def parse_link(a: Tag) -> Optional[str]:
def parse_link(a: Tag) -> str | None:
"""
Extract the hyperlink reference from an `<a>` element.
@@ -146,10 +146,7 @@ class HTMLParser(BaseParser[T], Generic[T]):
"""
rows: list[list[str]] = []
for tr in table.find_all("tr"):
cells = [
cell.get_text(strip=True)
for cell in tr.find_all(["td", "th"])
]
cells = [cell.get_text(strip=True) for cell in tr.find_all(["td", "th"])]
if cells:
rows.append(cells)
return rows

View File

@@ -1,6 +1,8 @@
from typing import Any, Generic, TypeVar, Optional, list, dict
from bs4 import BeautifulSoup, Tag
from omniread.core.content import ContentType, Content
from typing import Any, Generic, TypeVar
from bs4 import Tag
from omniread.core.content import Content, ContentType
from omniread.core.parser import BaseParser
T = TypeVar("T")
@@ -12,7 +14,7 @@ class HTMLParser(BaseParser[T], Generic[T]):
@staticmethod
def parse_div(div: Tag, *, separator: str = ...) -> str: ...
@staticmethod
def parse_link(a: Tag) -> Optional[str]: ...
def parse_link(a: Tag) -> str | None: ...
@staticmethod
def parse_table(table: Tag) -> list[list[str]]: ...
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
"""
from collections.abc import Mapping
from typing import Any
import httpx
from typing import Any, Mapping, Optional
from omniread.core.content import Content, ContentType
from omniread.core.scraper import BaseScraper
@@ -51,7 +53,7 @@ class HTMLScraper(BaseScraper):
*,
client: httpx.Client | None = None,
timeout: float = 15.0,
headers: Optional[Mapping[str, str]] = None,
headers: Mapping[str, str] | None = None,
follow_redirects: bool = True,
):
"""
@@ -97,15 +99,13 @@ class HTMLScraper(BaseScraper):
base_ct = raw_ct.split(";", 1)[0].strip().lower()
if base_ct != self.content_type.value:
raise ValueError(
f"Expected HTML content, got '{raw_ct}'"
)
raise ValueError(f"Expected HTML content, got '{raw_ct}'")
def fetch(
self,
source: str,
*,
metadata: Optional[Mapping[str, Any]] = None,
metadata: Mapping[str, Any] | None = None,
) -> Content:
"""
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
from typing import Any, Mapping, Optional
from omniread.core.content import Content, ContentType
from omniread.core.scraper import BaseScraper
class HTMLScraper(BaseScraper):
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 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 .scraper import PDFScraper
from .parser import PDFParser
from .scraper import PDFScraper
__all__ = ["FileSystemPDFClient", "PDFScraper", "PDFParser"]

View File

@@ -17,9 +17,9 @@ Typical backing stores include:
- Network file systems
"""
from typing import Any
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any
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.
"""
from typing import Generic, TypeVar
from abc import abstractmethod
from typing import Generic, TypeVar
from omniread.core.content import ContentType
from omniread.core.parser import BaseParser

View File

@@ -1,5 +1,6 @@
from abc import abstractmethod
from typing import Generic, TypeVar
from omniread.core.content import ContentType
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.
"""
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.scraper import BaseScraper
@@ -48,7 +49,7 @@ class PDFScraper(BaseScraper):
self,
source: Any,
*,
metadata: Optional[Mapping[str, Any]] = None,
metadata: Mapping[str, Any] | None = None,
) -> Content:
"""
Fetch a PDF document from the given source.

View File

@@ -1,8 +1,13 @@
from typing import Any, Mapping, Optional
from omniread.core.content import Content, ContentType
from collections.abc import Mapping
from typing import Any
from omniread.core.content import Content
from omniread.core.scraper import BaseScraper
from .client import BasePDFClient
class PDFScraper(BaseScraper):
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
"""
from typing import Any
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any
class BaseXlsxClient(ABC):

View File

@@ -14,15 +14,16 @@ detection or column interpretation beyond basic cell normalization.
import datetime
from io import BytesIO
from typing import List, Optional, Union
from typing import Any
import openpyxl
from omniread.core.content import Content
from .parser_base import XlsxParserBase
class XlsxParser(XlsxParserBase):
class XlsxParser(XlsxParserBase[list[list[str]]]):
"""
Generic xlsx parser producing string rows from a worksheet.
@@ -41,7 +42,9 @@ class XlsxParser(XlsxParserBase):
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.
@@ -57,7 +60,7 @@ class XlsxParser(XlsxParserBase):
super().__init__(content)
self._data_only = data_only
self._read_only = read_only
self._workbook: Optional[openpyxl.Workbook] = None
self._workbook: openpyxl.Workbook | None = None
@property
def workbook(self) -> openpyxl.Workbook:
@@ -73,13 +76,13 @@ class XlsxParser(XlsxParserBase):
return self._workbook
@property
def sheet_names(self) -> List[str]:
def sheet_names(self) -> list[str]:
"""
Names of all worksheets contained in the workbook.
"""
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.
@@ -91,10 +94,10 @@ class XlsxParser(XlsxParserBase):
def rows(
self,
sheet: Optional[Union[int, str]] = None,
sheet: int | str | None = None,
*,
skip_empty: bool = True,
) -> List[List[str]]:
) -> list[list[str]]:
"""
Extract normalized string rows from a worksheet.
@@ -113,7 +116,7 @@ class XlsxParser(XlsxParserBase):
If the requested sheet does not exist.
"""
ws = self._resolve_sheet(sheet)
out: List[List[str]] = []
out: list[list[str]] = []
for row in ws.iter_rows(values_only=True):
cells = [self._cell_str(v) for v in row]
while cells and not cells[-1].strip():
@@ -123,7 +126,7 @@ class XlsxParser(XlsxParserBase):
out.append(cells)
return out
def _resolve_sheet(self, sheet: Optional[Union[int, str]]):
def _resolve_sheet(self, sheet: int | str | None) -> Any:
names = self.workbook.sheetnames
if not names:
raise ValueError("Workbook contains no worksheets")
@@ -138,7 +141,7 @@ class XlsxParser(XlsxParserBase):
return self.workbook[names[index]]
@staticmethod
def _cell_str(value) -> str:
def _cell_str(value: Any) -> str:
if value is None:
return ""
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.
"""
from typing import Generic, TypeVar
from abc import abstractmethod
from typing import Generic, TypeVar
from omniread.core.content import ContentType
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.
"""
from typing import Any, Mapping, Optional
from collections.abc import Mapping
from typing import Any
from omniread.core.content import Content, ContentType
from .client import BaseXlsxClient
@@ -47,7 +49,7 @@ class XlsxScraper:
self,
source: Any,
*,
metadata: Optional[Mapping[str, Any]] = None,
metadata: Mapping[str, Any] | None = None,
) -> Content:
"""
Fetch an xlsx document from the given source.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ from omniread import (
# core
Content,
ContentType,
# xlsx
FileSystemXlsxClient,
XlsxParser,
@@ -22,8 +21,28 @@ def _fixture_xlsx_bytes() -> bytes:
ws.title = "Statement"
ws.append(["DETAILED STATEMENT", None, None, ""])
ws.append([])
ws.append(["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])
ws.append(
[
"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
ws.cell(row=row, column=5).value = 100.0 # ensure float rendering path
buf = BytesIO()
@@ -68,7 +87,9 @@ def test_client_directory_raises(tmp_path):
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()
@@ -76,14 +97,24 @@ def test_parser_rows_skip_empty_and_trim(xlsx_bytes):
assert len(rows) == 3
assert rows[0] == ["DETAILED STATEMENT"]
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]
assert data[3] == "UPI/NPCI BHIM/bhimcashback@h/B"
assert data[6] == "250285.24"
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)
@@ -91,7 +122,9 @@ def test_parser_keep_empty_rows(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.rows(sheet="Statement") == parser.rows(sheet=0)
@@ -108,11 +141,15 @@ def test_parser_date_cell_isoformat():
buf = BytesIO()
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
assert parser.parse()[0][0] == "2026-06-01T00:00:00"
def test_parser_rejects_non_xlsx_content():
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)
)