diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..9b88c5a1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,51 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repo Overview +- `dandischema` is a pure-Python library defining the DANDI metadata schemata (no CLI). +- Source lives under `dandischema/`, tests under `dandischema/tests/`. +- Helper scripts (schema publishing, license fetching, etc.) live under `tools/`. +- `pyproject.toml` is the single source of truth for dependencies and tool config; `tox.ini` orchestrates testing/linting; `.pre-commit-config.yaml` runs auto-formatters. + +## Build/Test Commands +- Run full test suite: `tox -e py3` (or `tox -e py` to use the active interpreter). +- Run a single test: `tox -e py3 -- dandischema/tests/test_models.py::test_function -v`. +- Inside an active venv (`uv venv` / `source .venv/bin/activate`): `python -m pytest dandischema`. +- Lint: `tox -e lint` (runs `codespell` + `flake8` over `dandischema/`). +- Type checking: `tox -e typing` (runs `mypy dandischema`). +- Lint + types together: `tox -e lint,typing`. +- Set `DANDI_TESTS_NONETWORK=1` to make tests refuse outbound HTTP (the `disable_http` autouse fixture in `dandischema/tests/conftest.py` points proxies at a dead address). +- DataCite-dependent tests require `DATACITE_DEV_LOGIN` / `DATACITE_DEV_PASSWORD`; set `NO_ET=1` to disable etelemetry. Both are passed through by tox. +- There is no `docker-compose` in this repo and no `hatch run test:run` env — don't suggest those (the dandi-cli pattern doesn't apply here). + +## Pre-commit +- Install hooks if missing (no `.git/hooks/pre-commit` present): `pre-commit install`. +- Hooks run: trailing-whitespace, end-of-file-fixer, check-yaml, check-added-large-files, black, isort, codespell, flake8. +- If a commit fails because hooks auto-fixed files, just re-run `git commit` — the second attempt usually succeeds. Investigate further only if it fails twice. + +## Test Markers / Pytest Plugin +- A pytest plugin lives at `dandischema/pytest_plugin.py` and is registered via `[project.entry-points.pytest11]` in `pyproject.toml` (so it's auto-loaded once the package is installed). +- It provides: + - `pytest_configure`: registers custom markers — currently only `ai_generated`. Add new markers to the `markers` list there (don't rely on `tox.ini`/`pytest.ini`); `filterwarnings = error` in `tox.ini` makes unregistered markers fail. + - `pytest_report_header`: prints versions of declared runtime dependencies on the pytest header. + - `pytest_assertrepr_compare`: custom diff for `DandiBaseModel == DandiBaseModel` so failures show the underlying dict diff instead of opaque pydantic reprs. +- Mark AI-generated tests with `@pytest.mark.ai_generated`. + +## Code Style +- Black-formatted, line length 100 (see `.flake8: max-line-length = 100`; flake8 ignores `E203,W503`). +- isort with `profile = "black"`, `force_sort_within_sections = true`, `reverse_relative = true`, `known_first_party = ["dandischema"]`. +- Type annotations required for all new code — `mypy` runs in strict-ish mode (`allow_untyped_defs = false`, `warn_return_any`, `warn_unreachable`, `pydantic.mypy` plugin enabled). Models use Pydantic v2 (`pydantic[email]~=2.4`). +- Class names: CamelCase; functions/variables: snake_case. +- Exception names end with `Error` (see `dandischema/exceptions.py`). +- Docstrings in NumPy style for public APIs; keep them in sync with signature changes. +- Prefer specific exceptions over generic ones. +- Imports organized stdlib / third-party / local, alphabetical within groups (isort handles ordering). +- All imports at module top — no function-local imports (per global rule), unless guarding an optional dep with `try/except ImportError`. + +## Schema / Data Files +- `dandischema/*.yaml` (e.g. `standards.yaml`, `model_standards.yaml`, `personinfo.yaml`) are tracked schema/data inputs — don't reformat or rename casually; downstream consumers and `tools/pubschemata.py` rely on them. +- `dandischema/_version.py` is generated by `versioningit` (see `[tool.versioningit.write]`); don't edit by hand. + +## Documentation Freshness +- When preparing a PR, check whether the documentation — both human-facing (e.g. `README.md`, docstrings, comments) and machine-facing (e.g. this `CLAUDE.md`, schema descriptions) — is still accurate with respect to the changes the PR introduces. If anything has drifted, update it as part of the same PR. diff --git a/dandischema/pytest_plugin.py b/dandischema/pytest_plugin.py new file mode 100644 index 00000000..8e928c2d --- /dev/null +++ b/dandischema/pytest_plugin.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from importlib.metadata import PackageNotFoundError, requires, version + +from packaging.requirements import Requirement +from pytest import Config + +from dandischema.models import DandiBaseModel + + +def pytest_configure(config: Config) -> None: + markers = [ + "ai_generated", + ] + for marker in markers: + config.addinivalue_line("markers", marker) + + +def pytest_report_header(config: Config) -> list[str]: + """Add version information for key dependencies to the pytest header.""" + try: + deps = {Requirement(dep).name for dep in (requires("dandischema") or [])} + except PackageNotFoundError: + deps = {"jsonschema", "pydantic", "requests"} + + versions = [] + for pkg in sorted(deps): + try: + version_str = f"-{version(pkg)}" + except PackageNotFoundError: + version_str = " NOT INSTALLED" + versions.append(f"{pkg}{version_str}") + + return [f"dependencies: {', '.join(versions)}"] if versions else [] + + +def pytest_assertrepr_compare(op: str, left: object, right: object) -> list[str] | None: + """Custom comparison representation for DandiBaseModel.""" + if ( + isinstance(left, DandiBaseModel) + and isinstance(right, DandiBaseModel) + and op == "==" + ): + ldict, rdict = dict(left), dict(right) + if ldict == rdict: + return [ + "dict representations of models are equal, but values aren't!", + f"Left: {left!r}", + f"Right: {right!r}", + ] + else: + # Rely on pytest "recursing" into interpreting the dict diff. + # TODO: could be further improved by accounting for ANY values etc. + assert ldict == rdict # for easier comprehension of diffs + return None diff --git a/pyproject.toml b/pyproject.toml index dca8b6b2..28889269 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,9 @@ dynamic = ["version"] Homepage = "http://dandiarchive.org" "Source Code" = "https://github.com/dandi/dandischema" +[project.entry-points.pytest11] +dandischema = "dandischema.pytest_plugin" + [project.optional-dependencies] style = [ "flake8", diff --git a/tox.ini b/tox.ini index 84f9d380..2a2eb841 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ passenv = DATACITE_DEV_PASSWORD NO_ET commands = - pytest -v {posargs} dandischema + pytest -v {posargs} [testenv:lint] skip_install = true @@ -31,7 +31,11 @@ commands = mypy dandischema [pytest] -addopts = --cov=dandischema --tb=short --durations=10 +# --import-mode=importlib avoids ImportPathMismatchError when the package +# (including dandischema/tests/) is also installed into the tox venv and the +# pytest plugin (registered via [project.entry-points.pytest11]) eagerly +# imports `dandischema` from site-packages. +addopts = --cov=dandischema --tb=short --durations=10 --import-mode=importlib filterwarnings = error #