diff --git a/edx_lint/__init__.py b/edx_lint/__init__.py index a59c6721..6192f130 100644 --- a/edx_lint/__init__.py +++ b/edx_lint/__init__.py @@ -2,4 +2,4 @@ edx_lint standardizes lint configuration and additional plugins for use in Open edX code. """ -__version__ = "6.0.0" +__version__ = "6.1.0" diff --git a/edx_lint/cmd/main.py b/edx_lint/cmd/main.py index 1874914f..a688b93f 100644 --- a/edx_lint/cmd/main.py +++ b/edx_lint/cmd/main.py @@ -7,6 +7,7 @@ from edx_lint.cmd.list import list_main from edx_lint.cmd.write import write_main from edx_lint.cmd.update import update_main +from edx_lint.cmd.write_uv_constraints import write_uv_constraints_main def main(argv=None): @@ -25,6 +26,8 @@ def main(argv=None): return write_main(argv[1:]) elif argv[0] == "update": return update_main(argv[1:]) + elif argv[0] == "write_uv_constraints": + return write_uv_constraints_main(argv[1:]) else: print("Don't understand {!r}".format(" ".join(argv))) show_help() @@ -40,5 +43,5 @@ def show_help(): Commands: """.format(VERSION=__version__) ) - for cmd in [write_main, check_main, list_main, update_main]: + for cmd in [write_main, check_main, list_main, update_main, write_uv_constraints_main]: print(cmd.__doc__.lstrip("\n")) diff --git a/edx_lint/cmd/write_uv_constraints.py b/edx_lint/cmd/write_uv_constraints.py new file mode 100644 index 00000000..11c8f573 --- /dev/null +++ b/edx_lint/cmd/write_uv_constraints.py @@ -0,0 +1,107 @@ +"""The edx_lint write_uv_constraints command.""" + +import argparse +import importlib.resources +import re + +import tomlkit + + +def _package_name(spec): + """Return the normalized package name from a constraint specifier like 'Django<6.0'.""" + name = re.split(r"[<>=!~\[;@]", spec, maxsplit=1)[0].strip() + return name.lower().replace("-", "_").replace(".", "_") + + +def _parse_constraints(text): + """Parse constraints file text, stripping comments and blank lines. + + Returns a tuple of (constraints, directives) where: + constraints: valid package-specifier lines + directives: pip directive lines (-c, -r, etc.) that are not valid here + """ + constraints = [] + directives = [] + for line in text.splitlines(): + line = line.split("#")[0].strip() + if not line: + continue + if line.startswith("-"): + directives.append(line) + else: + constraints.append(line) + return constraints, directives + + +def write_uv_constraints_main(argv): + """ + write_uv_constraints [pyproject.toml] + Write [tool.uv].constraint-dependencies in pyproject.toml by merging + edx-lint's global constraints with optional repo-specific constraints + from [tool.edx_lint].uv_constraints in the same file. + """ + parser = argparse.ArgumentParser(prog="edx_lint write_uv_constraints", add_help=False) + parser.add_argument("pyproject", nargs="?", default="pyproject.toml") + args, unknown = parser.parse_known_args(argv) + + if unknown: + print(f"Unknown arguments: {' '.join(unknown)}") + return 1 + + global_text = ( + importlib.resources.files("edx_lint") + .joinpath("files/common_constraints.txt") + .read_text(encoding="utf-8") + ) + global_constraints, _ = _parse_constraints(global_text) + + try: + with open(args.pyproject, encoding="utf-8") as f: + data = tomlkit.load(f) + except FileNotFoundError: + print(f"File not found: {args.pyproject}") + return 2 + + # Read optional repo-specific constraints from [tool.edx_lint].uv_constraints. + # These live in the same pyproject.toml so everything is in one place. + try: + local_constraints = list(data["tool"]["edx_lint"]["uv_constraints"]) + except KeyError: + local_constraints = [] + + # Merge global and local constraints, keyed by normalized package name so + # that a local entry for the same package overrides the global one. This + # lets repos tighten or pin a constraint during testing without the global + # version clobbering their pin. + merged = {_package_name(c): c for c in global_constraints} + for c in local_constraints: + merged[_package_name(c)] = c # local takes precedence + constraints = list(merged.values()) + + if "tool" not in data: + data.add("tool", tomlkit.table()) + if "uv" not in data["tool"]: + data["tool"].add("uv", tomlkit.table()) + + uv_table = data["tool"]["uv"] + constraint_array = tomlkit.array() + constraint_array.multiline(True) + constraint_array.extend(constraints) + + if "constraint-dependencies" in uv_table: + # Replace in-place so any existing comment above the key is preserved. + uv_table["constraint-dependencies"] = constraint_array + else: + # First write: add a prominent comment so humans know not to edit this. + uv_table.add(tomlkit.comment(" DO NOT EDIT constraint-dependencies DIRECTLY.")) + uv_table.add(tomlkit.comment(" This list is managed by `edx_lint write_uv_constraints`")) + uv_table.add(tomlkit.comment(" and will be overwritten the next time `make upgrade` is run.")) + uv_table.add(tomlkit.comment(" - GLOBAL constraints: edit edx_lint/files/common_constraints.txt")) + uv_table.add(tomlkit.comment(" - REPO-SPECIFIC constraints: edit [tool.edx_lint].uv_constraints in this file")) + uv_table.add("constraint-dependencies", constraint_array) + + with open(args.pyproject, "w", encoding="utf-8") as f: + tomlkit.dump(data, f) + + print(f"Wrote {len(constraints)} constraints to {args.pyproject}") + return 0 diff --git a/requirements/base.in b/requirements/base.in index 7e6238a8..c44cb087 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -7,3 +7,4 @@ pylint pylint-django pylint-celery six +tomlkit diff --git a/requirements/base.txt b/requirements/base.txt index 42c5cca7..68caaab9 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -8,14 +8,14 @@ astroid==4.0.4 # via # pylint # pylint-celery -click==8.3.1 +click==8.3.3 # via # -r requirements/base.in # click-log # code-annotations click-log==0.4.0 # via -r requirements/base.in -code-annotations==2.3.2 +code-annotations==3.0.0 # via # -c requirements/constraints.txt # -r requirements/base.in @@ -29,7 +29,7 @@ markupsafe==3.0.3 # via jinja2 mccabe==0.7.0 # via pylint -platformdirs==4.9.4 +platformdirs==4.9.6 # via pylint pylint==4.0.5 # via @@ -56,4 +56,6 @@ stevedore==5.7.0 text-unidecode==1.3 # via python-slugify tomlkit==0.14.0 - # via pylint + # via + # -r requirements/base.in + # pylint diff --git a/requirements/ci.txt b/requirements/ci.txt index 8e347215..441e26a3 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -4,22 +4,22 @@ # # make upgrade # -cachetools==7.0.3 +cachetools==7.0.6 # via tox colorama==0.4.6 # via tox distlib==0.4.0 # via virtualenv -filelock==3.25.0 +filelock==3.29.0 # via # python-discovery # tox # virtualenv -packaging==26.0 +packaging==26.1 # via # pyproject-api # tox -platformdirs==4.9.4 +platformdirs==4.9.6 # via # python-discovery # tox @@ -28,11 +28,13 @@ pluggy==1.6.0 # via tox pyproject-api==1.10.0 # via tox -python-discovery==1.1.0 - # via virtualenv +python-discovery==1.2.2 + # via + # tox + # virtualenv tomli-w==1.2.0 # via tox -tox==4.49.0 +tox==4.53.0 # via -r requirements/ci.in -virtualenv==21.1.0 +virtualenv==21.2.4 # via tox diff --git a/requirements/dev.txt b/requirements/dev.txt index 59945b6f..4cd20139 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -9,14 +9,14 @@ astroid==4.0.4 # -r requirements/base.txt # pylint # pylint-celery -click==8.3.1 +click==8.3.3 # via # -r requirements/base.txt # click-log # code-annotations click-log==0.4.0 # via -r requirements/base.txt -code-annotations==2.3.2 +code-annotations==3.0.0 # via # -c requirements/constraints.txt # -r requirements/base.txt @@ -26,7 +26,7 @@ dill==0.4.1 # pylint distlib==0.4.0 # via virtualenv -filelock==3.25.0 +filelock==3.29.0 # via # python-discovery # tox @@ -47,11 +47,11 @@ mccabe==0.7.0 # via # -r requirements/base.txt # pylint -packaging==26.0 +packaging==26.1 # via # pyproject-api # tox -platformdirs==4.9.4 +platformdirs==4.9.6 # via # -r requirements/base.txt # pylint @@ -76,8 +76,10 @@ pylint-plugin-utils==0.9.0 # -r requirements/base.txt # pylint-celery # pylint-django -python-discovery==1.1.0 - # via virtualenv +python-discovery==1.2.2 + # via + # tox + # virtualenv python-slugify==8.0.4 # via # -r requirements/base.txt @@ -108,5 +110,5 @@ tox==3.28.0 # tox-battery tox-battery==0.6.2 # via -r requirements/dev.in -virtualenv==21.1.0 +virtualenv==21.2.4 # via tox diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index ce8dc38f..89086ac7 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -4,11 +4,11 @@ # # make upgrade # -build==1.4.0 +build==1.4.4 # via pip-tools -click==8.3.1 +click==8.3.3 # via pip-tools -packaging==26.0 +packaging==26.1 # via # build # wheel @@ -18,11 +18,11 @@ pyproject-hooks==1.2.0 # via # build # pip-tools -wheel==0.46.3 +wheel==0.47.0 # via pip-tools # The following packages are considered to be unsafe in a requirements file: pip==26.0.1 # via pip-tools -setuptools==82.0.0 +setuptools==82.0.1 # via pip-tools diff --git a/requirements/test.txt b/requirements/test.txt index 2a36e80d..7fd67aec 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -11,18 +11,18 @@ astroid==4.0.4 # -r requirements/dev.txt # pylint # pylint-celery -click==8.3.1 +click==8.3.3 # via # -r requirements/dev.txt # click-log # code-annotations click-log==0.4.0 # via -r requirements/dev.txt -code-annotations==2.3.2 +code-annotations==3.0.0 # via # -c requirements/constraints.txt # -r requirements/dev.txt -coverage==7.13.4 +coverage==7.13.5 # via -r requirements/test.in dill==0.4.1 # via @@ -35,7 +35,7 @@ distlib==0.4.0 # via # -c edx_lint/files/common_constraints.txt # -r requirements/test.in -filelock==3.25.0 +filelock==3.29.0 # via # -r requirements/dev.txt # python-discovery @@ -59,12 +59,12 @@ mccabe==0.7.0 # via # -r requirements/dev.txt # pylint -packaging==26.0 +packaging==26.1 # via # -r requirements/dev.txt # pytest # tox -platformdirs==4.9.4 +platformdirs==4.9.6 # via # -r requirements/dev.txt # pylint @@ -79,7 +79,7 @@ py==1.11.0 # via # -r requirements/dev.txt # tox -pygments==2.19.2 +pygments==2.20.0 # via pytest pylint==4.0.5 # via @@ -96,9 +96,9 @@ pylint-plugin-utils==0.9.0 # -r requirements/dev.txt # pylint-celery # pylint-django -pytest==9.0.2 +pytest==9.0.3 # via -r requirements/test.in -python-discovery==1.1.0 +python-discovery==1.2.2 # via # -r requirements/dev.txt # virtualenv @@ -134,7 +134,7 @@ tox==3.28.0 # tox-battery tox-battery==0.6.2 # via -r requirements/dev.txt -virtualenv==21.1.0 +virtualenv==21.2.4 # via # -r requirements/dev.txt # tox diff --git a/test/test_cmd.py b/test/test_cmd.py index 4fa88d6b..be861dc4 100644 --- a/test/test_cmd.py +++ b/test/test_cmd.py @@ -1,6 +1,7 @@ """ Tests for the command line executable. """ import contextlib +import importlib.resources import io import os import shutil @@ -8,7 +9,10 @@ import unittest from unittest.mock import patch +import tomlkit + from edx_lint.cmd import main +from edx_lint.cmd.write_uv_constraints import _parse_constraints PYLINTRC = "pylintrc" @@ -225,3 +229,100 @@ def test_cant_check_a_missing_file(self): with capture_output() as output: self.assertEqual(2, self.call_command(["check", "xyzzy.foo"])) self.assertEqual(output.getvalue(), "xyzzy.foo doesn't exist\n") + + +PYPROJECT_MINIMAL = '[project]\nname = "test"\n' +# Read the actual bundled constraints so tests stay in sync with the file automatically. +_GLOBAL_CONSTRAINTS_TEXT = ( + importlib.resources.files("edx_lint") + .joinpath("files/common_constraints.txt") + .read_text(encoding="utf-8") +) +GLOBAL_CONSTRAINTS, _ = _parse_constraints(_GLOBAL_CONSTRAINTS_TEXT) +# Use the first entry as a representative value for presence checks. +GLOBAL_CONSTRAINT = GLOBAL_CONSTRAINTS[0] + + +class WriteUvConstraintsCommandTest(CommandTest): + """Tests for the write_uv_constraints command.""" + + def _make_pyproject(self, path="pyproject.toml", content=PYPROJECT_MINIMAL): + with open(path, "w") as f: + f.write(content) + + def test_no_args_updates_default(self): + self._make_pyproject() + self.assertEqual(0, self.call_command(["write_uv_constraints"])) + self.assert_file("pyproject.toml", contains=GLOBAL_CONSTRAINT) + + def test_explicit_pyproject_path(self): + self._make_pyproject("other.toml") + self.assertEqual(0, self.call_command(["write_uv_constraints", "other.toml"])) + self.assert_file("other.toml", contains=GLOBAL_CONSTRAINT) + + def test_local_merges_global_and_local(self): + self._make_pyproject( + content=PYPROJECT_MINIMAL + '[tool.edx_lint]\nuv_constraints = ["mypackage<2.0"]\n' + ) + assert self.call_command(["write_uv_constraints"]) == 0 + written = self._read_constraint_dependencies() + assert GLOBAL_CONSTRAINT in written + assert "mypackage<2.0" in written + + def test_idempotent(self): + self._make_pyproject() + self.assertEqual(0, self.call_command(["write_uv_constraints"])) + with open("pyproject.toml") as f: + first = f.read() + self.assertEqual(0, self.call_command(["write_uv_constraints"])) + with open("pyproject.toml") as f: + second = f.read() + self.assertEqual(first, second) + + def test_preserves_existing_content(self): + self._make_pyproject( + content='[project]\nname = "myproject"\nversion = "1.0"\n\n[tool.ruff]\nline-length = 88\n' + ) + self.assertEqual(0, self.call_command(["write_uv_constraints"])) + self.assert_file("pyproject.toml", contains='name = "myproject"') + self.assert_file("pyproject.toml", contains="line-length = 88") + self.assert_file("pyproject.toml", contains=GLOBAL_CONSTRAINT) + + def test_missing_pyproject_returns_2(self): + # No pyproject.toml created in temp dir + with capture_output(): + self.assertEqual(2, self.call_command(["write_uv_constraints"])) + + def test_unknown_args_returns_nonzero(self): + self._make_pyproject() + ret = self.call_command(["write_uv_constraints", "--bogus-flag"]) + self.assertNotEqual(0, ret) + + def _read_constraint_dependencies(self, path="pyproject.toml"): + """Read [tool.uv].constraint-dependencies from a pyproject.toml.""" + with open(path) as f: + data = tomlkit.load(f) + return list(data["tool"]["uv"]["constraint-dependencies"]) + + def test_local_overrides_global_for_same_package(self): + # Extract the package name from the first global constraint and write a + # stricter local version. The local pin should win. + first_global = GLOBAL_CONSTRAINTS[0] + pkg = first_global.split("<")[0].split(">")[0].split("=")[0].split("!")[0].strip() + local_pin = f"{pkg}==0.0.1" + self._make_pyproject( + content=PYPROJECT_MINIMAL + f'[tool.edx_lint]\nuv_constraints = ["{local_pin}"]\n' + ) + assert self.call_command(["write_uv_constraints"]) == 0 + written = self._read_constraint_dependencies() + assert local_pin in written + assert first_global not in written + + def test_deduplicates_exact_duplicates(self): + # Exact duplicate in [tool.edx_lint].uv_constraints should appear only once. + self._make_pyproject( + content=PYPROJECT_MINIMAL + f'[tool.edx_lint]\nuv_constraints = ["{GLOBAL_CONSTRAINTS[0]}"]\n' + ) + assert self.call_command(["write_uv_constraints"]) == 0 + written = self._read_constraint_dependencies() + assert written.count(GLOBAL_CONSTRAINTS[0]) == 1