Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ zenodo-maint --zenodo-json meta/v9.json \
zenodo-maint --zenodo-json meta/authors-9.json \
apply-metadata --creators-only --version-prefix 9. --execute

# fix ONLY the license across every version (e.g. a mis-declared license), leaving
# each record's curated title/description/creators untouched; already-correct
# records are skipped (idempotent).
zenodo-maint --zenodo-json .zenodo.json apply-metadata --license-only --execute

# scaffold the two standard files for a new repo
zenodo-maint --repo owner/repo bootstrap

Expand Down
1 change: 1 addition & 0 deletions skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ Zenodo calls, mints no concept, and installs no workflows. The rest is manual:
- Rename a relation on all versions: `zenodo-maint relink --from-relation isNewVersionOf --to-relation continues --execute`
- Re-apply metadata after editing `.zenodo.json` (e.g. authors): `zenodo-maint apply-metadata --execute`
- Fix only the author list on existing records, preserving their title/description (e.g. correct authorship without losing per-release notes): `zenodo-maint --zenodo-json authors.json apply-metadata --creators-only [--version-prefix 7.7.] --execute`. Swaps only `creators`; skips records already matching (idempotent); `--version-prefix` scopes to a major's records.
- Fix only the license across every version (e.g. a mis-declared license), preserving each record's curated title/description/creators: `zenodo-maint --zenodo-json .zenodo.json apply-metadata --license-only --execute`. Swaps only `license` (from the metadata file's `license`); skips records already matching, treating Zenodo's stored `mit-license` as equal to the SPDX `mit` you write in `.zenodo.json` (idempotent). Use for a whole-concept license correction without disturbing curated per-version titles.
- Curated / relabeled record (label decoupled from the source tag): `zenodo-maint --zenodo-json meta/rec.json archive-release --tag <build> --version <label> --title '<Title>' --date <YYYY-MM-DD> --execute`. Creators/description come from the `--zenodo-json` file; `--version`/`--title` set the displayed label; the tarball + GitHub link still track `--tag`. `backfill` entries may carry the same `version`/`title` overrides per tag. To relabel/re-author a record that already exists: `apply-metadata --record <id> --version <label> --title '<Title>'`. If the curated record reuses a tag that is already archived as a per-release record, add `--dedup-by label` (default `tag` skips on the source tag; `label` dedups on the version label so the reuse is allowed — idempotency is preserved either way since a matching label is always skipped).
- Preflight before wiring up a repo: `GH_TOKEN=$(gh auth token) zenodo-maint doctor` — flags a native-integration `zenodo.org` webhook (must be disabled), competing/forked concepts, and drift.
- Audit repo config hygiene (one repo, or many via `--monitored monitored.json`): `GH_TOKEN=$(gh auth token) zenodo-maint audit [--monitored monitored.json]`. Checks the standard files are present, both reusable workflows are pinned at the floating `@vN` (a `@vX.Y.Z`/SHA pin silently freezes a consumer on old code — this is the check that protects the moving-tag strategy), and `.zenodo.json` creators **and title** match the published record (title is what people see on the DOI page, so a mismatch is a hard problem — a curated repo whose record carries a per-version title will flag until its `.zenodo.json` title is reconciled). Tokenless but set `$GH_TOKEN` to beat GitHub's 60-req/hour unauthenticated limit; exits non-zero on problems. Complements `doctor` (webhook/competing-concept/drift). It does NOT diff `.zenodo.json` against `CITATION.cff` — that's `cffconvert`'s job (regenerate and diff by hand).
Expand Down
33 changes: 32 additions & 1 deletion tests/test_metadata_overrides.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
"""
import unittest

from zenodo_maint.cli import _creators_equal, _effective_version, _skip_reason
from zenodo_maint.cli import (
_creators_equal,
_effective_version,
_license_equal,
_skip_reason,
)


class EffectiveVersion(unittest.TestCase):
Expand Down Expand Up @@ -74,5 +79,31 @@ def test_orcid_change_differs(self) -> None:
self.assertFalse(_creators_equal(self.A, b))


class LicenseEqual(unittest.TestCase):
"""License equality for apply-metadata --license-only idempotency. The deposit
API returns a bare string; the records API wraps it as {'id': ...}."""

def test_string_forms(self) -> None:
self.assertTrue(_license_equal("mit", "mit"))
self.assertFalse(_license_equal("cc-by-4.0", "mit"))

def test_case_insensitive(self) -> None:
self.assertTrue(_license_equal("MIT", "mit"))

def test_dict_id_form(self) -> None:
self.assertTrue(_license_equal({"id": "mit"}, "mit"))
self.assertFalse(_license_equal({"id": "other-open"}, "mit"))

def test_zenodo_mit_license_suffix(self) -> None:
# Zenodo stores MIT as the deposit id "mit-license"; .zenodo.json writes "mit"
self.assertTrue(_license_equal({"id": "mit-license"}, "mit"))
self.assertTrue(_license_equal("mit-license", "mit"))
self.assertFalse(_license_equal("cc-by-4.0", "mit"))

def test_missing(self) -> None:
self.assertFalse(_license_equal(None, "mit"))
self.assertTrue(_license_equal(None, ""))


if __name__ == "__main__":
unittest.main()
64 changes: 64 additions & 0 deletions zenodo_maint/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,29 @@ def _skip_reason(
return None


def _license_str(lic: Any) -> str:
"""A license as a comparable string, tolerating the deposit API's bare-string
form and the records API's {'id': ...} shape."""
if isinstance(lic, dict):
return str(lic.get("id", ""))
return str(lic or "")


def _license_norm(lic: Any) -> str:
"""Normalized license id for comparison. Lowercases and strips Zenodo's
``-license`` suffix so the deposit id it stores (``mit-license``) matches the
SPDX-style id humans write in .zenodo.json (``mit``)."""
s = _license_str(lic).lower()
return s[:-len("-license")] if s.endswith("-license") else s


def _license_equal(a: Any, b: Any) -> bool:
"""License equality across the string / {'id': ...} forms and Zenodo's
``mit`` vs ``mit-license`` asymmetry. Used to skip records already carrying the
target license so --license-only is idempotent."""
return _license_norm(a) == _license_norm(b)


def _creators_equal(a: list[dict[str, Any]], b: list[dict[str, Any]]) -> bool:
"""Order-sensitive comparison of two creator lists on the fields we set
(name, affiliation, orcid). Used to skip records already carrying the target
Expand Down Expand Up @@ -381,6 +404,44 @@ def cmd_apply_metadata(args: argparse.Namespace) -> None:
+ ", ".join(v for v, _ in failed))
return

# --license-only: swap just the license on each record, preserving everything
# else (title/description/creators/version/date/related_identifiers). For fixing
# a mis-declared license across a concept without disturbing curated per-version
# titles — the license-side sibling of --creators-only.
if args.license_only:
want = zj.get("license")
if not want:
sys.exit("--license-only needs a 'license' in the metadata file")
print(f"{mode} set license only ({want!r}) on {len(targets)} record(s)")
changed = same = 0
failed = []
for x in targets:
ver = x["metadata"].get("version")
cur = x["metadata"].get("license")
if _license_equal(cur, want):
same += 1
continue
changed += 1
if not args.execute:
print(f" {ver}: would set license ({_license_str(cur)} -> {want})")
continue
try:
md = dict(cli.edit(x["id"])["metadata"]) # current metadata; preserve all
md["license"] = want
cli.set_metadata(x["id"], md)
cli.publish(x["id"])
print(f" {ver}: license updated ({_license_str(cur)} -> {want})")
except api.ZenodoError as e:
changed -= 1
failed.append((str(ver), str(e)))
print(f" {ver}: FAILED (id {x['id']}) — {e}")
print(f" ({changed} {'updated' if args.execute else 'to update'}, "
f"{same} already correct, {len(failed)} failed)")
if failed:
sys.exit(f"{len(failed)} record(s) could not be updated: "
+ ", ".join(v for v, _ in failed))
return

if (args.version or args.title) and not args.record:
print(" ! --version/--title relabel every targeted record; "
"pair with --record <id> to relabel just one")
Expand Down Expand Up @@ -683,6 +744,9 @@ def build_parser() -> argparse.ArgumentParser:
m.add_argument("--creators-only", action="store_true",
help="replace only the creators list (keep title/description/"
"version/date); skips records whose creators already match")
m.add_argument("--license-only", action="store_true",
help="replace only the license (keep title/description/creators/"
"version/date); skips records whose license already matches")
m.add_argument("--version-prefix",
help="limit to records whose version label starts with this prefix")
m.set_defaults(func=cmd_apply_metadata)
Expand Down
Loading