Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
96 commits
Select commit Hold shift + click to select a range
cffee39
build(hatch-env): specify env for conversion to LinkML and back
candleindark Mar 10, 2026
b5644c1
build(hatch-env): provide script to translate `dandischema.models`
candleindark Mar 10, 2026
cfe3bea
build(hatch-env): provide script to translate `dandischema/models.yaml`
candleindark Mar 10, 2026
73c46b6
Now we will sanitize the converted model addresses
yarikoptic Mar 13, 2026
c79b1e2
more sanitization
yarikoptic Mar 13, 2026
0b2b51d
feat: provide `prefixes` definition in the overlay file
candleindark Mar 13, 2026
6cb2f82
Added linkml: prefix and sorted all the entries
yarikoptic Mar 13, 2026
76526a9
Provide default prefix to be custom dandi_default
yarikoptic Mar 13, 2026
7fa95c2
use portable ERE sed pattern for normalizing memory addresses
yarikoptic Mar 13, 2026
fad6879
fix: correct `default_prefix` to `dandi`
candleindark Mar 16, 2026
5f78b87
Adding models_importstab.py so we could use it to import from convert…
yarikoptic Mar 13, 2026
0208c9c
Adding the tool to assist in establishing "merges" with regeneration …
yarikoptic Mar 20, 2026
488e2e9
We are doomed to use --no-verify for now as converted python code has…
yarikoptic Mar 20, 2026
0ba5ad7
BF the commit version specification
yarikoptic Mar 20, 2026
2faf932
Implement poor man patch queue
yarikoptic Mar 20, 2026
c177479
reflect that I rewritten how we handle patching
yarikoptic Mar 20, 2026
54cf276
Inform on what we are doing
yarikoptic Mar 20, 2026
5eb4a91
Now do patch diff of master in as well for good measure
yarikoptic Mar 20, 2026
76a9d77
Removing reporting branches - did not work
yarikoptic Mar 20, 2026
c6172c4
ENH: patch converted to pydantic model to strip dciteCOLO
yarikoptic Mar 20, 2026
e2d124c
do pre-commit
yarikoptic Mar 20, 2026
d8b1bea
feat: provide partial schema
candleindark Mar 26, 2026
c0fbd02
feat: enable `2linkml` to merge partial schema in `models_merge.yaml`
candleindark Mar 26, 2026
ae7b30d
feat: further override `schemaKey` slot to make it a valid type desig…
candleindark Mar 31, 2026
b92119c
feat: add YAML sanitization pipeline to `2linkml`
candleindark Mar 31, 2026
043c3d6
feat: make the `schemaKey` slot required
candleindark Apr 1, 2026
9209976
fix: generalize and fix COLON sed pattern in 2pydantic script
candleindark Apr 1, 2026
9609e89
feat: add script to find models with mismatched schemaKey defaults
candleindark Apr 6, 2026
e404946
feat: add note regarding mismatch of `schemaKey` default and model name
candleindark Apr 7, 2026
111e635
chore: group linkml conversion helpers under tools/linkml_conversion_…
candleindark Apr 7, 2026
7dd492d
style: attach `MANUAL_NOTE` prefix to manually added notes
candleindark Apr 7, 2026
c5903c9
refactor: generalize note-removal script to support multiple regex pa…
candleindark Apr 7, 2026
0a21040
feat: remove notes regarding translation max length constraints on st…
candleindark Apr 7, 2026
aeae21f
doc: claude report on analysis for schemaKey divergence for some classes
yarikoptic Apr 11, 2026
2c38801
feat: add script to remove schemaKey entries from slot_usage in class…
candleindark Apr 15, 2026
7773f75
feat: add script to sort LicenseType permissible values alphabetically
candleindark Apr 16, 2026
b688b3b
Import also original DANDI_NSKEY and get_schema_version into models.py
yarikoptic Apr 17, 2026
db7be48
ENH: add 2 hatch specs for conversions into jsonschema for comparisons
yarikoptic Apr 17, 2026
3b22cd7
Do also conversions to jsonschema for comparisons
yarikoptic Apr 17, 2026
14c5177
Harmonize converted files
yarikoptic Apr 17, 2026
05576e9
Split into two conversion script so we could play with linkml model q…
yarikoptic Apr 17, 2026
33dd458
Stage after all pre-commit changes
yarikoptic Apr 17, 2026
982491a
Pin linkml and linkml-runtime in linkml-auto-converted hatch env
candleindark Apr 21, 2026
4ec6cda
feat: update notes regarding length restrictions to be removed
candleindark Apr 21, 2026
8737dc9
Add scripts for LinkML validation of DANDI Archive metadata
candleindark Apr 26, 2026
4c21a62
Make fetch_metadata.py write metadata.json + info.json all-or-nothing
candleindark Apr 27, 2026
896d4fc
Use American English spelling in skill scripts
candleindark Apr 27, 2026
b2270b9
Use LinkML Python API for validation, with structured JSON output
candleindark Apr 29, 2026
6b04389
Rename "Status" column to "API Status" in REPORT.md
candleindark Apr 29, 2026
f5f9137
Add SKILL.md and reference docs for dandi-linkml-validation-report
candleindark Apr 29, 2026
c30f141
Pre-approve git and hatch invocations in skill frontmatter
candleindark Apr 29, 2026
9bf74c5
Rename top-level report from REPORT.md to README.md
candleindark Apr 29, 2026
36863e2
Drop references/ from the skill, lean on script docstrings
candleindark Apr 30, 2026
2f667ee
Migrate metadata before validating; flag migration failures in report
candleindark Apr 30, 2026
07e7d47
Flat output layout; schema-hash-aware resume in validate_metadata.py
candleindark Apr 30, 2026
cc12ce3
feat: ensure `title` in JSON schema is generated from `title` meta sl…
candleindark May 7, 2026
08e2d11
Make linkml_conversion script handle stacked branches and new files
candleindark May 7, 2026
23666ae
Replace sed post-processing in `2pydantic` with gen-pydantic template…
candleindark May 14, 2026
a0cdc4a
Merge pull request #410 from dandi/drop-sed-in-2pydantic
candleindark May 14, 2026
71be4ec
Add LinkML behavior tests for `required: False -> True` slot_usage re…
candleindark May 12, 2026
7623935
Validate against JSON Schema generated from the LinkML schema
candleindark May 13, 2026
4b2f003
Extract shared parametrize cases into `_cases.py`
candleindark May 14, 2026
fb74825
Validate against Pydantic models generated from the LinkML schema
candleindark May 15, 2026
04afaaf
build: pin python version of `linkml-behavior-test` env
candleindark May 15, 2026
37e0801
Add GitHub Actions workflow for LinkML behavior tests
candleindark May 15, 2026
8580859
Add detached `linkml-behavior-typing` hatch env
candleindark May 15, 2026
7aa10b7
Add GitHub Actions workflow for type-checking the LinkML behavior tests
candleindark May 15, 2026
f62f2e9
Merge pull request #408 from dandi/gh-405-linkml-behavior-test
candleindark May 15, 2026
cd892df
build(tox): pin pytest `testpaths` to `dandischema`
candleindark May 16, 2026
c931794
merge `master` branch
candleindark May 17, 2026
d1b4d82
Add foundation playbook for migration to LinkML
candleindark May 28, 2026
54fd279
Add role profiles to the LinkML migration playbook
candleindark May 29, 2026
43e4426
Populate topical role profiles from verified sources
candleindark May 29, 2026
50c84c4
Add type-designator demo exhibit to LinkML migration playbook
candleindark Jun 1, 2026
30b02b0
Merge branch 'master' into linkml-conversion
candleindark Jun 1, 2026
041a280
build(hatch env): update the LinkML pin in `linkml-auto-converted` ha…
candleindark Jun 1, 2026
bdfbed4
Update type-designator exhibit for linkml 1.11.1 pipeline pin
candleindark Jun 2, 2026
3965f41
Record JSON Schema dialect finding (LinkML 2019-09 vs 2020-12)
candleindark Jun 2, 2026
4e77ee5
Record finding: gen-pydantic ignores LinkML rules, gen-json-schema en…
candleindark Jun 3, 2026
373aa1c
refactor: drop no-op `ifabsent: null` from the `schemaKey` merge slot
candleindark Jul 24, 2026
6c7f0e7
feat: specify the range `Dandiset.wasGeneratedBy`
candleindark Jul 30, 2026
a786022
feat: specify the range `BareAsset.wasGeneratedBy`
candleindark Jul 30, 2026
75341f3
feat: allow note removal to be scoped to locations in the document
candleindark Jul 31, 2026
5b50b3c
feat: drop the obsolete `range` notes on the `wasGeneratedBy` slot us…
candleindark Jul 31, 2026
e259d12
test: make the `required_refinement` topic directory a package
candleindark Jul 31, 2026
5d603ae
test: pin the LinkML behaviors behind the `wasGeneratedBy` range over…
candleindark Aug 2, 2026
cc9cff5
refactor: extract the shared artifact generation out of the topic con…
candleindark Aug 2, 2026
daaa017
refactor: extract the shared validator drivers out of the topic test …
candleindark Aug 3, 2026
c3b2e1d
Merge branch 'master' into linkml-conversion
candleindark Aug 3, 2026
0eba7a1
docs: correct what `models_pydantic/*.json` is derived from
candleindark Aug 3, 2026
35f049a
Merge branch 'master' into linkml-conversion
candleindark Aug 7, 2026
09730f8
fix: drop `schemaKey` `MANUAL_NOTE` entries from `models_merge.yaml`
candleindark Aug 7, 2026
7ed984e
fix: stop generating JSON Schema for the dropped `Published*` classes
candleindark Aug 7, 2026
54138ad
fix: add `Published*` aliases to `models_importstab.py`
candleindark Aug 7, 2026
bedd297
docs: update playbook for the dropped `Published*` classes
candleindark Aug 7, 2026
36393de
docs: correct where the `BareAsset.wasGeneratedBy` `any_of` comes from
candleindark Aug 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions .claude/skills/dandi-linkml-validation-report/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
---
name: dandi-linkml-validation-report
description: Generate a Markdown report assessing how `dandischema/models.yaml` (the LinkML schema) validates against real DANDI Archive Dandiset metadata after migrating each instance to the latest schema version. Use when the user wants to assess schema fitness across the archive, investigate a class of validation failure across many dandisets, or compare before/after for a schema change. Covers fetching raw metadata for every dandiset (draft + every published version), migrating each instance via `dandischema.metadata.migrate`, running closed-world JSON-schema validation on successfully-migrated instances via the LinkML Python API, and aggregating per-version results into a top-level README.md bucketed by target class (Dandiset / PublishedDandiset) × schemaVersion. Versions whose metadata can't be migrated are flagged in the report; validation is skipped for them.
compatibility: Requires the `linkml-auto-converted` hatch env defined in this repo's pyproject.toml (provides linkml, linkml-runtime, dandi, typer) and network access to a DANDI Archive instance.
allowed-tools: Bash(git:*) Bash(hatch:*) Read
---

# DANDI LinkML validation report

Three-stage pipeline that fetches Dandiset metadata, validates it against
`dandischema/models.yaml`, and aggregates the results into a Markdown
report. Each stage is a Typer-based script under `scripts/`; each is
idempotent and resumable.

## When to use

- After updating the LinkML schema (or its Pydantic source in
`dandischema.models`) — see what breaks across the archive.
- To investigate the spread of a specific validation failure across
dandisets.
- To produce a before/after diff of schema changes.

## Prerequisites

- The `linkml-auto-converted` hatch env exists (defined in
`pyproject.toml`).
- `dandischema/models.yaml` is present and reflects the schema you want
to validate against. Typically, you stay on the `linkml-conversion`
branch and pull the YAML from the auto-generated branch:

```sh
git restore --source linkml-auto-converted -- dandischema/models.yaml
```

- Network access to the target DANDI instance (default: dani).

## Workflow

The pipeline writes everything under one flat directory:

```sh
ROOT=linkml-validation-reports
```

Raw metadata is schema-independent and only fetched once; subsequent
runs reuse it. Schema-dependent files (`metadata_migrated.json`,
`validation.{json,txt}`, `SUMMARY.md`, top-level `README.md`) are
rewritten in place when the schema content changes.

### 1. Fetch metadata

```sh
hatch run linkml-auto-converted:python \
.claude/skills/dandi-linkml-validation-report/scripts/fetch_metadata.py \
$ROOT/data
```

Downloads `metadata.json` + `info.json` for every dandiset's draft and
every published version into `$ROOT/data/<dandiset-id>/<version-or-draft>/`.
Already-downloaded versions are skipped. `--refresh` is a forceful
override that re-downloads everything regardless. `--limit N`
truncates to N dandisets for smoke tests. `-i <instance>` selects a
non-production DANDI instance.

### 2. Migrate + validate

```sh
hatch run linkml-auto-converted:python \
.claude/skills/dandi-linkml-validation-report/scripts/validate_metadata.py \
$ROOT/data --schema dandischema/models.yaml
```

For each version directory, runs `dandischema.metadata.migrate` on
the raw metadata first, then validates the migrated instance against
the LinkML schema (drafts → `Dandiset`, published → `PublishedDandiset`).
Writes `metadata_migrated.json` (when migration succeeds), plus
`validation.json` (structured record carrying `migration_status` and
`schema_sha256`), `validation.txt`, and `SUMMARY.md`. Versions whose
migration fails are recorded with the error and skipped for
validation.

The resume guard is schema-aware: each `validation.json` is stamped
with the SHA-256 of the schema file's bytes, and a re-run skips a
version only when its stamp matches the current schema. So changing
`dandischema/models.yaml` (committed or uncommitted) automatically
re-runs migration and validation for every version on the next call —
no flag needed. `--refresh` is a forceful override that ignores the
stamp and re-runs everything regardless.

### 3. Generate report

```sh
hatch run linkml-auto-converted:python \
.claude/skills/dandi-linkml-validation-report/scripts/generate_report.py \
$ROOT \
--commit-hash $(git rev-parse linkml-auto-converted) \
--commit-date $(git show -s --format=%cI linkml-auto-converted)
```

Writes `$ROOT/README.md`: overall counts, then per-bucket tables
(target class × schemaVersion) with top error patterns and links to
each version's `SUMMARY.md`. Always rewritten on invocation.

For details on the on-disk layout, JSON field shapes, and design
rationale, read the module docstrings of the three scripts directly.
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""Download raw ``Dandiset`` metadata from a DANDI Archive instance.

For every dandiset on the chosen instance this script writes the raw
metadata of the draft version *and* of every published version to::

<output-dir>/<dandiset-id>/<version-or-"draft">/metadata.json

Each version directory also gets an ``info.json`` with the few fields
the downstream validation and report scripts need:

{
"dandiset_id": "000003",
"version": "0.230629.1955", # or "draft"
"is_published": true, # false for the draft version
"status": "VALID", # archive-side status
"modified": "2023-06-29T...", # ISO 8601 or null
"schema_version": "0.6.4" # raw["schemaVersion"], may be null
}

Re-running is safe: versions whose ``metadata.json`` already exists are
skipped unless ``--refresh`` is passed. This makes it easy to resume
after a network blip or to top up a previously-fetched directory with
newly published versions.

Example
-------
::

python fetch_metadata.py linkml-validation-reports/data
"""

from __future__ import annotations

import json
import logging
import os
from pathlib import Path

from dandi.dandiapi import DandiAPIClient, RemoteDandiset
import typer

logger = logging.getLogger("fetch_metadata")

app = typer.Typer(add_completion=False, help=__doc__.splitlines()[0])


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _dump_json(data: object) -> str:
"""Serialize ``data`` as pretty-printed JSON with a trailing newline.

``data`` is expected to be a structure of plain JSON-compatible types
(dicts, lists, strings, numbers, bools, ``None``). Datetime objects
must be converted by the caller — see how ``_fetch_version`` calls
``.isoformat()`` before stashing values into ``info``.
"""
return json.dumps(data, indent=2) + "\n"


def _fetch_version(
dandiset: RemoteDandiset,
version_id: str,
*,
is_published: bool,
version_dir: Path,
refresh: bool,
) -> None:
"""Fetch ``metadata.json`` and ``info.json`` for one version.

All-or-nothing on the destination paths: this function performs the
network calls first, then writes both files to temporary paths in
``version_dir`` and only renames them into place once both have been
written successfully. If anything fails — a raised exception or even
abrupt termination of the process — neither destination file ever
appears in a partially-written state, so the resume guard at the top
of this function can trust ``metadata.json``/``info.json`` existence
as a signal that the version was previously fetched in full. (The
leftover ``.tmp`` files are harmless cruft that the next successful
fetch overwrites.)

Parameters
----------
dandiset:
The ``RemoteDandiset`` object returned by the DANDI client.
version_id:
Either the published version identifier (e.g. ``"0.230629.1955"``)
or the literal string ``"draft"``.
is_published:
``False`` for the draft version, ``True`` for any published
version. Persisted into ``info.json`` so the validator can pick
the right target class without re-querying the archive.
version_dir:
Destination directory; created if it does not yet exist.
refresh:
If ``False`` and the destination already contains both
``metadata.json`` and ``info.json``, do nothing.
"""
metadata_file = version_dir / "metadata.json"
info_file = version_dir / "info.json"
if metadata_file.exists() and info_file.exists() and not refresh:
logger.debug("skip %s/%s (already downloaded)", dandiset.identifier, version_id)
return

# --- Network: gather everything before touching the filesystem. ---
# ``for_version`` returns a fresh handle bound to the requested version,
# which is what ``get_raw_metadata`` and ``get_version`` need to operate on.
ds_at_version = dandiset.for_version(version_id)
raw = ds_at_version.get_raw_metadata()
version_info = ds_at_version.get_version(version_id)

info = {
"dandiset_id": dandiset.identifier,
"version": version_id,
"is_published": is_published,
# ``status`` is a ``VersionStatus`` enum member on the client.
"status": version_info.status.value,
# ``modified`` is a non-optional ``datetime`` per the ``Version``
# model, so an ``isoformat()`` is always safe.
"modified": version_info.modified.isoformat(),
# The raw metadata's ``schemaVersion`` field is the dimension we
# want to group by in the top-level report, so capture it now.
"schema_version": raw.get("schemaVersion"),
}

# --- Filesystem: write to .tmp paths then rename into place. ---
# Pre-rendering the JSON before opening any file keeps any
# serialization error from leaving stray ``.tmp`` files behind.
metadata_text = _dump_json(raw)
info_text = _dump_json(info)

version_dir.mkdir(parents=True, exist_ok=True)
metadata_tmp = metadata_file.with_suffix(metadata_file.suffix + ".tmp")
info_tmp = info_file.with_suffix(info_file.suffix + ".tmp")
metadata_tmp.write_text(metadata_text)
info_tmp.write_text(info_text)
# ``os.replace`` performs the POSIX ``rename(2)`` syscall, which the
# kernel cannot leave half-finished: either the destination ends up
# pointing at the new content, or it stays as it was before the call
# (i.e. nonexistent on the first fetch). That guarantee is what
# makes the all-or-nothing behavior above hold even under SIGKILL,
# since simply ``write_text``-ing the final paths would leave a
# truncated file behind if the process were killed mid-write.
os.replace(metadata_tmp, metadata_file)
os.replace(info_tmp, info_file)

logger.info("fetched %s/%s", dandiset.identifier, version_id)


# ---------------------------------------------------------------------------
# Typer entry point
# ---------------------------------------------------------------------------


@app.command()
def main(
output_dir: Path = typer.Argument(
...,
help="Directory under which <dandiset-id>/<version>/metadata.json "
"files will be written.",
),
dandi_instance: str = typer.Option(
"dandi",
"--dandi-instance",
"-i",
help="DANDI server instance name as understood by `DandiAPIClient."
"for_dandi_instance`",
),
refresh: bool = typer.Option(
False,
"--refresh",
help="Re-download versions whose metadata is already on disk.",
),
limit: int | None = typer.Option(
None,
"--limit",
help="Process at most N dandisets (useful for smoke tests).",
),
log_level: str = typer.Option("INFO", "--log-level", "-l"),
) -> None:
"""Fetch metadata for all dandisets (draft + published versions)."""
logging.basicConfig(
format="[%(asctime)s] %(levelname)s %(name)s: %(message)s",
level=getattr(logging, log_level.upper()),
)
output_dir.mkdir(parents=True, exist_ok=True)

with DandiAPIClient.for_dandi_instance(dandi_instance) as client:
for i, dandiset in enumerate(client.get_dandisets(draft=True, order="id")):
if limit is not None and i >= limit:
break
dandiset_id = dandiset.identifier
dandiset_dir = output_dir / dandiset_id
logger.info("processing %s", dandiset_id)

# The draft version always exists and is what new edits land on,
# so fetch it first.
try:
_fetch_version(
dandiset,
dandiset.draft_version.identifier,
is_published=False,
version_dir=dandiset_dir / "draft",
refresh=refresh,
)
except Exception as e:
# Never let a single dandiset blow up the whole run.
logger.error("failed draft of %s: %s", dandiset_id, e)

# Then walk every published version (skipping the draft, which
# ``get_versions`` also yields).
for v in dandiset.get_versions():
if v.identifier == "draft":
continue
try:
_fetch_version(
dandiset,
v.identifier,
is_published=True,
version_dir=dandiset_dir / v.identifier,
refresh=refresh,
)
except Exception as e:
logger.error("failed %s/%s: %s", dandiset_id, v.identifier, e)


if __name__ == "__main__":
app()
Loading
Loading