comicbox 5.2.1__tar.gz → 5.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {comicbox-5.2.1 → comicbox-5.2.2}/.dockerignore +0 -1
- {comicbox-5.2.1 → comicbox-5.2.2}/.prettierignore +1 -1
- {comicbox-5.2.1 → comicbox-5.2.2}/NEWS.md +8 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/PKG-INFO +2 -2
- comicbox-5.2.2/bin/ci-reuse-dist.sh +48 -0
- comicbox-5.2.2/bin/hub_prune_stale.py +519 -0
- comicbox-5.2.2/bin/lint-ci.sh +12 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/lint-python.sh +1 -1
- comicbox-5.2.2/bin/lint-vulture.sh +9 -0
- comicbox-5.2.2/bin/release-tag.sh +392 -0
- comicbox-5.2.2/bin/release_info.py +238 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/roman.py +32 -22
- {comicbox-5.2.1 → comicbox-5.2.2}/bun.lock +60 -515
- comicbox-5.2.2/cfg/eslint.config.base.js +256 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/cfg/python.mk +6 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/archive/pages.py +2 -4
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/computed/notes.py +1 -4
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/pages/extract.py +5 -9
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/config/online/build.py +1 -1
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/online/matcher.py +1 -1
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/online/outcome_stats.py +4 -4
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/online/rate_gate.py +1 -1
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comicvine_api/online_source.py +1 -1
- {comicbox-5.2.1 → comicbox-5.2.2}/docs/NEWS.md +8 -0
- comicbox-5.2.2/generate-schema/gen-schema-docs.sh +13 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/package.json +9 -33
- {comicbox-5.2.1 → comicbox-5.2.2}/pyproject.toml +18 -31
- {comicbox-5.2.1 → comicbox-5.2.2}/pyproject.toml.orig +31 -46
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/test_extract.py +14 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/test_pages.py +6 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_identifiers.py +1 -1
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_merge_mode_config.py +1 -1
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_prompt_dedup.py +1 -1
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_rar_time_overflow.py +1 -1
- {comicbox-5.2.1 → comicbox-5.2.2}/uv.lock +542 -500
- comicbox-5.2.1/.remarkignore +0 -99
- comicbox-5.2.1/bin/ci-download-dist-if-identical.sh +0 -38
- comicbox-5.2.1/bin/lint-ci.sh +0 -11
- comicbox-5.2.1/bin/release-tag.sh +0 -11
- comicbox-5.2.1/bin/remark-for-claude.sh +0 -7
- comicbox-5.2.1/cfg/eslint.config.base.js +0 -179
- {comicbox-5.2.1 → comicbox-5.2.2}/.gitignore +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/.readthedocs.yaml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/.shellcheckrc +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/Makefile +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/README.md +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/clean-pycache.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/cleanup-pypi-alpha.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/dev-module.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/docker-compose-exit.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/docker-tag-latest.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/fix-docker.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/fix-python.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/fix-sh.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/fix.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/kill-eslint_d.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/lint-complexity.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/lint-darwin.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/lint-docker.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/lint-sh.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/lint.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/prettier-nginx.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/publish-pypi.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/regenerate-pdf-test-pages.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/sort-ignore.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/test-docker.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/test-python.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/type-python.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/uml.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/update-deps-node.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/update-deps-python.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/version-node.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/bin/version-python.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/cfg/ci.mk +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/cfg/common.mk +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/cfg/docker.mk +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/cfg/docs.mk +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/cfg/gha_std.mk +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/cfg/help.mk +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/cfg/node.mk +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/cfg/node_root.mk +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/check-jsonschema.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/_pdf.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/_rar.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/archive/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/archive/archive.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/archive/archiveinfo.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/archive/filenames.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/archive/init.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/archive/mtime.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/archive/read.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/archive/sniff.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/archive/write.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/computed/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/computed/date.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/computed/identifiers.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/computed/issue.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/computed/pages.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/computed/stamp.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/computed/stories_title.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/computed/url_identifiers.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/computed/urls.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/dump.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/dump_files.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/init.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/load.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/merge.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/metadata.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/normalize.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/online_covers.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/online_lookup.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/pages/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/pages/covers.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/print.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/sources.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/types.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/validate/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/box/validate/guess_format.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/cli/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/cli/__main__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/cli/epilog.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/cli/parser.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/config/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/config/computed.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/config/formats.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/config/online/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/config/online/settings.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/config/online/template.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/config/paths.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/config/read.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/config/settings.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/config_default.yaml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/constants.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/empty.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/enums/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/enums/comet.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/enums/comicbookinfo.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/enums/comicbox.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/enums/comicinfo.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/enums/generic/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/enums/generic/age_rating.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/enums/generic/role.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/enums/maps/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/enums/maps/age_rating.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/enums/maps/formats.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/enums/maps/identifiers.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/enums/maps/reading_direction.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/enums/maps/roles.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/enums/metroninfo.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/events.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/exceptions.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/_base.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/fields/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/fields/collection_fields.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/fields/comicbox.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/fields/comicinfo.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/fields/enum_fields.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/fields/fields.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/fields/metroninfo.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/fields/number_fields.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/fields/pdf.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/fields/pycountry.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/fields/time_fields.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/fields/union.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/fields/xml_fields.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/online/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/online/auto_engage.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/online/cli_overrides.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/online/cover_hash.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/online/credentials.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/online/profile.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/online/prompt.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/online/rate_limits.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/online/retry.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/online/sanitize.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/online/selector.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/online/series_cache.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/online/series_filter.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/online/session_state.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/online/signals.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/online/sources/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/online/sources/base.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/online/transform_helpers.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/online/vacuum.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/online/warn_once.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/schemas/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/schemas/base.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/schemas/cache.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/schemas/decorators.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/schemas/error_store.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/schemas/json_schemas.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/schemas/xml_schemas.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/schemas/yaml.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/transforms/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/transforms/base.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/transforms/identifiers.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/transforms/price.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/transforms/publishing_tags.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/transforms/spec.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/transforms/xml_credits.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/base/transforms/xml_reprints.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comet/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comet/schema.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comet/transform/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comet/transform/reprints.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comic_book_info/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comic_book_info/schema.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comic_book_info/transform/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comic_book_info/transform/credits.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comic_book_info/transform/rating.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comic_info/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comic_info/schema.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comic_info/transform/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comic_info/transform/identifiers.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comic_info/transform/manga.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comic_info/transform/pages.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comic_info/transform/storyarcs.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comicbox/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comicbox/schema/README.md +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comicbox/schema/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comicbox/schema/cli.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comicbox/schema/identifiers.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comicbox/schema/json_schema.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comicbox/schema/pages.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comicbox/schema/publishing.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comicbox/schema/yaml.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comicbox/transform/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comicbox/transform/cli.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comicbox/transform/credits.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comicbox/transform/json.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comicbox/transform/name_objs.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comicbox/transform/yaml.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comicvine_api/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comicvine_api/schema.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/comicvine_api/transform.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/filename/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/filename/schema.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/filename/transform.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/metron_api/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/metron_api/online_source.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/metron_api/paced_session.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/metron_api/schema.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/metron_api/transform.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/metron_info/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/metron_info/schema/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/metron_info/schema/credits.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/metron_info/schema/identifiers.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/metron_info/schema/price.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/metron_info/schema/publishing.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/metron_info/schema/rating.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/metron_info/schema/resource.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/metron_info/transform/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/metron_info/transform/const.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/metron_info/transform/credits.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/metron_info/transform/identified_name.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/metron_info/transform/identifier_attribute.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/metron_info/transform/identifiers.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/metron_info/transform/publishing_tags.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/metron_info/transform/rating.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/metron_info/transform/reprints.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/metron_info/transform/resources.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/pdf/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/pdf/schema.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/pdf/transform/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/pdf/transform/credits.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/formats/sources.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/identifiers/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/identifiers/identifiers.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/identifiers/other.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/identifiers/urns.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/logger.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/merge/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/merge/mergedeep.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/online_estimate.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/online_session.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/predict.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/print.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/process.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/py.typed +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/run.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/CoMet-v1.1.xsd +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/ComicInfo-v2.1-Draft.xsd +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/MetronInfo-v1.1.xsd +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/comic-book-info-v1.0.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v1.0/comicbox-v1.0.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v1.0/page.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v1.0/reprint.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v1.0/series.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v1.0/volume.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v2.0/comicbox-v2.0.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v2.0/comicbox.example.yaml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v2.0/credit.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v2.0/date.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v2.0/identified-object.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v2.0/identified-objects.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v2.0/identifiers.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v2.0/named-identified-object.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v2.0/page.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v2.0/reprint.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v2.0/series.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v2.0/volume.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v3.0/alternative-name.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v3.0/comicbox-v3.0.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v3.0/comicbox.example.yaml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v3.0/credit.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v3.0/date.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v3.0/identified-object.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v3.0/identified-objects.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v3.0/identifiers.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v3.0/named-identified-object.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v3.0/page.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v3.0/reprint.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v3.0/role.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v3.0/series.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/schemas/v3.0/volume.schema.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/validate/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/validate/base.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/validate/json_validator.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/validate/spec.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/validate/xml_validator.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/validate/yaml_validator.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/version.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/comicbox/write.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/compose.yaml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/docs/README.md +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/docs/requirements.txt +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/docs/style.css +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/docs/style.mkdocs.css +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/eslint.config.js +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/generate-schema/gen-schema.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/generate-schema/mjs.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/mkdocs.yml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/calibration/.gitignore +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/calibration/README.md +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/calibration/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/calibration/_harness_helpers.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/calibration/bootstrap.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/calibration/compare.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/calibration/debug_search.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/calibration/fixtures.example.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/calibration/label_metron.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/calibration/run.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/calibration/sample.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/calibration/summarize.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/calibration/test_bootstrap.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/calibration/test_compare.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/calibration/test_harness.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/calibration/test_harness_checkpoint.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/calibration/test_harness_diagnostics.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/calibration/test_harness_filter.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/calibration/test_harness_resume.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/calibration/test_label_metron.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/calibration/test_sample.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/calibration/test_summarize.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/cli/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/cli/test_cli_exit_code.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/cli/test_cli_export.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/cli/test_cli_extract.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/cli/test_cli_online.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/cli/test_cli_print.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/cli/test_cli_rename.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/cli/test_cli_write.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/conftest.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/const.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/Captain Science #001 (1950) The Beginning - multi.cbz +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/Captain Science #001 (1950) The Beginning - nothing.cbz +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/Captain Science #001-cbi.cbr +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/Captain Science #001-cix-cbi.cbr +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/Captain Science #001-cix.cbt +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/Captain Science #001-cix.cbz +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/Captain Science #001-comet.cbz +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/Captain Science #001-metron.cbz +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/Captain Science #001.cb7 +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/Captain Science #001.cbz +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/Captain Science 001/CaptainScience#1_01.jpg +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/Captain Science 001/CaptainScience#1_02.jpg +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/Captain Science 001/CaptainScience#1_03.jpg +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/Captain Science 001/CaptainScience#1_04.jpg +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/Captain Science 001/CaptainScience#1_05.jpg +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/Captain Science 001/CoMet.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/Captain Science 001/comic-book-info.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/Captain Science 001/comicinfo-original.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/Captain Science 001/comicinfo.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/Captain Science 001/metroninfo-full.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/Captain Science 001/metroninfo.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/comicbox-cli.cbz +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/comicbox.cbz +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/create-archive.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/create_cs_archives.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/create_macos_resource_fork.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/empty.cbz +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/examples/comic-book-info-example.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/examples/metroninfo-v1.1-valid.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/export/comet.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/export/comic-book-info.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/export/comicbox-cli.yaml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/export/comicbox.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/export/comicbox.yaml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/export/comicinfo.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/export/create-export-archive.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/export/metroninfo.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/export/mupdf.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/export/pdf-metadata.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/export.cbz +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/list_recurse_output.txt +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/macos_resource_fork.cbz +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/comet-cover-path-list.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/comet-write.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/comet.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/comic-book-info-example.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/comic-book-info.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/comicbox-cli.yaml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/comicbox-filename.txt +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/comicbox-write.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/comicbox-write.yaml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/comicbox.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/comicbox.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/comicbox.yaml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/comicinfo-bad-multiple.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/comicinfo-cover-path-list.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/comicinfo-ids-from-tags.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/comicinfo-metron-origin.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/comicinfo-notes-date.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/comicinfo-validation.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/comicinfo-write.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/comicinfo.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/create-archives.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/metroninfo-no-primary-id.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/metroninfo-v1.1-valid.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/metroninfo-write.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/metroninfo.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/mupdf-nested.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/mupdf.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/pdf-cix.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/pdf-nested.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/metadata/pdf.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/multi/comet.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/multi/comic-book-info.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/multi/comicbox-filename.txt +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/multi/comicbox.json +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/multi/comicbox.yaml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/multi/comicinfo.xml +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/multi/create-multi-archive.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/patchme.cbz +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/pdf/0.pdf +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/pdf/1.pdf +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/pdf/2.pdf +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/pdf/3.pdf +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/test_cix.pdf +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/test_pdf.pdf +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/test_pdf_legacy_keywords.pdf +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/xml.cbz +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/files/yaml.cbz +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/schemas/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/schemas/test_cbi.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/schemas/test_cix.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/schemas/test_comet.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/schemas/test_filename.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/schemas/test_json.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/schemas/test_json_schema_drift.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/schemas/test_metron.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/schemas/test_multi.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/schemas/test_pdf_json.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/schemas/test_pdf_xml.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/schemas/test_validation.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/schemas/test_yaml.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/stress/.gitignore +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/stress/README.md +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/stress/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/stress/jobs_accuracy.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/stress/prompt_ux.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/stress/run.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/stress/test_jobs_accuracy.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/stress/test_prompt_ux.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/stress/test_run.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/test_codex_api.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/test_compute.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/test_convert_pdf.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/test_import_export.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/test_mtime.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/test_stories_title.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/test_validate_testfiles.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/test_writing.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_archive_errors.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_archive_read.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_archiveinfo_mtime.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_auto_engage.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_box_cache_invalidation.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_cbi_rating_normalize.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_cli_choices_enum_drift.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_cli_dest_folds.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_cli_dest_template_drift.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_comicinfo_alternates.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_comicinfo_series_groups.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_comicvine.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_comicvine_client.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_comicvine_session_lifecycle.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_computed_pipeline.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_computed_reprints.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_config_container_inputs.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_config_defaults_drift.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_config_env_tree.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_config_file_option.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_config_layering.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_config_no_path_guard.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_config_online_leaf.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_config_online_sources.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_cover_hash.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_credit_primary.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_defer_prompts.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_destination_occupied_error.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_error_store_visibility.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_exceptions.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_lazy_startup.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_library_logging.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_list_field_dedupe.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_list_field_sort_key_types.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_manga_and_age_rating.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_manga_volume.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_merge_order.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_merge_precedence.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_mergedeep.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_metron_community_rating.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_metron_id_attribute_fallback.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_metron_prefetch.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_metron_session_lifecycle.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_metron_source.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_metron_transform.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_online.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_online_abort_propagation.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_online_degradation.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_online_estimate.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_online_events.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_online_lookup.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_online_matcher.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_online_matcher_resolution.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_online_matcher_scoring.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_online_prompt.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_online_prompt_loops.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_online_result_contract.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_online_retry.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_online_selector.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_online_session.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_online_session_state.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_outcome_stats.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_paced_session.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_parse_decimal.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_parse_issue.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_pdf_pages_help.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_process.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_quota_reserved_skip.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_rate_gate.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_rate_gate_concurrency.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_rate_limits.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_read_events.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_refresh_cache.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_rename_and_convert_safety.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_reprints_and_alternative_names.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_rich_transforms.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_role_vocabulary.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_run_parallel.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_run_recurse.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_series_cache.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_series_filter.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_series_single_flight.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_session_series_batching.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_single_valued_tags.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_sniff.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_sources.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_to_metron_age_rating.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_transform_spec_dest_collisions.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_transform_spec_missing_keys.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_transform_spec_scope_phases.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_urls_and_identifiers.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_vacuum.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_validate.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_write_api.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_write_destination_prediction.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_write_destination_safety.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_write_order_and_claims.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_xml_cdata_fields.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/unit/test_yes_no_field.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/util/__init__.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/util/compare.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/util/diff.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/util/metadata.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/util/metron_transport.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/util/online_client.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/util/online_matcher.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/util/online_prompt.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/util/parser.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/util/tmp.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/tests/validate_cli.py +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/validate-schema.sh +0 -0
- {comicbox-5.2.1 → comicbox-5.2.2}/vulture_ignorelist.py +0 -0
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# 📰 News
|
|
2
2
|
|
|
3
|
+
## v5.2.2
|
|
4
|
+
|
|
5
|
+
- Fixes
|
|
6
|
+
- `get_page_by_index()` returns `None` for an index past the last page
|
|
7
|
+
instead of raising `StopIteration`.
|
|
8
|
+
- Extracting covers writes each cover once. A cover found by more than one
|
|
9
|
+
heuristic was written, and counted, once per heuristic.
|
|
10
|
+
|
|
3
11
|
## v5.2.1
|
|
4
12
|
|
|
5
13
|
- Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: comicbox
|
|
3
|
-
Version: 5.2.
|
|
3
|
+
Version: 5.2.2
|
|
4
4
|
Summary: Comic book archive multi format metadata read/write/transform tool and image extractor.
|
|
5
5
|
Keywords: cb7,cbr,cbt,cbz,comet,comic,comicbookinfo,comicinfo,metroninfo,pdf
|
|
6
6
|
Author: AJ Slater
|
|
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
14
14
|
Requires-Dist: bidict~=0.23
|
|
15
15
|
Requires-Dist: case-converter~=1.2
|
|
16
16
|
Requires-Dist: comicfn2dict>=0.3.2,<1.0.0
|
|
17
|
-
Requires-Dist: confuse~=2.
|
|
17
|
+
Requires-Dist: confuse~=2.3.0
|
|
18
18
|
Requires-Dist: glom~=25.12
|
|
19
19
|
Requires-Dist: httpx~=0.28
|
|
20
20
|
Requires-Dist: imagehash>=4.3.2
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Reuse python-dist from an earlier CI run that passed on this exact tree.
|
|
3
|
+
#
|
|
4
|
+
# devenv-check's result job uploads a ci-passed-<tree> marker artifact when
|
|
5
|
+
# lint, test and build all pass. Find the newest live marker for HEAD's tree
|
|
6
|
+
# and download that run's python-dist into dist/. On a pull request run the
|
|
7
|
+
# tree is the merge ref's, which is what a squash merge onto an unchanged
|
|
8
|
+
# base produces, so a main push reuses the dist its PR already tested.
|
|
9
|
+
#
|
|
10
|
+
# Runs from fork pull requests are ignored: a fork controls its own workflow
|
|
11
|
+
# and could upload a forged marker for a tree it can predict.
|
|
12
|
+
#
|
|
13
|
+
# Any failure falls through to a full lint, test and build.
|
|
14
|
+
#
|
|
15
|
+
# Environment: GH_REPO, GH_TOKEN, GITHUB_OUTPUT, GITHUB_RUN_ID
|
|
16
|
+
# Outputs: dist_found=true and source_run_id=<run id> on reuse.
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
|
|
19
|
+
TREE=$(git rev-parse 'HEAD^{tree}')
|
|
20
|
+
MARKER="ci-passed-$TREE"
|
|
21
|
+
CONTINUE="Continue with Lint, Test & Build."
|
|
22
|
+
|
|
23
|
+
if ! ARTIFACTS=$(gh api "repos/$GH_REPO/actions/artifacts?name=$MARKER&per_page=100"); then
|
|
24
|
+
echo "::warning::Could not list artifacts for tree $TREE. $CONTINUE"
|
|
25
|
+
exit 0
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
RUN_ID=$(jq -r --argjson run "${GITHUB_RUN_ID:-0}" '
|
|
29
|
+
[.artifacts[]
|
|
30
|
+
| select(.expired | not)
|
|
31
|
+
| select(.workflow_run.head_repository_id == .workflow_run.repository_id)
|
|
32
|
+
| select(.workflow_run.id != $run)]
|
|
33
|
+
| max_by(.created_at)
|
|
34
|
+
| .workflow_run.id // empty' <<<"$ARTIFACTS")
|
|
35
|
+
|
|
36
|
+
if [[ -z $RUN_ID ]]; then
|
|
37
|
+
echo "No earlier run passed on tree $TREE. $CONTINUE"
|
|
38
|
+
exit 0
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
echo "Tree $TREE passed in CI run $RUN_ID"
|
|
42
|
+
if gh run download "$RUN_ID" --name python-dist --dir dist; then
|
|
43
|
+
echo "dist_found=true" >>"$GITHUB_OUTPUT"
|
|
44
|
+
echo "source_run_id=$RUN_ID" >>"$GITHUB_OUTPUT"
|
|
45
|
+
echo "Reusing python-dist from run $RUN_ID"
|
|
46
|
+
else
|
|
47
|
+
echo "Could not download python-dist from run $RUN_ID. $CONTINUE"
|
|
48
|
+
fi
|
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Delete stale images and image indexes from a Docker Hub repository.
|
|
4
|
+
|
|
5
|
+
Stale means debris: a manifest no tag reaches, which is what an old digest
|
|
6
|
+
becomes when a tag is overwritten, along with the per-architecture images
|
|
7
|
+
that hung beneath it. Deleting those cannot break a pull.
|
|
8
|
+
|
|
9
|
+
Untagged is not the same as stale, and that difference is the whole job. The
|
|
10
|
+
per-arch images and attestations under a live multi-arch tag carry no tags of
|
|
11
|
+
their own; delete those and the tag breaks. So this works out what every tag
|
|
12
|
+
actually reaches -- the tagged digest, the architectures the tag list names,
|
|
13
|
+
and the attestation manifests only the index itself knows about -- and treats
|
|
14
|
+
that set as untouchable.
|
|
15
|
+
|
|
16
|
+
The repository's manifests come from
|
|
17
|
+
/v2/namespaces/{ns}/repositories/{repo}/manifests, which is what Docker Hub's
|
|
18
|
+
Image Management page reads. It is undocumented: absent from the current Hub
|
|
19
|
+
API spec, its currently_tagged and status parameters are ignored, page numbers
|
|
20
|
+
do nothing, and paging runs off a last_evaluated_key cursor at 100 per page.
|
|
21
|
+
|
|
22
|
+
Deletion goes through the registry API, which independently refuses (403)
|
|
23
|
+
anything still referenced, so every delete gets a second opinion. Registry
|
|
24
|
+
tokens expire after about five minutes, which a long prune outlives, so the
|
|
25
|
+
token is reissued before it lapses and again on any 401.
|
|
26
|
+
|
|
27
|
+
Credentials, the same env vars as the rest of the Docker tooling here:
|
|
28
|
+
DOCKER_USER Docker Hub username
|
|
29
|
+
DOCKER_PASS Docker Hub password or personal access token
|
|
30
|
+
A personal access token needs the "Read, Write, Delete" scope.
|
|
31
|
+
|
|
32
|
+
Dry run by default. Pass --execute to actually delete.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import os
|
|
38
|
+
import sys
|
|
39
|
+
import time
|
|
40
|
+
from argparse import ArgumentParser, RawDescriptionHelpFormatter
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
from datetime import datetime, timedelta, timezone
|
|
43
|
+
from http import HTTPStatus
|
|
44
|
+
from typing import TYPE_CHECKING, Any
|
|
45
|
+
|
|
46
|
+
import requests
|
|
47
|
+
from requests.auth import HTTPBasicAuth
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from collections.abc import Iterator
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Constants
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
# Overridable so the test harness can point at a local fake registry.
|
|
57
|
+
HUB_API: str = os.environ.get("HUB_API_URL", "https://hub.docker.com/v2")
|
|
58
|
+
REGISTRY_API: str = os.environ.get(
|
|
59
|
+
"REGISTRY_API_URL", "https://registry-1.docker.io/v2"
|
|
60
|
+
)
|
|
61
|
+
REGISTRY_AUTH: str = os.environ.get("REGISTRY_AUTH_URL", "https://auth.docker.io/token")
|
|
62
|
+
|
|
63
|
+
TIMEOUT: int = 30
|
|
64
|
+
PAGE_SIZE: int = 100 # The listing returns nothing at all above 100.
|
|
65
|
+
MAX_PAGES: int = 500 # A cursor that never ends is a bug, not a big repo.
|
|
66
|
+
TOKEN_LEEWAY: int = 30 # Reissue this many seconds before expiry.
|
|
67
|
+
DEFAULT_TOKEN_LIFETIME: int = 300
|
|
68
|
+
MAX_ATTEMPTS: int = 3
|
|
69
|
+
RETRY_WAIT: int = 2
|
|
70
|
+
|
|
71
|
+
ACCEPT_MANIFESTS: str = (
|
|
72
|
+
"application/vnd.oci.image.index.v1+json"
|
|
73
|
+
", application/vnd.docker.distribution.manifest.list.v2+json"
|
|
74
|
+
", application/vnd.oci.image.manifest.v1+json"
|
|
75
|
+
", application/vnd.docker.distribution.manifest.v2+json"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
DELETED_STATUSES: frozenset[int] = frozenset(
|
|
79
|
+
(HTTPStatus.OK, HTTPStatus.ACCEPTED, HTTPStatus.NOT_FOUND)
|
|
80
|
+
)
|
|
81
|
+
RETRY_STATUSES: frozenset[int] = frozenset(
|
|
82
|
+
(
|
|
83
|
+
HTTPStatus.TOO_MANY_REQUESTS,
|
|
84
|
+
HTTPStatus.BAD_GATEWAY,
|
|
85
|
+
HTTPStatus.SERVICE_UNAVAILABLE,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class PruneError(RuntimeError):
|
|
91
|
+
"""Something went wrong that must stop the prune."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class Manifest:
|
|
96
|
+
"""One manifest in a repository, as Docker Hub describes it."""
|
|
97
|
+
|
|
98
|
+
digest: str
|
|
99
|
+
media_type: str
|
|
100
|
+
tags: tuple[str, ...]
|
|
101
|
+
platform: str
|
|
102
|
+
size: int
|
|
103
|
+
last_pushed: str
|
|
104
|
+
last_pulled: str
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def is_index(self) -> bool:
|
|
108
|
+
"""Whether this is an image index rather than a single image."""
|
|
109
|
+
return "index" in self.media_type or "list" in self.media_type
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def kind(self) -> str:
|
|
113
|
+
"""Human label for the manifest's type."""
|
|
114
|
+
return "image index" if self.is_index else "image "
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def from_hub(cls, data: dict[str, Any]) -> Manifest:
|
|
118
|
+
"""Build a manifest from a Hub listing entry."""
|
|
119
|
+
# Every field can arrive as JSON null, hence the empty defaults.
|
|
120
|
+
arch = str(data.get("arch") or "")
|
|
121
|
+
return cls(
|
|
122
|
+
digest=str(data.get("manifest_digest") or ""),
|
|
123
|
+
media_type=str(data.get("media_type") or ""),
|
|
124
|
+
tags=tuple(_tag_names(data.get("tags"))),
|
|
125
|
+
platform=f"{data.get('os') or 'linux'}/{arch}" if arch else "",
|
|
126
|
+
size=int(data.get("total_size") or 0),
|
|
127
|
+
last_pushed=str(data.get("last_pushed") or ""),
|
|
128
|
+
last_pulled=str(data.get("last_pulled") or ""),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _tag_names(tags: Any) -> Iterator[str]:
|
|
133
|
+
"""Read tag names, whether the listing gives strings or objects."""
|
|
134
|
+
for tag in tags or ():
|
|
135
|
+
if isinstance(tag, dict):
|
|
136
|
+
name = tag.get("tag")
|
|
137
|
+
if name:
|
|
138
|
+
yield str(name)
|
|
139
|
+
else:
|
|
140
|
+
yield str(tag)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# Docker Hub
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class HubSession:
|
|
149
|
+
"""Authenticated client for the Docker Hub API."""
|
|
150
|
+
|
|
151
|
+
def __init__(self, user: str, secret: str) -> None:
|
|
152
|
+
"""Log in and keep the bearer token on the session."""
|
|
153
|
+
self._session = requests.Session()
|
|
154
|
+
response = self._session.post(
|
|
155
|
+
f"{HUB_API}/auth/token",
|
|
156
|
+
json={"identifier": user, "secret": secret},
|
|
157
|
+
timeout=TIMEOUT,
|
|
158
|
+
)
|
|
159
|
+
if not response.ok:
|
|
160
|
+
reason = f"Docker Hub login failed (HTTP {response.status_code})"
|
|
161
|
+
raise PruneError(reason)
|
|
162
|
+
token = response.json().get("access_token")
|
|
163
|
+
if not token:
|
|
164
|
+
reason = "Docker Hub returned no access token"
|
|
165
|
+
raise PruneError(reason)
|
|
166
|
+
self._session.headers["Authorization"] = f"Bearer {token}"
|
|
167
|
+
|
|
168
|
+
def get(self, url: str, params: dict[str, str | int]) -> dict[str, Any]:
|
|
169
|
+
"""GET a Hub endpoint that answers with JSON."""
|
|
170
|
+
response = self._session.get(url, params=params, timeout=TIMEOUT)
|
|
171
|
+
if not response.ok:
|
|
172
|
+
reason = f"{url} failed (HTTP {response.status_code})"
|
|
173
|
+
raise PruneError(reason)
|
|
174
|
+
return response.json()
|
|
175
|
+
|
|
176
|
+
def manifests(self, repo: str) -> list[Manifest]:
|
|
177
|
+
"""Every manifest in the repository, tagged or not."""
|
|
178
|
+
url = f"{HUB_API}/namespaces/{_namespace(repo)}/repositories/{_name(repo)}/manifests"
|
|
179
|
+
params: dict[str, str | int] = {"page_size": PAGE_SIZE}
|
|
180
|
+
manifests: list[Manifest] = []
|
|
181
|
+
for page in range(MAX_PAGES):
|
|
182
|
+
body = self.get(url, params)
|
|
183
|
+
entries = body.get("manifests") or body.get("results") or []
|
|
184
|
+
manifests.extend(Manifest.from_hub(entry) for entry in entries)
|
|
185
|
+
cursor = body.get("last_evaluated_key")
|
|
186
|
+
# Page numbers are ignored here; the cursor is the only way on.
|
|
187
|
+
if not cursor or len(entries) < PAGE_SIZE:
|
|
188
|
+
return manifests
|
|
189
|
+
params = {"page_size": PAGE_SIZE, "last_evaluated_key": cursor}
|
|
190
|
+
if page == MAX_PAGES - 1:
|
|
191
|
+
reason = (
|
|
192
|
+
f"the manifest listing did not end after {MAX_PAGES} pages; "
|
|
193
|
+
"refusing to work from a partial view of the repository"
|
|
194
|
+
)
|
|
195
|
+
raise PruneError(reason)
|
|
196
|
+
return manifests
|
|
197
|
+
|
|
198
|
+
def tags(self, repo: str) -> Iterator[dict[str, Any]]:
|
|
199
|
+
"""Every current tag in the repository."""
|
|
200
|
+
url = f"{HUB_API}/namespaces/{_namespace(repo)}/repositories/{_name(repo)}/tags"
|
|
201
|
+
page = 1
|
|
202
|
+
while True:
|
|
203
|
+
body = self.get(url, {"page_size": PAGE_SIZE, "page": page})
|
|
204
|
+
yield from body.get("results") or ()
|
|
205
|
+
if not body.get("next"):
|
|
206
|
+
return
|
|
207
|
+
page += 1
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
# Registry
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class RegistrySession:
|
|
216
|
+
"""Registry client that keeps its short-lived token fresh."""
|
|
217
|
+
|
|
218
|
+
def __init__(self, repo: str, user: str, secret: str) -> None:
|
|
219
|
+
"""Prepare a session; the token is fetched on first use."""
|
|
220
|
+
self._repo = repo
|
|
221
|
+
self._auth = HTTPBasicAuth(user, secret)
|
|
222
|
+
self._session = requests.Session()
|
|
223
|
+
self._expires = datetime.now(tz=timezone.utc)
|
|
224
|
+
self._token = ""
|
|
225
|
+
|
|
226
|
+
def _reissue(self) -> None:
|
|
227
|
+
"""Get a fresh pull/delete token for this repository."""
|
|
228
|
+
response = self._session.get(
|
|
229
|
+
REGISTRY_AUTH,
|
|
230
|
+
params={
|
|
231
|
+
"service": "registry.docker.io",
|
|
232
|
+
"scope": f"repository:{self._repo}:pull,delete",
|
|
233
|
+
},
|
|
234
|
+
auth=self._auth,
|
|
235
|
+
timeout=TIMEOUT,
|
|
236
|
+
)
|
|
237
|
+
if not response.ok:
|
|
238
|
+
reason = f"registry auth failed (HTTP {response.status_code})"
|
|
239
|
+
raise PruneError(reason)
|
|
240
|
+
body = response.json()
|
|
241
|
+
self._token = body.get("token") or body.get("access_token") or ""
|
|
242
|
+
if not self._token:
|
|
243
|
+
reason = "registry returned no token"
|
|
244
|
+
raise PruneError(reason)
|
|
245
|
+
lifetime = int(body.get("expires_in") or DEFAULT_TOKEN_LIFETIME)
|
|
246
|
+
self._expires = datetime.now(tz=timezone.utc) + timedelta(
|
|
247
|
+
seconds=max(lifetime - TOKEN_LEEWAY, 1)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def _request(self, method: str, digest: str, accept: str = "") -> requests.Response:
|
|
251
|
+
"""Call the registry, reissuing the token before and after it lapses."""
|
|
252
|
+
if datetime.now(tz=timezone.utc) >= self._expires:
|
|
253
|
+
self._reissue()
|
|
254
|
+
url = f"{REGISTRY_API}/{self._repo}/manifests/{digest}"
|
|
255
|
+
headers = {"Accept": accept} if accept else {}
|
|
256
|
+
response = requests.Response()
|
|
257
|
+
for attempt in range(MAX_ATTEMPTS):
|
|
258
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
259
|
+
response = self._session.request(
|
|
260
|
+
method, url, headers=headers, timeout=TIMEOUT
|
|
261
|
+
)
|
|
262
|
+
if response.status_code == HTTPStatus.UNAUTHORIZED:
|
|
263
|
+
# A long prune outlives its token even with the leeway.
|
|
264
|
+
self._reissue()
|
|
265
|
+
continue
|
|
266
|
+
if response.status_code in RETRY_STATUSES:
|
|
267
|
+
time.sleep(RETRY_WAIT * (attempt + 1))
|
|
268
|
+
continue
|
|
269
|
+
break
|
|
270
|
+
return response
|
|
271
|
+
|
|
272
|
+
def manifest(self, digest: str) -> dict[str, Any] | None:
|
|
273
|
+
"""Read the manifest at this digest, or None when it is already gone."""
|
|
274
|
+
response = self._request("GET", digest, accept=ACCEPT_MANIFESTS)
|
|
275
|
+
if response.status_code == HTTPStatus.NOT_FOUND:
|
|
276
|
+
return None
|
|
277
|
+
if not response.ok:
|
|
278
|
+
reason = f"could not read manifest {digest} (HTTP {response.status_code})"
|
|
279
|
+
raise PruneError(reason)
|
|
280
|
+
return response.json()
|
|
281
|
+
|
|
282
|
+
def delete(self, digest: str) -> int:
|
|
283
|
+
"""Delete the manifest at this digest, returning the status code."""
|
|
284
|
+
return self._request("DELETE", digest).status_code
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
# The prune
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _namespace(repo: str) -> str:
|
|
293
|
+
return repo.split("/", 1)[0]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _name(repo: str) -> str:
|
|
297
|
+
return repo.split("/", 1)[1]
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _tag_reach(hub: HubSession, repo: str) -> tuple[set[str], set[str]]:
|
|
301
|
+
"""Read the tag list: the tagged digests, and the architectures beneath them."""
|
|
302
|
+
tagged: set[str] = set()
|
|
303
|
+
children: set[str] = set()
|
|
304
|
+
for tag in hub.tags(repo):
|
|
305
|
+
digest = tag.get("digest")
|
|
306
|
+
if digest:
|
|
307
|
+
tagged.add(digest)
|
|
308
|
+
# The tag list names each architecture but omits attestations.
|
|
309
|
+
for image in tag.get("images") or ():
|
|
310
|
+
child = image.get("digest")
|
|
311
|
+
if child:
|
|
312
|
+
children.add(child)
|
|
313
|
+
return tagged, children
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def live_digests(hub: HubSession, registry: RegistrySession, repo: str) -> set[str]:
|
|
317
|
+
"""Every digest a tag reaches, directly or through an index."""
|
|
318
|
+
tagged, live = _tag_reach(hub, repo)
|
|
319
|
+
if not tagged:
|
|
320
|
+
reason = (
|
|
321
|
+
f"{repo} has no tags; refusing to prune a repository whose live set "
|
|
322
|
+
"cannot be established -- every manifest would look stale"
|
|
323
|
+
)
|
|
324
|
+
raise PruneError(reason)
|
|
325
|
+
live |= tagged
|
|
326
|
+
for digest in sorted(tagged):
|
|
327
|
+
index = registry.manifest(digest)
|
|
328
|
+
if index is None:
|
|
329
|
+
reason = (
|
|
330
|
+
f"tagged manifest {digest} is missing from the registry; "
|
|
331
|
+
"aborting rather than mistaking its children for stale"
|
|
332
|
+
)
|
|
333
|
+
raise PruneError(reason)
|
|
334
|
+
live.update(
|
|
335
|
+
child["digest"]
|
|
336
|
+
for child in index.get("manifests") or ()
|
|
337
|
+
if child.get("digest")
|
|
338
|
+
)
|
|
339
|
+
return live
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def select_stale(
|
|
343
|
+
manifests: list[Manifest], live: set[str], cutoff: str
|
|
344
|
+
) -> list[Manifest]:
|
|
345
|
+
"""Pick the debris: manifests untagged, unreachable, and old enough."""
|
|
346
|
+
return [
|
|
347
|
+
manifest
|
|
348
|
+
for manifest in manifests
|
|
349
|
+
if not manifest.tags
|
|
350
|
+
and manifest.digest not in live
|
|
351
|
+
and (
|
|
352
|
+
not cutoff
|
|
353
|
+
or (manifest.last_pushed < cutoff and manifest.last_pulled < cutoff)
|
|
354
|
+
)
|
|
355
|
+
]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def human_size(size: float) -> str:
|
|
359
|
+
"""Bytes in units a person reads."""
|
|
360
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
361
|
+
if size < 1024: # noqa: PLR2004
|
|
362
|
+
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
|
363
|
+
size /= 1024
|
|
364
|
+
return f"{size:.1f} TB"
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _day(stamp: str) -> str:
|
|
368
|
+
return stamp[:10] if stamp else "never "
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def report(stale: list[Manifest]) -> None:
|
|
372
|
+
"""Print what would go, and roughly how much room it frees."""
|
|
373
|
+
total = sum(manifest.size for manifest in stale)
|
|
374
|
+
print(f" {len(stale)} stale manifest(s), up to {human_size(total)} reclaimable:")
|
|
375
|
+
for manifest in stale:
|
|
376
|
+
pushed = _day(manifest.last_pushed)
|
|
377
|
+
pulled = _day(manifest.last_pulled)
|
|
378
|
+
digest = manifest.digest[:19]
|
|
379
|
+
print(
|
|
380
|
+
f" {manifest.kind} {digest} {manifest.platform:<12} pushed={pushed} pulled={pulled}"
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def delete_stale(registry: RegistrySession, stale: list[Manifest]) -> int:
|
|
385
|
+
"""Delete the debris, indexes first. Returns the number that failed."""
|
|
386
|
+
# While an index stands its children are still referenced, and the
|
|
387
|
+
# registry would rightly refuse to delete them.
|
|
388
|
+
ordered = [m for m in stale if m.is_index] + [m for m in stale if not m.is_index]
|
|
389
|
+
failed = 0
|
|
390
|
+
for number, manifest in enumerate(ordered, start=1):
|
|
391
|
+
status = registry.delete(manifest.digest)
|
|
392
|
+
prefix = f" [{number}/{len(ordered)}]"
|
|
393
|
+
if status in DELETED_STATUSES:
|
|
394
|
+
print(f"{prefix} deleted {manifest.digest[:19]}")
|
|
395
|
+
elif status == HTTPStatus.FORBIDDEN:
|
|
396
|
+
note = "the registry says it is still referenced"
|
|
397
|
+
print(f"{prefix} refused {manifest.digest[:19]}: {note}", file=sys.stderr)
|
|
398
|
+
failed += 1
|
|
399
|
+
else:
|
|
400
|
+
print(
|
|
401
|
+
f"{prefix} FAILED {manifest.digest[:19]}: HTTP {status}",
|
|
402
|
+
file=sys.stderr,
|
|
403
|
+
)
|
|
404
|
+
failed += 1
|
|
405
|
+
return failed
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# ---------------------------------------------------------------------------
|
|
409
|
+
# CLI
|
|
410
|
+
# ---------------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def build_parser() -> ArgumentParser:
|
|
414
|
+
"""Build cli arg parser."""
|
|
415
|
+
parser = ArgumentParser(
|
|
416
|
+
description=(
|
|
417
|
+
"Delete the stale images and image indexes from a Docker Hub "
|
|
418
|
+
"repository: the manifests no tag reaches, directly or through an "
|
|
419
|
+
"index. Everything a tag reaches is left alone, including the "
|
|
420
|
+
"per-architecture images and attestations that sit untagged "
|
|
421
|
+
"beneath a multi-arch tag."
|
|
422
|
+
),
|
|
423
|
+
formatter_class=RawDescriptionHelpFormatter,
|
|
424
|
+
epilog=(
|
|
425
|
+
"Credentials come from DOCKER_USER and DOCKER_PASS. A personal\n"
|
|
426
|
+
"access token needs the Read, Write, Delete scope.\n"
|
|
427
|
+
"Dry run unless --execute is given."
|
|
428
|
+
),
|
|
429
|
+
)
|
|
430
|
+
parser.add_argument(
|
|
431
|
+
"repository",
|
|
432
|
+
metavar="NAMESPACE/REPOSITORY",
|
|
433
|
+
help="e.g. ajslater/codex. A bare name uses $DOCKER_USER as the namespace.",
|
|
434
|
+
)
|
|
435
|
+
parser.add_argument(
|
|
436
|
+
"--min-age",
|
|
437
|
+
type=int,
|
|
438
|
+
default=0,
|
|
439
|
+
metavar="DAYS",
|
|
440
|
+
help=(
|
|
441
|
+
"Only delete manifests whose last push and last pull are both older "
|
|
442
|
+
"than DAYS. Keeps a push that is still in flight out of the blast radius."
|
|
443
|
+
),
|
|
444
|
+
)
|
|
445
|
+
parser.add_argument(
|
|
446
|
+
"--execute",
|
|
447
|
+
action="store_true",
|
|
448
|
+
help="Actually delete. Without it, only report.",
|
|
449
|
+
)
|
|
450
|
+
return parser
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _credentials() -> tuple[str, str]:
|
|
454
|
+
user = os.environ.get("DOCKER_USER", "")
|
|
455
|
+
secret = os.environ.get("DOCKER_PASS", "")
|
|
456
|
+
if not user or not secret:
|
|
457
|
+
reason = "DOCKER_USER and DOCKER_PASS must be set"
|
|
458
|
+
raise PruneError(reason)
|
|
459
|
+
return user, secret
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def prune(repo: str, min_age: int, *, execute: bool) -> int:
|
|
463
|
+
"""Find the stale manifests and, when told to, delete them."""
|
|
464
|
+
user, secret = _credentials()
|
|
465
|
+
if "/" not in repo:
|
|
466
|
+
repo = f"{user}/{repo}"
|
|
467
|
+
print(f"[{'EXECUTE' if execute else 'DRY RUN'}] {repo}")
|
|
468
|
+
|
|
469
|
+
hub = HubSession(user, secret)
|
|
470
|
+
registry = RegistrySession(repo, user, secret)
|
|
471
|
+
manifests = hub.manifests(repo)
|
|
472
|
+
if not manifests:
|
|
473
|
+
print(" Repository holds no manifests. Nothing to do.")
|
|
474
|
+
return 0
|
|
475
|
+
live = live_digests(hub, registry, repo)
|
|
476
|
+
|
|
477
|
+
cutoff = ""
|
|
478
|
+
if min_age:
|
|
479
|
+
moment = datetime.now(tz=timezone.utc) - timedelta(days=min_age)
|
|
480
|
+
cutoff = moment.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
481
|
+
print(f" Age filter: last push and last pull both before {cutoff}")
|
|
482
|
+
|
|
483
|
+
print(
|
|
484
|
+
f" {len(manifests)} manifest(s) in the repository, {len(live)} reachable from a tag."
|
|
485
|
+
)
|
|
486
|
+
stale = select_stale(manifests, live, cutoff)
|
|
487
|
+
if not stale:
|
|
488
|
+
print(" No stale manifests. Nothing to delete.")
|
|
489
|
+
return 0
|
|
490
|
+
report(stale)
|
|
491
|
+
|
|
492
|
+
if not execute:
|
|
493
|
+
print("\nDry run only. Re-run with --execute to delete.")
|
|
494
|
+
return 0
|
|
495
|
+
|
|
496
|
+
print(" Deleting...")
|
|
497
|
+
failed = delete_stale(registry, stale)
|
|
498
|
+
print(f" Deleted {len(stale) - failed} manifest(s).")
|
|
499
|
+
if failed:
|
|
500
|
+
print(f" {failed} deletion(s) did not succeed.", file=sys.stderr)
|
|
501
|
+
return 1
|
|
502
|
+
return 0
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def main() -> None:
|
|
506
|
+
"""Run program."""
|
|
507
|
+
args = build_parser().parse_args()
|
|
508
|
+
try:
|
|
509
|
+
sys.exit(prune(args.repository, args.min_age, execute=args.execute))
|
|
510
|
+
except PruneError as exc:
|
|
511
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
512
|
+
sys.exit(1)
|
|
513
|
+
except KeyboardInterrupt:
|
|
514
|
+
print("\ninterrupted", file=sys.stderr)
|
|
515
|
+
sys.exit(130)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
if __name__ == "__main__":
|
|
519
|
+
main()
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Lint dead code with vulture
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
# vulture fnmatches its excludes against absolute paths, so no pattern can skip
|
|
6
|
+
# the root's dot-dirs (.venv, .claude/worktrees/) without also skipping every
|
|
7
|
+
# file of a checkout under one. Pass the root's other entries instead of ".".
|
|
8
|
+
find . -mindepth 1 -maxdepth 1 ! -name '.*' \( -type d -o -type f -name '*.py' \) \
|
|
9
|
+
-exec uv run --group lint vulture {} +
|