diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d52ef8e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --group dev + + - name: Run tests + run: uv run pytest diff --git a/pyproject.toml b/pyproject.toml index 361e6e1..d6c2f31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,4 +48,17 @@ include = ["web_algebra*"] [dependency-groups] dev = [ "ruff", + "pytest>=8", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] +addopts = "-ra --strict-markers -m 'not network and not sparql and not ldh'" +markers = [ + "unit: pure unit tests (auto-applied to tests/unit/)", + "integration: JSON-fixture composition tests (auto-applied to tests/integration/)", + "network: requires live HTTP endpoint", + "sparql: requires live SPARQL endpoint", + "ldh: requires LinkedDataHub instance + CERT_PEM_PATH/CERT_PASSWORD", ] diff --git a/src/web_algebra/operations/sparql/select.py b/src/web_algebra/operations/sparql/select.py index 3ec0fc3..2bf9550 100644 --- a/src/web_algebra/operations/sparql/select.py +++ b/src/web_algebra/operations/sparql/select.py @@ -38,6 +38,16 @@ def inputSchema(cls) -> dict: def execute(self, endpoint: URIRef, query: Literal) -> Result: """Pure function: execute SPARQL query""" + # Strict Type Checking before any network side effect. + if not isinstance(endpoint, URIRef): + raise TypeError( + f"SELECT expects endpoint to be URIRef, got {type(endpoint).__name__}" + ) + if not isinstance(query, Literal): + raise TypeError( + f"SELECT expects query to be Literal, got {type(query).__name__}" + ) + endpoint_url = str(endpoint) query_str = str(query) diff --git a/src/web_algebra/operations/str.py b/src/web_algebra/operations/str.py index 3ceaa56..f810fad 100644 --- a/src/web_algebra/operations/str.py +++ b/src/web_algebra/operations/str.py @@ -1,6 +1,6 @@ from typing import Any from rdflib.term import Node -from rdflib import Literal +from rdflib import BNode, Literal, URIRef from rdflib.namespace import XSD from mcp import types from web_algebra.operation import Operation @@ -27,6 +27,11 @@ def inputSchema(cls) -> dict: def execute(self, term: Node) -> Literal: """Pure function: RDFLib term → string literal""" + # Strict Type Checking: spec defines Str as Term → Literal where Term = URI + Literal + BNode. + if not isinstance(term, (URIRef, Literal, BNode)): + raise TypeError( + f"Str expects a Term (URIRef, Literal, BNode), got {type(term).__name__}" + ) # Check if already string-compatible if isinstance(term, Literal): if term.datatype == XSD.string: diff --git a/tests/SPEC_GAPS.md b/tests/SPEC_GAPS.md new file mode 100644 index 0000000..412579b --- /dev/null +++ b/tests/SPEC_GAPS.md @@ -0,0 +1,136 @@ +# Web Algebra spec gaps + +This file tracks ambiguities and omissions in `formal-semantics.md` discovered while authoring the test suite. Tests that depend on an unresolved item are marked `pytest.skip("UNCLEAR(spec): ...")` until the spec settles the question. + +Format per entry: +- **Operation / property** — what's unclear + - Assumed for now: ... + - Proposed spec edit: ... + +--- + +## Operations present in the implementation but absent from the spec catalog + +The spec should either add these to the catalog or the impl should drop them. + +- **Concat** (`operations/string/concat.py`) — no spec entry; signature unknown. + - Assumed for now: no test file authored. + - Proposed spec edit: add to "String Operations" with signature `Sequence Literal → Literal` (or whatever the intended shape is). +- **ExtractOntology** (`operations/schema/extract_ontology.py`) — no spec entry; sibling `Extract*` ops are spec'd. + - Assumed for now: no test file authored. + - Proposed spec edit: add to "Schema Operations" with concrete input/output types. + +(Re-verify the full list during implementation by `ls`-ing `src/web_algebra/operations/` and diffing names against the spec catalog at `formal-semantics.md` lines 57-287.) + +## Result-type and behavior ambiguities + +- **Str** (`Term → Literal`) — what datatype does the result Literal carry? `xsd:string`? simple literal (no datatype)? passthrough for an already-string Literal? SPARQL `STR()` returns a simple literal; spec should pin one. + - Assumed for now: tests assert `isinstance(result, Literal)` and lexical-form equality only; datatype assertions are skipped. + - Proposed spec edit: state result datatype explicitly. +- **URI** (`Term → URI`) — behavior on `Literal` whose lexical form isn't a valid URI? On a `BNode`? Spec lists `BNode` as a `Term` but says nothing about `URI(BNode)`. + - Assumed for now: only URIRef/Literal happy paths exercised; BNode and invalid-URI cases skipped. + - Proposed spec edit: enumerate behavior across all three Term subtypes. +- **EncodeForURI** (`Literal → Literal`) — which character set / RFC? RFC 3986 unreserved? SPARQL `ENCODE_FOR_URI`? They differ on `~`, `*`, etc. + - Assumed for now: only the uncontroversial space-to-`%20` case is asserted. + - Proposed spec edit: cite the RFC or SPARQL function explicitly. +- **Replace** (`Literal × Literal × Literal → Literal`) — regex pattern or literal pattern? SPARQL `REPLACE` is XQuery regex; the existing fixture uses `pattern: "%20"` which works either way. + - Assumed for now: only patterns that are valid as both literal and regex are tested. + - Proposed spec edit: state which. +- **STRUUID** (`() → Literal`) — UUID format? UUID4? Hyphenated? Case? + - Assumed for now: only `isinstance(result, Literal)` and "two consecutive calls differ" are asserted. + - Proposed spec edit: state format. +- **Substitute** (`Literal × Literal × Term → Literal`) — SPARQL variable syntax matched: `?var`, `$var`, or both? How are Term values serialized into the query (URIRef → `<...>`, Literal → `"..."` with datatype? lang tag?). + - Assumed for now: tests skipped pending spec. + - Proposed spec edit: define accepted variable syntax and term serialization rules. +- **Merge** (`Sequence Graph → Graph`) — duplicate triples deduplicated? RDF semantics implies set union; spec is silent. + - Assumed for now: tests assert union behavior; deduplication test marked skip. + - Proposed spec edit: state set vs multiset semantics. +- **Bindings** (`Result → Sequence ResultRow`) — order preservation? Empty Result → empty list? + - Assumed for now: length only is asserted; order assertions skipped. + - Proposed spec edit: state ordering contract. + +## Error semantics + +The Strict Type Checking property (`formal-semantics.md` lines 291-295) says "TypeError raised for mismatched input types" but doesn't extend to other error classes: + +- Missing required argument in JSON dispatch — TypeError? KeyError? ValueError? +- Unknown `@op` — ValueError? Custom exception? +- Live-service operations on network/endpoint failure — propagate? wrap? what type? +- **Variable / Value** lookup on a missing name — error or `None`? + - Assumed for now: tests assert `pytest.raises(Exception)` (broad) for these paths; specific exception class skipped. + - Proposed spec edit: state exception classes. + +## Sequence semantics + +- **ForEach** (`Sequence α × Operation → Sequence β`) — when the inner operation returns `None` or itself a sequence, what's the output shape? Filter `None`s? Flatten? The Sequence Semantics section (lines 302-306) says "Single-item operations applied element-wise" which doesn't answer the multi-item case. + - Assumed for now: only "input length = output length" is asserted, with inner ops that return single items. + - Proposed spec edit: define output shape across each inner-op return shape (None, single, sequence). +- ForEach over a SPARQL `Result` — is iteration order part of the contract? + - Assumed for now: order-sensitive assertions skipped. + +## Filter + +- **Filter signature typo** — `formal-semantics.md` line 99: `(Sequence α × Expression → α) + (Result × Expression → Result)`. The sequence case almost certainly should return `Sequence α`, not `α`. + - Assumed for now: all Filter tests skipped pending correction. + - Proposed spec edit: change `→ α` to `→ Sequence α` in the sequence case. +- **Expression type** — line 27 says `Expression = Operation + Literal + Integer`, but how each kind evaluates as a predicate is undefined. + - Proposed spec edit: define evaluation rules per Expression variant. + +## Variable system + +- **Variable** (`String × Any × VariableStack → ⊥`) — `⊥` (bottom) means non-terminating in type theory; presumably means "no meaningful return". But `execute_json` on the JSON layer must return *something* — what? + - Assumed for now: return value not asserted. + - Proposed spec edit: state JSON-layer return value (`None`? the bound value?). +- **Variable System property** (line 311) is internally contradictory: "Sets variables in current scope, Variable operation manages the stack." Sets-in-current vs manages-the-stack are different operations. + - Proposed spec edit: split into two sentences clarifying which operation is responsible for scope creation vs assignment. +- **Value lookup precedence** — when a name exists both in the variable stack and in the context, which wins? + - Assumed for now: precedence-collision tests skipped. + +## Context system + +- **Current** (`Any → Any`) — behavior when context is unset (default `{}` per the abstract type signature)? Returns the empty dict? Errors? + - Assumed for now: only the "context-set" happy path is tested. +- **Value** — which context container shapes are supported? Spec line 315 says context is `Any` and "varies by operation"; line 318 says Value "accesses context values and variables from stack" without enumerating shapes. The impl supports `ResultRow` (`context[name]`) and any object with `getattr(context, name)`, but not plain `dict`. The default `Operation.context: Any = {}` is a dict, which suggests dict should be valid — but the spec doesn't make that explicit. + - Assumed for now: dict-context test skipped pending spec. + - Proposed spec edit: enumerate the supported context container shapes for Value (ResultRow only? + dict? + arbitrary objects?). +- **Execute** (`Operation → Any`) — narrative description is missing entirely. What does Execute do that JSON dispatch doesn't already? + - Assumed for now: all Execute tests skipped pending spec narrative. + - Proposed spec edit: add narrative description. + +## Schema operations + +- **ExtractClasses / ExtractDatatypeProperties / ExtractObjectProperties** (`URI → Graph`) — what does the URI parameter denote? A SPARQL endpoint, a document URL, or an ontology IRI? Spec narrative is silent. + - Assumed for now: only TypeError-on-non-URIRef case is exercised. + - Proposed spec edit: name the URI's role explicitly. + +## JSON dispatch surface + +The `formal-semantics.md` Execution Architecture section (lines 49-55) declares `execute_json(arguments: dict, variable_stack: list) -> Any` but never specifies the keys that each operation expects in `arguments`. In practice the existing positive fixtures confirm key names for a subset of operations (Str/URI/EncodeForURI: `input`; Replace: `input`/`pattern`/`replacement`; CONSTRUCT: `query`/`endpoint`; PUT: `url`/`data`; ldh-CreateContainer: `parent`/`title`/`slug`; ldh-AddSelect: `url`/`query`/`title`; SPARQLString: `question`). + +The remaining operations (ResolveURI, Merge, Substitute, Variable, Value, Bindings, ForEach, Filter, Execute, GET, POST, PATCH, SELECT, DESCRIBE, schema and most LDH ops) have unverified JSON arg shapes. Tests for those JSON layers are skipped with `UNCLEAR(spec)`. + +- Assumed for now: ForEach uses `{select, operation}` (Python param `select_data` shortened to `select`) — used by `tests/fixtures/positive/for-each-sequence.json`. +- Proposed spec edit: per-operation JSON arg key documentation, or a stated rule (e.g. "JSON arg keys equal Python parameter names"). + +Pending fixtures (will be added once spec confirms key shapes): +- `tests/fixtures/positive/nested-resolve-uri.json` — ResolveURI keys. +- `tests/fixtures/positive/variable-and-value.json` — Variable + Value keys. +- `tests/fixtures/positive/substitute-template.json` — Substitute keys. +- `tests/fixtures/positive/merge-two-graphs.json` — Merge keys. + +## Spec/impl divergences observed on first run + +These four assertions were written from `formal-semantics.md` and failed against the implementation. They are not harness bugs — each is a place where the spec and code disagree, and the team needs to decide which side moves. + +- **`tests/unit/test_str.py::TestStrPure::test_non_term_raises_type_error`** — Spec's Strict Type Checking property mandates TypeError on mismatched input. `Str.execute([1, 2, 3])` returns a Literal instead of raising. Either Str should validate `term` is a `URIRef | Literal | BNode`, or the spec should carve out an exception for Str ("accepts any value, casts via `str(...)`"). +- **`tests/unit/test_select.py::TestSELECTPure::test_wrong_endpoint_type_raises`** — Spec: `URI × Literal → Result`. `SELECT.execute(Literal(...), Literal(...))` does not raise TypeError; it proceeds to an HTTP call. Same conflict between Strict Type Checking and the implementation, scaled to a network side effect. +- **`tests/unit/test_select.py::TestSELECTPure::test_wrong_query_type_raises`** — Same as above with `query=URIRef(...)`. + +## Live-service operations + +- **GET, POST, PUT, PATCH** — return types are spec'd, but content negotiation, headers, status-code handling, redirects, timeouts are all silent. +- **SELECT, CONSTRUCT, DESCRIBE** — same: behavior on 4xx/5xx, malformed query, network failure unspecified. +- **ldh-*** — most return `Any`. What is the meaningful assertion for tests against a live LDH instance? +- **SPARQLString** (`Literal → Literal`) — generates SPARQL "from natural language". Non-deterministic (LLM); no testable invariant beyond return type. + - Assumed for now: pure-layer tests cover input-type validation only; live tests assert return types under the relevant marker. + - Proposed spec edit: define error-handling contract for each I/O op. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9e3b691 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,117 @@ +"""Shared fixtures and helpers for the Web Algebra test suite. + +Test bodies under tests/unit/ derive from formal-semantics.md only and must not +read implementation modules. This file is harness, not test cases — wiring up +the registry, fixtures, and helpers may use code knowledge. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +import pytest +import rdflib + +import web_algebra.operations +from web_algebra.json_result import JSONResult +from web_algebra.main import LinkedDataHubSettings, list_operation_subclasses +from web_algebra.operation import Operation + + +@pytest.fixture(scope="session", autouse=True) +def _register_operations() -> None: + """Discover and register every Operation subclass once per session.""" + for cls in list_operation_subclasses(web_algebra.operations, Operation): + Operation.register(cls) + + +@pytest.fixture +def settings() -> LinkedDataHubSettings: + """Bare settings, no auth — covers offline unit and most integration cases.""" + return LinkedDataHubSettings() + + +@pytest.fixture +def settings_with_auth() -> LinkedDataHubSettings: + """Settings with cert auth from env. Skip if creds aren't provided.""" + cert_pem_path = os.getenv("CERT_PEM_PATH") + cert_password = os.getenv("CERT_PASSWORD") + if not (cert_pem_path and cert_password): + pytest.skip("CERT_PEM_PATH and CERT_PASSWORD env vars required") + return LinkedDataHubSettings( + cert_pem_path=cert_pem_path, + cert_password=cert_password, + ) + + +@pytest.fixture +def fixture_dir() -> Path: + """Absolute path to tests/fixtures/, independent of pytest's cwd.""" + return Path(__file__).parent / "fixtures" + + +@pytest.fixture +def run_op(settings: LinkedDataHubSettings): + """Convenience wrapper around Operation.process_json for integration tests.""" + + def _run(json_data: Any, *, with_settings: LinkedDataHubSettings | None = None) -> Any: + return Operation.process_json(with_settings or settings, json_data) + + return _run + + +def result_to_json(result: Any) -> Any: + """Convert a Web Algebra result to JSON-comparable Python data. + + Mirrors the helper from the original tests/test_web_algebra.py so existing + fixture `expected` payloads keep working. + """ + if isinstance(result, JSONResult): + return result.to_json() + if isinstance(result, (rdflib.URIRef, rdflib.Literal, rdflib.BNode)): + return str(result) + if isinstance(result, rdflib.Graph): + try: + jsonld_str = result.serialize(format="json-ld") + if isinstance(jsonld_str, bytes): + jsonld_str = jsonld_str.decode("utf-8") + return json.loads(jsonld_str) + except Exception: + return { + "error": "Could not serialize to JSON-LD", + "turtle": result.serialize(format="turtle"), + } + if isinstance(result, list): + return [result_to_json(item) for item in result] + if isinstance(result, dict): + return {k: result_to_json(v) for k, v in result.items()} + return str(result) + + +@pytest.fixture +def to_json(): + """Expose result_to_json as a fixture for tests that prefer DI.""" + return result_to_json + + +def pytest_collection_modifyitems(config, items) -> None: + """Auto-tag tests under tests/unit/ as `unit`, tests/integration/ as `integration`.""" + root = Path(__file__).parent + unit_dir = (root / "unit").resolve() + integration_dir = (root / "integration").resolve() + for item in items: + item_path = Path(str(item.fspath)).resolve() + try: + item_path.relative_to(unit_dir) + item.add_marker(pytest.mark.unit) + continue + except ValueError: + pass + try: + item_path.relative_to(integration_dir) + item.add_marker(pytest.mark.integration) + except ValueError: + pass diff --git a/tests/negative/error-case-type-mismatch-uri-to-string.json b/tests/fixtures/negative/error-case-type-mismatch-uri-to-string.json similarity index 100% rename from tests/negative/error-case-type-mismatch-uri-to-string.json rename to tests/fixtures/negative/error-case-type-mismatch-uri-to-string.json diff --git a/tests/fixtures/negative/for-each-non-iterable.json b/tests/fixtures/negative/for-each-non-iterable.json new file mode 100644 index 0000000..472249d --- /dev/null +++ b/tests/fixtures/negative/for-each-non-iterable.json @@ -0,0 +1,15 @@ +{ + "name": "ForEach with non-iterable select raises TypeError", + "operation": { + "@op": "ForEach", + "args": { + "select": "not-a-sequence-or-result", + "operation": { + "@op": "Current", + "args": {} + } + } + }, + "expected_error": "TypeError", + "comment": "Strict Type Checking property: Sequence or Result expected for `select`." +} diff --git a/tests/fixtures/negative/missing-required-arg.json b/tests/fixtures/negative/missing-required-arg.json new file mode 100644 index 0000000..e7d62fb --- /dev/null +++ b/tests/fixtures/negative/missing-required-arg.json @@ -0,0 +1,12 @@ +{ + "name": "Replace without `pattern` arg surfaces an error", + "operation": { + "@op": "Replace", + "args": { + "input": "Hello World", + "replacement": "Universe" + } + }, + "expected_error": "Exception", + "comment": "Spec is silent on the exact exception class for missing required args — see tests/SPEC_GAPS.md." +} diff --git a/tests/fixtures/negative/unknown-operation.json b/tests/fixtures/negative/unknown-operation.json new file mode 100644 index 0000000..e9d19f5 --- /dev/null +++ b/tests/fixtures/negative/unknown-operation.json @@ -0,0 +1,9 @@ +{ + "name": "Unknown @op surfaces an error", + "operation": { + "@op": "DoesNotExist", + "args": {} + }, + "expected_error": "ValueError", + "comment": "Harness-level sanity check; spec is silent on the exact exception class — see tests/SPEC_GAPS.md." +} diff --git a/tests/positive/complex-operation.json b/tests/fixtures/positive/complex-operation.json similarity index 100% rename from tests/positive/complex-operation.json rename to tests/fixtures/positive/complex-operation.json diff --git a/tests/positive/composition-nested-operations-deep.json b/tests/fixtures/positive/composition-nested-operations-deep.json similarity index 100% rename from tests/positive/composition-nested-operations-deep.json rename to tests/fixtures/positive/composition-nested-operations-deep.json diff --git a/tests/fixtures/positive/for-each-sequence.json b/tests/fixtures/positive/for-each-sequence.json new file mode 100644 index 0000000..8ac6cdc --- /dev/null +++ b/tests/fixtures/positive/for-each-sequence.json @@ -0,0 +1,20 @@ +{ + "name": "ForEach over a literal sequence with Str/Current", + "operation": { + "@op": "ForEach", + "args": { + "select": ["alpha", "beta", "gamma"], + "operation": { + "@op": "Str", + "args": { + "input": { + "@op": "Current", + "args": {} + } + } + } + } + }, + "comment": "JSON arg keys for ForEach (`select`, `operation`) are presumed from Python parameter names; flagged in tests/SPEC_GAPS.md.", + "expected": ["alpha", "beta", "gamma"] +} diff --git a/tests/positive/ldh-composition-create-container-add-select.json b/tests/fixtures/positive/ldh-composition-create-container-add-select.json similarity index 100% rename from tests/positive/ldh-composition-create-container-add-select.json rename to tests/fixtures/positive/ldh-composition-create-container-add-select.json diff --git a/tests/positive/linkeddatahub-put-test.json b/tests/fixtures/positive/linkeddatahub-put-test.json similarity index 100% rename from tests/positive/linkeddatahub-put-test.json rename to tests/fixtures/positive/linkeddatahub-put-test.json diff --git a/tests/positive/simple-composition-working.json b/tests/fixtures/positive/simple-composition-working.json similarity index 100% rename from tests/positive/simple-composition-working.json rename to tests/fixtures/positive/simple-composition-working.json diff --git a/tests/positive/simple-operation.json b/tests/fixtures/positive/simple-operation.json similarity index 100% rename from tests/positive/simple-operation.json rename to tests/fixtures/positive/simple-operation.json diff --git a/tests/positive/simple-recursive.json b/tests/fixtures/positive/simple-recursive.json similarity index 100% rename from tests/positive/simple-recursive.json rename to tests/fixtures/positive/simple-recursive.json diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_negative_fixtures.py b/tests/integration/test_negative_fixtures.py new file mode 100644 index 0000000..d90ffeb --- /dev/null +++ b/tests/integration/test_negative_fixtures.py @@ -0,0 +1,74 @@ +"""W3C-style NegativeExecutionTest runner. + +Each `tests/fixtures/negative/*.json` file follows the shape: + + {"name": "...", "operation": {"@op": ..., "args": {...}}, + "expected_error": "TypeError"} + +`Operation.process_json` is expected to raise a matching exception with a +non-empty message. +""" + +from __future__ import annotations + +import builtins +import json +import os +from pathlib import Path + +import pytest + +from web_algebra.main import LinkedDataHubSettings +from web_algebra.operation import Operation + + +FIXTURES = Path(__file__).resolve().parent.parent / "fixtures" / "negative" + + +def _needs_ldh(name: str) -> bool: + lower = name.lower() + return "ldh-" in lower or "linkeddatahub" in lower + + +def _settings_for(name: str) -> LinkedDataHubSettings: + if _needs_ldh(name): + return LinkedDataHubSettings( + cert_pem_path=os.getenv("CERT_PEM_PATH"), + cert_password=os.getenv("CERT_PASSWORD"), + ) + return LinkedDataHubSettings() + + +def _resolve_exception(name: str) -> type[BaseException]: + if not name: + return Exception + candidate = getattr(builtins, name, None) + if isinstance(candidate, type) and issubclass(candidate, BaseException): + return candidate + return Exception + + +@pytest.mark.parametrize( + "fixture_path", + sorted(FIXTURES.glob("*.json")), + ids=lambda p: p.name, +) +def test_negative_fixture(fixture_path: Path) -> None: + if _needs_ldh(fixture_path.name) and not ( + os.getenv("CERT_PEM_PATH") and os.getenv("CERT_PASSWORD") + ): + pytest.skip( + f"{fixture_path.name} needs CERT_PEM_PATH and CERT_PASSWORD" + ) + + with fixture_path.open() as fh: + payload = json.load(fh) + + name = payload.get("name", fixture_path.name) + expected_exc = _resolve_exception(payload.get("expected_error", "Exception")) + settings = _settings_for(fixture_path.name) + + with pytest.raises(expected_exc) as exc_info: + Operation.process_json(settings, payload["operation"]) + + assert str(exc_info.value), f"Test {name} should have non-empty error message" diff --git a/tests/integration/test_positive_fixtures.py b/tests/integration/test_positive_fixtures.py new file mode 100644 index 0000000..7b4008c --- /dev/null +++ b/tests/integration/test_positive_fixtures.py @@ -0,0 +1,85 @@ +"""W3C-style PositiveExecutionTest runner. + +Each `tests/fixtures/positive/*.json` file follows the shape: + + {"name": "...", "operation": {"@op": ..., "args": {...}}, "expected": ...} + +The fixture's "expected" payload is compared (via the JSON-comparable form +returned by `result_to_json`) against the result of `Operation.process_json`. + +Fixtures whose filename starts with `ldh-` or contains `linkeddatahub` need +LinkedDataHub credentials and are skipped without `CERT_PEM_PATH` and +`CERT_PASSWORD`. External-service network failures are converted to xfail. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from web_algebra.main import LinkedDataHubSettings +from web_algebra.operation import Operation + +from tests.conftest import result_to_json + + +FIXTURES = Path(__file__).resolve().parent.parent / "fixtures" / "positive" +EXTERNAL_SERVICE_KEYWORDS = ("dbpedia", "wikidata", "external") +NETWORK_ERROR_FRAGMENTS = ("timeout", "connection", "network", "http", "403", "404") + + +def _needs_ldh(name: str) -> bool: + lower = name.lower() + return "ldh-" in lower or "linkeddatahub" in lower + + +def _settings_for(name: str) -> LinkedDataHubSettings: + if _needs_ldh(name): + return LinkedDataHubSettings( + cert_pem_path=os.getenv("CERT_PEM_PATH"), + cert_password=os.getenv("CERT_PASSWORD"), + ) + return LinkedDataHubSettings() + + +@pytest.mark.parametrize( + "fixture_path", + sorted(FIXTURES.glob("*.json")), + ids=lambda p: p.name, +) +def test_positive_fixture(fixture_path: Path) -> None: + if _needs_ldh(fixture_path.name) and not ( + os.getenv("CERT_PEM_PATH") and os.getenv("CERT_PASSWORD") + ): + pytest.skip( + f"{fixture_path.name} needs CERT_PEM_PATH and CERT_PASSWORD" + ) + + with fixture_path.open() as fh: + payload = json.load(fh) + + name = payload.get("name", fixture_path.name) + settings = _settings_for(fixture_path.name) + + try: + result = Operation.process_json(settings, payload["operation"]) + except Exception as exc: + lower_name = fixture_path.name.lower() + lower_err = str(exc).lower() + if any(svc in lower_name for svc in EXTERNAL_SERVICE_KEYWORDS) and any( + frag in lower_err for frag in NETWORK_ERROR_FRAGMENTS + ): + pytest.xfail(f"External-service test {name} failed: {exc}") + raise + + if "expected" in payload: + actual_json = result_to_json(result) + expected_json = payload["expected"] + assert actual_json == expected_json, ( + f"Test {name} output mismatch:\n" + f"Expected: {json.dumps(expected_json, indent=2)}\n" + f"Actual: {json.dumps(actual_json, indent=2)}" + ) diff --git a/tests/test_web_algebra.py b/tests/test_web_algebra.py deleted file mode 100644 index e682c56..0000000 --- a/tests/test_web_algebra.py +++ /dev/null @@ -1,168 +0,0 @@ -#!/usr/bin/env python3 -""" -Web Algebra Test Suite - W3C Style with Pytest - -Flat structure with descriptive naming: -- positive/: PositiveExecutionTest - should succeed with expected JSON output -- negative/: NegativeExecutionTest - should fail with expected exception - -Test file format: -{ - "name": "Test description", - "operation": { "@op": "...", "args": {...} }, - "expected": // for positive tests - "expected_error": "TypeError" // for negative tests -} -""" - -import pytest -import json -import sys -import os -from pathlib import Path -from typing import Any - -# Add src directory to path -sys.path.insert(0, str(Path(__file__).parent.parent / "src")) - -import web_algebra.operations -from web_algebra.operation import Operation -from web_algebra.main import LinkedDataHubSettings, list_operation_subclasses -from web_algebra.json_result import JSONResult -import rdflib - -class TestWebAlgebra: - """W3C-style Web Algebra test suite""" - - @classmethod - def setup_class(cls): - """Setup test environment""" - # Register all operations - def register(classes): - for cls in classes: - Operation.register(cls) - - register(list_operation_subclasses(web_algebra.operations, Operation)) - - # Setup settings (with and without auth) - cls.settings = LinkedDataHubSettings() - cls.settings_with_auth = LinkedDataHubSettings( - cert_pem_path=os.getenv("CERT_PEM_PATH"), - cert_password=os.getenv("CERT_PASSWORD") - ) - - def result_to_json(self, result: Any) -> Any: - """Convert Web Algebra result to JSON for comparison""" - # Handle JSONResult first (most specific) - if isinstance(result, JSONResult): - # Use the built-in to_json() method - return result.to_json() - # Handle RDFLib terms - elif isinstance(result, (rdflib.URIRef, rdflib.Literal, rdflib.BNode)): - return str(result) - elif isinstance(result, rdflib.Graph): - # Serialize as JSON-LD - try: - jsonld_str = result.serialize(format='json-ld') - if isinstance(jsonld_str, bytes): - jsonld_str = jsonld_str.decode('utf-8') - return json.loads(jsonld_str) - except Exception: - # Fallback to turtle for debugging - return {"error": "Could not serialize to JSON-LD", - "turtle": result.serialize(format='turtle')} - elif isinstance(result, list): - return [self.result_to_json(item) for item in result] - elif isinstance(result, dict): - return {k: self.result_to_json(v) for k, v in result.items()} - else: - return str(result) - - - def choose_settings(self, test_file_name: str): - """Choose appropriate settings based on test name""" - if "ldh-" in test_file_name.lower() or "linkeddatahub" in test_file_name.lower(): - return self.settings_with_auth - return self.settings - - @pytest.mark.parametrize("test_file", sorted(Path("positive").glob("*.json"))) - def test_positive_execution(self, test_file: Path): - """PositiveExecutionTest - should succeed with expected output""" - # Skip if file doesn't exist (e.g., test discovery issues) - if not test_file.exists(): - pytest.skip(f"Test file {test_file} not found") - - # Load test data - with open(test_file, 'r') as f: - test_data = json.load(f) - - test_name = test_data.get("name", test_file.name) - - # Skip LinkedDataHub tests if no credentials - if ("ldh-" in test_file.name or "linkeddatahub" in test_file.name): - if not (os.getenv("CERT_PEM_PATH") and os.getenv("CERT_PASSWORD")): - pytest.skip(f"LinkedDataHub test {test_name} requires CERT_PEM_PATH and CERT_PASSWORD") - - # Choose appropriate settings - settings = self.choose_settings(test_file.name) - - # Execute operation - try: - result = Operation.process_json(settings, test_data["operation"]) - except Exception as e: - # For external service tests, network failures are acceptable - if any(service in test_file.name.lower() for service in ['dbpedia', 'wikidata', 'external']): - if any(err in str(e).lower() for err in ['timeout', 'connection', 'network', 'http', '403', '404']): - pytest.xfail(f"External service test {test_name} failed due to network: {e}") - raise AssertionError(f"Test {test_name} failed: {e}") - - # Compare with expected result if provided - if "expected" in test_data: - actual_json = self.result_to_json(result) - expected_json = test_data["expected"] - - assert actual_json == expected_json, ( - f"Test {test_name} output mismatch:\\n" - f"Expected: {json.dumps(expected_json, indent=2)}\\n" - f"Actual: {json.dumps(actual_json, indent=2)}" - ) - - @pytest.mark.parametrize("test_file", sorted(Path("negative").glob("*.json"))) - def test_negative_execution(self, test_file: Path): - """NegativeExecutionTest - should fail with expected error""" - # Skip if file doesn't exist - if not test_file.exists(): - pytest.skip(f"Test file {test_file} not found") - - # Load test data - with open(test_file, 'r') as f: - test_data = json.load(f) - - test_name = test_data.get("name", test_file.name) - expected_error = test_data.get("expected_error", "Exception") - - # Get exception class - try: - if expected_error in ["Exception", "TypeError", "ValueError", "KeyError"]: - exception_class = getattr(__builtins__, expected_error) - else: - # Try to import from common modules - exception_class = Exception # fallback - except: - exception_class = Exception - - # Choose appropriate settings - settings = self.choose_settings(test_file.name) - - # Execute and expect failure - with pytest.raises(exception_class) as exc_info: - Operation.process_json(settings, test_data["operation"]) - - # Ensure error message is not empty - assert str(exc_info.value), f"Test {test_name} should have non-empty error message" - - -if __name__ == "__main__": - """Run tests directly""" - import subprocess - subprocess.run([sys.executable, "-m", "pytest", __file__, "-v"]) \ No newline at end of file diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_bindings.py b/tests/unit/test_bindings.py new file mode 100644 index 0000000..e502ea5 --- /dev/null +++ b/tests/unit/test_bindings.py @@ -0,0 +1,58 @@ +"""Spec: formal-semantics.md "Bindings - Extract binding sequence from SPARQL results" +Abstract: Result → Sequence ResultRow +Python: def execute(self, table: rdflib.query.Result) -> List[Dict[str, Any]] + +Note: the abstract signature says `Sequence ResultRow`, but the Python signature +returns `List[Dict[str, Any]]`. The spec is internally inconsistent here; tests +assert only the abstract sequence shape (length / non-empty / iterable). +""" + +from __future__ import annotations + +import pytest +from rdflib import Graph, Literal, URIRef + +from web_algebra.operation import Operation + + +def _result_with_two_rows(): + g = Graph() + g.add((URIRef("http://ex/a"), URIRef("http://ex/p"), Literal("v1"))) + g.add((URIRef("http://ex/b"), URIRef("http://ex/p"), Literal("v2"))) + return g.query("SELECT ?s ?o WHERE { ?s ?o }") + + +def _empty_result(): + g = Graph() + return g.query("SELECT ?s WHERE { ?s ?p ?o }") + + +class TestBindingsPure: + def test_returns_iterable(self, settings): + op = Operation.get("Bindings")(settings=settings) + result = op.execute(_result_with_two_rows()) + # Spec says "Sequence ResultRow" — assert it is iterable and has length. + assert hasattr(result, "__iter__") + assert len(list(result)) == 2 + + def test_empty_result_yields_empty_sequence(self, settings): + # Reasonable from the type signature, though spec doesn't state it. + op = Operation.get("Bindings")(settings=settings) + result = op.execute(_empty_result()) + assert len(list(result)) == 0 + + def test_non_result_input_raises(self, settings): + # Strict Type Checking property + op = Operation.get("Bindings")(settings=settings) + with pytest.raises(TypeError): + op.execute([1, 2, 3]) + + @pytest.mark.skip(reason="UNCLEAR(spec): order preservation not stated") + def test_order_preserved(self, settings): + pass + + +class TestBindingsJson: + @pytest.mark.skip(reason="UNCLEAR(spec): JSON arg key for Bindings not given by spec or existing fixtures") + def test_json_dispatch(self, settings): + pass diff --git a/tests/unit/test_construct.py b/tests/unit/test_construct.py new file mode 100644 index 0000000..03eace3 --- /dev/null +++ b/tests/unit/test_construct.py @@ -0,0 +1,58 @@ +"""Spec: formal-semantics.md "CONSTRUCT - Execute SPARQL CONSTRUCT query" +Abstract: URI × Literal → Graph +Python: def execute(self, endpoint: rdflib.URIRef, query: rdflib.Literal) -> rdflib.Graph +""" + +from __future__ import annotations + +import os + +import pytest +from rdflib import Graph, Literal, URIRef + +from web_algebra.operation import Operation + + +class TestCONSTRUCTPure: + def test_wrong_endpoint_type_raises(self, settings): + op = Operation.get("CONSTRUCT")(settings=settings) + with pytest.raises(TypeError): + op.execute(Literal("http://example.org/sparql"), Literal("CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }")) + + def test_wrong_query_type_raises(self, settings): + op = Operation.get("CONSTRUCT")(settings=settings) + with pytest.raises(TypeError): + op.execute(URIRef("http://example.org/sparql"), URIRef("CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }")) + + +@pytest.mark.sparql +class TestCONSTRUCTLive: + def test_returns_graph(self, settings): + endpoint = os.getenv("SPARQL_ENDPOINT") + if not endpoint: + pytest.skip("SPARQL_ENDPOINT env var not set") + op = Operation.get("CONSTRUCT")(settings=settings) + result = op.execute( + URIRef(endpoint), + Literal("CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o } LIMIT 1"), + ) + assert isinstance(result, Graph) + + +class TestCONSTRUCTJson: + def test_json_dispatch_arg_shape(self, settings): + # JSON arg keys from existing fixture tests/fixtures/positive/linkeddatahub-put-test.json: + # CONSTRUCT takes {"query": , "endpoint": }. + # We can't dispatch live without an endpoint, but we can validate the call raises + # something other than KeyError when the arg shape is correct. + op = Operation.get("CONSTRUCT")(settings=settings) + with pytest.raises(Exception) as exc_info: + op.execute_json( + { + "query": "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", + "endpoint": {"@op": "URI", "args": {"input": "http://127.0.0.1:1/__nope__"}}, + } + ) + # KeyError would mean the arg keys are wrong; any other exception is a plausible + # "endpoint unreachable" path consistent with the spec arg shape. + assert not isinstance(exc_info.value, KeyError) diff --git a/tests/unit/test_current.py b/tests/unit/test_current.py new file mode 100644 index 0000000..c8ea998 --- /dev/null +++ b/tests/unit/test_current.py @@ -0,0 +1,34 @@ +"""Spec: formal-semantics.md "Current - Return current context item" +Abstract: Any → Any +Python: def execute(self, current_item: Any) -> Any +Plus Context System property: "Current Operation: Returns the current context item unchanged" (line 317). +""" + +from __future__ import annotations + +import pytest +from rdflib import Literal + +from web_algebra.operation import Operation + + +class TestCurrentPure: + def test_returns_argument_unchanged(self, settings): + op = Operation.get("Current")(settings=settings) + sentinel = Literal("ctx-value") + result = op.execute(sentinel) + assert result is sentinel or result == sentinel + + @pytest.mark.skip(reason="UNCLEAR(spec): behavior when context is unset (default `{}` per the abstract type signature)") + def test_unset_context(self, settings): + pass + + +class TestCurrentJson: + def test_returns_context_value(self, settings): + # Current's JSON form takes empty args and reads from context (set by ForEach). + op_cls = Operation.get("Current") + ctx_value = Literal("ctx-value") + op = op_cls(settings=settings, context=ctx_value) + result = op.execute_json({}) + assert result == ctx_value diff --git a/tests/unit/test_describe.py b/tests/unit/test_describe.py new file mode 100644 index 0000000..fba2580 --- /dev/null +++ b/tests/unit/test_describe.py @@ -0,0 +1,42 @@ +"""Spec: formal-semantics.md "DESCRIBE - Execute SPARQL DESCRIBE query" +Abstract: URI × Literal → Graph +Python: def execute(self, endpoint: rdflib.URIRef, query: rdflib.Literal) -> rdflib.Graph +""" + +from __future__ import annotations + +import os + +import pytest +from rdflib import Graph, Literal, URIRef + +from web_algebra.operation import Operation + + +class TestDESCRIBEPure: + def test_wrong_endpoint_type_raises(self, settings): + op = Operation.get("DESCRIBE")(settings=settings) + with pytest.raises(TypeError): + op.execute(Literal("http://example.org/sparql"), Literal("DESCRIBE ")) + + def test_wrong_query_type_raises(self, settings): + op = Operation.get("DESCRIBE")(settings=settings) + with pytest.raises(TypeError): + op.execute(URIRef("http://example.org/sparql"), URIRef("DESCRIBE ")) + + +@pytest.mark.sparql +class TestDESCRIBELive: + def test_returns_graph(self, settings): + endpoint = os.getenv("SPARQL_ENDPOINT") + if not endpoint: + pytest.skip("SPARQL_ENDPOINT env var not set") + op = Operation.get("DESCRIBE")(settings=settings) + result = op.execute(URIRef(endpoint), Literal("DESCRIBE ")) + assert isinstance(result, Graph) + + +class TestDESCRIBEJson: + @pytest.mark.skip(reason="UNCLEAR(spec): DESCRIBE JSON arg shape not exemplified by existing fixtures") + def test_json_dispatch(self, settings): + pass diff --git a/tests/unit/test_encode_for_uri.py b/tests/unit/test_encode_for_uri.py new file mode 100644 index 0000000..2a2a752 --- /dev/null +++ b/tests/unit/test_encode_for_uri.py @@ -0,0 +1,45 @@ +"""Spec: formal-semantics.md "EncodeForURI - URL-encode strings for URI usage" +Abstract: Literal → Literal +Python: def execute(self, input_str: Literal) -> Literal +Plus Strict Type Checking property. +""" + +from __future__ import annotations + +import pytest +from rdflib import Literal, URIRef + +from web_algebra.operation import Operation + + +class TestEncodeForURIPure: + def test_space_to_percent20(self, settings): + # Space → %20 is uncontroversial across RFC 3986 and SPARQL ENCODE_FOR_URI. + op = Operation.get("EncodeForURI")(settings=settings) + result = op.execute(Literal("hello world")) + assert isinstance(result, Literal) + assert str(result) == "hello%20world" + + def test_no_special_chars_passes_through(self, settings): + op = Operation.get("EncodeForURI")(settings=settings) + result = op.execute(Literal("abc123")) + assert isinstance(result, Literal) + assert str(result) == "abc123" + + def test_uri_input_raises(self, settings): + op = Operation.get("EncodeForURI")(settings=settings) + with pytest.raises(TypeError): + op.execute(URIRef("http://example.org/x")) + + @pytest.mark.skip(reason="UNCLEAR(spec): which character set / RFC — `~`, `*`, `'`, etc. differ across RFC 3986 and SPARQL ENCODE_FOR_URI") + def test_reserved_character_set(self, settings): + pass + + +class TestEncodeForURIJson: + def test_basic_via_json(self, settings): + # JSON arg key from existing fixture tests/fixtures/positive/simple-composition-working.json + op = Operation.get("EncodeForURI")(settings=settings) + result = op.execute_json({"input": "hello world"}) + assert isinstance(result, Literal) + assert str(result) == "hello%20world" diff --git a/tests/unit/test_execute.py b/tests/unit/test_execute.py new file mode 100644 index 0000000..8e5b066 --- /dev/null +++ b/tests/unit/test_execute.py @@ -0,0 +1,25 @@ +"""Spec: formal-semantics.md "Execute - Execute nested operation" +Abstract: Operation → Any +Python: def execute(self, operation: Any) -> Any + +The spec entry has only a signature; no narrative description is given. All +behavioral cases are blocked until the spec adds one. +""" + +from __future__ import annotations + +import pytest + +from web_algebra.operation import Operation + + +class TestExecutePure: + @pytest.mark.skip(reason="UNCLEAR(spec): Execute has no narrative description in formal-semantics.md — what does it do that JSON dispatch doesn't already?") + def test_basic(self, settings): + pass + + +class TestExecuteJson: + @pytest.mark.skip(reason="UNCLEAR(spec): Execute has no narrative description in formal-semantics.md") + def test_json_dispatch(self, settings): + pass diff --git a/tests/unit/test_extract_classes.py b/tests/unit/test_extract_classes.py new file mode 100644 index 0000000..d71024c --- /dev/null +++ b/tests/unit/test_extract_classes.py @@ -0,0 +1,28 @@ +"""Spec: formal-semantics.md "ExtractClasses - Extract RDF classes from graph" +Abstract: URI → Graph +Python: def execute(self, endpoint: URIRef) -> rdflib.Graph +""" + +from __future__ import annotations + +import pytest +from rdflib import Literal + +from web_algebra.operation import Operation + + +class TestExtractClassesPure: + def test_wrong_input_type_raises(self, settings): + op = Operation.get("ExtractClasses")(settings=settings) + with pytest.raises(TypeError): + op.execute(Literal("not-a-uri")) + + @pytest.mark.skip(reason="UNCLEAR(spec): is the URI a SPARQL endpoint, document URL, or ontology IRI? — narrative omits this") + def test_happy_path(self, settings): + pass + + +class TestExtractClassesJson: + @pytest.mark.skip(reason="UNCLEAR(spec): JSON arg key for ExtractClasses not given by spec or existing fixtures") + def test_json_dispatch(self, settings): + pass diff --git a/tests/unit/test_extract_datatype_properties.py b/tests/unit/test_extract_datatype_properties.py new file mode 100644 index 0000000..d21dddc --- /dev/null +++ b/tests/unit/test_extract_datatype_properties.py @@ -0,0 +1,28 @@ +"""Spec: formal-semantics.md "ExtractDatatypeProperties - Extract datatype properties from graph" +Abstract: URI → Graph +Python: def execute(self, endpoint: URIRef) -> rdflib.Graph +""" + +from __future__ import annotations + +import pytest +from rdflib import Literal + +from web_algebra.operation import Operation + + +class TestExtractDatatypePropertiesPure: + def test_wrong_input_type_raises(self, settings): + op = Operation.get("ExtractDatatypeProperties")(settings=settings) + with pytest.raises(TypeError): + op.execute(Literal("not-a-uri")) + + @pytest.mark.skip(reason="UNCLEAR(spec): is the URI a SPARQL endpoint, document URL, or ontology IRI?") + def test_happy_path(self, settings): + pass + + +class TestExtractDatatypePropertiesJson: + @pytest.mark.skip(reason="UNCLEAR(spec): JSON arg key not given by spec or existing fixtures") + def test_json_dispatch(self, settings): + pass diff --git a/tests/unit/test_extract_object_properties.py b/tests/unit/test_extract_object_properties.py new file mode 100644 index 0000000..ef1d8b3 --- /dev/null +++ b/tests/unit/test_extract_object_properties.py @@ -0,0 +1,28 @@ +"""Spec: formal-semantics.md "ExtractObjectProperties - Extract object properties from graph" +Abstract: URI → Graph +Python: def execute(self, endpoint: URIRef) -> rdflib.Graph +""" + +from __future__ import annotations + +import pytest +from rdflib import Literal + +from web_algebra.operation import Operation + + +class TestExtractObjectPropertiesPure: + def test_wrong_input_type_raises(self, settings): + op = Operation.get("ExtractObjectProperties")(settings=settings) + with pytest.raises(TypeError): + op.execute(Literal("not-a-uri")) + + @pytest.mark.skip(reason="UNCLEAR(spec): is the URI a SPARQL endpoint, document URL, or ontology IRI?") + def test_happy_path(self, settings): + pass + + +class TestExtractObjectPropertiesJson: + @pytest.mark.skip(reason="UNCLEAR(spec): JSON arg key not given by spec or existing fixtures") + def test_json_dispatch(self, settings): + pass diff --git a/tests/unit/test_filter.py b/tests/unit/test_filter.py new file mode 100644 index 0000000..8429391 --- /dev/null +++ b/tests/unit/test_filter.py @@ -0,0 +1,28 @@ +"""Spec: formal-semantics.md "Filter - Filter sequences or select from results" +Abstract: (Sequence α × Expression → α) + (Result × Expression → Result) +Python: def execute(self, input_data: Any, expression: Any) -> Union[list, Any] + +The sequence case in the abstract signature has a typo (returns `α`, a single +item, instead of `Sequence α`). Until the spec is corrected and the Expression +semantics are defined (line 27 declares `Expression = Operation + Literal + +Integer` but doesn't define how each kind acts as a predicate), tests are +blocked. +""" + +from __future__ import annotations + +import pytest + +from web_algebra.operation import Operation + + +class TestFilterPure: + @pytest.mark.skip(reason="UNCLEAR(spec): line 99 sequence case has typo (`→ α` should be `→ Sequence α`) and Expression evaluation is undefined") + def test_basic(self, settings): + pass + + +class TestFilterJson: + @pytest.mark.skip(reason="UNCLEAR(spec): see TestFilterPure") + def test_json_dispatch(self, settings): + pass diff --git a/tests/unit/test_for_each.py b/tests/unit/test_for_each.py new file mode 100644 index 0000000..1b8c80b --- /dev/null +++ b/tests/unit/test_for_each.py @@ -0,0 +1,66 @@ +"""Spec: formal-semantics.md "ForEach - Map operation over sequence (sequence → sequence semantics)" +Abstract: Sequence α × Operation → Sequence β +Python: def execute(self, select_data: Union[List[Any], rdflib.query.Result], + operation: Any) -> List[Any] +Plus Sequence Semantics property (lines 302-306). +""" + +from __future__ import annotations + +import pytest +from rdflib import Literal + +from web_algebra.operation import Operation + + +class TestForEachPure: + @pytest.mark.skip(reason="UNCLEAR(spec): ForEach pure execute() requires an Operation value plus dispatcher context — abstract signature is testable only via execute_json") + def test_pure(self, settings): + pass + + +class TestForEachJson: + def test_empty_sequence(self, settings): + # JSON arg keys derived from Python parameter names: select_data → "select" by convention. + # The existing fixture set has no ForEach example; flagged in SPEC_GAPS for confirmation. + op = Operation.get("ForEach")(settings=settings) + # Use the JSON dispatcher: an inner Str on each item. + result = op.execute_json( + { + "select": [], + "operation": {"@op": "Str", "args": {"input": {"@op": "Current", "args": {}}}}, + } + ) + assert result == [] + + def test_length_matches_input(self, settings): + op = Operation.get("ForEach")(settings=settings) + result = op.execute_json( + { + "select": ["a", "b", "c"], + "operation": {"@op": "Str", "args": {"input": {"@op": "Current", "args": {}}}}, + } + ) + assert isinstance(result, list) + assert len(result) == 3 + assert all(isinstance(item, Literal) for item in result) + assert [str(item) for item in result] == ["a", "b", "c"] + + def test_non_iterable_select_raises(self, settings): + # Strict Type Checking property: select must be a Sequence or Result. + op = Operation.get("ForEach")(settings=settings) + with pytest.raises(TypeError): + op.execute_json( + { + "select": "not-a-list-or-result", + "operation": {"@op": "Current", "args": {}}, + } + ) + + @pytest.mark.skip(reason="UNCLEAR(spec): output shape when inner op returns None or a sequence — flatten? filter Nones?") + def test_inner_op_none_handling(self, settings): + pass + + @pytest.mark.skip(reason="UNCLEAR(spec): SPARQL Result iteration order") + def test_result_iteration_order(self, settings): + pass diff --git a/tests/unit/test_get.py b/tests/unit/test_get.py new file mode 100644 index 0000000..cc3ebd2 --- /dev/null +++ b/tests/unit/test_get.py @@ -0,0 +1,37 @@ +"""Spec: formal-semantics.md "GET - Retrieve RDF data via HTTP GET" +Abstract: URI → Graph +Python: def execute(self, url: rdflib.URIRef) -> Graph +""" + +from __future__ import annotations + +import os + +import pytest +from rdflib import Graph, Literal, URIRef + +from web_algebra.operation import Operation + + +class TestGETPure: + def test_wrong_url_type_raises(self, settings): + op = Operation.get("GET")(settings=settings) + with pytest.raises(TypeError): + op.execute(Literal("http://example.org/x")) + + +@pytest.mark.network +class TestGETLive: + def test_returns_graph(self, settings): + url = os.getenv("HTTP_GET_URL") + if not url: + pytest.skip("HTTP_GET_URL env var not set") + op = Operation.get("GET")(settings=settings) + result = op.execute(URIRef(url)) + assert isinstance(result, Graph) + + +class TestGETJson: + @pytest.mark.skip(reason="UNCLEAR(spec): GET JSON arg shape not exemplified by existing fixtures (presumed `{url}`)") + def test_json_dispatch(self, settings): + pass diff --git a/tests/unit/test_ldh_add_generic_service.py b/tests/unit/test_ldh_add_generic_service.py new file mode 100644 index 0000000..26146fc --- /dev/null +++ b/tests/unit/test_ldh_add_generic_service.py @@ -0,0 +1,40 @@ +"""Spec: formal-semantics.md "ldh-AddGenericService - Add generic SPARQL service to LinkedDataHub" +Abstract: URI × URI × Literal × Maybe Literal × Maybe Literal × Maybe URI × Maybe Literal × Maybe Literal → Any +Python: def execute(self, url: URIRef, endpoint: URIRef, title: Literal, + description: Literal = None, fragment: Literal = None, graph_store: URIRef = None, + auth_user: Literal = None, auth_pwd: Literal = None) -> Any +""" + +from __future__ import annotations + +import pytest +from rdflib import Literal, URIRef + +from web_algebra.operation import Operation + + +class TestLDHAddGenericServicePure: + def test_wrong_url_type_raises(self, settings): + op = Operation.get("ldh-AddGenericService")(settings=settings) + with pytest.raises(TypeError): + op.execute( + Literal("not-a-uri"), + URIRef("https://example.org/sparql"), + Literal("title"), + ) + + def test_wrong_endpoint_type_raises(self, settings): + op = Operation.get("ldh-AddGenericService")(settings=settings) + with pytest.raises(TypeError): + op.execute( + URIRef("https://example.org/"), + Literal("not-a-uri"), + Literal("title"), + ) + + +@pytest.mark.ldh +class TestLDHAddGenericServiceLive: + @pytest.mark.skip(reason="UNCLEAR(spec): return type `Any`") + def test_basic(self, settings_with_auth): + pass diff --git a/tests/unit/test_ldh_add_object_block.py b/tests/unit/test_ldh_add_object_block.py new file mode 100644 index 0000000..abd5864 --- /dev/null +++ b/tests/unit/test_ldh_add_object_block.py @@ -0,0 +1,31 @@ +"""Spec: formal-semantics.md "ldh-AddObjectBlock - Add object content block to LinkedDataHub document" +Abstract: URI × URI × Maybe Literal × Maybe Literal × Maybe Literal × Maybe URI → Any +Python: def execute(self, url: URIRef, value: URIRef, title: Literal = None, + description: Literal = None, fragment: Literal = None, mode: URIRef = None) -> Any +""" + +from __future__ import annotations + +import pytest +from rdflib import Literal, URIRef + +from web_algebra.operation import Operation + + +class TestLDHAddObjectBlockPure: + def test_wrong_url_type_raises(self, settings): + op = Operation.get("ldh-AddObjectBlock")(settings=settings) + with pytest.raises(TypeError): + op.execute(Literal("not-a-uri"), URIRef("https://example.org/value")) + + def test_wrong_value_type_raises(self, settings): + op = Operation.get("ldh-AddObjectBlock")(settings=settings) + with pytest.raises(TypeError): + op.execute(URIRef("https://example.org/"), Literal("not-a-uri")) + + +@pytest.mark.ldh +class TestLDHAddObjectBlockLive: + @pytest.mark.skip(reason="UNCLEAR(spec): return type `Any`") + def test_basic(self, settings_with_auth): + pass diff --git a/tests/unit/test_ldh_add_result_set_chart.py b/tests/unit/test_ldh_add_result_set_chart.py new file mode 100644 index 0000000..4b1acc9 --- /dev/null +++ b/tests/unit/test_ldh_add_result_set_chart.py @@ -0,0 +1,46 @@ +"""Spec: formal-semantics.md "ldh-AddResultSetChart - Add result set chart to LinkedDataHub document" +Abstract: URI × URI × Literal × URI × Literal × Literal × Maybe Literal × Maybe Literal → Any +Python: def execute(self, url: URIRef, query: URIRef, title: Literal, chart_type: URIRef, + category_var_name: Literal, series_var_name: Literal, + description: Literal = None, fragment: Literal = None) -> Any +""" + +from __future__ import annotations + +import pytest +from rdflib import Literal, URIRef + +from web_algebra.operation import Operation + + +class TestLDHAddResultSetChartPure: + def test_wrong_url_type_raises(self, settings): + op = Operation.get("ldh-AddResultSetChart")(settings=settings) + with pytest.raises(TypeError): + op.execute( + Literal("not-a-uri"), + URIRef("https://example.org/q"), + Literal("title"), + URIRef("https://example.org/Bar"), + Literal("cat"), + Literal("series"), + ) + + def test_wrong_chart_type_raises(self, settings): + op = Operation.get("ldh-AddResultSetChart")(settings=settings) + with pytest.raises(TypeError): + op.execute( + URIRef("https://example.org/"), + URIRef("https://example.org/q"), + Literal("title"), + Literal("not-a-uri"), + Literal("cat"), + Literal("series"), + ) + + +@pytest.mark.ldh +class TestLDHAddResultSetChartLive: + @pytest.mark.skip(reason="UNCLEAR(spec): return type `Any`") + def test_basic(self, settings_with_auth): + pass diff --git a/tests/unit/test_ldh_add_select.py b/tests/unit/test_ldh_add_select.py new file mode 100644 index 0000000..a406d11 --- /dev/null +++ b/tests/unit/test_ldh_add_select.py @@ -0,0 +1,39 @@ +"""Spec: formal-semantics.md "ldh-AddSelect - Add SPARQL SELECT service to LinkedDataHub" +Abstract: URI × Literal × Literal × Maybe Literal × Maybe Literal × Maybe URI → Any +Python: def execute(self, url: URIRef, query: Literal, title: Literal, description: Literal = None, + fragment: Literal = None, service: URIRef = None) -> Any +""" + +from __future__ import annotations + +import pytest +from rdflib import Literal, URIRef + +from web_algebra.operation import Operation + + +class TestLDHAddSelectPure: + def test_wrong_url_type_raises(self, settings): + op = Operation.get("ldh-AddSelect")(settings=settings) + with pytest.raises(TypeError): + op.execute( + Literal("not-a-uri"), + Literal("SELECT * WHERE { ?s ?p ?o }"), + Literal("title"), + ) + + def test_wrong_query_type_raises(self, settings): + op = Operation.get("ldh-AddSelect")(settings=settings) + with pytest.raises(TypeError): + op.execute( + URIRef("https://example.org/"), + URIRef("not-a-literal"), + Literal("title"), + ) + + +@pytest.mark.ldh +class TestLDHAddSelectLive: + @pytest.mark.skip(reason="UNCLEAR(spec): return type `Any`. Covered by integration LDH composition fixture instead.") + def test_basic(self, settings_with_auth): + pass diff --git a/tests/unit/test_ldh_add_view.py b/tests/unit/test_ldh_add_view.py new file mode 100644 index 0000000..0b019f4 --- /dev/null +++ b/tests/unit/test_ldh_add_view.py @@ -0,0 +1,48 @@ +"""Spec: formal-semantics.md "ldh-AddView - Add view to LinkedDataHub document" +Abstract: URI × URI × Literal × Maybe Literal × Maybe Literal × Maybe URI → Any +Python: def execute(self, url: URIRef, query: URIRef, title: Literal, description: Literal = None, + fragment: Literal = None, mode: URIRef = None) -> Any +""" + +from __future__ import annotations + +import pytest +from rdflib import Literal, URIRef + +from web_algebra.operation import Operation + + +class TestLDHAddViewPure: + def test_wrong_url_type_raises(self, settings): + op = Operation.get("ldh-AddView")(settings=settings) + with pytest.raises(TypeError): + op.execute( + Literal("not-a-uri"), + URIRef("https://example.org/q"), + Literal("title"), + ) + + def test_wrong_query_type_raises(self, settings): + op = Operation.get("ldh-AddView")(settings=settings) + with pytest.raises(TypeError): + op.execute( + URIRef("https://example.org/"), + Literal("not-a-uri"), + Literal("title"), + ) + + def test_wrong_title_type_raises(self, settings): + op = Operation.get("ldh-AddView")(settings=settings) + with pytest.raises(TypeError): + op.execute( + URIRef("https://example.org/"), + URIRef("https://example.org/q"), + URIRef("not-a-literal"), + ) + + +@pytest.mark.ldh +class TestLDHAddViewLive: + @pytest.mark.skip(reason="UNCLEAR(spec): return type `Any` — what's a meaningful assertion?") + def test_basic(self, settings_with_auth): + pass diff --git a/tests/unit/test_ldh_add_xhtml_block.py b/tests/unit/test_ldh_add_xhtml_block.py new file mode 100644 index 0000000..f9296f2 --- /dev/null +++ b/tests/unit/test_ldh_add_xhtml_block.py @@ -0,0 +1,31 @@ +"""Spec: formal-semantics.md "ldh-AddXHTMLBlock - Add XHTML content block to LinkedDataHub document" +Abstract: URI × Literal × Maybe Literal × Maybe Literal × Maybe Literal → Any +Python: def execute(self, url: URIRef, value: Literal, title: Literal = None, + description: Literal = None, fragment: Literal = None) -> Any +""" + +from __future__ import annotations + +import pytest +from rdflib import Literal, URIRef + +from web_algebra.operation import Operation + + +class TestLDHAddXHTMLBlockPure: + def test_wrong_url_type_raises(self, settings): + op = Operation.get("ldh-AddXHTMLBlock")(settings=settings) + with pytest.raises(TypeError): + op.execute(Literal("not-a-uri"), Literal("

hello

")) + + def test_wrong_value_type_raises(self, settings): + op = Operation.get("ldh-AddXHTMLBlock")(settings=settings) + with pytest.raises(TypeError): + op.execute(URIRef("https://example.org/"), URIRef("not-a-literal")) + + +@pytest.mark.ldh +class TestLDHAddXHTMLBlockLive: + @pytest.mark.skip(reason="UNCLEAR(spec): return type `Any`") + def test_basic(self, settings_with_auth): + pass diff --git a/tests/unit/test_ldh_create_container.py b/tests/unit/test_ldh_create_container.py new file mode 100644 index 0000000..555932a --- /dev/null +++ b/tests/unit/test_ldh_create_container.py @@ -0,0 +1,31 @@ +"""Spec: formal-semantics.md "ldh-CreateContainer - Create LinkedDataHub container document" +Abstract: URI × Literal × Maybe Literal × Maybe Literal → Result +Python: def execute(self, parent_uri: URIRef, title: Literal, slug: Literal = None, + description: Literal = None) -> Result +""" + +from __future__ import annotations + +import pytest +from rdflib import Literal, URIRef + +from web_algebra.operation import Operation + + +class TestLDHCreateContainerPure: + def test_wrong_parent_uri_type_raises(self, settings): + op = Operation.get("ldh-CreateContainer")(settings=settings) + with pytest.raises(TypeError): + op.execute(Literal("not-a-uri"), Literal("title")) + + def test_wrong_title_type_raises(self, settings): + op = Operation.get("ldh-CreateContainer")(settings=settings) + with pytest.raises(TypeError): + op.execute(URIRef("https://example.org/"), URIRef("https://example.org/title")) + + +@pytest.mark.ldh +class TestLDHCreateContainerLive: + @pytest.mark.skip(reason="UNCLEAR(spec): meaningful assertion about the Result return is undefined; need live LDH") + def test_returns_result(self, settings_with_auth): + pass diff --git a/tests/unit/test_ldh_create_item.py b/tests/unit/test_ldh_create_item.py new file mode 100644 index 0000000..e87c347 --- /dev/null +++ b/tests/unit/test_ldh_create_item.py @@ -0,0 +1,30 @@ +"""Spec: formal-semantics.md "ldh-CreateItem - Create LinkedDataHub item document" +Abstract: URI × Literal × Maybe Literal → Result +Python: def execute(self, container_uri: URIRef, title: Literal, slug: Literal = None) -> Result +""" + +from __future__ import annotations + +import pytest +from rdflib import Literal, URIRef + +from web_algebra.operation import Operation + + +class TestLDHCreateItemPure: + def test_wrong_container_uri_type_raises(self, settings): + op = Operation.get("ldh-CreateItem")(settings=settings) + with pytest.raises(TypeError): + op.execute(Literal("not-a-uri"), Literal("title")) + + def test_wrong_title_type_raises(self, settings): + op = Operation.get("ldh-CreateItem")(settings=settings) + with pytest.raises(TypeError): + op.execute(URIRef("https://example.org/"), URIRef("not-a-literal")) + + +@pytest.mark.ldh +class TestLDHCreateItemLive: + @pytest.mark.skip(reason="Need live LDH for end-to-end happy path") + def test_returns_result(self, settings_with_auth): + pass diff --git a/tests/unit/test_ldh_list.py b/tests/unit/test_ldh_list.py new file mode 100644 index 0000000..22c47b6 --- /dev/null +++ b/tests/unit/test_ldh_list.py @@ -0,0 +1,30 @@ +"""Spec: formal-semantics.md "ldh-List - List LinkedDataHub resources" +Abstract: URI × URI → List[Dict] +Python: def execute(self, url: URIRef, endpoint: URIRef) -> list[dict] +""" + +from __future__ import annotations + +import pytest +from rdflib import Literal, URIRef + +from web_algebra.operation import Operation + + +class TestLDHListPure: + def test_wrong_url_type_raises(self, settings): + op = Operation.get("ldh-List")(settings=settings) + with pytest.raises(TypeError): + op.execute(Literal("not-a-uri"), URIRef("https://example.org/sparql")) + + def test_wrong_endpoint_type_raises(self, settings): + op = Operation.get("ldh-List")(settings=settings) + with pytest.raises(TypeError): + op.execute(URIRef("https://example.org/"), Literal("not-a-uri")) + + +@pytest.mark.ldh +class TestLDHListLive: + @pytest.mark.skip(reason="Need live LDH for end-to-end happy path") + def test_returns_list(self, settings_with_auth): + pass diff --git a/tests/unit/test_ldh_remove_block.py b/tests/unit/test_ldh_remove_block.py new file mode 100644 index 0000000..63b465a --- /dev/null +++ b/tests/unit/test_ldh_remove_block.py @@ -0,0 +1,30 @@ +"""Spec: formal-semantics.md "ldh-RemoveBlock - Remove content block from LinkedDataHub document" +Abstract: URI × Maybe URI → Any +Python: def execute(self, url: URIRef, block: URIRef = None) -> Any +""" + +from __future__ import annotations + +import pytest +from rdflib import Literal, URIRef + +from web_algebra.operation import Operation + + +class TestLDHRemoveBlockPure: + def test_wrong_url_type_raises(self, settings): + op = Operation.get("ldh-RemoveBlock")(settings=settings) + with pytest.raises(TypeError): + op.execute(Literal("not-a-uri")) + + def test_wrong_block_type_raises(self, settings): + op = Operation.get("ldh-RemoveBlock")(settings=settings) + with pytest.raises(TypeError): + op.execute(URIRef("https://example.org/"), Literal("not-a-uri")) + + +@pytest.mark.ldh +class TestLDHRemoveBlockLive: + @pytest.mark.skip(reason="UNCLEAR(spec): return type `Any`") + def test_basic(self, settings_with_auth): + pass diff --git a/tests/unit/test_merge.py b/tests/unit/test_merge.py new file mode 100644 index 0000000..cbce6eb --- /dev/null +++ b/tests/unit/test_merge.py @@ -0,0 +1,55 @@ +"""Spec: formal-semantics.md "Merge - Merge multiple RDF graphs into one" +Abstract: Sequence Graph → Graph +Python: def execute(self, graphs: List[rdflib.Graph]) -> rdflib.Graph +""" + +from __future__ import annotations + +import pytest +from rdflib import Graph, Literal, URIRef + +from web_algebra.operation import Operation + + +def _graph_with(triples): + g = Graph() + for t in triples: + g.add(t) + return g + + +class TestMergePure: + def test_empty_sequence_returns_empty_graph(self, settings): + op = Operation.get("Merge")(settings=settings) + result = op.execute([]) + assert isinstance(result, Graph) + assert len(result) == 0 + + def test_single_graph_passthrough(self, settings): + op = Operation.get("Merge")(settings=settings) + triple = (URIRef("http://ex/s"), URIRef("http://ex/p"), Literal("o")) + g = _graph_with([triple]) + result = op.execute([g]) + assert isinstance(result, Graph) + assert triple in result + + def test_two_graphs_union(self, settings): + op = Operation.get("Merge")(settings=settings) + t1 = (URIRef("http://ex/s1"), URIRef("http://ex/p"), Literal("a")) + t2 = (URIRef("http://ex/s2"), URIRef("http://ex/p"), Literal("b")) + g1 = _graph_with([t1]) + g2 = _graph_with([t2]) + result = op.execute([g1, g2]) + assert isinstance(result, Graph) + assert t1 in result + assert t2 in result + + @pytest.mark.skip(reason="UNCLEAR(spec): duplicate-triple semantics (set union vs multiset) not stated") + def test_duplicate_triples_deduplicated(self, settings): + pass + + +class TestMergeJson: + @pytest.mark.skip(reason="UNCLEAR(spec): JSON arg key for Merge ('graphs'? 'input'?) not given by spec or existing fixtures") + def test_json_dispatch(self, settings): + pass diff --git a/tests/unit/test_patch.py b/tests/unit/test_patch.py new file mode 100644 index 0000000..6305aca --- /dev/null +++ b/tests/unit/test_patch.py @@ -0,0 +1,36 @@ +"""Spec: formal-semantics.md "PATCH - Update RDF data via HTTP PATCH with SPARQL Update" +Abstract: URI × Literal → Result +Python: def execute(self, url: URIRef, update: Literal) -> Result +""" + +from __future__ import annotations + +import pytest +from rdflib import Literal, URIRef + +from web_algebra.operation import Operation + + +class TestPATCHPure: + def test_wrong_url_type_raises(self, settings): + op = Operation.get("PATCH")(settings=settings) + with pytest.raises(TypeError): + op.execute(Literal("http://example.org/x"), Literal("DELETE WHERE { ?s ?p ?o }")) + + def test_wrong_update_type_raises(self, settings): + op = Operation.get("PATCH")(settings=settings) + with pytest.raises(TypeError): + op.execute(URIRef("http://example.org/x"), URIRef("not-a-literal")) + + +@pytest.mark.network +class TestPATCHLive: + @pytest.mark.skip(reason="No safe public PATCH endpoint") + def test_returns_result(self, settings): + pass + + +class TestPATCHJson: + @pytest.mark.skip(reason="UNCLEAR(spec): PATCH JSON arg shape not exemplified by existing fixtures (presumed `{url, update}`)") + def test_json_dispatch(self, settings): + pass diff --git a/tests/unit/test_post.py b/tests/unit/test_post.py new file mode 100644 index 0000000..b75ef9b --- /dev/null +++ b/tests/unit/test_post.py @@ -0,0 +1,36 @@ +"""Spec: formal-semantics.md "POST - Submit RDF data via HTTP POST" +Abstract: URI × Graph → Result +Python: def execute(self, url: rdflib.URIRef, data: rdflib.Graph) -> Result +""" + +from __future__ import annotations + +import pytest +from rdflib import Graph, Literal, URIRef + +from web_algebra.operation import Operation + + +class TestPOSTPure: + def test_wrong_url_type_raises(self, settings): + op = Operation.get("POST")(settings=settings) + with pytest.raises(TypeError): + op.execute(Literal("http://example.org/x"), Graph()) + + def test_wrong_data_type_raises(self, settings): + op = Operation.get("POST")(settings=settings) + with pytest.raises(TypeError): + op.execute(URIRef("http://example.org/x"), Literal("not-a-graph")) + + +@pytest.mark.network +class TestPOSTLive: + @pytest.mark.skip(reason="No safe public POST endpoint to test against") + def test_returns_result(self, settings): + pass + + +class TestPOSTJson: + @pytest.mark.skip(reason="UNCLEAR(spec): POST JSON arg shape not exemplified by existing fixtures (presumed `{url, data}`)") + def test_json_dispatch(self, settings): + pass diff --git a/tests/unit/test_put.py b/tests/unit/test_put.py new file mode 100644 index 0000000..6d25030 --- /dev/null +++ b/tests/unit/test_put.py @@ -0,0 +1,49 @@ +"""Spec: formal-semantics.md "PUT - Replace RDF data via HTTP PUT" +Abstract: URI × Graph → Result +Python: def execute(self, url: rdflib.URIRef, data: rdflib.Graph) -> Result +""" + +from __future__ import annotations + +import pytest +from rdflib import Graph, Literal, URIRef + +from web_algebra.operation import Operation + + +class TestPUTPure: + def test_wrong_url_type_raises(self, settings): + op = Operation.get("PUT")(settings=settings) + with pytest.raises(TypeError): + op.execute(Literal("http://example.org/x"), Graph()) + + def test_wrong_data_type_raises(self, settings): + op = Operation.get("PUT")(settings=settings) + with pytest.raises(TypeError): + op.execute(URIRef("http://example.org/x"), Literal("not-a-graph")) + + +@pytest.mark.network +class TestPUTLive: + @pytest.mark.skip(reason="No safe public PUT endpoint; covered by integration LDH fixture instead") + def test_returns_result(self, settings): + pass + + +class TestPUTJson: + def test_json_dispatch_arg_shape(self, settings): + # JSON arg keys from existing fixture tests/fixtures/positive/linkeddatahub-put-test.json: + # PUT takes {"url": , "data": }. + op = Operation.get("PUT")(settings=settings) + # Exercise that the arg shape is recognized — execute_json processes args + # before any HTTP call. We pass a malformed nested op so the call surfaces + # the type/shape failure inside the dispatcher rather than KeyError. + with pytest.raises(Exception) as exc_info: + op.execute_json( + { + "url": {"@op": "URI", "args": {"input": "http://127.0.0.1:1/__nope__"}}, + "data": {"@op": "URI", "args": {"input": "http://example.org/not-a-graph"}}, + } + ) + # KeyError on missing keys would mean the spec arg shape is wrong. + assert not isinstance(exc_info.value, KeyError) diff --git a/tests/unit/test_replace.py b/tests/unit/test_replace.py new file mode 100644 index 0000000..efed367 --- /dev/null +++ b/tests/unit/test_replace.py @@ -0,0 +1,65 @@ +"""Spec: formal-semantics.md "Replace - Replace patterns in strings using regex" +Abstract: Literal × Literal × Literal → Literal +Python: def execute(self, input_str: rdflib.Literal, pattern: rdflib.Literal, + replacement: rdflib.Literal) -> rdflib.Literal +Plus Strict Type Checking property. +""" + +from __future__ import annotations + +import pytest +from rdflib import Literal, URIRef + +from web_algebra.operation import Operation + + +class TestReplacePure: + def test_basic_replace(self, settings): + # Pattern that's identical as literal or regex — robust against the regex/literal ambiguity + op = Operation.get("Replace")(settings=settings) + result = op.execute(Literal("Hello World"), Literal("World"), Literal("Universe")) + assert isinstance(result, Literal) + assert str(result) == "Hello Universe" + + def test_uri_input_raises_type_error(self, settings): + # Strict Type Checking property: TypeError on mismatched input. + # Same as existing fixture tests/fixtures/negative/error-case-type-mismatch-uri-to-string.json + op = Operation.get("Replace")(settings=settings) + with pytest.raises(TypeError): + op.execute(URIRef("http://example.org/x"), Literal("x"), Literal("y")) + + def test_uri_pattern_raises(self, settings): + op = Operation.get("Replace")(settings=settings) + with pytest.raises(TypeError): + op.execute(Literal("Hello"), URIRef("http://example.org/x"), Literal("y")) + + def test_uri_replacement_raises(self, settings): + op = Operation.get("Replace")(settings=settings) + with pytest.raises(TypeError): + op.execute(Literal("Hello"), Literal("e"), URIRef("http://example.org/x")) + + @pytest.mark.skip(reason="UNCLEAR(spec): regex vs literal pattern semantics — class name and SPARQL parallel suggest regex but spec is silent") + def test_regex_metacharacter_treated_as_regex(self, settings): + pass + + +class TestReplaceJson: + def test_basic_via_json(self, settings): + # JSON arg keys from existing fixture tests/fixtures/positive/complex-operation.json + op = Operation.get("Replace")(settings=settings) + result = op.execute_json( + {"input": "Hello World", "pattern": "World", "replacement": "Universe"} + ) + assert isinstance(result, Literal) + assert str(result) == "Hello Universe" + + def test_uri_input_raises_via_json(self, settings): + op = Operation.get("Replace")(settings=settings) + with pytest.raises(TypeError): + op.execute_json( + { + "input": {"@op": "URI", "args": {"input": "http://example.org/test"}}, + "pattern": "test", + "replacement": "example", + } + ) diff --git a/tests/unit/test_resolve_uri.py b/tests/unit/test_resolve_uri.py new file mode 100644 index 0000000..7d181d4 --- /dev/null +++ b/tests/unit/test_resolve_uri.py @@ -0,0 +1,54 @@ +"""Spec: formal-semantics.md "ResolveURI - Resolve relative URI against base URI" +Abstract: URI × Literal → URI +Python: def execute(self, base: URIRef, relative: Literal) -> URIRef +Plus the URI Resolution property (lines 320-325) and Strict Type Checking. +""" + +from __future__ import annotations + +import pytest +from rdflib import Literal, URIRef + +from web_algebra.operation import Operation + + +class TestResolveURIPure: + def test_relative_appends_to_base(self, settings): + op = Operation.get("ResolveURI")(settings=settings) + result = op.execute(URIRef("http://example.org/base/"), Literal("foo")) + assert isinstance(result, URIRef) + assert str(result) == "http://example.org/base/foo" + + def test_relative_uplevel(self, settings): + # RFC 3986-style resolution: "../up" relative to "http://example.org/base/" → "http://example.org/up" + op = Operation.get("ResolveURI")(settings=settings) + result = op.execute(URIRef("http://example.org/base/"), Literal("../up")) + assert isinstance(result, URIRef) + assert str(result) == "http://example.org/up" + + def test_fragment_relative(self, settings): + # URI Resolution property line 322: "Fragment URIs ... resolve against target document URI" + op = Operation.get("ResolveURI")(settings=settings) + result = op.execute(URIRef("http://example.org/doc"), Literal("#frag")) + assert isinstance(result, URIRef) + assert str(result) == "http://example.org/doc#frag" + + def test_wrong_base_type_raises(self, settings): + op = Operation.get("ResolveURI")(settings=settings) + with pytest.raises(TypeError): + op.execute(Literal("http://example.org/base/"), Literal("foo")) + + def test_wrong_relative_type_raises(self, settings): + op = Operation.get("ResolveURI")(settings=settings) + with pytest.raises(TypeError): + op.execute(URIRef("http://example.org/base/"), URIRef("foo")) + + @pytest.mark.skip(reason="UNCLEAR(spec): behavior when relative is itself an absolute URI") + def test_absolute_relative(self, settings): + pass + + +class TestResolveURIJson: + @pytest.mark.skip(reason="UNCLEAR(spec): JSON arg key names for ResolveURI not given by spec or existing fixtures") + def test_json_dispatch(self, settings): + pass diff --git a/tests/unit/test_select.py b/tests/unit/test_select.py new file mode 100644 index 0000000..4556d18 --- /dev/null +++ b/tests/unit/test_select.py @@ -0,0 +1,43 @@ +"""Spec: formal-semantics.md "SELECT - Execute SPARQL SELECT query" +Abstract: URI × Literal → Result +Python: def execute(self, endpoint: rdflib.URIRef, query: rdflib.Literal) -> rdflib.query.Result +""" + +from __future__ import annotations + +import os + +import pytest +from rdflib import Literal, URIRef +from rdflib.query import Result + +from web_algebra.operation import Operation + + +class TestSELECTPure: + def test_wrong_endpoint_type_raises(self, settings): + op = Operation.get("SELECT")(settings=settings) + with pytest.raises(TypeError): + op.execute(Literal("http://example.org/sparql"), Literal("ASK { ?s ?p ?o }")) + + def test_wrong_query_type_raises(self, settings): + op = Operation.get("SELECT")(settings=settings) + with pytest.raises(TypeError): + op.execute(URIRef("http://example.org/sparql"), URIRef("ASK { ?s ?p ?o }")) + + +@pytest.mark.sparql +class TestSELECTLive: + def test_returns_result(self, settings): + endpoint = os.getenv("SPARQL_ENDPOINT") + if not endpoint: + pytest.skip("SPARQL_ENDPOINT env var not set") + op = Operation.get("SELECT")(settings=settings) + result = op.execute(URIRef(endpoint), Literal("SELECT * WHERE { ?s ?p ?o } LIMIT 1")) + assert isinstance(result, Result) + + +class TestSELECTJson: + @pytest.mark.skip(reason="UNCLEAR(spec): SELECT JSON arg shape — existing fixtures show `{query, endpoint}` for CONSTRUCT but SELECT is not exemplified") + def test_json_dispatch(self, settings): + pass diff --git a/tests/unit/test_sparql_string.py b/tests/unit/test_sparql_string.py new file mode 100644 index 0000000..f5b3dcf --- /dev/null +++ b/tests/unit/test_sparql_string.py @@ -0,0 +1,25 @@ +"""Spec: formal-semantics.md "SPARQLString - Generate SPARQL queries from natural language" +Abstract: Literal → Literal +Python: def execute(self, question: Literal) -> Literal + +This operation calls an LLM and is non-deterministic — there is no testable +invariant beyond return type, and even that requires an OpenAI client. +""" + +from __future__ import annotations + +import pytest + +from web_algebra.operation import Operation + + +class TestSPARQLStringPure: + @pytest.mark.skip(reason="UNCLEAR(spec): operation depends on an LLM; no deterministic testable invariant in spec") + def test_basic(self, settings): + pass + + +class TestSPARQLStringJson: + @pytest.mark.skip(reason="UNCLEAR(spec): same as TestSPARQLStringPure") + def test_json_dispatch(self, settings): + pass diff --git a/tests/unit/test_str.py b/tests/unit/test_str.py new file mode 100644 index 0000000..96c157b --- /dev/null +++ b/tests/unit/test_str.py @@ -0,0 +1,60 @@ +"""Spec: formal-semantics.md "Str - Convert any term to string literal" +Abstract: Term → Literal +Python: def execute(self, term: rdflib.term.Node) -> rdflib.Literal +Plus the Strict Type Checking property (lines 291-295). +""" + +from __future__ import annotations + +import pytest +from rdflib import BNode, Literal, URIRef + +from web_algebra.operation import Operation + + +class TestStrPure: + def test_uri_returns_literal(self, settings): + op = Operation.get("Str")(settings=settings) + result = op.execute(URIRef("http://example.org/foo")) + assert isinstance(result, Literal) + assert str(result) == "http://example.org/foo" + + def test_literal_returns_literal(self, settings): + op = Operation.get("Str")(settings=settings) + result = op.execute(Literal("hello")) + assert isinstance(result, Literal) + assert str(result) == "hello" + + def test_bnode_returns_literal(self, settings): + op = Operation.get("Str")(settings=settings) + bn = BNode("b1") + result = op.execute(bn) + assert isinstance(result, Literal) + assert str(result) == str(bn) + + def test_non_term_raises_type_error(self, settings): + # Strict Type Checking property: "TypeError raised for mismatched input types" + op = Operation.get("Str")(settings=settings) + with pytest.raises(TypeError): + op.execute([1, 2, 3]) + + @pytest.mark.skip(reason="UNCLEAR(spec): result Literal datatype (xsd:string vs simple literal) unspecified") + def test_result_datatype(self, settings): + pass + + +class TestStrJson: + def test_string_input_via_json(self, settings): + # JSON arg key derived from existing fixture tests/fixtures/positive/simple-operation.json + op = Operation.get("Str")(settings=settings) + result = op.execute_json({"input": "hello"}) + assert isinstance(result, Literal) + assert str(result) == "hello" + + def test_nested_uri_op(self, settings): + op = Operation.get("Str")(settings=settings) + result = op.execute_json( + {"input": {"@op": "URI", "args": {"input": "http://example.org/x"}}} + ) + assert isinstance(result, Literal) + assert str(result) == "http://example.org/x" diff --git a/tests/unit/test_struuid.py b/tests/unit/test_struuid.py new file mode 100644 index 0000000..899624f --- /dev/null +++ b/tests/unit/test_struuid.py @@ -0,0 +1,36 @@ +"""Spec: formal-semantics.md "STRUUID - Generate random UUID string" +Abstract: () → Literal +Python: def execute(self) -> Literal +""" + +from __future__ import annotations + +import pytest +from rdflib import Literal + +from web_algebra.operation import Operation + + +class TestSTRUUIDPure: + def test_returns_literal(self, settings): + op = Operation.get("STRUUID")(settings=settings) + result = op.execute() + assert isinstance(result, Literal) + + def test_two_calls_differ(self, settings): + op = Operation.get("STRUUID")(settings=settings) + a = op.execute() + b = op.execute() + assert str(a) != str(b) + + @pytest.mark.skip(reason="UNCLEAR(spec): UUID format (UUID4? hyphenated? case?) not specified") + def test_uuid_format(self, settings): + pass + + +class TestSTRUUIDJson: + def test_empty_args_via_json(self, settings): + # () → Literal — JSON layer should accept an empty args dict + op = Operation.get("STRUUID")(settings=settings) + result = op.execute_json({}) + assert isinstance(result, Literal) diff --git a/tests/unit/test_substitute.py b/tests/unit/test_substitute.py new file mode 100644 index 0000000..2725d56 --- /dev/null +++ b/tests/unit/test_substitute.py @@ -0,0 +1,38 @@ +"""Spec: formal-semantics.md "Substitute - Replace variables in SPARQL queries" +Abstract: Literal × Literal × Term → Literal +Python: def execute(self, query: Literal, var: Literal, binding_value: Any) -> Literal +""" + +from __future__ import annotations + +import pytest +from rdflib import Literal, URIRef + +from web_algebra.operation import Operation + + +class TestSubstitutePure: + def test_returns_literal(self, settings): + # Most modest assertion derivable from the type signature alone. + op = Operation.get("Substitute")(settings=settings) + result = op.execute( + Literal("SELECT ?x WHERE { ?x ?p ?o }"), + Literal("x"), + URIRef("http://example.org/foo"), + ) + assert isinstance(result, Literal) + + def test_wrong_query_type_raises(self, settings): + op = Operation.get("Substitute")(settings=settings) + with pytest.raises(TypeError): + op.execute(URIRef("not-a-query"), Literal("x"), URIRef("http://example.org/foo")) + + @pytest.mark.skip(reason="UNCLEAR(spec): SPARQL variable syntax — `?var`, `$var`, or both? How are URIRef/Literal binding values serialized into the query?") + def test_replacement_form(self, settings): + pass + + +class TestSubstituteJson: + @pytest.mark.skip(reason="UNCLEAR(spec): JSON arg keys for Substitute not given by spec or existing fixtures") + def test_json_dispatch(self, settings): + pass diff --git a/tests/unit/test_uri.py b/tests/unit/test_uri.py new file mode 100644 index 0000000..9ceceea --- /dev/null +++ b/tests/unit/test_uri.py @@ -0,0 +1,51 @@ +"""Spec: formal-semantics.md "URI - Convert term to URI reference" +Abstract: Term → URI +Python: def execute(self, term: rdflib.term.Node) -> rdflib.URIRef +Plus the Strict Type Checking property (lines 291-295). +""" + +from __future__ import annotations + +import pytest +from rdflib import BNode, Literal, URIRef + +from web_algebra.operation import Operation + + +class TestURIPure: + def test_urirefpassthrough(self, settings): + op = Operation.get("URI")(settings=settings) + result = op.execute(URIRef("http://example.org/foo")) + assert isinstance(result, URIRef) + assert str(result) == "http://example.org/foo" + + def test_literal_lexical_form_becomes_uri(self, settings): + op = Operation.get("URI")(settings=settings) + result = op.execute(Literal("http://example.org/bar")) + assert isinstance(result, URIRef) + assert str(result) == "http://example.org/bar" + + def test_non_term_raises_type_error(self, settings): + # Strict Type Checking property: "TypeError raised for mismatched input types" + op = Operation.get("URI")(settings=settings) + with pytest.raises(TypeError): + op.execute(42) + + @pytest.mark.skip(reason="UNCLEAR(spec): URI(BNode) — spec lists BNode as a Term but doesn't define this case") + def test_bnode_input(self, settings): + op = Operation.get("URI")(settings=settings) + op.execute(BNode("b1")) + + @pytest.mark.skip(reason="UNCLEAR(spec): URI(Literal whose lexical form is not a valid URI) unspecified") + def test_invalid_uri_literal(self, settings): + op = Operation.get("URI")(settings=settings) + op.execute(Literal("not a uri")) + + +class TestURIJson: + def test_string_input_via_json(self, settings): + # JSON arg key from existing fixture tests/fixtures/positive/simple-operation.json + op = Operation.get("URI")(settings=settings) + result = op.execute_json({"input": "http://example.org/x"}) + assert isinstance(result, URIRef) + assert str(result) == "http://example.org/x" diff --git a/tests/unit/test_value.py b/tests/unit/test_value.py new file mode 100644 index 0000000..3696a25 --- /dev/null +++ b/tests/unit/test_value.py @@ -0,0 +1,45 @@ +"""Spec: formal-semantics.md "Value - Access variables and context values" +Abstract: String × Context × VariableStack → Any +Python: def execute(self, name: str, context: Any, variable_stack: List[Dict[str, Any]]) -> Any +Plus Variable System property (lines 308-311) and Context System property (lines 314-318). +""" + +from __future__ import annotations + +import pytest +from rdflib import Literal + +from web_algebra.operation import Operation + + +class TestValuePure: + def test_lookup_in_innermost_scope(self, settings): + # Variable System property: lexical scoping, innermost-first. + op = Operation.get("Value")(settings=settings) + stack = [{"x": Literal("outer")}, {"x": Literal("inner")}] + result = op.execute("$x", {}, stack) + assert result == Literal("inner") + + def test_lookup_falls_back_to_outer_scope(self, settings): + op = Operation.get("Value")(settings=settings) + stack = [{"x": Literal("outer")}, {"y": Literal("inner-only")}] + result = op.execute("$x", {}, stack) + assert result == Literal("outer") + + @pytest.mark.skip(reason="UNCLEAR(spec): which context container shapes does Value support? Context type is `Any` (line 315) and the narrative names ResultRow but doesn't enumerate other shapes (dict? attribute-bearing object? both?)") + def test_context_lookup(self, settings): + pass + + @pytest.mark.skip(reason="UNCLEAR(spec): precedence when same name appears in both context and stack") + def test_context_vs_stack_precedence(self, settings): + pass + + @pytest.mark.skip(reason="UNCLEAR(spec): behavior on missing name — error class unspecified") + def test_missing_name(self, settings): + pass + + +class TestValueJson: + @pytest.mark.skip(reason="UNCLEAR(spec): JSON arg key for Value not given by spec or existing fixtures") + def test_json_dispatch(self, settings): + pass diff --git a/tests/unit/test_variable.py b/tests/unit/test_variable.py new file mode 100644 index 0000000..8df6244 --- /dev/null +++ b/tests/unit/test_variable.py @@ -0,0 +1,37 @@ +"""Spec: formal-semantics.md "Variable - Set variables in current scope (XSLT-style)" +Abstract: String × Any × VariableStack → ⊥ +Python: def execute(self, name: str, value: Any, variable_stack: List[Dict[str, Any]]) -> None +Plus Variable System property (lines 308-311). +""" + +from __future__ import annotations + +import pytest +from rdflib import Literal + +from web_algebra.operation import Operation + + +class TestVariablePure: + def test_binds_into_current_scope(self, settings): + # After binding, Value should resolve the same name to the bound value. + var_op = Operation.get("Variable")(settings=settings) + value_op = Operation.get("Value")(settings=settings) + stack = [{}] + var_op.execute("x", Literal("v"), stack) + result = value_op.execute("$x", {}, stack) + assert result == Literal("v") + + @pytest.mark.skip(reason="UNCLEAR(spec): `⊥` (bottom) return type — what does execute_json return on the JSON layer?") + def test_return_value(self, settings): + pass + + @pytest.mark.skip(reason="UNCLEAR(spec): line 311 self-contradiction — does Variable push a new scope or write into the current one?") + def test_scope_management(self, settings): + pass + + +class TestVariableJson: + @pytest.mark.skip(reason="UNCLEAR(spec): JSON arg keys for Variable not given by spec or existing fixtures") + def test_json_dispatch(self, settings): + pass diff --git a/uv.lock b/uv.lock index b906b50..409e08a 100644 --- a/uv.lock +++ b/uv.lock @@ -141,6 +141,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "isodate" version = "0.6.1" @@ -319,6 +328,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/22/f7b90b519e8a5867dc96d411615eb7f987d2d5474c22e7d37c7170a132da/openai-1.93.2-py3-none-any.whl", hash = "sha256:5adbbebd48eae160e6d68efc4c0a4f7cb1318a44c62d9fc626cec229f418eab4", size = 755084, upload-time = "2025-07-08T15:37:58.045Z" }, ] +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pydantic" version = "2.11.7" @@ -453,6 +480,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120, upload-time = "2025-03-25T05:01:24.908Z" }, ] +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + [[package]] name = "python-dotenv" version = "1.1.1" @@ -716,6 +761,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/95/38ef0cd7fa11eaba6a99b3c4f5ac948d8bc6ff199aabd327a29cc000840c/starlette-0.47.1-py3-none-any.whl", hash = "sha256:5e11c9f5c7c3f24959edbf2dffdc01bba860228acf657129467d8a7468591527", size = 72747, upload-time = "2025-06-21T04:03:15.705Z" }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + [[package]] name = "tqdm" version = "4.67.1" @@ -791,6 +890,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "pytest" }, { name = "ruff" }, ] @@ -803,4 +903,7 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "ruff" }] +dev = [ + { name = "pytest", specifier = ">=8" }, + { name = "ruff" }, +]