diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..0f243d7 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "superpowers@claude-plugins-official": true + } +} diff --git a/.claude/skills/fableplan/SKILL.md b/.claude/skills/fableplan/SKILL.md new file mode 100644 index 0000000..50091b2 --- /dev/null +++ b/.claude/skills/fableplan/SKILL.md @@ -0,0 +1,89 @@ +--- +name: fableplan +description: | + Toggle "Fable Plan Mode" for this repo, on this machine only — plan with + Fable 5, execute with Sonnet (opusplan-style). Use when the user runs + /fableplan, optionally with "off" to disable, or "1m" to use the + 1M-context Fable variant. +--- + +# Fableplan — plan with Fable 5, execute with Sonnet (opt-in, per-user) + +Claude Code has a built-in `opusplan` model setting ("Opus in plan mode, +Sonnet otherwise") but no Fable equivalent. This skill recreates it by +combining `opusplan` with the `ANTHROPIC_DEFAULT_OPUS_MODEL` environment +variable override, which redirects the "opus" alias to Fable 5 — giving +Fable 5 in plan mode and Sonnet for execution, at lower cost than running +Fable continuously. + +This is **opt-in only**: nothing in this repo enables it by default. Running +this skill writes to `.claude/settings.local.json` and +`.claude/.fableplan-backup.json` — both gitignored and specific to this +machine. Nothing is committed, and no other user or repo config is affected. + +Adapted from [bapttiste73/fableplan](https://github.com/bapttiste73/fableplan) +(MIT licensed), retargeted from the user's global `~/.claude/settings.json` +to this repo's local settings file so the effect stays scoped to this repo. + +## Arguments + +- *(none)* — enable fableplan locally with `claude-fable-5` +- `1m` — enable fableplan locally with `claude-fable-5[1m]` (1M context + window; higher per-token cost, use deliberately) +- `off` — disable fableplan locally, reverting to this repo's normal model + configuration + +## Enabling (no argument, or `1m`) + +1. Read `.claude/settings.local.json` (repo-relative). If it does not exist, + create it with an empty object first. +2. If `.claude/.fableplan-backup.json` does not exist, create it before changing + settings. Record whether `"model"` exists and its current value, and whether + `"env"."ANTHROPIC_DEFAULT_OPUS_MODEL"` exists and its current value. This + backup represents the pre-fableplan configuration and must not be overwritten + on subsequent enables. +3. Merge the following keys, preserving every other existing setting (notably + `permissions` and `defaultMode`, if present): + - Set `"model": "opusplan"`. + - Under `"env"` (create the object if missing, preserve its other + entries), set `"ANTHROPIC_DEFAULT_OPUS_MODEL"` to `"claude-fable-5"` (or + `"claude-fable-5[1m]"` if the argument is `1m`). +4. Validate that both resulting files are valid JSON. +5. Tell the user: + - Fableplan is enabled on this machine: plan mode runs on Fable 5, + execution runs on Sonnet. + - They must **restart their Claude Code session** for the change to take + effect. + - The UI will display "Opus Plan Mode" — that label is cosmetic; plan mode + actually runs Fable 5. + - This only changed gitignored local files — nothing is committed and no + other user is affected. + +## Disabling (`off`) + +1. Read `.claude/settings.local.json`. If it does not exist, there is nothing + to do — tell the user fableplan is already off (it was never enabled on + this machine). +2. Read `.claude/.fableplan-backup.json`. If it does not exist, do not change + `"model"` or `"ANTHROPIC_DEFAULT_OPUS_MODEL"`: their origin cannot be + determined safely. Tell the user that fableplan's backup is missing and its + settings were left intact. +3. Otherwise, restore `"model"` and `"env"."ANTHROPIC_DEFAULT_OPUS_MODEL"` to + the values recorded in the backup. Remove each key when the backup records + that it was absent; drop the `"env"` key entirely if it becomes empty. Leave + every other key untouched, then delete `.claude/.fableplan-backup.json`. +4. Validate JSON and tell the user: + - Fableplan is disabled on this machine; the repo's normal model + configuration applies again. + - They must restart their Claude Code session for the change to take + effect. + - Run `/fableplan` (no argument) at any time to re-enable. + +## Important caveats (mention them when enabling for the first time) + +- `ANTHROPIC_DEFAULT_OPUS_MODEL` is an undocumented override — a future CLI + update could change or remove it. +- Fable 5 is not available on every plan/account. If requests fail after + enabling, run `/fableplan off` to revert. +- Anything else that resolves the "opus" alias (e.g. fast mode) will also + point to Fable while `opusplan` + the env override are active. diff --git a/.gitignore b/.gitignore index 5e84f16..12cfdc4 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,6 @@ pip-wheel-metadata !.gitignore !.markdownlint.yaml !.editorconfig +!.claude +.claude/settings.local.json site diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..098680d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,50 @@ +# AGENTS.md + +`sob` is a type-enforced JSON serialization/deserialization library for +authoring introspective client-side data models, developed to support +[oapi](https://github.com/enorganic/oapi) (OpenAPI SDK generation). +Python ~=3.10, no runtime deps besides `iso8601` and `typing-extensions`. + +## Commands + +Everything runs through [hatch](https://hatch.pypa.io) (`pipx install hatch`): + +- `make` — create all hatch environments (first-time setup) +- `make test` — lint check + mypy + full test matrix (slow) +- `hatch test` — tests only, current Python +- `hatch test -- tests/test_model.py` — single test file +- `make format` — ruff format + lint --fix + mypy (run before committing) +- `make refresh-test-data` — regenerate `tests/regression-data/` then test +- `make docs` — build & serve mkdocs site + +## Layout + +- `src/sob/` — the package. Key modules: `model.py` (Object/Array/Dictionary + model classes, serialization), `properties.py` (property/type declarations), + `meta.py` (model metadata), `abc.py` (abstract base classes declaring all + public interfaces), `thesaurus.py` (infer models from example data), + `hooks.py`, `errors.py`, `utilities.py`. Underscore-prefixed modules are + internal. +- `tests/` — pytest; `tests/regression-data/` holds generated fixtures + (excluded from lint). +- `docs/` — mkdocs-material + mkdocstrings; API pages map 1:1 to modules. + +## Style & constraints + +- Line length 79 (ruff, black-style formatting). Strict-ish mypy: + all defs fully typed (`disallow_untyped_defs`). +- Doctests run in CI (`--doctest-modules`) — keep docstring examples valid, + and wrap docstrings to 79 chars. +- When changing a public class/function signature, update the matching + interface in `src/sob/abc.py` and the docs page under `docs/api/`. +- Support Python 3.10–3.13; avoid syntax/stdlib features newer than 3.10. + +## Gotchas + +- **Merging a `pyproject.toml` change to `main` triggers a PyPI release** + (`distribute.yml` tags the version and publishes). Do not bump `version` + unless a release is intended. +- CI (`test.yml`) runs `hatch fmt --check && hatch run mypy` plus the test + matrix on Linux/macOS/Windows × 3.10–3.13. +- Dependency pins are managed with `dependence` via `make upgrade` / + `make requirements` — don't hand-edit pinned versions. diff --git a/docs/superpowers/plans/2026-08-01-test-coverage-gaps.md b/docs/superpowers/plans/2026-08-01-test-coverage-gaps.md new file mode 100644 index 0000000..9b99319 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-test-coverage-gaps.md @@ -0,0 +1,955 @@ +# Close Remaining Test-Coverage Gaps in `sob` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Raise `src/sob`'s test coverage from 84% overall (45%/64% on the +two worst modules) to ≥95% overall / ≥90% per module, adding only real +integration tests (no mocks), per +`docs/superpowers/specs/2026-08-01-test-coverage-gaps-design.md`. + +**Architecture:** No production behavior changes except two small bug +fixes (a stale docstring example, a Python-3.10-only datetime bug) needed +to make the doctest harness trustworthy. Every other task adds test +functions to the existing `tests/test_*.py` files, constructing real +`sob.Object`/`sob.Array`/`sob.Dictionary`/`sob.Property`/`sob.Meta`/ +`sob.Hooks` instances and asserting on their real behavior — mirroring the +100%-mock-free style already used throughout `tests/`. + +**Tech Stack:** Python 3.10 (dev)/3.10–3.13 (CI matrix), `hatch`, `pytest`, +`coverage.py` (via `hatch test --cover`), `mypy` (strict on `tests/` too). + +## Global Constraints + +- **No mocks.** Never import `unittest.mock` or `pytest-mock`; every + assertion exercises a real, constructed `sob` object calling real code. + (Source: spec §1/§6 — confirmed by `grep -rl "mock\|Mock\|monkeypatch" + tests/ src/` returning nothing today.) +- **Line length 79** (ruff/black-style). Wrap docstrings to 79 chars too. +- **Full typing required in tests.** `pyproject.toml`'s `[tool.mypy]` + applies `disallow_untyped_defs`/`disallow_incomplete_defs` to `tests/` + as well as `src/` — every new `test_*` function needs a `-> None` + return annotation and fully-typed locals, matching existing style. +- **Follow existing per-file conventions**: `from __future__ import + annotations` at the top, plain `test_*` functions (no test classes), + and an `if __name__ == "__main__": pytest.main([__file__, "-s", + "-vv"])` trailer at the bottom of every test file touched. +- Run `make format` (ruff format + lint --fix + mypy) before every commit. +- Run `hatch test -- tests/test_.py` for the specific file after + each task, and `hatch test --cover` (or `hatch run hatch-test.py3.10: + coverage report -m`) periodically to confirm the target lines listed in + the spec are actually now covered. +- Do **not** touch `pyproject.toml`'s `version` field — merging a version + bump to `main` triggers a real PyPI release (see `AGENTS.md`). +- If a task touches `get_models_source`/`get_model_from_meta` regression + output or the `thesaurus` regression fixture, regenerate with `make + refresh-test-data` rather than hand-editing golden files under + `tests/regression-data/`. +- Skip the non-goals listed in spec §4 (abstract-method `pass` stubs, + `TYPE_CHECKING`-only asserts, a few unreachable defensive guards in + `get_models_source`) — do not write tests chasing them. + +--- + +## Task 1: Fix the doctest harness and the two bugs it was hiding + +**Files:** +- Modify: `tests/test_datetime.py`, `tests/test_io.py`, `tests/test_model.py`, + `tests/test_thesaurus.py` (no doctest there — skip), + `tests/test_types.py` (no `test_doctest` — skip), `tests/test_utilities.py`, + `tests/test_version.py` — every file with a `test_doctest()` function. +- Modify: `src/sob/utilities.py` (docstring fix for `suffix_long_lines`), + `src/sob/_datetime.py` (behavior fix for `str2datetime`). + +**Interfaces:** +- Consumes: `doctest.testmod`. +- Produces: a `test_doctest()` pattern (`assert doctest.testmod(module, + verbose=False).failed == 0`) that every subsequent task's module can + rely on to actually fail if a docstring example is wrong. + +- [ ] **Step 1: Confirm the two currently-failing doctests** + + Run: + ``` + hatch run hatch-test.py3.10:python -m pytest -vv --doctest-modules \ + --ignore=.scratch.py src/sob/_datetime.py src/sob/utilities.py + ``` + Confirm `sob.utilities.suffix_long_lines` and + `sob._datetime.str2datetime` are the only two `FAILED` items. + +- [ ] **Step 2: Change every `test_doctest()` to assert on the result** + + In each of `tests/test_datetime.py`, `tests/test_io.py`, + `tests/test_model.py`, `tests/test_utilities.py`, `tests/test_version.py`, + change: + ```python + def test_doctest() -> None: + doctest.testmod(utilities) + ``` + to: + ```python + def test_doctest() -> None: + results: doctest.TestResults = doctest.testmod(utilities) + assert results.failed == 0, results + ``` + (substituting the correct module name per file). This step alone should + now make `test_utilities.py::test_doctest` and + `test_datetime.py::test_doctest` fail via `hatch test`. + +- [ ] **Step 3: Run tests and confirm the two expected failures** + + `hatch test -- tests/test_utilities.py tests/test_datetime.py -vv` should + show exactly 2 failures now (previously silently swallowed). + +- [ ] **Step 4: Fix the `suffix_long_lines` docstring** + + Read `src/sob/utilities.py` around line 580 (the `suffix_long_lines` + docstring example). The function correctly appends `# noqa: E501` to the + wrapped long line; the docstring's expected output is stale (missing + that suffix). Update the expected output in the docstring to match the + function's real, correct behavior (do not change the function itself + unless the *behavior*, not just the example, turns out to be wrong on + inspection). + +- [ ] **Step 5: Fix the `str2datetime` Python-3.10 `Z`-suffix bug** + + Read `src/sob/_datetime.py:46-74` (`str2datetime`). On Python 3.10, + `datetime.fromisoformat` doesn't understand a trailing `Z`, so the + function falls through to `iso8601.parse_date`, which returns a + UTC-aware datetime — but the subsequent "iso8601 incorrectly sets the + UTC offset to 0 instead of None" correction then strips tzinfo entirely, + even though the original string explicitly ended in `Z` (meaning UTC, + not "no timezone given"). Fix the condition so it only strips tzinfo + when the string did **not** explicitly indicate a timezone (i.e., don't + strip when the fallback was entered specifically because of a trailing + `Z`) — the corrected behavior must return + `datetime.datetime(2023, 10, 1, 12, 0, tzinfo=datetime.timezone.utc)` + for `str2datetime("2023-10-01T12:00:00Z")` on every supported Python + version (3.10–3.13), matching the module's own docstring. + +- [ ] **Step 6: Run the full suite on Python 3.10 and confirm all doctests pass** + + ``` + hatch run hatch-test.py3.10:python -m pytest -vv --doctest-modules \ + --ignore=.scratch.py + ``` + Expect `0 failed`. Then `hatch test -- -vv` (the asserted `test_doctest` + functions) should also be green. + +- [ ] **Step 7: Run `make format` and commit** + + Commit message should explain both the harness fix and the two + behavioral fixes it uncovered, e.g. "Assert doctest results in + test_doctest(); fix suffix_long_lines example and str2datetime Z-suffix + handling on Python 3.10". + +--- + +## Task 2: `_datetime.py` coverage (93% → 100%) + +**Files:** +- Modify: `tests/test_datetime.py` + +**Interfaces:** +- Consumes: `sob._datetime.str2datetime`, `sob._datetime.str2date`. + +- [ ] **Step 1: Write the tests** + + Add, parallel to the existing `test_raise_str2date_type_error`: + ```python + def test_raise_str2datetime_type_error() -> None: + error_caught: bool = False + try: + sob._datetime.str2datetime(123) # type: ignore # noqa: SLF001 + except TypeError: + error_caught = True + assert error_caught + + + def test_raise_str2date_type_error_non_str() -> None: + error_caught: bool = False + try: + sob._datetime.str2date(123) # type: ignore # noqa: SLF001 + except TypeError: + error_caught = True + assert error_caught + ``` + (rename to avoid clashing with the existing `test_raise_str2date_type_error`, + which currently tests a *different* input — check the existing test + first and don't duplicate its name or intent). + +- [ ] **Step 2: Run and confirm pass** + + `hatch test -- tests/test_datetime.py -vv` + +- [ ] **Step 3: Confirm coverage** + + `hatch run hatch-test.py3.10:coverage report -m src/sob/_datetime.py` + should show 100%. + +- [ ] **Step 4: `make format` and commit** + +--- + +## Task 3: `_io.py` coverage (79% → 100%) + +**Files:** +- Modify: `tests/test_utilities.py` (it already has `test_io()` calling + `doctest.testmod(_io)` — add a sibling test function alongside it). + +**Interfaces:** +- Consumes: `sob._io.read`. + +- [ ] **Step 1: Write the tests** + + ```python + class UnsupportedReadProxy: + def read(self) -> str: + raise UnsupportedOperation + + class NotReadableProxy: + pass + + def test_read_type_error() -> None: + error_caught: bool = False + try: + _io.read(NotReadableProxy()) # type: ignore + except TypeError: + error_caught = True + assert error_caught + + def test_read_unsupported_operation() -> None: + error_caught: bool = False + try: + _io.read(UnsupportedReadProxy()) # type: ignore + except TypeError: + error_caught = True + assert error_caught + ``` + Add `from io import UnsupportedOperation` to the imports. + +- [ ] **Step 2: Run and confirm pass** — `hatch test -- tests/test_utilities.py -vv` +- [ ] **Step 3: Confirm coverage** — `_io.py` should show 100%. +- [ ] **Step 4: `make format` and commit** + +--- + +## Task 4: `_types.py` coverage (93% → 100%) + +**Files:** +- Modify: `tests/test_types.py` + +**Interfaces:** +- Consumes: `sob.UNDEFINED`, `sob.NULL`, `sob.Null._marshal`. + +- [ ] **Step 1: Write the tests** + + Extend `test_undefined()`: + ```python + assert hash(sob.UNDEFINED) == 0 + ``` + Extend `test_null()`: + ```python + assert hash(sob.NULL) == 0 + assert str(sob.NULL) == "null" + assert sob.Null._marshal() is None # noqa: SLF001 + ``` + +- [ ] **Step 2: Run and confirm pass** — `hatch test -- tests/test_types.py -vv` +- [ ] **Step 3: Confirm coverage** — `_types.py` should show 100%. +- [ ] **Step 4: `make format` and commit** + +--- + +## Task 5: `_utilities.py` coverage (89% → 100%) + +**Files:** +- Modify: `tests/test_utilities.py` + +**Interfaces:** +- Consumes: `sob._utilities.deprecated`, `sob._utilities.get_readable_url`. + +- [ ] **Step 1: Write the tests** + + ```python + def test_deprecated() -> None: + @_utilities.deprecated("this is deprecated") + def old_function(value: int) -> int: + return value * 2 + + with pytest.warns(DeprecationWarning, match="this is deprecated"): + result: int = old_function(21) + assert result == 42 + + + class URLNonStringProxy: + url = 123 + + + class NoAttributesProxy: + pass + + + def test_get_readable_url_type_error() -> None: + error_caught: bool = False + try: + get_readable_url(URLNonStringProxy()) + except TypeError: + error_caught = True + assert error_caught + + + def test_get_readable_url_none() -> None: + assert get_readable_url(NoAttributesProxy()) is None + ``` + Import `_utilities` (module) alongside the existing + `from sob._utilities import get_readable_url`. + +- [ ] **Step 2: Run and confirm pass** — `hatch test -- tests/test_utilities.py -vv` +- [ ] **Step 3: Confirm coverage** — `_utilities.py` should show 100%. +- [ ] **Step 4: `make format` and commit** + +--- + +## Task 6: `errors.py` coverage (88% → 100%) + +**Files:** +- Modify: `tests/test_utilities.py` (or create `tests/test_errors.py` if + it reads more clearly as its own file — prefer adding to + `test_utilities.py` unless it grows unwieldy, per the plan's "don't + fragment files" constraint). + +**Interfaces:** +- Consumes: `sob.errors.DeserializeError`, `sob.errors.append_exception_text`. + +- [ ] **Step 1: Write the tests** + + ```python + def test_deserialize_error() -> None: + error = sob.errors.DeserializeError(data="bad-data", message="oops") + assert error.data == "bad-data" + assert error.message == "oops" + assert repr(error) == "oops\nCould not parse:\nbad-data" + assert str(error) == repr(error) + + + def test_append_exception_text_strerror() -> None: + error = OSError(1, "boom") + sob.errors.append_exception_text(error, " (more info)") + assert error.strerror is not None + assert error.strerror.endswith(" (more info)") + + + def test_append_exception_text_no_string_arg() -> None: + error = Exception() + sob.errors.append_exception_text(error, "appended") + assert error.args == ("appended",) + ``` + +- [ ] **Step 2: Run and confirm pass** — `hatch test -- tests/test_utilities.py -vv` +- [ ] **Step 3: Confirm coverage** — `errors.py` should show 100%. +- [ ] **Step 4: `make format` and commit** + +--- + +## Task 7: `types.py` coverage (81% → 100%) + +**Files:** +- Modify: `tests/test_types.py` + +**Interfaces:** +- Consumes: `sob.Types`, `sob.MutableTypes`. + +- [ ] **Step 1: Write the tests** + + ```python + def test_types_bare_type() -> None: + types_ = sob.Types(str) + assert list(types_) == [str] + + + def test_types_copy() -> None: + types_ = sob.Types([int, str]) + copied = copy(types_) + assert copied is not types_ + assert list(copied) == list(types_) + + + def test_mutable_types_protocol() -> None: + types_: sob.MutableTypes = sob.MutableTypes([int, str]) + types_[0] = float + assert types_[0] is float + types_.extend([bool]) + assert bool in types_ + del types_[0] + assert float not in types_ + types_ += [bytes] + assert bytes in types_ + new_types = types_ + [complex] + assert complex in new_types + assert complex not in types_ + ``` + +- [ ] **Step 2: Run and confirm pass** — `hatch test -- tests/test_types.py -vv` +- [ ] **Step 3: Confirm coverage** — `types.py` should show 100%. +- [ ] **Step 4: `make format` and commit** + +--- + +## Task 8: `properties.py` coverage (96% → 100%) + +**Files:** +- Modify: `tests/test_model.py` (or a new `tests/test_properties.py` if + the additions don't fit naturally near existing content). + +**Interfaces:** +- Consumes: `sob.properties.has_mutable_types`, `sob.Property`, + `sob.StringProperty`. + +- [ ] **Step 1: Write the tests** + + ```python + def test_has_mutable_types() -> None: + assert sob.properties.has_mutable_types(sob.Property()) + assert not sob.properties.has_mutable_types(sob.StringProperty) + error_caught = False + try: + sob.properties.has_mutable_types(object()) # type: ignore + except TypeError: + error_caught = True + assert error_caught + + + def test_property_types_immutable() -> None: + string_property = sob.StringProperty() + error_caught = False + try: + string_property.types = [int] # type: ignore + except TypeError: + error_caught = True + assert error_caught + + + def test_property_types_invalid() -> None: + property_ = sob.Property() + error_caught = False + try: + property_.types = "not-a-type" # type: ignore + except TypeError: + error_caught = True + assert error_caught + + + def test_property_versions_invalid() -> None: + property_ = sob.Property() + error_caught = False + try: + property_.versions = 123 # type: ignore + except TypeError: + error_caught = True + assert error_caught + ``` + +- [ ] **Step 2: Run and confirm pass** — `hatch test -- tests/test_model.py -vv` +- [ ] **Step 3: Confirm coverage** — `properties.py` should show 100%. +- [ ] **Step 4: `make format` and commit** + +--- + +## Task 9: `version.py` coverage (80% → 100%) + +**Files:** +- Modify: `tests/test_version.py` + +**Interfaces:** +- Consumes: `sob.Version`, `sob.version._version_as_tuple` (module-internal, + acceptable to import directly per spec §5.9). + +- [ ] **Step 1: Write the tests** + + ```python + def test_version_equality_precision() -> None: + assert sob.Version(equals="1.2") == "1.2.0" + assert sob.Version(equals="1.2") == "1.2" + + + def test_version_string_value_error() -> None: + error_caught = False + try: + sob.Version("not-a-version") + except ValueError: + error_caught = True + assert error_caught + + + def test_version_numeric_and_sequence_inputs() -> None: + assert sob.Version(compatible_with=1.2) == "1.2" + assert sob.Version(compatible_with=(1, 2)) == "1.2" + + + def test_version_as_tuple_type_error() -> None: + error_caught = False + try: + sob.version._version_as_tuple(object()) # type: ignore # noqa: SLF001 + except TypeError: + error_caught = True + assert error_caught + + + def test_version_string_type_error() -> None: + error_caught = False + try: + sob.Version(123) # type: ignore + except TypeError: + error_caught = True + assert error_caught + + + def test_version_conflicting_specifications() -> None: + error_caught = False + try: + sob.Version("a==1,b==2") + except ValueError: + error_caught = True + assert error_caught + + + def test_version_str_no_specification() -> None: + version = sob.Version(equals="1.0") + version.specification = None # type: ignore + error_caught = False + try: + str(version) + except RuntimeError: + error_caught = True + assert error_caught + ``` + +- [ ] **Step 2: Run and confirm pass** — `hatch test -- tests/test_version.py -vv` +- [ ] **Step 3: Confirm coverage** — `version.py` should show 100%. +- [ ] **Step 4: `make format` and commit** + +--- + +## Task 10: `utilities.py` coverage (85% → ~97%) + +**Files:** +- Modify: `tests/test_utilities.py` + +**Interfaces:** +- Consumes: `sob.utilities.get_relative_url`, `_align_indent`, + `_split_long_comment_line`, `split_long_docstring_lines`, + `get_qualified_name`, `get_calling_module_name`, + `get_calling_function_qualified_name`, `_repr_list`, `_repr_set`, + `_repr_dict`, `represent`, `get_method` (several are private/underscore + — import directly from `sob.utilities` as the unit under test, matching + how `tests/test_utilities.py` already imports `_io`/`_types` directly). + +- [ ] **Step 1: Write the tests** + + One test function per bullet in spec §5.10 — follow the exact + scenarios listed there (malformed URL → `ValueError`; two URLs with no + shared prefix for `get_relative_url`; `_align_indent` on a no-indent + line; a short line through `_split_long_comment_line`; an + already-uniform-indent docstring and one containing a `"""`/`'''` + literal through `split_long_docstring_lines`; unsupported-type, + generic-alias, and unresolvable-name cases for `get_qualified_name`; + out-of-range and non-`int` `depth` for the calling-frame helpers; empty + `list`/`set`/`dict` through `_repr_list`/`_repr_set`/`_repr_dict`; + `represent` on a bare `type`; `get_method` with no `default` on a + missing attribute, and with/without `default` on a non-callable + attribute). Write these as small, focused `test_*` functions rather + than one giant test. + +- [ ] **Step 2: Run and confirm pass** — `hatch test -- tests/test_utilities.py -vv` +- [ ] **Step 3: Confirm coverage** — `utilities.py` should be ≥ 97%; check + `coverage report -m` for any remaining lines and decide per spec §4 + whether they're a real gap or a non-goal. +- [ ] **Step 4: `make format` and commit** + +--- + +## Task 11: `hooks.py` coverage (45% → ~90%+) — highest value + +**Files:** +- Modify: `tests/test_model.py`, or create `tests/test_hooks.py` if it + reads more clearly standalone (recommended, given this is the biggest + net-new test surface in the plan — a dedicated file keeps + `test_model.py` from growing unwieldy; if created, give it the same + `from __future__ import annotations` / `if __name__ == "__main__"` + structure as its siblings). + +**Interfaces:** +- Consumes: `sob.ObjectHooks`, `sob.ArrayHooks`, `sob.DictionaryHooks`, + `sob.write_model_hooks`, `sob.read_model_hooks`, + `sob.get_writable_model_hooks`, `sob.get_writable_object_hooks`, + `sob.get_writable_array_hooks`, `sob.get_writable_dictionary_hooks`, + `sob.get_model_hooks_type`; real `ObjectA`/`ArrayA` classes from + `tests/test_model.py` (import them if writing a separate file) or a + small dedicated `Dictionary` subclass. + +- [ ] **Step 1: Object hooks test** + + Build a real `Object` subclass, register `ObjectHooks(before_setattr=fn, + after_setattr=fn)` via `sob.write_model_hooks`, set an attribute, and + assert both real (plain-function, list-appending) callables fired with + the arguments you expect. + +- [ ] **Step 2: Array hooks test** + + Same pattern with `ArrayHooks(before_append=fn, after_append=fn)` on an + `Array` subclass and `.append(...)`. + +- [ ] **Step 3: Dictionary hooks test** + + Same pattern with `DictionaryHooks(before_setitem=fn, after_setitem=fn)` + on a `Dictionary` subclass and `d["k"] = v`. + +- [ ] **Step 4: `read_model_hooks` type-guard test** + + `sob.read_model_hooks("not-a-model")` → `TypeError`. + +- [ ] **Step 5: `get_writable_*_hooks` tests** + + On a fresh class with no hooks assigned: `sob.get_writable_model_hooks + (cls)` returns a new `ObjectHooks`/`ArrayHooks`/`DictionaryHooks` + (assert type); a second call / `sob.read_model_hooks(cls)` returns the + *same* object (idempotency); repeat on an instance; call + `sob.get_writable_object_hooks`/`get_writable_array_hooks`/ + `get_writable_dictionary_hooks` directly on each container type; and + `sob.get_writable_model_hooks(42)` → `TypeError`. + +- [ ] **Step 6: `get_model_hooks_type` tests** + + Assert `ObjectHooks`/`ArrayHooks`/`DictionaryHooks` returned correctly + for each container's class and instance; `sob.get_model_hooks_type(str)` + → `TypeError`. + +- [ ] **Step 7: `write_model_hooks` error-branch tests** + + `sob.write_model_hooks(object_cls, sob.ArrayHooks())` (wrong hooks type + for that model) → `ValueError`; `sob.write_model_hooks("not-a-model", + hooks)` → `TypeError`. + +- [ ] **Step 8: Run and confirm pass** + + `hatch test -- tests/test_hooks.py -vv` (or `tests/test_model.py -vv`). + +- [ ] **Step 9: Confirm coverage** + + `hooks.py` should reach ~90%+; check `coverage report -m src/sob/hooks.py` + against the remaining line numbers in spec §5.11 and decide if any + residual gap is worth a follow-up test. + +- [ ] **Step 10: `make format` and commit** + +--- + +## Task 12: `meta.py` coverage (79% → ~93%) + +**Files:** +- Modify: `tests/test_model.py`, `tests/test_version.py`. + +**Interfaces:** +- Consumes: `sob.get_writable_object_meta`, `sob.Properties`, + `sob.read_model_meta`, `sob.write_model_meta`, `sob.meta.pointer`, + `sob.meta.url`, `sob.meta.version_model`, `sob.meta._copy_model_meta_to`, + `sob.meta._read_object_properties`, `sob.meta._read_object_property_names`. + +- [ ] **Step 1: Bare-type property assignment tests** + + `DictionaryProperty(value_types=sob.StringProperty())` and + `ArrayProperty(item_types=str)` (single, not wrapped in a list) — + assert construction succeeds and the resulting `Types` contains the one + item. + +- [ ] **Step 2: `Properties` mapping-protocol tests** + + Using `sob.get_writable_object_meta(ObjectA).properties` (a real, + already-populated `Properties`): `.pop(...)`, `.popitem()`, + `.setdefault(...)` (valid and invalid-type cases), `.get("missing")`, + `.clear()`, `copy.copy(...)`, `repr(...)`, equality between two + independently-built `Properties` with identical items, + `properties["bad"] = "not-a-property"` → `TypeError`, `.update({...})`. + Take care to do this on a **copy** of `ObjectA`'s properties (or a + freshly-declared test-only `Object` subclass) so mutating/clearing them + doesn't break other tests that depend on `ObjectA`'s metadata. + +- [ ] **Step 3: `read_model_meta`/`get_writable_model_meta` guard tests** + + A brand-new `Object` subclass with no metadata ever assigned → + `sob.read_model_meta(NewClass)` is `None`; `sob.get_writable_object_meta + ("not-a-model")` and `sob.get_writable_object_meta(int)` → `TypeError`. + +- [ ] **Step 4: `write_model_meta` guard tests** + + `sob.write_model_meta(ObjectA, sob.ArrayMeta())` → `ValueError`; + `sob.write_model_meta("not-a-model", None)` → `TypeError`. + +- [ ] **Step 5: `_read_object_properties`/`_read_object_property_names` tests** + + On a class with no metadata → both return `None`. + +- [ ] **Step 6: `pointer`/`url` deprecated-alias tests** + + `sob.meta.pointer(instance, "/foo/bar")` then a getter-only call; + `sob.meta.pointer(123)` → `TypeError`; `sob.meta.url(instance, + "https://example.com")`. + +- [ ] **Step 7: Versioned `Array`/`Dictionary` tests** + + In `tests/test_version.py`, extend the existing versioning pattern: give + an `ArrayProperty(item_types=...)` or `DictionaryProperty(value_types= + ...)` a nested `Property` with `versions=[...]`, call + `sob.meta.version_model(container_instance, "test-specification", "1.0")` + directly, and assert the container's types were filtered as expected. + Also construct a case where a to-be-removed versioned property still + has a value set, and assert `sob.errors.VersionError` is raised; and + `sob.meta.version_model(42, "spec", "1.0")` → `TypeError`. + +- [ ] **Step 8: `_copy_model_meta_to` guard test** + + Import `sob.meta._copy_model_meta_to` directly and call it with a + non-`abc.Model` `source` → confirm it raises. + +- [ ] **Step 9: Run and confirm pass** + + `hatch test -- tests/test_model.py tests/test_version.py -vv` + +- [ ] **Step 10: Confirm coverage** + + `meta.py` should reach ~93%; check `coverage report -m src/sob/meta.py` + against spec §5.12 for any residual gap worth chasing. + +- [ ] **Step 11: `make format` and commit** + +--- + +## Task 13: `model.py` coverage (87% → ~95%) + +**Files:** +- Modify: `tests/test_model.py`. + +**Interfaces:** +- Consumes: `sob.Array`, `sob.Dictionary`, `sob.marshal`, `sob.unmarshal`, + `sob.serialize`, `sob.deserialize`, `sob.validate`, + `sob.replace_model_nulls`, `sob.get_model_from_meta`, + `sob.get_models_source`, plus real `ObjectA`/`ArrayA` (and a new + `DictionaryA` test class, mirroring `MemberDictionaryA` from + `tests/test_version.py`) from this file. + +- [ ] **Step 1: `Model`/format-guard test** + + `sob.Array(123)` and `sob.Dictionary(123)` → `TypeError`. + +- [ ] **Step 2: `Array` protocol test** + + Build `arr = ArrayA([ObjectA(string="a")])` and exercise `.append(...)`, + `arr[0] = ...`, `del arr[0]`, `.sort()`, `.extend([...])`, + `reversed(arr)`, `.pop()`, `.remove(...)`, `copy.copy(arr)`, + `repr(arr)`, `str(arr)`, `arr == ArrayA()` (type and length mismatch). + In a follow-up within the same test or a sibling one, assign real + `ArrayHooks` (`before_setitem`/`after_setitem`/`before_marshal`/ + `after_marshal`/`before_validate`) via `sob.write_model_hooks` and call + `sob.validate(arr)` against an invalid item to hit the + `ValidationError` raise. + +- [ ] **Step 3: `Dictionary` protocol test** + + Declare `class DictionaryA(sob.Dictionary)` (new test fixture class, + `item`/`value_types=sob.Types([ObjectA])`, following the `ArrayA` + pattern already in this file). Build `d = DictionaryA({"a": + ObjectA(...)})` and exercise `.update({...}, [("c", ...)], kw=...)`, + `.setdefault(...)`, `.pop(...)`, `.popitem()`, `"a" in d`, + `list(reversed(d))`, `copy.copy(d)`/`copy.deepcopy(d)`, `d == + DictionaryA()`, construction from a tuple-iterable instead of a `dict`, + and real `DictionaryHooks` on `__setitem__`/`_marshal`. + +- [ ] **Step 4: `Object` extras/copy-init test** + + Construct an `ObjectA` from another object with an incompatible + property type to hit `_copy_init`'s exception-augmentation path; + `obj["extra_key"] = "value"`, read it back, `del obj["extra_key"]` to + hit the `_extra` branches; assign a real `ObjectHooks(before_setattr=fn)` + and set an attribute. + +- [ ] **Step 5: `marshal()` direct-call tests** + + `sob.marshal({"a": 1})`, `sob.marshal([1, 2])`, `sob.marshal(Decimal + ("1.5"))`, `sob.marshal(datetime.now())`, `sob.marshal(b"data")`, + `sob.marshal(object())` → `ValueError`, `sob.marshal(1, types=(str,))` + → `TypeError`. + +- [ ] **Step 6: `unmarshal()` tests** + + `sob.unmarshal({"string": "a"}, types=ObjectA)` (single type, not + iterable); `sob.unmarshal((x for x in [1, 2]))` (generator); `sob. + unmarshal(None)`; a real `before_unmarshal` hook registered on `ObjectA`. + +- [ ] **Step 7: `serialize`/`deserialize` tests** + + Real `before_serialize`/`after_serialize` hooks on `ObjectA`; `sob. + deserialize(b'{"a": 1}')`; `sob.deserialize(123)` → `TypeError`. + +- [ ] **Step 8: `validate()` bare-type test** + + `sob.validate(ObjectA(), types=(ObjectA,))`. + +- [ ] **Step 9: `replace_model_nulls` on an `Array` test** + + `arr = ArrayA([sob.NULL]); sob.replace_model_nulls(arr); assert arr[0] + is None`. + +- [ ] **Step 10: `get_model_from_meta`/`get_models_source` extension** + + Extend `test_get_model_from_meta_regression` with a `DictionaryMeta`- + based class and `docstring=`/`pre_init_source=` arguments, and include + it in the combined `get_models_source(...)` call. Run `make + refresh-test-data` if this changes the golden regression output, and + review the diff before committing the regenerated fixture. + +- [ ] **Step 11: Run and confirm pass** + + `hatch test -- tests/test_model.py -vv` + +- [ ] **Step 12: Confirm coverage** + + `model.py` should reach ~95%; check `coverage report -m src/sob/model.py` + against spec §5.13 for residual gaps. + +- [ ] **Step 13: `make format` and commit** + +--- + +## Task 14: `thesaurus.py` coverage (64% → ~88%) + +**Files:** +- Create: a second fixture — either extend + `tests/static-data/thesaurus.json` or add + `tests/static-data/thesaurus_polymorphic.json`. +- Modify: `tests/test_thesaurus.py`. + +**Interfaces:** +- Consumes: `sob.thesaurus.Synonyms`, `sob.thesaurus.Thesaurus`, + `sob.thesaurus.get_class_meta_attribute_assignment_source`. + +- [ ] **Step 1: Build the richer fixture** + + Add `tests/static-data/thesaurus_polymorphic.json` containing: a base64 + string value, a plain ISO date string, a datetime string, a key whose + value is sometimes a JSON object and sometimes a JSON array across + records (to trigger the merge-conflict error path), a key whose + synonym values include an empty object `{}` alongside populated ones, + and a key that is always `null`. + +- [ ] **Step 2: `Synonyms` construction/inference tests** + + `Synonyms().add(object())` → `TypeError`; `Synonyms([1.5, 2])` keeps + the inferred type as `float`; a `Synonyms` built from the base64/date/ + datetime fixture values infers `bytes`/`date`/`datetime` respectively; + a `Synonyms` where every value is `None` yields an untyped property. + +- [ ] **Step 3: `Synonyms` file-like input test** + + Add a real `io.StringIO`/`io.BytesIO` containing JSON as a value and + confirm `Synonyms`/`Thesaurus` decode it correctly. + +- [ ] **Step 4: `Synonyms` mutation/set-algebra tests** + + Build two real `Synonyms` from different fixture groups; exercise + `.discard(item)`, `.remove(item)`, `.pop()`, `&`, `^`, `-`, `<=`, `<`, + `>`, `>=`, `==`, `in`, `.isdisjoint(...)`, `copy.copy(...)`, + `copy.deepcopy(...)`. + +- [ ] **Step 5: `Synonyms.get_models` guard tests** + + `synonyms.get_models(pointer, name=123)` → `TypeError`; + `Synonyms([None]).get_models(pointer)` → `RuntimeError`. + +- [ ] **Step 6: Metadata-merge error-path test** + + Using the mixed object/array fixture key from Step 1, assert + `.get_models()`/`.get_module_source()` on the corresponding `Thesaurus` + raises `TypeError` (incompatible container kinds at the same pointer). + +- [ ] **Step 7: Metadata-merge success-path test** + + Since the success-merge branch isn't reachable through nested fixture + data, call the module-internal `_update_object_meta`/ + `_update_object_class_from_meta` (and `_array`/`_dictionary` + counterparts) directly with two real, hand-built `sob.meta` instances + and two real classes from `sob.model.get_model_from_meta`; assert the + merged metadata/class reflects the union of properties/types. + +- [ ] **Step 8: `get_class_meta_attribute_assignment_source` test** + + Call directly with a real `ArrayMeta`/`ObjectMeta` that sets a + non-default attribute (e.g. `item_types`) and assert the generated + source string. + +- [ ] **Step 9: `Thesaurus` mapping/set-protocol tests** + + Build a real `Thesaurus` from fixture data; exercise `.popitem()`, + `.update(new=[...])`, `.setdefault("k", [...])`, `thesaurus["new"]` + (auto-vivifying) vs. `thesaurus["existing"]`, `"k" in thesaurus`, + `.keys()`, `.values()`, `t1 == t2`, `copy.copy(t)`, `reversed(t)`, + `copy.deepcopy(t)`, `t1 += t2` / `t1 + t2` (two `Thesaurus` built from + disjoint fixture subsets). + +- [ ] **Step 10: `get_module`/`save_module` tests** + + `thesaurus.get_module()` — assert generated classes are real, + importable attributes on the returned module. Using pytest's `tmp_path` + fixture, call `thesaurus.save_module(tmp_path / "model.py")` against a + path guaranteed not to exist, and confirm the file is written and + re-importable (e.g. via `importlib` against the written path). + +- [ ] **Step 11: Run and confirm pass** + + `hatch test -- tests/test_thesaurus.py -vv` + +- [ ] **Step 12: Confirm coverage** + + `thesaurus.py` should reach ~88%; check `coverage report -m + src/sob/thesaurus.py` against spec §5.14 for residual gaps. + +- [ ] **Step 13: `make format` and commit** + +--- + +## Task 15: Final verification against acceptance criteria + +**Files:** none (verification only). + +**Interfaces:** +- Consumes: the full test suite and coverage report built up by Tasks 1–14. + +- [ ] **Step 1: Full local run** + + `hatch test -c -vv` (matches CI's `hatch test -c -py `) on + Python 3.10. Confirm zero failures, including all `--doctest-modules` + items. + +- [ ] **Step 2: Coverage check against acceptance criteria** + + `hatch run hatch-test.py3.10:coverage report -m`. Confirm: every module + ≥ 90%, `hooks.py` and `thesaurus.py` ≥ 85%, overall ≥ 95%. If any module + falls short, check whether the shortfall is an already-documented + non-goal (spec §4) or a real gap that needs one more small test. + +- [ ] **Step 3: Full matrix (optional but recommended before merging)** + + `hatch test -c` across the matrix if time/CI budget allows, or push a + branch and let GitHub Actions' `test.yml` run all OS/Python + combinations. + +- [ ] **Step 4: `make format && hatch run mypy`** + + Confirm lint/type-check are clean across all touched files (this should + already be true if `make format` was run after each task, but re-check + once at the end for cross-task interactions). + +- [ ] **Step 5: Update the spec's `Status` line** + + Edit `docs/superpowers/specs/2026-08-01-test-coverage-gaps-design.md`'s + header from `**Status:** Draft — pending user review` to `**Status:** + Implemented` (or similar), and commit. diff --git a/docs/superpowers/specs/2026-08-01-test-coverage-gaps-design.md b/docs/superpowers/specs/2026-08-01-test-coverage-gaps-design.md new file mode 100644 index 0000000..cd305ab --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-test-coverage-gaps-design.md @@ -0,0 +1,519 @@ +# Close remaining test-coverage gaps in `sob` + +**Status:** Implemented (2026-08-01). Overall coverage 84%→87%; most +modules hit or exceeded target (see plan's Task 15 for final numbers). +`abc.py` (74%, all remaining lines are non-goal abstract stubs), +`model.py` (85%, target 90%+), and `thesaurus.py` (82%, target 85%+) +fell short of their per-module targets, and overall coverage (87%) +fell short of the ≥95% target — the remaining gaps are deep/low-value +branches (see plan for details). Four real bugs found and fixed along +the way: two hidden by a no-op doctest assertion, `indent()`'s +negative-`stop` sign error, `Properties.__eq__` comparing the wrong +operand, and two related polymorphism-merge bugs in `thesaurus.py`. +**Scope:** `src/sob/**` (all 16 modules), `tests/**` + +## 1. Goal + +Bring every module in `src/sob` up from its current measured coverage to +as close to 100% as is meaningful, using **real integration tests** — +constructing actual `sob` model/property/meta/hooks objects and exercising +them end-to-end — rather than mocks or patched internals. This matches the +existing test suite's own convention: `grep -rl "mock\|Mock\|monkeypatch" +tests/ src/` returns nothing today, and every existing test builds real +`sob.Object`/`sob.Array`/`sob.Dictionary` subclasses (see +`tests/test_model.py`, `tests/test_version.py`, `tests/test_utilities.py`). +This spec preserves that convention; no new test dependency (e.g. +`pytest-mock`, `unittest.mock`) should be introduced. + +## 2. Current state (baseline) + +Measured via `hatch test --cover` on 2026-08-01 (Python 3.10 leg): + +``` +Name Stmts Miss Cover +------------------------------------------- +src/sob/__init__.py 11 0 100% +src/sob/_datetime.py 28 2 93% +src/sob/_inspect.py 11 0 100% +src/sob/_io.py 19 4 79% +src/sob/_types.py 57 4 93% +src/sob/_utilities.py 28 3 89% +src/sob/abc.py 529 13 98% +src/sob/errors.py 59 7 88% +src/sob/hooks.py 110 60 45% +src/sob/meta.py 463 98 79% +src/sob/model.py 1308 176 87% +src/sob/properties.py 228 10 96% +src/sob/thesaurus.py 465 168 64% +src/sob/types.py 70 13 81% +src/sob/utilities.py 315 46 85% +src/sob/version.py 96 19 80% +------------------------------------------- +TOTAL 3797 623 84% +``` + +`pyproject.toml` sets `fail_under = 70` — already passing overall, but +`hooks.py` (45%) and `thesaurus.py` (64%) are both individually below that +bar, and several modules have entire classes of behavior (error branches, +direct-construction paths, mapping-protocol methods) with zero exercise. + +**Target:** every module ≥ 90%, `hooks.py` and `thesaurus.py` ≥ 85%, +overall ≥ 95%. Some lines (documented in §4) are genuinely low-value or +unreachable and are called out as explicit non-goals rather than chased. + +## 3. Important finding: the doctest harness doesn't fail on doctest errors + +Every `test_doctest()` function across the suite (e.g. +`tests/test_utilities.py:13`, `tests/test_datetime.py:11`) does: + +```python +def test_doctest() -> None: + doctest.testmod(utilities) +``` + +`doctest.testmod()` returns a `TestResults(failed, attempted)` tuple and +**never raises** on failure — it just prints a report to stdout. Since +nothing asserts on the return value, these test functions always pass +regardless of whether the docstring examples in the module actually work. +Confirmed by running `hatch test --cover` (33 passed, no failures reported) +versus running pytest directly with `--doctest-modules` (the flag already +configured in `pyproject.toml` under `[tool.hatch.envs.hatch-test] +extra-args`, which collects each doctest as its own pytest item): + +``` +src/sob/utilities.py::sob.utilities.suffix_long_lines FAILED +src/sob/_datetime.py::sob._datetime.str2datetime FAILED +2 failed, 49 passed +``` + +Two real, currently-failing doctests exist in the docstrings today: + +- **`sob.utilities.suffix_long_lines`** — the example output doesn't + include the `# noqa: E501` suffix the function actually appends; looks + like a stale docstring after a recent edit (see commits `89e906a`, + `c51259b`, `e3d58f0` — all docstring-wrap fixes). +- **`sob._datetime.str2datetime`** — `str2datetime("2023-10-01T12:00:00Z")` + is documented to return a UTC-aware `datetime`, but on Python 3.10 it + returns a **naive** `datetime` (the `Z`-suffix branch differs because + `datetime.fromisoformat` only gained `Z`-parsing in Python 3.11; on 3.10 + it falls through to the `iso8601.parse_date` fallback, which the + Python-3.10-only "incorrect UTC offset" correction then strips to naive). + Since the test matrix covers 3.10–3.13, this doctest's expected output is + only accurate on 3.11+. + +**Decision needed from user before implementation:** should the plan (a) +fix `test_doctest()` in every test file to assert `failed == 0` (e.g. +`assert doctest.testmod(module).failed == 0`), which will immediately turn +these two into real failures that must be fixed as a prerequisite, or (b) +leave the harness as-is and just note the two bugs separately? Recommended: +**(a)** — it's a one-line change per file, it's the highest-leverage single +fix in this whole effort, and leaving it disabled defeats the purpose of +raising coverage elsewhere. The two underlying docstring/behavior bugs it +exposes should be fixed as their own small prerequisite fixes (not silently +adjusted expectations) before the coverage-gap tests are added, so the +gap-filling tests start from a green baseline. + +## 4. Non-goals + +Do not chase coverage on: + +- **`abc.py` abstract-method stub bodies** (`pass` under `@abstractmethod`, + e.g. lines 470, 757, 824, 966, 993, 1018, 1048, 1070, 1106, 1175, 1188, + 1195) — every concrete subclass overrides these; the stub body itself + never executes in real usage and testing it would require calling the + abstract method through a deliberately broken subclass, which tests + nothing meaningful. +- **`TYPE_CHECKING`-only `assert` statements** (e.g. `model.py:1812`) — + dead at runtime by construction. +- **`model.py` `_marshal`/`_validate` abstract `pass` bodies** (148, 162, + 166 area) — same reasoning as `abc.py`. +- A handful of deeply-defensive `TypeError`/exec-failure guards in + `model.py`'s `get_models_source` (3412, 3482, 3491) that would require + contriving malformed metadata objects bypassing all public constructors + to trigger — flagged as low-priority, skip unless trivial. + +## 5. Per-module test plan + +Each subsection lists concrete, mock-free scenarios. Where a module already +has a test file, new tests extend it; naming follows the existing +`test_` convention (see `tests/test_model.py`, +`tests/test_types.py`). + +### 5.1 `_datetime.py` (93% → ~100%) +File: `tests/test_datetime.py` +- `str2datetime` raises `TypeError` for a non-`str` argument (parallel to + the existing `test_raise_str2date_type_error`) — covers line 59. +- `str2date` raises `TypeError` for a non-`str` argument — covers line 88. +- (Prerequisite, see §3) fix the `str2datetime("...Z")` docstring/behavior + mismatch on Python 3.10 before/alongside this work. + +### 5.2 `_io.py` (79% → 100%) +File: `tests/test_utilities.py` (or a new `tests/test__io.py`) +- A file-like proxy whose `read()` raises `io.UnsupportedOperation` (and + has no `readall`) to hit the `except UnsupportedOperation: pass` branch, + followed by exhausting both method names — e.g. a class with `read` that + always raises, and no `readall`, confirming the final `TypeError` is + raised (`f"{file!r} is not a file-like object"`) — covers lines 39-42. +- A plain `object()` (no `seek`/`read`/`readall`) passed to `sob._io.read` + to hit the `TypeError` path directly too. + +### 5.3 `_types.py` (93% → 100%) +File: `tests/test_types.py` +- `hash(sob.UNDEFINED) == 0` — covers line 41. +- `hash(sob.NULL) == 0`, `str(sob.NULL) == "null"`, + `sob.Null._marshal() is None` — covers lines 102, 105, 109. + +### 5.4 `_utilities.py` (89% → 100%) +File: `tests/test_utilities.py` +- Decorate a real function with `sob._utilities.deprecated("message")`, + call it, and assert (via `pytest.warns(DeprecationWarning, match=...)`) + that the warning fires and the wrapped function's return value passes + through — covers line 34. (This one line also silently backs every + `deprecated`-wrapped alias across `abc.py`, `properties.py`, `meta.py`, + `hooks.py`, `model.py`, `thesaurus.py`, and `utilities.py` — e.g. + `sob.hooks.Object`, `sob.meta.read`, `sob.properties.String` — none of + which is currently invoked anywhere in the test suite. Once this line is + covered directly, calling a representative one or two of those aliases + under `pytest.warns(DeprecationWarning)` in their respective module's + test file is a cheap way to also confirm the aliases themselves are + wired correctly, though it isn't required to close this specific gap.) +- A proxy object with a non-`str` `.url` attribute → + `get_readable_url` raises `TypeError` — covers line 50. +- A proxy object with none of `geturl`/`url`/`name` → returns `None` — + covers line 61. + +### 5.5 `abc.py` (98% → ~99%, remainder is §4 non-goals) +No new tests needed beyond what naturally falls out of exercising the +concrete classes elsewhere in this plan (`abc.py`'s only non-non-goal gaps +are abstract stubs). + +### 5.6 `errors.py` (88% → 100%) +File: `tests/test_utilities.py` or a new `tests/test_errors.py` +- Construct `sob.errors.DeserializeError(data="bad", message="oops")` + directly; assert `.data`, `.message`, `repr(error)` (message + `"Could + not parse:\n..."`), and `str(error) == repr(error)` — covers 57-59, 62, + 68. +- `append_exception_text`: build a real exception with a `strerror` + attribute (e.g. `OSError(1, "boom")`, which sets `.strerror`) and confirm + the message is appended to `.strerror` — covers line 161. +- `append_exception_text` on an exception with empty `.args` (e.g. + `Exception()`) to hit the "not found" `else` branch — covers line 174. + +### 5.7 `types.py` (81% → 100%) +File: `tests/test_types.py` +- `sob.Types(str)` (bare type, not wrapped in a sequence) — covers line 43. +- `copy.copy(sob.Types([int, str]))` — covers line 49. +- On a `sob.MutableTypes` instance: `types[0] = float` (`__setitem__`), + `types.extend([bool])`, `del types[0]` (`__delitem__`), `types += [str]` + (`__iadd__`), and `new_types = types + [int]` (`__add__`, returning a + fresh `MutableTypes`) — covers 123-124, 130, 133, 139-140, 145-149. + +### 5.8 `properties.py` (96% → 100%) +File: `tests/test_model.py` or a new `tests/test_properties.py` +- `has_mutable_types` with a plain (non-`Property`) argument that is not a + `type[abc.Property]` subclass → `TypeError` — covers 66-72 (also hits + both branches of the `isinstance(property_, abc.Property)` check with a + `Property` instance vs. a `Property` subclass passed as a bare type). +- Attempt to reassign `.types` on a `Property` instance whose class defines + `_types` at the class level (e.g. `sob.StringProperty().types = [int]`) + → `TypeError` ("... .types` is immutable") — covers line 163. +- Assign an invalid (non-type, non-`Property`, non-`None`) value to + `.types` on a property whose `_types` is *not* class-level-fixed (a bare + `sob.Property().types = "not-a-type"`) → `TypeError` — covers line 172. +- Assign an invalid `.versions` value (e.g. `sob.Property().versions = + 123`) → `TypeError` — covers lines 192, 196. + +### 5.9 `version.py` (80% → 100%) +File: `tests/test_version.py` +`sob.Version` is currently only exercised indirectly through +`version_model`/property `versions=` args — no test constructs or compares +`sob.Version` instances directly. Add: +- `sob.version._are_versions_compatible`/`_are_versions_equal` via + `sob.Version(equals="1.2") == "1.2.0"` and `== "1.2"` (differing + precision) to cover the truncation/length-mismatch branches (lines 24, + 31, 37-43). +- `sob.version._version_string_as_tuple("not-a-version")` (or + constructing `sob.Version("not-a-version")`) → `ValueError` — line 81. +- `sob.Version(compatible_with=1.2)` and `sob.Version(compatible_with= + (1, 2))` (a bare float and a bare sequence, exercising the numeric and + sequence branches of `_version_as_tuple`) — lines 88, 94, 116-119. +- `sob.version._version_as_tuple(object())` → `TypeError` — line 113. +- `sob.Version(123)` (non-`str` `version_string`) → `TypeError` — line 226. +- `sob.Version("a==1,b==2")` (two different specification prefixes in one + string) → `ValueError` — line 249. +- `specification` defaults to `""` (verified: a default-built `sob.Version` + never leaves it `None`), so the `RuntimeError` at line 301 can't be + triggered through the constructor alone. `specification` is a plain + public attribute (no property/validation), so directly assigning + `version.specification = None` after construction and then calling + `str(version)` is normal use of the public interface, not a mock or + monkeypatch — use that to cover line 301. + +### 5.10 `utilities.py` (85% → ~97%) +File: `tests/test_utilities.py` +- URL helpers: call the internal URL-splitting helper with a malformed URL + to hit its `ValueError`, and `get_relative_url("https://a.com/x/y", + "https://a.com/x/z")` plus a case with **no shared prefix** between + absolute and base URL — covers 389-393, 402-416. +- `_align_indent("no-leading-space")` — covers line 427. +- `_split_long_comment_line` with a short line (no wrap needed) — covers + line 473. +- `split_long_docstring_lines`: an already-uniformly-indented docstring + (covers 505, 510-511) and a docstring containing a `"""`/`'''` literal + that closes on the same or later line, needing the suffix re-appended — + covers 540-548 (`suffix_long_lines`'s quote-tracking loop). +- `get_qualified_name`: pass an unsupported type → `TypeError` (line 657); + pass a generic alias like `list[int]` to hit the `__origin__`/`repr()` + fallback (line 675); construct an object whose type truly can't resolve + a qualified name to hit the final `TypeError` (line 682). +- `get_calling_module_name`/`get_calling_function_qualified_name`: call + with `depth` deep enough to exceed the real stack to hit the + `IndexError`/short-stack `None` returns (745-746, 772, 775-776), and call + with a non-`int` `depth` → `TypeError` (line 772's guard). +- `_repr_list([])`, `_repr_set(set())`, `_repr_dict({})` — the empty- + collection short-circuit branches (`"[]"`, `"set()"`, `"{}"`) — covers + 818-819, 842, 852. +- `represent(SomeClass)` (representing a bare `type`, not an instance) — + covers line 870. +- `get_method`: an object missing the requested method with **no** + `default` (so `default` stays `sob.UNDEFINED`) → re-raises `AttributeError` + (910); an object where the attribute exists but isn't callable, with and + without a `default` → returns default or raises — covers 914-920. + +### 5.11 `hooks.py` (45% → ~90%+) — highest-value gap +File: `tests/test_model.py` or a new `tests/test_hooks.py`. Currently +**zero** test in the suite constructs `ObjectHooks`/`ArrayHooks`/ +`DictionaryHooks` or calls any `*_hooks` function — `model.py` calls the +read-side internally on every operation (which is why 45% is already +covered passively), but nothing ever registers a hook, so the entire +write/get-writable/dispatch surface is dark. +- `ObjectHooks(before_setattr=fn, after_setattr=fn)` assigned via + `sob.write_model_hooks(ObjectA, hooks)`; set an attribute on an instance + and assert both real (non-mock) callables fired with expected arguments + — covers `ObjectHooks.__init__` (244-257) and the `write_model_hooks` + body (835-849). +- `ArrayHooks(before_append=fn, after_append=fn)` on `sob.ArrayA`; + `.append(...)` and assert hooks fired — covers 382-395. +- `DictionaryHooks(before_setitem=fn, after_setitem=fn)` on a `Dictionary` + subclass; `d["k"] = v` — covers 505-516. +- `sob.read_model_hooks("not-a-model")` → `TypeError` — covers 549-560. +- `sob.get_writable_model_hooks(ObjectA)` on a class with no hooks yet + assigned → returns a fresh `ObjectHooks`, confirmed idempotent via a + second `sob.read_model_hooks(ObjectA)` call returning the same object; + repeat on an instance; and `sob.get_writable_model_hooks(42)` → + `TypeError` — covers 626, 633, 639, 645, 665-704. +- `sob.get_writable_object_hooks`/`get_writable_array_hooks`/ + `get_writable_dictionary_hooks` called directly on each container type — + covers 730, 756, 785. +- `sob.get_model_hooks_type(ObjectA)` → `ObjectHooks` (and same for + `Array`/`Dictionary`, both class and instance); `sob.get_model_hooks_type + (str)` → `TypeError` — covers 800-820. +- `sob.write_model_hooks(ObjectA, sob.ArrayHooks())` (wrong hooks type) → + `ValueError`; `sob.write_model_hooks("not-a-model", hooks)` → `TypeError` + — rounds out 835-849. + +### 5.12 `meta.py` (79% → ~93%) +File: `tests/test_model.py` / `tests/test_version.py` +- Assign a bare (non-iterable) type/Property to `DictionaryProperty( + value_types=sob.StringProperty())` and `ArrayProperty(item_types=str)` + (not wrapped in a list) — covers 217-219, 271-273. +- Exercise `sob.Properties` mapping protocol directly on + `sob.get_writable_object_meta(ObjectA).properties`: `.pop("string")`, + `.popitem()`, `.setdefault("x", sob.StringProperty())`, + `.get("missing")`, `.clear()`, `copy.copy(properties)`, `repr(...)`, + equality between two independently-built `Properties` with identical + items, `properties["bad"] = "not-a-property"` → `TypeError`, + `.setdefault("x", 5)` → `TypeError`, `.update({...})` with a plain + `dict` — covers 304-318, 370, 376, 385, 396, 403, 406, 427, 430, 443-445, + 450, 453-455. +- Define a brand-new `sob.Object` subclass with no metadata ever assigned + and call `sob.read_model_meta(NewClass)` → `None` — covers 488-491. +- `sob.get_writable_object_meta("not-a-model")` and `sob. + get_writable_object_meta(int)` → `TypeError` — covers 610-619. +- `sob.write_model_meta(ObjectA, sob.ArrayMeta())` → `ValueError`; + `sob.write_model_meta("not-a-model", None)` → `TypeError` — covers 714, + 716-717, 750, 754-755. +- `sob.meta._read_object_properties`/`_read_object_property_names` on a + class with no metadata → `None` returns — covers 784, 793, 802. +- `sob.meta.pointer(instance, "/foo/bar")` then re-call with + `pointer_=None` to hit only the getter; `sob.meta.pointer(123)` → + `TypeError`; `sob.meta.url(instance, "https://example.com")` — covers + 883-886, 962-963. +- Extend `test_version.py`'s pattern to a versioned **`Array`**/ + **`Dictionary`** (not just `Object`): give `ArrayProperty(item_types=...)` + or `DictionaryProperty(value_types=...)` a nested `Property` with + `versions=[...]`, call `sob.meta.version_model(container_instance, + "test-specification", "1.0")` directly, and assert the container's + types were filtered — covers 1001, 1022-1097, 1114-1149. Also + deliberately trigger `sob.errors.VersionError` by setting a + to-be-removed versioned property's value before calling `version_model` + with an incompatible version. `sob.meta.version_model(42, "spec", "1.0")` + → `TypeError` — covers 1182, 1185-1187. +- `sob.meta._copy_model_meta_to` with a non-`abc.Model` `source` (module- + internal, acceptable to import directly since it's the unit under test) + — covers 1248, 1274. + +### 5.13 `model.py` (87% → ~95%) +File: `tests/test_model.py`. This is the largest module; group tests by +the affected class/function. +- **`Model`/format guard**: `sob.Array(123)`, `sob.Dictionary(123)` → + `TypeError` (line 148). +- **`Array` protocol**: build `arr = ArrayA([ObjectA(string="a")])` and + exercise `arr.append(...)`, `arr[0] = ObjectA(string="b")`, `del + arr[0]`, `arr.sort()`, `arr.extend([...])`, `reversed(arr)`, `arr.pop()`, + `arr.remove(...)`, `copy.copy(arr)`, `repr(arr)`, `str(arr)`, `arr == + ArrayA()` (type and length mismatch), plus real `ArrayHooks` + (`before_setitem`/`after_setitem`/`before_marshal`/`after_marshal`/ + `before_validate`) assigned via `sob.write_model_hooks` and a + `sob.validate(arr)` call against an invalid item to hit the + `ValidationError` raise (lines 391, 414, 436-597, 652-673). +- **`Dictionary` protocol**: build `d = DictionaryA({"a": ObjectA(...)})` + (mirroring `MemberDictionaryA` from `test_version.py`) and exercise + `d.update({...}, [("c", ...)], kw=...)`, `d.setdefault(...)`, `d.pop(...)`, + `d.popitem()`, `"a" in d`, `list(reversed(d))`, `copy.copy(d)`/ + `copy.deepcopy(d)`, `d == DictionaryA()`, construct from a tuple-iterable + instead of a `dict`, and real `DictionaryHooks` on `__setitem__`/ + `_marshal` (lines 935-1343). +- **`Object` extras/copy-init**: construct `ObjectA(other_object_with_a_ + type-incompatible_property)` to hit `_copy_init`'s exception- + augmentation path; `obj["extra_key"] = "value"`, read it back, and + `del obj["extra_key"]` to hit the `_extra` (non-metadata-attribute) + branches of `__setitem__`/`__getitem__`/`__delitem__`; real + `ObjectHooks(before_setitem=fn)` on `obj["string"] = "x"` (lines + 1600-1994). +- **Module-level `marshal()`**: call directly (not just via `Object. + _marshal`) on a raw `dict`, `list`, `set`, `Decimal`, `datetime`, `date`, + `bytes`, and an unsupported `object()` (→ `ValueError`), plus `marshal(1, + types=(str,))` (→ `TypeError`) (lines 2013-2129). +- **`unmarshal()`**: `sob.unmarshal({"string": "a"}, types=ObjectA)` + (single type, not iterable); `sob.unmarshal((x for x in [1, 2]))` + (generator input); `sob.unmarshal(None)`; a real `before_unmarshal` hook + registered on `ObjectA` (lines 2171-2381). +- **`serialize`/`deserialize`**: real `before_serialize`/`after_serialize` + hooks on `ObjectA`; `sob.deserialize(b'{"a": 1}')` (bytes path); + `sob.deserialize(123)` → `TypeError` (lines 2502-2594). +- **`validate()`**: `sob.validate(ObjectA(), types=(ObjectA,))` (bare type, + not `Property`) (line 2635). +- **`replace_model_nulls`**: `arr = ArrayA([sob.NULL]); + sob.replace_model_nulls(arr)`; assert `arr[0] is None` — Array-item NULL + replacement is untested today (only Object-property NULLs are) (line + 3012). +- **`get_model_from_meta`/`get_models_source`**: extend + `test_get_model_from_meta_regression` with a `DictionaryMeta`-based + class and `docstring=`/`pre_init_source=` arguments, and include it in + the combined `get_models_source(...)` call (lines 3061-3520 area). + +### 5.14 `thesaurus.py` (64% → ~88%) — second-highest-value gap +File: `tests/test_thesaurus.py` (currently only 28 lines: one regression +test that round-trips `tests/static-data/thesaurus.json` through +`Thesaurus(...).get_module_source()` against a golden file). `Thesaurus` +infers an `sob` data model from *example* JSON data (rather than a +schema); `Synonyms` is the set-like collection of interchangeable values +used to infer one property's type, `Thesaurus` is the dict-like collection +of named `Synonyms` keyed by pointer/identifier. The single flat fixture +(bools/strings/one array-of-arrays-of-int) never exercises polymorphism- +merging, date/base64/bytes detection, error paths, or most of the +`Synonyms`/`Thesaurus` mapping/set protocol — hence the large gap. Add a +**second, deliberately richer static fixture** +(`tests/static-data/thesaurus_polymorphic.json`, or extend the existing +one) containing: a base64 string, a plain date string, a datetime string, +a key that is sometimes an object and sometimes an array (to trigger the +type-conflict error path), a key whose synonym values include an empty +object `{}` alongside populated ones, and a key that is always `null`. +Then: +- **`Synonyms` construction/inference** (462, 540, 549, 691-698, 800): + `Synonyms().add(object())` → `TypeError` (line 540); `Synonyms([1.5, + 2])` keeps the inferred type as `float`, not `int` (line 549); a + `Synonyms` built from base64/date/datetime strings infers `bytes`/ + `date`/`datetime` respectively (691-698, 462); a shared key whose values + are always `None` falls back to an untyped `Property(name=key)` (800). +- **`Synonyms` file-like input** (71-76): add a real `io.StringIO`/ + `io.BytesIO` containing JSON as a `Synonyms`/`Thesaurus` value and + confirm it decodes via both the `str` and `bytes` branches of `_read`. +- **`Synonyms` mutation/set-algebra** (540-579, 630-681): build two real + `Synonyms` from different fixture groups and exercise `.discard(item)`, + `.remove(item)`, `.pop()`, `&`, `^`, `-`, `<=`, `<`, `>`, `>=`, `==`, + `in`, `.isdisjoint(...)`, `copy.copy(...)`, `copy.deepcopy(...)`. +- **`Synonyms.get_models` guards** (908, 913-914, 922): `synonyms. + get_models(pointer, name=123)` (non-callable `name`) → `TypeError`; + `Synonyms([None]).get_models(pointer)` (nothing inferable) → + `RuntimeError`. +- **Metadata-merge chain** (`_update_types`, `_update_array_meta`, + `_update_dictionary_meta`, `_update_object_meta`, the three + `_update_*_class_from_meta` functions, and `_get_models_from_meta`'s + memo-hit branches — lines 93-111, 129-143, 161-175, 194-232, 254-269, + 286-291, 311-318, 340-358, 412-413, 417): these only run when the same + JSON pointer produces metadata twice in one `.get_models()` call. + - The **error branch** is reachable through the public API: the mixed + object/array fixture key above should make `.get_models()`/ + `.get_module_source()` raise `TypeError` when the same pointer + resolves to incompatible container kinds — assert with + `pytest.raises(TypeError)`. + - The **success merge** path (two `ObjectMeta`/`ArrayMeta`/ + `DictionaryMeta` at the same pointer with the *same* container kind + but different properties/types) isn't reachable through nested + fixture data without contrived recursive pointer collisions; test it + by calling the module-internal `_update_object_meta`/ + `_update_object_class_from_meta` (and `_array`/`_dictionary` + counterparts) directly with two real, hand-built `sob.meta` instances + and two real classes from `sob.model.get_model_from_meta`, then assert + the merged metadata/class reflects the union. +- **`get_class_meta_attribute_assignment_source`** (989-996): call + directly with a real `sob.meta.ArrayMeta`/`ObjectMeta` that sets a + non-default attribute (e.g. `item_types`) and assert the generated + source string. +- **`Thesaurus` mapping/set protocol** (1124, 1146, 1160-1211): build a + real `Thesaurus` from fixture data and exercise `.popitem()`, + `.update(new=[...])`, `.setdefault("k", [...])`, `thesaurus["new"]` + (auto-vivifying `__getitem__`) vs. `thesaurus["existing"]`, `"k" in + thesaurus`, `.keys()`, `.values()`, `t1 == t2`, `copy.copy(t)`, + `reversed(t)`, `copy.deepcopy(t)`, and `t1 += t2` / `t1 + t2` (two real + `Thesaurus` built from disjoint fixture subsets). +- **`Thesaurus.get_module`/`save_module`** (1275-1276, 1301-1306): + `thesaurus.get_module()` returns an executed `ModuleType` — assert the + generated classes are real, importable attributes on it. The existing + regression test's `save_module` call never exercises the actual file- + write branch because the golden file already exists on disk; add a + `tmp_path`-based test that calls `thesaurus.save_module(tmp_path / + "model.py")` against a path guaranteed not to exist, and confirm the + file is written and re-importable. + +## 6. Testing conventions to follow + +- **No mocks.** Every new test builds real `sob` objects (subclasses of + `sob.Object`/`sob.Array`/`sob.Dictionary`, real `Property`/`Meta`/`Hooks` + instances) and calls real functions — matching 100% of existing tests. +- **Real callables for hooks**, not `unittest.mock.Mock` — e.g. a closure + that appends to a list the test asserts against afterward, as already + done implicitly elsewhere in the codebase's style (plain functions/ + classes as fixtures, e.g. `HTTPResponseProxy1` in `test_utilities.py`). +- Follow the file's existing structure: `from __future__ import + annotations`, a `test_doctest()` per module (once fixed per §3), plain + `test_*` functions (no test classes), and the `if __name__ == "__main__": + pytest.main([__file__, "-s", "-vv"])` trailer. +- Where a module has no test file yet (none currently — every `src/sob/*` + module maps to a `tests/test_*.py` except the underscore-prefixed + internals, which are tested from within `tests/test_utilities.py`/ + `tests/test_types.py`), keep using that existing file rather than + fragmenting into more files, matching current 1:1-ish structure. +- Regression-style tests (`tests/regression-data/`) are the established + pattern for "build once, compare generated output" cases (see + `test_thesaurus`, `test_get_model_from_meta_regression`, + `test_serialization_regression`) — reuse this pattern for new + `thesaurus.py` and `get_models_source` coverage rather than inventing a + new fixture mechanism. +- Run `hatch test -- tests/test_.py` per file during development; + `hatch test --cover` (or `hatch run hatch-test.py3.10:coverage report + -m`) to confirm line-level coverage before/after. + +## 7. Acceptance criteria + +- `hatch test -c` passes on all four Python versions (3.10–3.13) with the + `test_doctest()` fix from §3 applied (i.e. it actually fails if a + docstring example breaks). +- `coverage report -m` shows every module ≥ 90%, with `hooks.py` and + `thesaurus.py` ≥ 85%, and overall ≥ 95%. +- No new test uses `unittest.mock`, `pytest-mock`, or monkeypatches any + `sob` internals — all assertions exercise real, constructed objects. +- Lines listed under §4 (Non-goals) remain uncovered by design and are not + flagged as regressions in future coverage diffs. diff --git a/pyproject.toml b/pyproject.toml index f5e4bd6..c5f57fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "sob" -version = "2.2.1" +version = "2.2.2" description = "A type-enforced framework for serializing and deserializing JSON" readme = "README.md" license = "MIT" diff --git a/src/sob/_datetime.py b/src/sob/_datetime.py index f59ecb1..192cf16 100644 --- a/src/sob/_datetime.py +++ b/src/sob/_datetime.py @@ -63,9 +63,11 @@ def str2datetime(str_value: str) -> datetime: except ValueError: datetime_value = parse_date(str_value) # `iso8601` incorrectly sets the UTC offset to `0` instead of `None` - # when no time zone is provided + # when no time zone is provided. A trailing "Z" *is* an explicit + # (UTC) time zone indicator, so only strip the offset when the + # original string had no such indicator. if ( - str_value.endswith("Z") + not str_value.endswith("Z") and "+" not in str_value and len(str_value.split("-")) == 3 # noqa: PLR2004 and (datetime_value.tzinfo is not None) diff --git a/src/sob/meta.py b/src/sob/meta.py index 34fdb6a..cc33573 100644 --- a/src/sob/meta.py +++ b/src/sob/meta.py @@ -452,7 +452,7 @@ def get( def __eq__(self, other: object) -> bool: if type(self) is not type(other): return False - return self._dict.__eq__(other) + return self._dict == cast("Properties", other)._dict # noqa: SLF001 def __len__(self) -> int: return self._dict.__len__() diff --git a/src/sob/thesaurus.py b/src/sob/thesaurus.py index d4b88e6..5ec7642 100644 --- a/src/sob/thesaurus.py +++ b/src/sob/thesaurus.py @@ -94,9 +94,9 @@ def _update_types( if ( isinstance(new_type, type) and issubclass(new_type, abc.Model) - and (type.__name__ in memo) + and (new_type.__name__ in memo) ): - existing_type: type = memo[type.__name__] + existing_type: type = memo[new_type.__name__] new_type_meta: abc.Meta | None = meta.read_model_meta(new_type) if not isinstance( new_type_meta, @@ -201,7 +201,7 @@ def _update_object_meta( new_metadata_keys: set[str] = set(new_metadata.properties.keys()) # Add properties that don't exist key: str - for key in sorted(metadata_keys - new_metadata_keys): + for key in sorted(new_metadata_keys - metadata_keys): metadata.properties[key] = new_metadata.properties[key] # Update shared properties for key in sorted(metadata_keys & new_metadata_keys): diff --git a/src/sob/utilities.py b/src/sob/utilities.py index 629a668..de66f7b 100644 --- a/src/sob/utilities.py +++ b/src/sob/utilities.py @@ -368,7 +368,7 @@ def indent( lines: list[str] = string.split("\n") if stop: if stop < 0: - stop = len(lines) - stop + stop = len(lines) + stop else: stop = len(lines) index: int @@ -416,6 +416,17 @@ def get_url_relative_to(absolute_url: str, base_url: str) -> str: return relative_url +def _align_indent(line: str, tab_width: int = 4) -> str: + """ + Strip whitespace from a line until the leading whitespace is divisible + by `tab_width`. + """ + indent: str = re.match("^[ ]*", line).group() # type: ignore[union-attr] + if not indent: + return line + return line[len(indent) % tab_width :] + + def _split_long_comment_line( line: str, max_line_length: int = MAX_LINE_LENGTH, prefix: str = "#" ) -> str: @@ -451,10 +462,12 @@ def _split_long_comment_line( ) <= max_line_length: wrapped_line += word else: - lines.append(indent_ + wrapped_line.rstrip()) + lines.append( + f"{indent_}{_align_indent(wrapped_line)}".rstrip() + ) wrapped_line = "" if not word.strip() else word if wrapped_line: - lines.append(f"{indent_}{wrapped_line}".rstrip()) + lines.append(f"{indent_}{_align_indent(wrapped_line)}".rstrip()) wrapped_line = "\n".join(lines) else: wrapped_line = line @@ -485,11 +498,11 @@ def split_long_docstring_lines( indent_: str = " " if "\t" in docstring: docstring = docstring.replace("\t", indent_) - lines: list[str] = ( - docstring.replace("\r\n", "\n").replace("\r", "\n").split("\n") - ) + lines: tuple[str, ...] = tuple(re.split(r"(?:\r\n|\r|\n)", docstring)) indentation_length: int = sys.maxsize - for line in filter(None, lines): + for line in lines: + if not line.strip(): + continue matched = re.match(r"^[ ]+", line) if matched: indentation_length = min(indentation_length, len(matched.group())) @@ -500,14 +513,14 @@ def split_long_docstring_lines( if indentation_length < sys.maxsize: docstring = "\n".join( _split_long_comment_line( - indent_ + line[indentation_length:], + f"{indent_}{line[indentation_length:]}", max_line_length, prefix="", ) for line in lines ) # Strip trailing whitespace and empty lines - return re.sub(r"[ ]+(\n|$)", r"\1", docstring) + return re.sub(r"[ ]+(\r\n|\r|\n|$)", r"\1", docstring) def _iter_suffix_long_lines( @@ -576,7 +589,7 @@ def suffix_long_lines( ... ) ... ) A short line... - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam faucibu odio a urna elementum, eu tempor nisl efficitur. + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam faucibu odio a urna elementum, eu tempor nisl efficitur. # noqa: E501 ...another short line """ # noqa: E501 diff --git a/tests/test_datetime.py b/tests/test_datetime.py index dc2ea98..97c1ead 100644 --- a/tests/test_datetime.py +++ b/tests/test_datetime.py @@ -12,7 +12,10 @@ def test_doctest() -> None: """ Run docstring tests """ - doctest.testmod(sob._datetime) # noqa: SLF001 + results: doctest.TestResults = doctest.testmod( + sob._datetime # noqa: SLF001 + ) + assert results.failed == 0, results def test_raise_date2str_type_error() -> None: @@ -57,5 +60,29 @@ def test_raise_str2date_type_error() -> None: assert error_caught +def test_raise_str2datetime_type_error() -> None: + """ + Test raising of exceptions for invalid types. + """ + error_caught: bool = False + try: + sob._datetime.str2datetime(123) # type: ignore # noqa: SLF001 + except TypeError: + error_caught = True + assert error_caught + + +def test_raise_str2date_non_str_type_error() -> None: + """ + Test raising of exceptions for invalid types. + """ + error_caught: bool = False + try: + sob._datetime.str2date(123) # type: ignore # noqa: SLF001 + except TypeError: + error_caught = True + assert error_caught + + if __name__ == "__main__": pytest.main([__file__, "-s", "-vv"]) diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 0000000..18b88a1 --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +from typing import IO, TYPE_CHECKING + +import pytest + +import sob + +if TYPE_CHECKING: + from collections.abc import Sequence + +# region Declare classes + + +class HookedObject(sob.Object): + __slots__: tuple[str, ...] = ("value",) + + def __init__( + self, + _data: str | bytes | dict | Sequence | IO | None = None, + value: str | None = None, + ) -> None: + self.value: str | None = value + super().__init__(_data) + + +sob.get_writable_object_meta(HookedObject).properties = sob.Properties( + [("value", sob.StringProperty())] +) + + +class HookedArray(sob.Array): + def __init__( + self, + items: Sequence[str] | str | bytes | IO | None = None, + ) -> None: + super().__init__(items) + + +sob.get_writable_array_meta(HookedArray).item_types = sob.Types([str]) + + +class HookedDictionary(sob.Dictionary): + def __init__( + self, + items: dict[str, str] | str | bytes | IO | None = None, + ) -> None: + super().__init__(items) + + +sob.get_writable_dictionary_meta(HookedDictionary).value_types = sob.Types( + [str] +) + + +class PlainObjectA(sob.Object): + pass + + +class PlainObjectB(sob.Object): + pass + + +class PlainObjectC(sob.Object): + pass + + +# endregion + + +def test_object_hooks() -> None: + calls: list[tuple[str, str, str | None]] = [] + + def before_setattr( + instance: sob.Object, name: str, value: str | None + ) -> tuple[str, str | None]: + calls.append(("before_setattr", name, value)) + return name, value + + def after_setattr( + instance: sob.Object, name: str, value: str | None + ) -> None: + calls.append(("after_setattr", name, value)) + + sob.write_model_hooks( + HookedObject, + sob.ObjectHooks( + before_setattr=before_setattr, # type: ignore + after_setattr=after_setattr, # type: ignore + ), + ) + instance: HookedObject = HookedObject() + instance.value = "hi" + assert ("before_setattr", "value", "hi") in calls + assert ("after_setattr", "value", "hi") in calls + + +def test_array_hooks() -> None: + calls: list[tuple[str, str]] = [] + + def before_append(array: sob.Array, value: str) -> str: + calls.append(("before_append", value)) + return value + + def after_append(array: sob.Array, value: str) -> None: + calls.append(("after_append", value)) + + sob.write_model_hooks( + HookedArray, + sob.ArrayHooks( + before_append=before_append, # type: ignore + after_append=after_append, # type: ignore + ), + ) + array: HookedArray = HookedArray() + array.append("x") + assert ("before_append", "x") in calls + assert ("after_append", "x") in calls + + +def test_dictionary_hooks() -> None: + calls: list[tuple[str, str, str]] = [] + + def before_setitem( + dictionary: sob.Dictionary, key: str, value: str + ) -> tuple[str, str]: + calls.append(("before_setitem", key, value)) + return key, value + + def after_setitem( + dictionary: sob.Dictionary, key: str, value: str + ) -> None: + calls.append(("after_setitem", key, value)) + + sob.write_model_hooks( + HookedDictionary, + sob.DictionaryHooks( + before_setitem=before_setitem, # type: ignore + after_setitem=after_setitem, # type: ignore + ), + ) + dictionary: HookedDictionary = HookedDictionary() + dictionary["k"] = "v" + assert ("before_setitem", "k", "v") in calls + assert ("after_setitem", "k", "v") in calls + + +def test_read_model_hooks_type_error() -> None: + error_caught: bool = False + try: + sob.read_model_hooks("not-a-model") # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +def test_get_writable_model_hooks_class_creates_and_persists() -> None: + hooks1: sob.abc.Hooks = sob.get_writable_model_hooks(PlainObjectA) + assert isinstance(hooks1, sob.ObjectHooks) + hooks2: sob.abc.Hooks | None = sob.read_model_hooks(PlainObjectA) + assert hooks2 is hooks1 + + +def test_get_writable_model_hooks_instance_creates() -> None: + instance: PlainObjectB = PlainObjectB() + instance_hooks: sob.abc.Hooks = sob.get_writable_model_hooks(instance) + assert isinstance(instance_hooks, sob.ObjectHooks) + + +def test_get_writable_model_hooks_type_error() -> None: + error_caught: bool = False + try: + sob.get_writable_model_hooks(42) # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +def test_get_writable_object_hooks() -> None: + assert isinstance( + sob.get_writable_object_hooks(HookedObject), sob.ObjectHooks + ) + + +def test_get_writable_array_hooks() -> None: + assert isinstance( + sob.get_writable_array_hooks(HookedArray), sob.ArrayHooks + ) + + +def test_get_writable_dictionary_hooks() -> None: + assert isinstance( + sob.get_writable_dictionary_hooks(HookedDictionary), + sob.DictionaryHooks, + ) + + +def test_get_model_hooks_type() -> None: + assert sob.get_model_hooks_type(HookedObject) is sob.ObjectHooks + assert sob.get_model_hooks_type(HookedObject()) is sob.ObjectHooks + assert sob.get_model_hooks_type(HookedArray) is sob.ArrayHooks + assert sob.get_model_hooks_type(HookedDictionary) is sob.DictionaryHooks + + +def test_get_model_hooks_type_non_type_type_error() -> None: + # Neither a `type`, `Object`, `Dictionary`, nor `Array` instance. + error_caught: bool = False + try: + sob.get_model_hooks_type(42) # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +def test_get_model_hooks_type_wrong_class_type_error() -> None: + # A real `type`, but not a `Model` subclass. + error_caught: bool = False + try: + sob.get_model_hooks_type(str) # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +def test_write_model_hooks_value_error() -> None: + error_caught: bool = False + try: + sob.write_model_hooks(HookedObject, sob.ArrayHooks()) + except ValueError: + error_caught = True + assert error_caught + + +def test_write_model_hooks_instance_clears_hooks() -> None: + # `PlainObjectC` has no class-level hooks, so instance-level state is + # unambiguous: assigning `None` after assigning real hooks clears it. + instance: PlainObjectC = PlainObjectC() + sob.write_model_hooks(instance, sob.ObjectHooks()) + assert sob.read_model_hooks(instance) is not None + sob.write_model_hooks(instance, None) + assert sob.read_model_hooks(instance) is None + + +def test_write_model_hooks_type_error() -> None: + # A real `type`, but not a `Model` subclass. + error_caught: bool = False + try: + sob.write_model_hooks(str, None) # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +if __name__ == "__main__": + pytest.main([__file__, "-s", "-vv"]) diff --git a/tests/test_io.py b/tests/test_io.py index 6ee69c6..cda6a17 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -1,6 +1,7 @@ from __future__ import annotations import doctest +from io import UnsupportedOperation from pathlib import Path import pytest @@ -14,7 +15,8 @@ def test_doctest() -> None: """ Run docstring tests """ - doctest.testmod(_io) + results: doctest.TestResults = doctest.testmod(_io) + assert results.failed == 0, results def test_read() -> None: @@ -22,5 +24,32 @@ def test_read() -> None: _io.read(rainbow_io) +class UnsupportedReadProxy: + def read(self) -> str: + raise UnsupportedOperation + + +class NotReadableProxy: + pass + + +def test_read_unsupported_operation() -> None: + error_caught: bool = False + try: + _io.read(UnsupportedReadProxy()) # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +def test_read_type_error() -> None: + error_caught: bool = False + try: + _io.read(NotReadableProxy()) # type: ignore + except TypeError: + error_caught = True + assert error_caught + + if __name__ == "__main__": pytest.main([__file__, "-s", "-vv"]) diff --git a/tests/test_meta.py b/tests/test_meta.py new file mode 100644 index 0000000..a8d784c --- /dev/null +++ b/tests/test_meta.py @@ -0,0 +1,458 @@ +from __future__ import annotations + +import collections.abc +from copy import copy +from typing import IO, TYPE_CHECKING + +import pytest + +import sob +import sob.meta + +if TYPE_CHECKING: + from collections.abc import Sequence + +# region Declare classes + + +class MetaObjectA(sob.Object): + __slots__: tuple[str, ...] = ("name",) + + def __init__( + self, + _data: str | bytes | dict | Sequence | IO | None = None, + name: str | None = None, + ) -> None: + self.name: str | None = name + super().__init__(_data) + + +sob.get_writable_object_meta(MetaObjectA).properties = sob.Properties( + [("name", sob.StringProperty())] +) + + +class MetaObjectNoMeta(sob.Object): + pass + + +class MetaArrayA(sob.Array): + def __init__( + self, + items: Sequence[str] | str | bytes | IO | None = None, + ) -> None: + super().__init__(items) + + +sob.get_writable_array_meta(MetaArrayA).item_types = sob.Types([str]) + + +class MetaDictionaryA(sob.Dictionary): + def __init__( + self, + items: dict[str, str] | str | bytes | IO | None = None, + ) -> None: + super().__init__(items) + + +sob.get_writable_dictionary_meta(MetaDictionaryA).value_types = sob.Types( + [str] +) + + +class MinimalMapping(collections.abc.Mapping): + """ + A minimal `Mapping` implementation that is *not* `Reversible`, used to + exercise the "sorted items" branch of `Properties.update`. + """ + + def __init__(self, data: dict[str, sob.Property]) -> None: + self._data: dict[str, sob.Property] = data + + def __getitem__(self, key: str) -> sob.Property: + return self._data[key] + + def __iter__(self) -> collections.abc.Iterator[str]: + return iter(self._data) + + def __len__(self) -> int: + return len(self._data) + + +# endregion + + +class BareMetaDictionaryA(sob.Dictionary): + pass + + +class BareMetaArrayA(sob.Array): + pass + + +def test_dictionary_meta_bare_value_type() -> None: + dictionary_meta: sob.abc.DictionaryMeta = sob.get_writable_dictionary_meta( + BareMetaDictionaryA + ) + dictionary_meta.value_types = sob.StringProperty() # type: ignore + assert dictionary_meta.value_types is not None + assert len(dictionary_meta.value_types) == 1 + assert isinstance(dictionary_meta.value_types[0], sob.StringProperty) + + +def test_array_meta_bare_item_type() -> None: + array_meta: sob.abc.ArrayMeta = sob.get_writable_array_meta(BareMetaArrayA) + array_meta.item_types = str # type: ignore + assert array_meta.item_types is not None + assert len(array_meta.item_types) == 1 + assert array_meta.item_types[0] is str + + +def test_properties_mapping_protocol() -> None: + original_properties: sob.abc.Properties | None = ( + sob.get_writable_object_meta(MetaObjectA).properties + ) + assert original_properties is not None + properties: sob.abc.Properties = copy(original_properties) + assert list(properties.values()) == [properties["name"]] + assert repr(properties) + assert repr(sob.Properties()) == "sob.Properties()" + popped: sob.abc.Property = properties.pop("name") + assert isinstance(popped, sob.StringProperty) + properties["name"] = popped + del properties["name"] + assert "name" not in properties + properties["name"] = popped + key, _value = properties.popitem() + assert key == "name" + properties.clear() + assert len(properties) == 0 + assert properties.get("missing") is None + properties.setdefault("name", sob.StringProperty()) + assert isinstance(properties.get("name"), sob.StringProperty) + + +def test_properties_setitem_mapped_type() -> None: + properties: sob.Properties = sob.Properties() + properties["x"] = str # type: ignore + assert isinstance(properties["x"], sob.StringProperty) + + +def test_properties_setitem_type_error() -> None: + properties: sob.Properties = sob.Properties() + error_caught: bool = False + try: + properties["bad"] = "not-a-property-or-mapped-type" # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +def test_properties_setdefault_type_error() -> None: + properties: sob.Properties = sob.Properties() + error_caught: bool = False + try: + properties.setdefault("x", 5) # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +def test_properties_update_non_reversible_mapping() -> None: + properties: sob.Properties = sob.Properties() + properties.update(MinimalMapping({"name": sob.StringProperty()})) + assert isinstance(properties["name"], sob.StringProperty) + + +def test_properties_equality() -> None: + properties_a: sob.Properties = sob.Properties( + [("name", sob.StringProperty())] + ) + properties_b: sob.Properties = sob.Properties( + [("name", properties_a["name"])] + ) + assert properties_a == properties_b + assert properties_a != "not-properties" + + +def test_read_model_meta_type_error() -> None: + error_caught: bool = False + try: + sob.read_model_meta("not-a-model") # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +def test_read_model_meta_none() -> None: + assert sob.read_model_meta(MetaObjectNoMeta) is None + + +def test_get_writable_object_meta_type_error() -> None: + error_caught: bool = False + try: + sob.get_writable_object_meta("not-a-model") # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +def test_get_model_meta_type() -> None: + assert sob.meta.get_model_meta_type(MetaObjectA) is sob.ObjectMeta + assert sob.meta.get_model_meta_type(MetaArrayA) is sob.ArrayMeta + assert sob.meta.get_model_meta_type(MetaDictionaryA) is sob.DictionaryMeta + + +def test_get_model_meta_type_errors() -> None: + error_caught: bool = False + try: + sob.meta.get_model_meta_type(42) # type: ignore + except TypeError: + error_caught = True + assert error_caught + error_caught = False + try: + sob.meta.get_model_meta_type(str) # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +def test_write_model_meta_value_error() -> None: + error_caught: bool = False + try: + sob.write_model_meta(MetaObjectA, sob.ArrayMeta()) + except ValueError: + error_caught = True + assert error_caught + + +def test_write_model_meta_type_error() -> None: + error_caught: bool = False + try: + sob.write_model_meta(str, None) # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +class ClassMetaObjectA(sob.Object): + pass + + +def test_write_model_meta_class_success() -> None: + sob.write_model_meta(ClassMetaObjectA, None) + assert sob.read_model_meta(ClassMetaObjectA) is None + new_meta: sob.ObjectMeta = sob.ObjectMeta() + sob.write_model_meta(ClassMetaObjectA, new_meta) + assert sob.read_model_meta(ClassMetaObjectA) is new_meta + + +def test_properties_hash() -> None: + properties: sob.Properties = sob.Properties( + [("name", sob.StringProperty())] + ) + assert isinstance(hash(properties), int) + + +def test_read_object_properties_no_metadata() -> None: + assert sob.meta._read_object_properties(MetaObjectNoMeta) is None # noqa: SLF001 + assert sob.meta._read_object_property_names(MetaObjectNoMeta) is None # noqa: SLF001 + + +def test_read_object_type_error() -> None: + class FakeObjectMeta(sob.Object): + pass + + # Force class-level metadata to be a non-`ObjectMeta` instance, + # bypassing `write_model_meta`'s own type enforcement, to exercise + # `_read_object`'s defensive type check. + FakeObjectMeta._class_meta = sob.ArrayMeta() # type: ignore # noqa: SLF001 + error_caught: bool = False + try: + sob.meta._read_object(FakeObjectMeta) # noqa: SLF001 + except TypeError: + error_caught = True + assert error_caught + + +def test_pointer_getter_setter() -> None: + instance: MetaObjectA = MetaObjectA() + with pytest.warns(DeprecationWarning): + assert sob.meta.pointer(instance, "/foo/bar") == "/foo/bar" + assert sob.meta.pointer(instance) == "/foo/bar" + + +def test_pointer_type_error() -> None: + error_caught: bool = False + try: + sob.meta.pointer(123) # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +def test_set_model_url_type_errors() -> None: + error_caught: bool = False + try: + sob.set_model_url(123, "https://example.com") # type: ignore + except TypeError: + error_caught = True + assert error_caught + error_caught = False + try: + sob.set_model_url(MetaObjectA(), 123) # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +def test_url_getter_setter() -> None: + instance: MetaObjectA = MetaObjectA() + with pytest.warns(DeprecationWarning): + assert sob.meta.url(instance, "https://example.com") == ( + "https://example.com" + ) + assert sob.meta.url(instance) == "https://example.com" + + +class VersionedItemArray(sob.Array): + def __init__( + self, + items: Sequence[str | int] | str | bytes | IO | None = None, + ) -> None: + super().__init__(items) + + +sob.get_writable_array_meta(VersionedItemArray).item_types = sob.Types( + [ + sob.StringProperty(versions=["test-meta-spec<1.2"]), + sob.NumberProperty(versions=["test-meta-spec~=1.2"]), + ] +) + + +class VersionedValueDictionary(sob.Dictionary): + def __init__( + self, + items: dict[str, str | int] | str | bytes | IO | None = None, + ) -> None: + super().__init__(items) + + +sob.get_writable_dictionary_meta( + VersionedValueDictionary +).value_types = sob.Types( + [ + sob.StringProperty(versions=["test-meta-spec<1.2"]), + sob.NumberProperty(versions=["test-meta-spec~=1.2"]), + ] +) + + +def test_version_model_array() -> None: + array: VersionedItemArray = VersionedItemArray(["a", "b"]) + sob.meta.version_model(array, "test-meta-spec", "1.5") + item_types: sob.abc.Types | None = sob.read_array_meta(array).item_types # type: ignore + assert item_types is not None + assert len(item_types) == 1 + assert isinstance(item_types[0], sob.NumberProperty) + + +def test_version_model_dictionary() -> None: + dictionary: VersionedValueDictionary = VersionedValueDictionary({"a": "x"}) + sob.meta.version_model(dictionary, "test-meta-spec", "1.5") + value_types: sob.abc.Types | None = sob.read_dictionary_meta( + dictionary + ).value_types # type: ignore + assert value_types is not None + assert len(value_types) == 1 + assert isinstance(value_types[0], sob.NumberProperty) + + +class NoMetaVersionedObject(sob.Object): + pass + + +def test_version_model_no_metadata_runtime_error() -> None: + error_caught: bool = False + try: + sob.meta.version_model(NoMetaVersionedObject(), "spec", "1.0") + except RuntimeError: + error_caught = True + assert error_caught + + +class NestedItemArray(sob.Array): + def __init__( + self, + items: Sequence[MetaObjectA] | str | bytes | IO | None = None, + ) -> None: + super().__init__(items) + + +sob.get_writable_array_meta(NestedItemArray).item_types = sob.Types( + [MetaObjectA] +) + + +class NestedValueDictionary(sob.Dictionary): + def __init__( + self, + items: dict[str, MetaObjectA] | str | bytes | IO | None = None, + ) -> None: + super().__init__(items) + + +sob.get_writable_dictionary_meta( + NestedValueDictionary +).value_types = sob.Types([MetaObjectA]) + + +def test_version_model_recurses_into_array_items() -> None: + array: NestedItemArray = NestedItemArray([MetaObjectA(name="a")]) + sob.meta.version_model(array, "test-meta-spec", "1.0") + assert isinstance(array[0], MetaObjectA) + + +def test_version_model_recurses_into_dictionary_values() -> None: + dictionary: NestedValueDictionary = NestedValueDictionary( + {"a": MetaObjectA(name="a")} + ) + sob.meta.version_model(dictionary, "test-meta-spec", "1.0") + assert isinstance(dictionary["a"], MetaObjectA) + + +def test_version_model_type_errors() -> None: + error_caught: bool = False + try: + sob.meta.version_model(42, "spec", "1.0") # type: ignore + except TypeError: + error_caught = True + assert error_caught + error_caught = False + try: + sob.meta.version_model( + MetaObjectA(), + "spec", + object(), # type: ignore + ) + except TypeError: + error_caught = True + assert error_caught + + +def test_copy_model_meta_to_type_errors() -> None: + error_caught: bool = False + try: + sob.meta._copy_model_meta_to( # noqa: SLF001 + "not-a-model", # type: ignore + MetaObjectA(), + ) + except TypeError: + error_caught = True + assert error_caught + + +if __name__ == "__main__": + pytest.main([__file__, "-s", "-vv"]) diff --git a/tests/test_model.py b/tests/test_model.py index 8548d0b..b7fcc9b 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -3,11 +3,11 @@ import doctest import os from base64 import b64encode -from copy import deepcopy +from copy import copy, deepcopy from datetime import date, datetime from decimal import Decimal from pathlib import Path -from typing import IO, TYPE_CHECKING, cast +from typing import IO, TYPE_CHECKING, Any, cast import pytest from iso8601.iso8601 import parse_date @@ -92,6 +92,21 @@ def __init__( sob.get_writable_array_meta(ArrayA).item_types = sob.Types([ObjectA]) +class DictionaryA(sob.Dictionary): + def __init__( + self, + items: ( + dict[str, ObjectA] | sob.abc.Readable | str | bytes | None + ) = None, + ) -> None: + super().__init__(items) + + +sob.get_writable_dictionary_meta(DictionaryA).value_types = sob.Types( + [ObjectA] +) + + class ObjectB(sob.Object): __slots__: tuple[str, ...] = ( "alpha", @@ -600,7 +615,8 @@ def test_doctest() -> None: """ Run docstring tests """ - doctest.testmod(sob.model) + results: doctest.TestResults = doctest.testmod(sob.model) + assert results.failed == 0, results def test_copy() -> None: @@ -771,5 +787,381 @@ def test_replace_model_nulls() -> None: assert testy_copy.null_value is None +def test_replace_model_nulls_array() -> None: + """ + Verify that `replace_model_nulls` also replaces `sob.NULL` items + within an `Array`, not just `Object` properties. A non-`None` + replacement value is used because, unlike `Object.__setattr__`, + `Array.__setitem__` always unmarshals its assigned value -- and `None` + unmarshals back to `sob.NULL` (there being no "unset" concept for an + array item as there is for an object attribute). + """ + array: sob.Array = sob.Array() + array._list.append(sob.NULL) # noqa: SLF001 + sob.replace_model_nulls(array, "replaced") + assert array[0] == "replaced" + + +def test_model_format_type_error() -> None: + error_caught: bool = False + try: + sob.Array(123) # type: ignore + except TypeError: + error_caught = True + assert error_caught + error_caught = False + try: + sob.Dictionary(123) # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +# region Array protocol + + +def test_array_mutation_protocol() -> None: + array: ArrayA = ArrayA([ObjectA(string="a")]) + array.append(ObjectA(string="b")) + assert len(array) == 2 # noqa: PLR2004 + array[0] = ObjectA(string="c") + assert array[0].string == "c" + del array[0] + assert len(array) == 1 + array.extend([ObjectA(string="d")]) + assert len(array) == 2 # noqa: PLR2004 + array.sort(key=lambda item: item.string or "") + array.reverse() + popped: Any = array.pop() + assert isinstance(popped, ObjectA) + array.remove(array[0]) + assert len(array) == 0 + array.insert(0, ObjectA(string="e")) + assert array[0].string == "e" + assert list(reversed(array)) == list(array)[::-1] + assert ObjectA(string="e") in array + array.clear() + assert len(array) == 0 + + +def test_array_copy_and_repr_and_str() -> None: + array: ArrayA = ArrayA([ObjectA(string="a")]) + copied: ArrayA = copy(array) + assert copied == array + assert copied is not array + assert repr(array) + assert str(array) + assert (array + [ObjectA(string="b")]) != array + array += [ObjectA(string="b")] + assert len(array) == 2 # noqa: PLR2004 + + +def test_array_equality_mismatches() -> None: + array: ArrayA = ArrayA([ObjectA(string="a")]) + assert array != ArrayA() + assert array != DictionaryA() + + +def test_array_hooks_wired_through_validate() -> None: + calls: list[str] = [] + + def before_validate(array: sob.Array) -> sob.Array: + calls.append("before_validate") + return array + + def after_validate(array: sob.Array) -> None: + calls.append("after_validate") + + array: ArrayA = ArrayA([ObjectA(string="a")]) + sob.write_model_hooks( + array, + sob.ArrayHooks( + before_validate=before_validate, # type: ignore + after_validate=after_validate, # type: ignore + ), + ) + sob.validate(array) + assert calls == ["before_validate", "after_validate"] + + +# endregion +# region Dictionary protocol + + +def test_dictionary_mutation_protocol() -> None: + dictionary: DictionaryA = DictionaryA({"a": ObjectA(string="a")}) + dictionary.update({"b": ObjectA(string="b")}, [("c", ObjectA(string="c"))]) + assert len(dictionary) == 3 # noqa: PLR2004 + dictionary.setdefault("d", ObjectA(string="d")) + assert "d" in dictionary + popped: ObjectA = dictionary.pop("d") + assert isinstance(popped, ObjectA) + key, value = dictionary.popitem() + assert isinstance(key, str) + assert isinstance(value, ObjectA) + assert "a" in dictionary + assert list(reversed(dictionary)) == list(reversed(list(dictionary))) + del dictionary["a"] + assert "a" not in dictionary + + +def test_dictionary_from_tuple_iterable() -> None: + dictionary: DictionaryA = DictionaryA( + [("a", ObjectA(string="a"))] # type: ignore + ) + assert dictionary["a"].string == "a" + + +def test_dictionary_copy() -> None: + dictionary: DictionaryA = DictionaryA({"a": ObjectA(string="a")}) + assert copy(dictionary) == dictionary + assert deepcopy(dictionary) == dictionary + assert deepcopy(dictionary) is not dictionary + + +def test_dictionary_equality_mismatch() -> None: + dictionary: DictionaryA = DictionaryA({"a": ObjectA(string="a")}) + assert dictionary != DictionaryA() + assert dictionary != ArrayA() + + +def test_dictionary_hooks_wired_through_setitem() -> None: + calls: list[str] = [] + + def before_setitem( + dictionary: sob.Dictionary, key: str, value: Any + ) -> tuple[str, Any]: + calls.append("before_setitem") + return key, value + + def after_setitem( + dictionary: sob.Dictionary, key: str, value: Any + ) -> None: + calls.append("after_setitem") + + dictionary: DictionaryA = DictionaryA() + sob.write_model_hooks( + dictionary, + sob.DictionaryHooks( + before_setitem=before_setitem, # type: ignore + after_setitem=after_setitem, # type: ignore + ), + ) + dictionary["a"] = ObjectA(string="a") + assert calls == ["before_setitem", "after_setitem"] + + +# endregion +# region Object extras/copy-init + + +def test_object_extra_attributes() -> None: + obj: ObjectA = ObjectA() + obj["extra_key"] = "extra value" + assert obj["extra_key"] == "extra value" + del obj["extra_key"] + error_caught: bool = False + try: + obj["extra_key"] + except KeyError: + error_caught = True + assert error_caught + + +def test_object_getitem_key_error_no_extras_at_all() -> None: + # A fresh instance where `_extra` has never been assigned at all (as + # opposed to having been assigned and later emptied). + obj: ObjectA = ObjectA() + error_caught: bool = False + try: + obj["never-set"] + except KeyError: + error_caught = True + assert error_caught + + +def test_object_delitem_key_error_no_extras_at_all() -> None: + obj: ObjectA = ObjectA() + error_caught: bool = False + try: + del obj["never-set"] + except KeyError: + error_caught = True + assert error_caught + + +def test_object_getitem_setitem_delitem_real_property() -> None: + obj: ObjectA = ObjectA(string="a") + assert obj["string"] == "a" + obj["string"] = "b" + assert obj.string == "b" + del obj["string"] + assert obj.string is None + + +def test_object_hooks_wired_through_setitem() -> None: + calls: list[str] = [] + + def before_setitem( + obj: sob.Object, key: str, value: Any + ) -> tuple[str, Any]: + calls.append("before_setitem") + return key, value + + def after_setitem(obj: sob.Object, key: str, value: Any) -> None: + calls.append("after_setitem") + + obj: ObjectA = ObjectA() + sob.write_model_hooks( + obj, + sob.ObjectHooks( + before_setitem=before_setitem, # type: ignore + after_setitem=after_setitem, # type: ignore + ), + ) + obj["string"] = "hi" + assert calls == ["before_setitem", "after_setitem"] + assert obj.string == "hi" + + +# endregion +# region get_model_from_meta() / get_models_source() + + +def test_get_model_from_meta_dictionary_docstring_pre_init_source() -> None: + """ + Exercise `get_model_from_meta`'s `DictionaryMeta` branch, along with + its `docstring=`/`pre_init_source=` arguments -- not part of the + `Tesstee` regression fixture (test_get_model_from_meta_regression), so + this doesn't require regenerating any checked-in golden file. + """ + dictionary_meta: sob.abc.DictionaryMeta = cast( + "sob.abc.DictionaryMeta", sob.read_dictionary_meta(DictionaryA) + ) + model_class: type = sob.get_model_from_meta( + "GeneratedDictionaryA", + dictionary_meta, + module="__main__", + docstring="A generated dictionary model.", + pre_init_source="X = 1", + ) + assert issubclass(model_class, sob.Dictionary) + source: str = sob.get_models_source(model_class) + assert "class GeneratedDictionaryA" in source + assert "A generated dictionary model." in source + assert "X = 1" in source + + +# endregion +# region marshal()/unmarshal()/serialize()/deserialize()/validate() + + +def test_marshal_raw_data() -> None: + assert sob.marshal({"a": 1}) == {"a": 1} + assert sob.marshal([1, 2]) == [1, 2] + assert sob.marshal(Decimal("1.5")) == 1.5 # noqa: PLR2004 + assert isinstance(sob.marshal(datetime(2024, 1, 1)), str) + assert isinstance(sob.marshal(date(2024, 1, 1)), str) + import base64 + + assert sob.marshal(b"data") == str(base64.b64encode(b"data"), "ascii") + + +def test_marshal_unsupported_type_error() -> None: + error_caught: bool = False + try: + sob.marshal(object()) # type: ignore + except ValueError: + error_caught = True + assert error_caught + + +def test_marshal_types_type_error() -> None: + # `types` is only consulted for data which isn't already a + # `Decimal`/`None`/`str`/`int`/`float`/`sob.NULL`/`sob.Model`. + error_caught: bool = False + try: + sob.marshal(object(), types=(str,)) # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +def test_unmarshal_single_type_not_iterable() -> None: + obj: Any = sob.unmarshal({"string": "a"}, types=ObjectA) + assert isinstance(obj, ObjectA) + assert obj.string == "a" + + +def test_unmarshal_generator() -> None: + result: Any = sob.unmarshal(x for x in (1, 2)) + assert list(result) == [1, 2] + + +def test_unmarshal_none() -> None: + # `None` unmarshals to the explicit `sob.NULL` sentinel. + assert sob.unmarshal(None) is sob.NULL + + +def test_unmarshal_before_unmarshal_hook() -> None: + calls: list[str] = [] + + def before_unmarshal(data: Any) -> Any: + calls.append("before_unmarshal") + return data + + sob.write_model_hooks( + ObjectA, sob.ObjectHooks(before_unmarshal=before_unmarshal) + ) + try: + sob.unmarshal({"string": "a"}, types=(ObjectA,)) + assert calls == ["before_unmarshal"] + finally: + sob.write_model_hooks(ObjectA, None) + + +def test_serialize_before_after_hooks() -> None: + calls: list[str] = [] + + def before_serialize(data: Any) -> Any: + calls.append("before_serialize") + return data + + def after_serialize(data: str) -> str: + calls.append("after_serialize") + return data + + obj: ObjectA = ObjectA(string="a") + sob.write_model_hooks( + obj, + sob.ObjectHooks( + before_serialize=before_serialize, # type: ignore + after_serialize=after_serialize, # type: ignore + ), + ) + sob.serialize(obj) + assert calls == ["before_serialize", "after_serialize"] + + +def test_deserialize_bytes() -> None: + assert sob.deserialize(b'{"a": 1}') == {"a": 1} + + +def test_deserialize_type_error() -> None: + error_caught: bool = False + try: + sob.deserialize(123) # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +def test_validate_bare_type() -> None: + assert sob.validate(ObjectA(string="a"), types=(ObjectA,)) == [] + + +# endregion + + if __name__ == "__main__": pytest.main([__file__, "-s", "-vv"]) diff --git a/tests/test_properties.py b/tests/test_properties.py new file mode 100644 index 0000000..e4903ca --- /dev/null +++ b/tests/test_properties.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import pytest + +import sob +import sob.properties + + +def test_has_mutable_types() -> None: + assert sob.properties.has_mutable_types(sob.Property()) + assert not sob.properties.has_mutable_types(sob.StringProperty) + error_caught: bool = False + try: + sob.properties.has_mutable_types(int) # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +def test_property_types_immutable() -> None: + string_property: sob.StringProperty = sob.StringProperty() + error_caught: bool = False + try: + string_property.types = [int] # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +def test_property_types_invalid() -> None: + property_: sob.Property = sob.Property() + error_caught: bool = False + try: + property_.types = "not-a-type" # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +def test_property_versions_invalid() -> None: + property_: sob.Property = sob.Property() + error_caught: bool = False + try: + property_.versions = 123 # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +if __name__ == "__main__": + pytest.main([__file__, "-s", "-vv"]) diff --git a/tests/test_thesaurus.py b/tests/test_thesaurus.py index d6c856a..ddc9cf6 100644 --- a/tests/test_thesaurus.py +++ b/tests/test_thesaurus.py @@ -1,11 +1,20 @@ from __future__ import annotations +import io import json +from copy import copy, deepcopy +from datetime import date, datetime from pathlib import Path +from types import ModuleType import pytest -from sob.thesaurus import Thesaurus +import sob +from sob.thesaurus import ( + Synonyms, + Thesaurus, + get_class_meta_attribute_assignment_source, +) THESAURUS_JSON: Path = Path(__file__).parent / "static-data" / "thesaurus.json" THESAURUS_MODEL_PY: Path = ( @@ -24,5 +33,261 @@ def test_thesaurus() -> None: thesaurus.save_module(THESAURUS_MODEL_PY) +# region Synonyms construction/inference + + +def test_synonyms_add_type_error() -> None: + error_caught: bool = False + try: + Synonyms().add(object()) # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +def test_synonyms_int_after_float_stays_float() -> None: + synonyms: Synonyms = Synonyms([1.5, 2]) + assert synonyms._type == {float} # noqa: SLF001 + + +def test_synonyms_base64_inference() -> None: + synonyms: Synonyms = Synonyms(["aGVsbG8=", "d29ybGQ="]) + assert list(synonyms._iter_simple_types()) == [bytes] # noqa: SLF001 + + +def test_synonyms_date_inference() -> None: + synonyms: Synonyms = Synonyms(["2020-01-01", "2021-02-02"]) + assert list(synonyms._iter_simple_types()) == [date] # noqa: SLF001 + + +def test_synonyms_datetime_inference() -> None: + synonyms: Synonyms = Synonyms( + ["2020-01-01T00:00:00", "2021-02-02T00:00:00"] + ) + assert list(synonyms._iter_simple_types()) == [ # noqa: SLF001 + datetime + ] + + +def test_synonyms_always_null_property() -> None: + thesaurus: Thesaurus = Thesaurus( + { + "item": [ + {"always_null": None, "name": "a"}, + {"always_null": None, "name": "b"}, + ] + } + ) + source: str = thesaurus.get_module_source() + assert 'sob.Property(\n name="always_null"' in source + + +def test_synonyms_file_like_input() -> None: + synonyms: Synonyms = Synonyms() + synonyms.add(io.StringIO("[42]")) + assert list(synonyms) == [42] + synonyms_bytes: Synonyms = Synonyms() + synonyms_bytes.add(io.BytesIO(b"[42]")) + assert list(synonyms_bytes) == [42] + + +# endregion +# region Synonyms mutation/set-algebra + + +def test_synonyms_mutation() -> None: + synonyms: Synonyms = Synonyms(["a", "b", "c"]) + synonyms.discard("a") + assert "a" not in synonyms + synonyms.remove("b") + assert "b" not in synonyms + popped: str = synonyms.pop() # type: ignore + assert popped == "c" + assert len(synonyms) == 0 + + +def test_synonyms_set_algebra() -> None: + synonyms_a: Synonyms = Synonyms(["a", "b"]) + synonyms_b: Synonyms = Synonyms(["b", "c"]) + assert set(synonyms_a & synonyms_b) == {"b"} + assert set(synonyms_a ^ synonyms_b) == {"a", "c"} + assert set(synonyms_a - synonyms_b) == {"a"} + assert Synonyms(["a"]) <= synonyms_a + assert Synonyms(["a"]) < synonyms_a + assert synonyms_a > Synonyms(["a"]) + assert synonyms_a >= Synonyms(["a", "b"]) + assert synonyms_a == Synonyms(["a", "b"]) + assert "a" in synonyms_a + assert synonyms_a.isdisjoint(Synonyms(["z"])) + assert not synonyms_a.isdisjoint(synonyms_b) + assert copy(synonyms_a) == synonyms_a + assert deepcopy(synonyms_a) == synonyms_a + + +# endregion +# region Synonyms.get_models guards + + +def test_synonyms_get_models_name_type_error() -> None: + error_caught: bool = False + try: + list(Synonyms(["a"]).get_models("ptr", name=123)) # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +def test_synonyms_get_models_runtime_error() -> None: + error_caught: bool = False + try: + list(Synonyms([sob.NULL]).get_models("ptr")) + except RuntimeError: + error_caught = True + assert error_caught + + +# endregion +# region metadata-merge chain + + +def test_thesaurus_mixed_object_array_conflict() -> None: + """ + The same JSON pointer resolving to both an object and an array (across + different records sharing a key) is a real, reachable error via the + public API. + """ + thesaurus: Thesaurus = Thesaurus( + { + "item": [ + {"tags": {"id": 1}}, + {"tags": [{"id": 1}, {"id": 2}]}, + ] + } # type: ignore + ) + error_caught: bool = False + try: + thesaurus.get_module_source() + except TypeError: + error_caught = True + assert error_caught + + +def test_update_object_class_from_meta_merges_new_properties() -> None: + """ + Regression test: `_update_object_meta` used to compute + `metadata_keys - new_metadata_keys` (properties unique to the + *existing* metadata) when deciding which properties to copy over from + `new_metadata`, then look those keys up *in* `new_metadata` -- which + doesn't have them. This either silently added nothing (when the + existing metadata's properties were a subset of the new metadata's) + or raised a `KeyError` (when the existing metadata had properties the + new metadata didn't). Fixed to compute + `new_metadata_keys - metadata_keys` instead, so properties introduced + by a later-encountered, differently-shaped record actually get merged + in, per this function's own docstring. + """ + initial_meta: sob.ObjectMeta = sob.ObjectMeta() + initial_meta.properties = sob.Properties( + [ + ("a", sob.StringProperty()), + ("c", sob.BooleanProperty()), + ] + ) + model_class: type = sob.get_model_from_meta( + "MergeTarget", initial_meta, module="__main__" + ) + new_meta: sob.ObjectMeta = sob.ObjectMeta() + new_meta.properties = sob.Properties( + [ + ("a", sob.StringProperty()), + ("b", sob.IntegerProperty()), + ] + ) + sob.thesaurus._update_object_class_from_meta( # noqa: SLF001 + model_class, new_meta, memo={} + ) + updated_meta: sob.abc.ObjectMeta | None = sob.read_object_meta(model_class) + assert updated_meta is not None + assert updated_meta.properties is not None + assert set(updated_meta.properties.keys()) == {"a", "b", "c"} + + +# endregion + + +def test_get_class_meta_attribute_assignment_source() -> None: + array_meta: sob.ArrayMeta = sob.ArrayMeta(item_types=[str]) + source: str = get_class_meta_attribute_assignment_source( + "MyClass", "item_types", array_meta + ) + assert "MyClass" in source + assert "item_types" in source + + +# region Thesaurus mapping/set protocol + + +def test_thesaurus_mapping_protocol() -> None: + thesaurus: Thesaurus = Thesaurus({"a": ["x", "y"], "b": ["z"]}) + assert set(thesaurus.keys()) == {"a", "b"} + assert isinstance(thesaurus["a"], Synonyms) + # Auto-vivifying `__getitem__` + assert isinstance(thesaurus["new"], Synonyms) + thesaurus.update(c=["w"]) + assert "c" in thesaurus + thesaurus.setdefault("d", ["v"]) + assert "d" in thesaurus + key: str + key, _synonyms = thesaurus.popitem() + assert key not in thesaurus + assert copy(thesaurus) == thesaurus + assert deepcopy(thesaurus) == thesaurus + assert list(reversed(thesaurus).keys()) == list( # type: ignore + reversed(list(thesaurus.keys())) + ) + + +def test_thesaurus_add() -> None: + thesaurus_a: Thesaurus = Thesaurus({"a": ["x"]}) + thesaurus_b: Thesaurus = Thesaurus({"b": ["y"]}) + combined: Thesaurus = thesaurus_a + thesaurus_b + assert set(combined.keys()) == {"a", "b"} + thesaurus_a += thesaurus_b + assert set(thesaurus_a.keys()) == {"a", "b"} + + +def test_thesaurus_iadd_type_error() -> None: + thesaurus: Thesaurus = Thesaurus({"a": ["x"]}) + error_caught: bool = False + try: + thesaurus += "not-a-thesaurus" # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +# endregion +# region get_module()/save_module() + + +def test_thesaurus_get_module() -> None: + thesaurus: Thesaurus = Thesaurus({"item": [{"name": "a"}, {"name": "b"}]}) + module: ModuleType = thesaurus.get_module() + assert hasattr(module, "Item") + assert issubclass(module.Item, sob.Object) + + +def test_thesaurus_save_module(tmp_path: Path) -> None: + thesaurus: Thesaurus = Thesaurus({"item": [{"name": "a"}, {"name": "b"}]}) + module_path: Path = tmp_path / "generated_model.py" + assert not module_path.exists() + thesaurus.save_module(module_path) + assert module_path.exists() + assert "class Item" in module_path.read_text() + + +# endregion + + if __name__ == "__main__": pytest.main([__file__, "-s", "-vv"]) diff --git a/tests/test_types.py b/tests/test_types.py index 4150898..8d3c22e 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,3 +1,4 @@ +import decimal import pickle from copy import copy @@ -22,6 +23,8 @@ def test_undefined() -> None: assert error_raised # Make sure UNDEFINED evaluates as False assert not sob.UNDEFINED + # Make sure UNDEFINED hashes to a constant value + assert hash(sob.UNDEFINED) == 0 # Make sure UNDEFINED copies correctly assert copy(sob.UNDEFINED) is sob.UNDEFINED # Make sure UNDEFINED pickles correctly @@ -49,6 +52,10 @@ def test_null() -> None: assert error_raised # Make sure NULL evaluates as False assert not sob.NULL + # Make sure NULL hashes to a constant value and stringifies as "null" + assert hash(sob.NULL) == 0 + assert str(sob.NULL) == "null" + assert sob.Null._marshal() is None # noqa: SLF001 # Make sure NULL copies correctly assert copy(sob.NULL) is sob.NULL # Make sure NULL pickles correctly @@ -98,5 +105,38 @@ def test_mutable_types() -> None: types.pop(0) +def test_types_bare_type() -> None: + """ + A bare type (not wrapped in a sequence) is accepted and wrapped. + """ + types_ = sob.Types(str) + assert list(types_) == [str] + + +def test_types_copy() -> None: + types_ = sob.Types([int, str]) + copied = copy(types_) + assert copied is not types_ + assert list(copied) == list(types_) + + +def test_mutable_types_protocol() -> None: + """ + Exercise the full `MutableList`-like protocol of `MutableTypes`. + """ + types_: sob.MutableTypes = sob.MutableTypes([int, str]) + types_[0] = float + assert types_[0] is float + types_.extend([bool]) + assert bool in types_ + del types_[0] + assert float not in types_ + types_ += [bytes] + assert bytes in types_ + new_types = types_ + [decimal.Decimal] + assert decimal.Decimal in new_types + assert decimal.Decimal not in types_ + + if __name__ == "__main__": pytest.main([__file__, "-s", "-vv"]) diff --git a/tests/test_utilities.py b/tests/test_utilities.py index 364952d..d3983ed 100644 --- a/tests/test_utilities.py +++ b/tests/test_utilities.py @@ -6,7 +6,7 @@ import pytest import sob -from sob import _io, _types, utilities +from sob import _io, _types, _utilities, utilities from sob._utilities import get_readable_url @@ -14,7 +14,8 @@ def test_doctest() -> None: """ Run docstring tests """ - doctest.testmod(utilities) + results: doctest.TestResults = doctest.testmod(utilities) + assert results.failed == 0, results def test_utilities() -> None: @@ -104,11 +105,13 @@ def test_get_calling_function_qualified_name() -> None: def test_io() -> None: - doctest.testmod(_io) + results: doctest.TestResults = doctest.testmod(_io) + assert results.failed == 0, results def test_types() -> None: - doctest.testmod(_types) + results: doctest.TestResults = doctest.testmod(_types) + assert results.failed == 0, results class HTTPResponseProxy1: @@ -128,6 +131,14 @@ class UnixFileProxy: name = "/a/b/c" +class URLNonStringProxy: + url = 123 + + +class NoAttributesProxy: + pass + + def test_get_readable_url() -> None: assert get_readable_url(HTTPResponseProxy1()) == "https://example.com" assert get_readable_url(HTTPResponseProxy2()) == "https://example.com" @@ -135,5 +146,218 @@ def test_get_readable_url() -> None: assert get_readable_url(UnixFileProxy()) == "file:///a/b/c" +def test_get_readable_url_type_error() -> None: + error_caught: bool = False + try: + get_readable_url(URLNonStringProxy()) + except TypeError: + error_caught = True + assert error_caught + + +def test_get_readable_url_none() -> None: + assert get_readable_url(NoAttributesProxy()) is None + + +def test_deserialize_error() -> None: + error = sob.errors.DeserializeError(data="bad-data", message="oops") + assert error.data == "bad-data" + assert error.message == "oops" + assert repr(error) == "oops\nCould not parse:\nbad-data" + assert str(error) == repr(error) + + +def test_append_exception_text_strerror() -> None: + error = OSError(1, "boom") + sob.errors.append_exception_text(error, " (more info)") + assert error.strerror is not None + assert error.strerror.endswith(" (more info)") + + +def test_append_exception_text_no_string_arg() -> None: + error = Exception() + sob.errors.append_exception_text(error, "appended") + assert error.args == ("appended",) + + +def test_deprecated() -> None: + @_utilities.deprecated("this is deprecated") + def old_function(value: int) -> int: + return value * 2 + + with pytest.warns(DeprecationWarning, match="this is deprecated"): + result: int = old_function(21) + assert result == 42 + + +def test_get_class_name_leading_digit() -> None: + assert utilities.get_class_name("123 abc").startswith("_") + + +def test_indent_negative_stop() -> None: + # A negative `stop` excludes lines counting back from the end, similar + # to slice notation. + assert utilities.indent("a\nb\nc\nd", stop=-1) == ("a\n b\n c\nd") + + +def test_url_directory_and_file_name_value_error() -> None: + error_caught: bool = False + try: + utilities._url_directory_and_file_name("no-slash-here") + except ValueError: + error_caught = True + assert error_caught + + +def test_get_url_relative_to_no_shared_prefix() -> None: + assert ( + utilities.get_url_relative_to("https://a.com/x/y", "https://a.com/x/z") + == "y" + ) + assert ( + utilities.get_url_relative_to("https://a.com/x/y", "https://b.com/p/q") + == "../../a.com/x/y" + ) + + +def test_align_indent_no_leading_whitespace() -> None: + assert utilities._align_indent("no-leading-space") == "no-leading-space" + + +def test_align_indent_with_leading_whitespace() -> None: + # 6 leading spaces, tab_width=4 -> strip 6 % 4 == 2 spaces + assert utilities._align_indent(" indented", tab_width=4) == ( + " indented" + ) + + +def test_split_long_comment_line_short() -> None: + assert ( + utilities._split_long_comment_line("# a short line") + == "# a short line" + ) + + +def test_split_long_docstring_lines_tab_and_blank_line() -> None: + docstring: str = ( + "\tLine one.\n\n\tLine two, which continues on the next line." + ) + result: str = utilities.split_long_docstring_lines(docstring) + assert "\t" not in result + assert "\n\n" in result + + +def test_split_long_docstring_lines_no_leading_indent() -> None: + docstring: str = ( + "Summary line with no leading indent and quite a few words in " + "it so that it wraps onto more than one output line here.\n" + " Detail line." + ) + result: str = utilities.split_long_docstring_lines(docstring) + assert result.startswith(" Summary line") + + +def test_suffix_long_lines_multiline_string_literal() -> None: + text: str = '"""\n' + ("word " * 20) + '\n"""' + result: str = utilities.suffix_long_lines(text) + lines: list[str] = result.split("\n") + assert lines[-1] == '""" # noqa: E501' + + +def test_get_qualified_name_type_error() -> None: + error_caught: bool = False + try: + utilities.get_qualified_name(123) # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +def test_get_qualified_name_module() -> None: + assert utilities.get_qualified_name(utilities) == "sob.utilities" + + +class GenericAliasProxy: + __origin__ = list + + def __call__(self) -> None: + pass + + +def test_get_qualified_name_generic_alias_repr_fallback() -> None: + name: str = utilities.get_qualified_name(GenericAliasProxy()) # type: ignore + assert "GenericAliasProxy object at" in name + + +class NoNameCallableProxy: + def __call__(self) -> None: + pass + + +def test_get_qualified_name_unresolvable() -> None: + error_caught: bool = False + try: + utilities.get_qualified_name(NoNameCallableProxy()) # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +def test_get_calling_module_name_out_of_range() -> None: + assert utilities.get_calling_module_name(depth=99999) == "__main__" + + +def test_get_calling_function_qualified_name_type_error() -> None: + error_caught: bool = False + try: + utilities.get_calling_function_qualified_name(depth="not-an-int") # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +def test_get_calling_function_qualified_name_out_of_range() -> None: + assert utilities.get_calling_function_qualified_name(depth=99999) is None + + +def test_get_source_fallback() -> None: + source: str = utilities.get_source(utilities.get_qualified_name) + assert "def get_qualified_name" in source + + +def test_repr_empty_collections() -> None: + assert utilities.represent([]) == "[]" + assert utilities.represent([1, "a"]) != "[]" + assert utilities.represent(set()) == "set()" + assert utilities.represent({}) == "{}" + + +class NonCallableAttributeProxy: + value = 123 + + +def test_get_method_missing_no_default() -> None: + error_caught: bool = False + try: + utilities.get_method(object(), "nonexistent_method") + except AttributeError: + error_caught = True + assert error_caught + + +def test_get_method_not_callable() -> None: + error_caught: bool = False + try: + utilities.get_method(NonCallableAttributeProxy(), "value") + except AttributeError: + error_caught = True + assert error_caught + # When a `default` is provided, the non-callable attribute value itself + # is returned rather than raising. + assert ( + utilities.get_method(NonCallableAttributeProxy(), "value", None) == 123 + ) + + if __name__ == "__main__": pytest.main([__file__, "-s", "-vv"]) diff --git a/tests/test_version.py b/tests/test_version.py index 3b834f8..6256498 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -182,6 +182,25 @@ def __init__( self.versioned_container = versioned_container +class VersionedGoneObject(sob.Object): + """ + This class has a property which is only applicable to a single, + specific version, used to test that `sob.meta.version_model` raises + a `VersionError` when a property excluded by a version filter still + has a value assigned. + """ + + __slots__: tuple[str, ...] = ("gone_property",) + + def __init__( + self, + _data: str | None = None, + gone_property: str | None = None, + ) -> None: + self.gone_property: str | None = gone_property + super().__init__(_data) + + # endregion # region Metadata @@ -225,6 +244,21 @@ def __init__( [("property_a", sob.IntegerProperty(name="propertyA"))] ) +sob.meta.get_writable_object_meta( + VersionedGoneObject +).properties = sob.meta.Properties( + [ + ( + "gone_property", + sob.properties.Property( + name="goneProperty", + types=[str], + versions=["test-specification==2.0"], + ), + ), + ] +) + # endregion @@ -232,7 +266,8 @@ def test_doctest() -> None: """ Run docstring tests """ - doctest.testmod(sob.version) + results: doctest.TestResults = doctest.testmod(sob.version) + assert results.failed == 0, results def test_version_1() -> None: @@ -254,5 +289,103 @@ def test_version_1() -> None: VersionedObject(version=1.2) +def test_version_recurses_into_nested_container() -> None: + """ + Verify that `sob.meta.version_model` recurses into a nested model-typed + property value. `version_model` is called explicitly, after + construction, so that `versioned_container` already holds a real + `MemberObjectA` instance when the recursive call is made (during + `__init__`, `version_model` runs *before* `versioned_container` is + assigned). + """ + versioned_object: VersionedObject = VersionedObject(version=1.2) + versioned_object.versioned_container = MemberObjectA(property_a=1) + sob.meta.version_model(versioned_object, "test-specification", "1.2") + assert isinstance(versioned_object.versioned_container, MemberObjectA) + + +def test_version_error_on_removed_property_with_value() -> None: + """ + Verify that `sob.meta.version_model` raises a `VersionError` if a + property excluded by the target version still has a (non-`None`) + value assigned. + """ + gone_object: VersionedGoneObject = VersionedGoneObject( + gone_property="still here" + ) + error_caught: bool = False + try: + sob.meta.version_model(gone_object, "test-specification", "1.0") + except sob.errors.VersionError: + error_caught = True + assert error_caught + + +def test_version_equality_precision() -> None: + assert sob.Version(equals="1.2") == "1.2.0" + assert sob.Version(equals="1.2") == "1.2" + + +def test_version_compatible_with_precision() -> None: + # `other` has *less* precision than `compatible_with` + assert sob.Version(compatible_with="1.2.3") == "1" + # `compatible_with` has only one version component + assert sob.Version(compatible_with="1") != "1.5" + # Ordinary same-minor-version compatibility + assert sob.Version(compatible_with="1.2") == "1.2.5" + + +def test_version_string_value_error() -> None: + error_caught: bool = False + try: + bool(sob.Version(equals="1.0") == "not-a-version") + except ValueError: + error_caught = True + assert error_caught + + +def test_version_numeric_and_sequence_inputs() -> None: + assert sob.Version(compatible_with=1.2) == "1.2" # type: ignore + assert sob.Version(compatible_with=(1, 2)) == "1.2" + + +def test_version_as_tuple_type_error() -> None: + error_caught: bool = False + try: + sob.version._version_as_tuple(object()) # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +def test_version_string_type_error() -> None: + error_caught: bool = False + try: + sob.Version(123) # type: ignore + except TypeError: + error_caught = True + assert error_caught + + +def test_version_conflicting_specifications() -> None: + error_caught: bool = False + try: + sob.Version("a==1,b==2") + except ValueError: + error_caught = True + assert error_caught + + +def test_version_str_no_specification() -> None: + version: sob.Version = sob.Version(equals="1.0") + version.specification = None # type: ignore + error_caught: bool = False + try: + str(version) + except RuntimeError: + error_caught = True + assert error_caught + + if __name__ == "__main__": pytest.main([__file__, "-s", "-vv"])