diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b37ba86..ed2dfcc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,17 +20,14 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip setuptools wheel - pip install coverage - pip install --editable . - - name: Run test - run: python -m unittest discover + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: Run tests + run: uv run pytest diff --git a/.gitignore b/.gitignore index 1f4b930..333e320 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ dist MANIFEST bagit.egg-info .idea +uv.lock +*.log diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9f1dc32..31dc8e5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,19 +2,19 @@ exclude: ".*test-data.*" repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.9 + rev: v0.15.2 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: check-added-large-files args: ["--maxkb=128"] - id: check-ast - - id: check-byte-order-marker + - id: fix-byte-order-marker - id: check-case-conflict - id: check-docstring-first - id: check-executables-have-shebangs diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index db7a199..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,7 +0,0 @@ -prune test-data -exclude .* -exclude Dockerfile -exclude MANIFEST.in -exclude test.py -exclude bench.py -recursive-include locale *.po *.mo diff --git a/Makefile b/Makefile index 9907ac7..17b0319 100644 --- a/Makefile +++ b/Makefile @@ -3,13 +3,13 @@ COMPILED_MESSAGES=$(patsubst %.po,%.mo, $(wildcard locale/*/LC_MESSAGES/bagit-py all: messages compile clean: - rm -f locale/*/LC_MESSAGES/*.mo + rm -f src/bagit/locale/*/LC_MESSAGES/*.mo messages: - xgettext --language=python -d bagit-python --no-location -o locale/bagit-python.pot bagit.py + xgettext --language=python -d bagit-python --no-location -o src/bagit/locale/bagit-python.pot src/bagit/__init__.py # Until http://savannah.gnu.org/bugs/?20923 is fixed: - sed -i '' -e 's/CHARSET/UTF-8/g' locale/bagit-python.pot - msgmerge --no-fuzzy-matching --lang=en --output-file=locale/en/LC_MESSAGES/bagit-python.po locale/en/LC_MESSAGES/bagit-python.po locale/bagit-python.pot + sed -i '' -e 's/CHARSET/UTF-8/g' src/bagit/locale/bagit-python.pot + msgmerge --no-fuzzy-matching --lang=en --output-file=src/bagit/locale/en/LC_MESSAGES/bagit-python.po src/bagit/locale/en/LC_MESSAGES/bagit-python.po src/bagit/locale/bagit-python.pot %.mo: %.po msgfmt -o $@ $< diff --git a/README.rst b/README.rst index ed33262..1ad7710 100644 --- a/README.rst +++ b/README.rst @@ -219,23 +219,23 @@ Contributing to bagit-python development % git clone git://github.com/LibraryOfCongress/bagit-python.git % cd bagit-python # MAKE CHANGES - % python test.py + % uv run pytest Running the tests ~~~~~~~~~~~~~~~~~ -You can quickly run the tests using the built-in unittest framework: +You can quickly run the tests using `uv `_. :: - python -m unittest discover + uv run pytest If you have Docker installed, you can run the tests under Linux inside a container: :: - % docker build -t bagit:latest . && docker run -it bagit:latest + docker build -t bagit:latest . && docker run -it bagit:latest Benchmarks ---------- @@ -246,7 +246,7 @@ bench utility: :: - % ./bench.py + ./utils/bench.py License ------- diff --git a/pyproject.toml b/pyproject.toml index 7cd2d39..937984a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] -requires = ["setuptools>=64", "setuptools-scm>=8"] -build-backend = "setuptools.build_meta" +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" [project] name = "bagit" @@ -8,7 +8,8 @@ dynamic = ["version"] description = "Create and validate BagIt packages" readme = {file = "README.rst", content-type = "text/x-rst"} authors = [ - { name = "Ed Summers", email = "ehs@pobox.com" }, + { name = "Chris Adams", email = "chris@improbable.org" }, + { name = "Ed Summers", email = "ehs@pobox.com" } ] classifiers = [ "Intended Audience :: Developers", @@ -18,6 +19,10 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Filesystems", ] +requires-python = ">= 3.9" + +[project.scripts] +"bagit" = "bagit:main" [project.urls] Homepage = "https://libraryofcongress.github.io/bagit-python/" @@ -27,14 +32,34 @@ Homepage = "https://libraryofcongress.github.io/bagit-python/" [tool.ruff] target-version = "py38" - -[tool.setuptools_scm] - [tool.isort] line_length = 110 default_section = "THIRDPARTY" known_first_party = "bagit" +[tool.pytest.ini_options] +testpaths = ["test.py"] +addopts = "-v" + [tool.coverage.run] branch = true -include = "bagit.py" +include = "src" + +[tool.hatch.version] +source = "vcs" + +[tool.hatch.build.targets.sdist] +packages = ["src/bagit"] +include = [ + "src/bagit/*.py", + "src/bagit/locale/*.po", + "src/bagit/locale/*.mo", +] + + +[dependency-groups] +dev = [ + "coverage>=7.8.2", + "pytest>=8.4.0", + "ruff>=0.11.12", +] diff --git a/setup.py b/setup.py deleted file mode 100644 index 15c91b4..0000000 --- a/setup.py +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env python -# encoding: utf-8 - -from __future__ import absolute_import, print_function - -import glob -import os -import subprocess -import sys - -from setuptools import setup - -description = "Create and validate BagIt packages" - - -def get_message_catalogs(): - message_catalogs = [] - - for po_file in glob.glob("locale/*/LC_MESSAGES/bagit-python.po"): - mo_file = po_file.replace(".po", ".mo") - - if not os.path.exists(mo_file) or os.path.getmtime(mo_file) < os.path.getmtime( - po_file - ): - try: - subprocess.check_call(["msgfmt", "-o", mo_file, po_file]) - except (OSError, subprocess.CalledProcessError) as exc: - print( - "Translation catalog %s could not be compiled (is gettext installed?) " - " — translations will not be available for this language: %s" - % (po_file, exc), - file=sys.stderr, - ) - continue - - message_catalogs.append((os.path.dirname(mo_file), (mo_file,))) - - return message_catalogs - - -setup( - name="bagit", - use_scm_version=True, - url="https://libraryofcongress.github.io/bagit-python/", - author="Ed Summers", - author_email="ehs@pobox.com", - py_modules=["bagit"], - scripts=["bagit.py"], - data_files=get_message_catalogs(), - description=description, - platforms=["POSIX"], - setup_requires=["setuptools_scm"], - classifiers=[ - "License :: Public Domain", - "Intended Audience :: Developers", - "Topic :: Communications :: File Sharing", - "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: System :: Filesystems", - "Programming Language :: Python :: 3", - ], -) diff --git a/bagit.py b/src/bagit/__init__.py similarity index 98% rename from bagit.py rename to src/bagit/__init__.py index eab85d3..c17b1f2 100755 --- a/bagit.py +++ b/src/bagit/__init__.py @@ -236,7 +236,11 @@ def make_bag( break except PermissionError as e: if hasattr(e, "winerror") and e.winerror == 5: - LOGGER.warning(_("PermissionError [WinError 5] when renaming temp folder. Retrying in 10 seconds...")) + LOGGER.warning( + _( + "PermissionError [WinError 5] when renaming temp folder. Retrying in 10 seconds..." + ) + ) time.sleep(10) else: raise @@ -250,7 +254,7 @@ def make_bag( ) LOGGER.info(_("Creating bagit.txt")) - txt = """BagIt-Version: 0.97\nTag-File-Character-Encoding: UTF-8\n""" + txt = """BagIt-Version: 1.0\nTag-File-Character-Encoding: UTF-8\n""" with open_text_file("bagit.txt", "w") as bagit_file: bagit_file.write(txt) @@ -946,7 +950,7 @@ def _path_is_dangerous(self, path): real_path = os.path.normpath(real_path) bag_path = os.path.realpath(self.path) bag_path = os.path.normpath(bag_path) - common = os.path.commonprefix((bag_path, real_path)) + common = os.path.commonpath((bag_path, real_path)) return not (common == bag_path) @@ -1124,12 +1128,12 @@ def _calc_hashes(args): full_path = os.path.join(base_path, rel_path) # Create a clone of the default empty hash objects: - f_hashers = dict((alg, hashlib.new(alg)) for alg in hashes if alg in algorithms) + f_hashers = {alg: hashlib.new(alg) for alg in hashes if alg in algorithms} try: f_hashes = _calculate_file_hashes(full_path, f_hashers) except BagValidationError as e: - f_hashes = dict((alg, str(e)) for alg in f_hashers.keys()) + f_hashes = {alg: str(e) for alg in f_hashers} return rel_path, f_hashes, hashes @@ -1489,10 +1493,7 @@ def _make_parser(): checksum_args = parser.add_argument_group( _("Checksum Algorithms"), - _( - "Select the manifest algorithms to be used when creating bags" - " (default=%s)" - ) + _("Select the manifest algorithms to be used when creating bags (default=%s)") % ", ".join(DEFAULT_CHECKSUMS), ) diff --git a/locale/bagit-python.pot b/src/bagit/locale/bagit-python.pot similarity index 96% rename from locale/bagit-python.pot rename to src/bagit/locale/bagit-python.pot index 3543c31..85bf167 100644 --- a/locale/bagit-python.pot +++ b/src/bagit/locale/bagit-python.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2018-06-26 10:28-0400\n" +"POT-Creation-Date: 2025-06-05 12:14-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -226,20 +226,23 @@ msgid "" "%(found_byte_count)d bytes" msgstr "" -msgid "Bag validation failed" +msgid "Bag is incomplete" msgstr "" #, python-format msgid "Unable to calculate file hashes for %s" msgstr "" +msgid "Bag validation failed" +msgstr "" + msgid "bagit.txt must not contain a byte-order mark" msgstr "" #, python-format msgid "" -"%(path)s %(algorithm)s validation failed: expected=\"%(expected)s\" found=" -"\"%(found)s\"" +"%(path)s %(algorithm)s validation failed: expected=\"%(expected)s\" " +"found=\"%(found)s\"" msgstr "" #, python-format @@ -345,16 +348,23 @@ msgstr "" msgid "bagit-python version %s" msgstr "" -msgid "The number of processes must be 0 or greater" +msgid "The number of processes must be greater than 0" msgstr "" msgid "--fast is only allowed as an option for --validate!" msgstr "" +msgid "--completeness-only is only allowed as an option for --validate!" +msgstr "" + #, python-format msgid "%s valid according to Payload-Oxum" msgstr "" +#, python-format +msgid "%s is complete and valid according to Payload-Oxum" +msgstr "" + #, python-format msgid "%s is valid" msgstr "" diff --git a/locale/bagit.pot b/src/bagit/locale/bagit.pot similarity index 100% rename from locale/bagit.pot rename to src/bagit/locale/bagit.pot diff --git a/locale/en/LC_MESSAGES/bagit-python.po b/src/bagit/locale/en/LC_MESSAGES/bagit-python.po similarity index 96% rename from locale/en/LC_MESSAGES/bagit-python.po rename to src/bagit/locale/en/LC_MESSAGES/bagit-python.po index ddf0f3f..8767e56 100644 --- a/locale/en/LC_MESSAGES/bagit-python.po +++ b/src/bagit/locale/en/LC_MESSAGES/bagit-python.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2018-06-26 10:28-0400\n" +"POT-Creation-Date: 2025-06-05 12:14-0400\n" "PO-Revision-Date: 2017-04-27 15:02-0400\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" @@ -233,20 +233,23 @@ msgid "" "%(found_byte_count)d bytes" msgstr "" -msgid "Bag validation failed" +msgid "Bag is incomplete" msgstr "" #, python-format msgid "Unable to calculate file hashes for %s" msgstr "Unable to calculate file hashes for %s" +msgid "Bag validation failed" +msgstr "" + msgid "bagit.txt must not contain a byte-order mark" msgstr "" #, python-format msgid "" -"%(path)s %(algorithm)s validation failed: expected=\"%(expected)s\" found=" -"\"%(found)s\"" +"%(path)s %(algorithm)s validation failed: expected=\"%(expected)s\" " +"found=\"%(found)s\"" msgstr "" #, python-format @@ -352,16 +355,23 @@ msgstr "" msgid "bagit-python version %s" msgstr "" -msgid "The number of processes must be 0 or greater" +msgid "The number of processes must be greater than 0" msgstr "" msgid "--fast is only allowed as an option for --validate!" msgstr "" +msgid "--completeness-only is only allowed as an option for --validate!" +msgstr "" + #, python-format msgid "%s valid according to Payload-Oxum" msgstr "%s valid according to Payload-Oxum" +#, python-format +msgid "%s is complete and valid according to Payload-Oxum" +msgstr "" + #, python-format msgid "%s is valid" msgstr "%s is valid" diff --git a/test.py b/test.py index 735bd73..f79bca0 100644 --- a/test.py +++ b/test.py @@ -523,7 +523,7 @@ def test_make_bag(self): tagmanifest_txt = slurp_text_file( j(self.tmpdir, "tagmanifest-md5.txt") ).splitlines() - self.assertIn("9e5ad981e0d29adc278f6a294b8c2aca bagit.txt", tagmanifest_txt) + self.assertIn("eaa2c609ff6371712f623f5531945b44 bagit.txt", tagmanifest_txt) self.assertIn( "a0ce6631a2a6d1a88e6d38453ccc72a5 manifest-md5.txt", tagmanifest_txt ) @@ -1157,10 +1157,9 @@ def test_invalid_fast_validate(self): os.remove(j(self.tmpdir, "data", "loc", "2478433644_2839c5e8b8_o_d.jpg")) testargs = ["bagit.py", "--validate", "--completeness-only", self.tmpdir] - with self.assertLogs() as captured: - with self.assertRaises(SystemExit) as cm: - with mock.patch.object(sys, "argv", testargs): - bagit.main() + with self.assertLogs() as captured, self.assertRaises(SystemExit) as cm: + with mock.patch.object(sys, "argv", testargs): + bagit.main() self.assertEqual(cm.exception.code, 1) self.assertIn( @@ -1172,10 +1171,9 @@ def test_valid_fast_validate(self): bagit.make_bag(self.tmpdir) testargs = ["bagit.py", "--validate", "--fast", self.tmpdir] - with self.assertLogs() as captured: - with self.assertRaises(SystemExit) as cm: - with mock.patch.object(sys, "argv", testargs): - bagit.main() + with self.assertLogs() as captured, self.assertRaises(SystemExit) as cm: + with mock.patch.object(sys, "argv", testargs): + bagit.main() self.assertEqual(cm.exception.code, 0) self.assertEqual( @@ -1206,10 +1204,9 @@ def test_invalid_completeness_validate(self): testargs = ["bagit.py", "--validate", "--completeness-only", self.tmpdir] - with self.assertLogs() as captured: - with self.assertRaises(SystemExit) as cm: - with mock.patch.object(sys, "argv", testargs): - bagit.main() + with self.assertLogs() as captured, self.assertRaises(SystemExit) as cm: + with mock.patch.object(sys, "argv", testargs): + bagit.main() self.assertEqual(cm.exception.code, 1) self.assertIn( @@ -1221,10 +1218,9 @@ def test_valid_completeness_validate(self): bagit.make_bag(self.tmpdir) testargs = ["bagit.py", "--validate", "--completeness-only", self.tmpdir] - with self.assertLogs() as captured: - with self.assertRaises(SystemExit) as cm: - with mock.patch.object(sys, "argv", testargs): - bagit.main() + with self.assertLogs() as captured, self.assertRaises(SystemExit) as cm: + with mock.patch.object(sys, "argv", testargs): + bagit.main() self.assertEqual(cm.exception.code, 0) self.assertEqual( @@ -1242,10 +1238,9 @@ def test_invalid_full_validate(self): testargs = ["bagit.py", "--validate", self.tmpdir] - with self.assertLogs() as captured: - with self.assertRaises(SystemExit) as cm: - with mock.patch.object(sys, "argv", testargs): - bagit.main() + with self.assertLogs() as captured, self.assertRaises(SystemExit) as cm: + with mock.patch.object(sys, "argv", testargs): + bagit.main() self.assertEqual(cm.exception.code, 1) self.assertIn("Bag validation failed", captured.records[-1].getMessage()) @@ -1254,10 +1249,9 @@ def test_valid_full_validate(self): bagit.make_bag(self.tmpdir) testargs = ["bagit.py", "--validate", self.tmpdir] - with self.assertLogs() as captured: - with self.assertRaises(SystemExit) as cm: - with mock.patch.object(sys, "argv", testargs): - bagit.main() + with self.assertLogs() as captured, self.assertRaises(SystemExit) as cm: + with mock.patch.object(sys, "argv", testargs): + bagit.main() self.assertEqual(cm.exception.code, 0) self.assertEqual("%s is valid" % self.tmpdir, captured.records[-1].getMessage()) @@ -1267,10 +1261,9 @@ def test_failed_create_bag(self): testargs = ["bagit.py", self.tmpdir] - with self.assertLogs() as captured: - with self.assertRaises(SystemExit) as cm: - with mock.patch.object(sys, "argv", testargs): - bagit.main() + with self.assertLogs() as captured, self.assertRaises(SystemExit) as cm: + with mock.patch.object(sys, "argv", testargs): + bagit.main() self.assertEqual(cm.exception.code, 1) self.assertIn( @@ -1281,10 +1274,9 @@ def test_failed_create_bag(self): def test_create_bag(self): testargs = ["bagit.py", self.tmpdir] - with self.assertLogs() as captured: - with self.assertRaises(SystemExit) as cm: - with mock.patch.object(sys, "argv", testargs): - bagit.main() + with self.assertLogs() as captured, self.assertRaises(SystemExit) as cm: + with mock.patch.object(sys, "argv", testargs): + bagit.main() for rec in captured.records: print(rec.getMessage()) diff --git a/bench.py b/utils/bench.py similarity index 100% rename from bench.py rename to utils/bench.py diff --git a/utils/locales.py b/utils/locales.py new file mode 100755 index 0000000..aaed193 --- /dev/null +++ b/utils/locales.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python +# encoding: utf-8 + +import sys +import subprocess + +from pathlib import Path + +src_dir = Path(__file__).parent.parent / "src" + +for po_file in src_dir.glob("bagit/locale/*/LC_MESSAGES/bagit-python.po"): + mo_file = po_file.with_suffix(".mo") + + if not mo_file.is_file() or mo_file.stat().st_mtime < po_file.stat().st_mtime: + try: + print(f"compiling {po_file} to {mo_file}") + subprocess.check_call(["msgfmt", "-o", mo_file, po_file]) + except (OSError, subprocess.CalledProcessError) as exc: + print( + "Translation catalog %s could not be compiled (is gettext installed?) " + " — translations will not be available for this language: %s" + % (po_file, exc), + file=sys.stderr, + ) + continue