From 2c2ec7e875675159aec484dd00d5d0d5df271d4d Mon Sep 17 00:00:00 2001 From: mauritsvanrees Date: Thu, 30 Nov 2023 20:12:59 +0100 Subject: [PATCH] [fc] Repository: plone.releaser Branch: refs/heads/master Date: 2023-09-19T00:12:49+02:00 Author: Maurits van Rees (mauritsvanrees) Commit: https://github.com/plone/plone.releaser/commit/99c840723a4104ba4c1880d98b64f64ac336f208 Add manage command 'versions2constraints'. Files changed: M plone/releaser/manage.py M plone/releaser/utils.py Repository: plone.releaser Branch: refs/heads/master Date: 2023-09-19T00:12:50+02:00 Author: Maurits van Rees (mauritsvanrees) Commit: https://github.com/plone/plone.releaser/commit/a2d72e2d592b0e8d8dc09abd2d5fe6c0dbfc764e Support writing to [versions:pythonx] Files changed: M plone/releaser/buildout.py M plone/releaser/manage.py M plone/releaser/tests/input/versions.cfg M plone/releaser/tests/test_buildout.py M plone/releaser/utils.py Repository: plone.releaser Branch: refs/heads/master Date: 2023-09-19T00:12:50+02:00 Author: Maurits van Rees (mauritsvanrees) Commit: https://github.com/plone/plone.releaser/commit/fdad9eb0a2f6092bfb62a58aa5771df7fa66de06 Read 'extends' lines from versions.cfg. Only local for now. Files changed: A plone/releaser/tests/input/versions2.cfg A plone/releaser/tests/input/versions3.cfg A plone/releaser/tests/input/versions4.cfg M plone/releaser/buildout.py M plone/releaser/tests/test_buildout.py Repository: plone.releaser Branch: refs/heads/master Date: 2023-10-07T20:54:02+02:00 Author: Maurits van Rees (mauritsvanrees) Commit: https://github.com/plone/plone.releaser/commit/3d800c3caa7129eea4e671f8d05e25a1a77e230d Support reading extends (-c) in constraints as well. Files changed: A plone/releaser/tests/input/constraints2.txt A plone/releaser/tests/input/constraints3.txt A plone/releaser/tests/input/constraints4.txt M plone/releaser/pip.py M plone/releaser/tests/input/constraints.txt M plone/releaser/tests/test_buildout.py M plone/releaser/tests/test_pip.py Repository: plone.releaser Branch: refs/heads/master Date: 2023-10-07T22:15:36+02:00 Author: Maurits van Rees (mauritsvanrees) Commit: https://github.com/plone/plone.releaser/commit/990f64eb555805e3c989d932f7f2c1c09e665e24 Add VersionsFile.rewrite. This takes the parsed 'extends' and 'versions' and writes them back. This normalizes some stuff, so it will not be exactly the same. In the end we want to take versions.cfg and translate it to constraints.txt. But translating versions.cfg to its own should work as well, and is a good first step. Files changed: M plone/releaser/buildout.py M plone/releaser/tests/test_buildout.py Repository: plone.releaser Branch: refs/heads/master Date: 2023-10-09T12:05:44+02:00 Author: Maurits van Rees (mauritsvanrees) Commit: https://github.com/plone/plone.releaser/commit/b41ea5ba8ecc9a9ec5feaf1d25159412d176cc2a constraints: platform_system is darwin, not macosx. Files changed: M plone/releaser/tests/input/constraints4.txt M plone/releaser/tests/test_pip.py Repository: plone.releaser Branch: refs/heads/master Date: 2023-10-12T20:37:44+02:00 Author: Maurits van Rees (mauritsvanrees) Commit: https://github.com/plone/plone.releaser/commit/fb2968b4000c7ad45fade472e1a48d8d66186b3d Add ConstraintsFile.rewrite. Files changed: M plone/releaser/pip.py M plone/releaser/tests/test_pip.py Repository: plone.releaser Branch: refs/heads/master Date: 2023-10-12T22:45:33+02:00 Author: Maurits van Rees (mauritsvanrees) Commit: https://github.com/plone/plone.releaser/commit/a034175da9cfec19ea71132733490ed0d68a81fb Add CheckoutsFile.rewrite. Files changed: M plone/releaser/buildout.py M plone/releaser/tests/test_buildout.py Repository: plone.releaser Branch: refs/heads/master Date: 2023-10-12T23:30:30+02:00 Author: Maurits van Rees (mauritsvanrees) Commit: https://github.com/plone/plone.releaser/commit/6b56b8f44abcdeb5c39cef5aea3f4fd04057a5ee Add Inifile.rewrite. Files changed: M plone/releaser/pip.py M plone/releaser/tests/test_pip.py Repository: plone.releaser Branch: refs/heads/master Date: 2023-10-27T00:10:23+02:00 Author: Maurits van Rees (mauritsvanrees) Commit: https://github.com/plone/plone.releaser/commit/667d09c2be62b6e8fd5dbac06c672bd0b4bf0c05 Fix manage.py to work with argh 0.30+. Full traceback with info: ``` $ bin/manage -h Traceback (most recent call last): File "/Users/maurits/community/plone-coredev/6.0/bin/manage", line 63, in <module> sys.exit(plone.releaser.manage.manage()) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/maurits/community/plone-coredev/6.0/src/plone.releaser/plone/releaser/manage.py", line 277, in __call__ parser.add_commands( File "/Users/maurits/shared-eggs/cp312/argh-0.30.2-py3.12.egg/argh/helpers.py", line 47, in add_commands return add_commands(self, *args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/maurits/shared-eggs/cp312/argh-0.30.2-py3.12.egg/argh/assembling.py", line 592, in add_commands set_default_command( File "/Users/maurits/shared-eggs/cp312/argh-0.30.2-py3.12.egg/argh/assembling.py", line 331, in set_default_command inferred_args: List[ParserAddArgumentSpec] = list( ^^^^^ File "/Users/maurits/shared-eggs/cp312/argh-0.30.2-py3.12.egg/argh/assembling.py", line 155, in infer_argspecs_from_function raise ArgumentNameMappingError( argh.assembling.ArgumentNameMappingError: Argument "path" in function "add_checkout" is not keyword-only but has a default value. Please note that since Argh v.0.30 the default name mapping policy has changed. More information: https://argh.readthedocs.io/en/latest/changes.html#version-0-30-0-2023-10-21 You need to upgrade your functions so that the arguments that have default values become keyword-only: f(x=1) -> f(*, x=1) If you actually want an optional positional argument, please set the name mapping policy explicitly to `BY_NAME_IF_KWONLY`. If you choose to postpone the migration, you have two options: a) set the policy explicitly to `BY_NAME_IF_HAS_DEFAULT`; b) pin Argh version to 0.29 until you are ready to migrate. Thank you for understanding! ``` Files changed: M plone/releaser/manage.py Repository: plone.releaser Branch: refs/heads/master Date: 2023-10-30T10:56:01+01:00 Author: Maurits van Rees (mauritsvanrees) Commit: https://github.com/plone/plone.releaser/commit/4f5492cc20279e4ed3a6f76ac4a9afd85ee5aaa9 SourcesFile: use config/data and a raw version without interpolation. Files changed: M plone/releaser/buildout.py M plone/releaser/tests/test_buildout.py Repository: plone.releaser Branch: refs/heads/master Date: 2023-11-16T18:50:15+01:00 Author: Maurits van Rees (mauritsvanrees) Commit: https://github.com/plone/plone.releaser/commit/d03ac9a24f9e10058d0df5821255d5f3719d386a SourcesFile.rewrite: take all non-sources sections. Files changed: M plone/releaser/buildout.py M plone/releaser/tests/test_buildout.py Repository: plone.releaser Branch: refs/heads/master Date: 2023-11-16T19:34:08+01:00 Author: Maurits van Rees (mauritsvanrees) Commit: https://github.com/plone/plone.releaser/commit/9a2ba07110ed0d35fe9db4a3617c8373101bb009 Get versions2constraints working for simple cases. Files changed: A plone/releaser/tests/test_versions2constraints.py M plone/releaser/manage.py Repository: plone.releaser Branch: refs/heads/master Date: 2023-11-16T19:59:26+01:00 Author: Maurits van Rees (mauritsvanrees) Commit: https://github.com/plone/plone.releaser/commit/b718af04079eff3820bfd2cb1c8a2043379d4d2d Add BaseBuildoutFile and let the others inherit from it. Then everything has a config, raw_config, and extends. Files changed: M plone/releaser/buildout.py Repository: plone.releaser Branch: refs/heads/master Date: 2023-11-16T23:26:00+01:00 Author: Maurits van Rees (mauritsvanrees) Commit: https://github.com/plone/plone.releaser/commit/28983c6c5194a6fa258ba0feac1ce87cee356f9c Get versions2constraints working Files changed: M plone/releaser/buildout.py M plone/releaser/manage.py M plone/releaser/pip.py M plone/releaser/tests/test_utils.py M plone/releaser/tests/test_versions2constraints.py M plone/releaser/utils.py Repository: plone.releaser Branch: refs/heads/master Date: 2023-11-17T00:24:47+01:00 Author: Maurits van Rees (mauritsvanrees) Commit: https://github.com/plone/plone.releaser/commit/004aed8ae276f737fb5d14f9999ddc6698775e5e Add news snippet: Add bin/manage versions2constraints command. Files changed: A news/3670.feature Repository: plone.releaser Branch: refs/heads/master Date: 2023-11-30T20:12:59+01:00 Author: Maurits van Rees (mauritsvanrees) Commit: https://github.com/plone/plone.releaser/commit/c59ad3f8766a9d9289f142dd48d637cbc824fca1 Merge pull request #62 from plone/maurits-buildout2pip Add bin/manage versions2constraints command. Files changed: A news/3670.feature A plone/releaser/tests/input/constraints2.txt A plone/releaser/tests/input/constraints3.txt A plone/releaser/tests/input/constraints4.txt A plone/releaser/tests/input/versions2.cfg A plone/releaser/tests/input/versions3.cfg A plone/releaser/tests/input/versions4.cfg A plone/releaser/tests/test_versions2constraints.py M plone/releaser/buildout.py M plone/releaser/manage.py M plone/releaser/pip.py M plone/releaser/tests/input/constraints.txt M plone/releaser/tests/input/versions.cfg M plone/releaser/tests/test_buildout.py M plone/releaser/tests/test_pip.py M plone/releaser/tests/test_utils.py M plone/releaser/utils.py --- last_commit.txt | 360 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 334 insertions(+), 26 deletions(-) diff --git a/last_commit.txt b/last_commit.txt index d0fcb3c9a0..d53581770b 100644 --- a/last_commit.txt +++ b/last_commit.txt @@ -1,48 +1,356 @@ -Repository: plone.volto +Repository: plone.releaser -Branch: refs/heads/main -Date: 2023-11-29T09:29:23+01:00 -Author: Victor Fernandez de Alba (sneridagh) -Commit: https://github.com/plone/plone.volto/commit/69411d3f0b04295052263473febccab8136249ac +Branch: refs/heads/master +Date: 2023-09-19T00:12:49+02:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/99c840723a4104ba4c1880d98b64f64ac336f208 -Add guard for template used in the Volto installed status message that is Plone 6 only +Add manage command 'versions2constraints'. Files changed: -M src/plone/volto/browser/configure.zcml +M plone/releaser/manage.py +M plone/releaser/utils.py -b'diff --git a/src/plone/volto/browser/configure.zcml b/src/plone/volto/browser/configure.zcml\nindex faffda5c..78ce7e6c 100644\n--- a/src/plone/volto/browser/configure.zcml\n+++ b/src/plone/volto/browser/configure.zcml\n@@ -57,6 +57,7 @@\n template="voltobackendwarning.pt"\n permission="zope2.View"\n layer="plone.volto.interfaces.IPloneVoltoCoreLayer"\n+ zcml:condition="have plone-60"\n />\n \n \n' +b'diff --git a/plone/releaser/manage.py b/plone/releaser/manage.py\nindex 2345b53..9a7749d 100644\n--- a/plone/releaser/manage.py\n+++ b/plone/releaser/manage.py\n@@ -231,6 +231,28 @@ def set_package_version(package_name, new_version, path=None):\n constraints.set(package_name, new_version)\n \n \n+def versions2constraints(path=None):\n+ """Take a Buildout versions file and create a pip constraints file out of it.\n+\n+ If no path is given, we use versions*.cfg.\n+ """\n+ if path:\n+ paths = [path]\n+ else:\n+ paths = glob.glob("versions*.cfg")\n+ for path in paths:\n+ versions = VersionsFile(path)\n+ constraints_path = path.replace("versions", "constraints").replace(\n+ ".cfg", ".txt"\n+ )\n+ constraints = ConstraintsFile(constraints_path)\n+ if not constraints.path.exists():\n+ with constraints.path.open("w") as myfile:\n+ myfile.write("")\n+ for package_name, version in versions.items():\n+ constraints[package_name] = version\n+\n+\n class Manage:\n def __call__(self, **kwargs):\n parser = ArghParser()\n@@ -247,6 +269,7 @@ def __call__(self, **kwargs):\n set_package_version,\n get_package_version,\n jenkins_report,\n+ versions2constraints,\n ]\n )\n parser.dispatch()\ndiff --git a/plone/releaser/utils.py b/plone/releaser/utils.py\nindex 432d943..7d148a2 100644\n--- a/plone/releaser/utils.py\n+++ b/plone/releaser/utils.py\n@@ -54,4 +54,7 @@ def update_contents(contents, line_check, newline, filename, stop_check=None):\n if content_lines:\n lines.extend(content_lines)\n \n- return "\\n".join(lines) + "\\n"\n+ result = "\\n".join(lines)\n+ if not result.endswith("\\n"):\n+ result += "\\n"\n+ return result\n' -Repository: plone.volto +Repository: plone.releaser -Branch: refs/heads/main -Date: 2023-11-29T09:30:33+01:00 -Author: Victor Fernandez de Alba (sneridagh) -Commit: https://github.com/plone/plone.volto/commit/1a94b44d6611aa3b004155baa36f94fab38f5dc9 +Branch: refs/heads/master +Date: 2023-09-19T00:12:50+02:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/a2d72e2d592b0e8d8dc09abd2d5fe6c0dbfc764e -Changelog +Support writing to [versions:pythonx] Files changed: -A news/135.bugfix +M plone/releaser/buildout.py +M plone/releaser/manage.py +M plone/releaser/tests/input/versions.cfg +M plone/releaser/tests/test_buildout.py +M plone/releaser/utils.py -b'diff --git a/news/135.bugfix b/news/135.bugfix\nnew file mode 100644\nindex 0000000..74f0e81\n--- /dev/null\n+++ b/news/135.bugfix\n@@ -0,0 +1 @@\n+Add guard for template used in the Volto installed status message that is Plone 6 only @sneridagh\n' +b'diff --git a/plone/releaser/buildout.py b/plone/releaser/buildout.py\nindex 15db5af..d98bac3 100644\n--- a/plone/releaser/buildout.py\n+++ b/plone/releaser/buildout.py\n@@ -1,5 +1,6 @@\n from .base import BaseFile\n from .utils import update_contents\n+from collections import defaultdict\n from collections import OrderedDict\n from configparser import ConfigParser\n from configparser import ExtendedInterpolation\n@@ -54,9 +55,11 @@ def __eq__(self, other):\n \n \n class VersionsFile(BaseFile):\n- def __init__(self, file_location):\n+ def __init__(self, file_location, with_markers=False):\n self.file_location = file_location\n self.path = pathlib.Path(self.file_location).resolve()\n+ self.with_markers = with_markers\n+ self.markers = set()\n \n @property\n def data(self):\n@@ -76,6 +79,9 @@ def data(self):\n plone.releaser to support such a corner case.\n \n So we do not want to report or edit anything except the versions section.\n+\n+ Ah, but we *do* need this information when translating to pip.\n+ For that: set self.with_markers = True.\n """\n config = ConfigParser(interpolation=ExtendedInterpolation(), strict=False)\n with self.path.open() as f:\n@@ -83,12 +89,31 @@ def data(self):\n # https://github.com/plone/plone.releaser/issues/42\n if config.has_section("buildout"):\n config["buildout"]["directory"] = os.getcwd()\n- versions = {}\n+ versions = defaultdict(list)\n for section in config.sections():\n if section == "versions":\n for package, version in config[section].items():\n # Note: the package names are lower case.\n- versions[package] = version\n+ if not self.with_markers:\n+ versions[package] = version\n+ else:\n+ versions[package].append(version)\n+ if not self.with_markers:\n+ continue\n+ parts = section.split(":")\n+ if len(parts) != 2 or parts[0] != "versions":\n+ continue\n+ marker = parts[1]\n+ self.markers.add(marker)\n+ for package, version in config[section].items():\n+ # Note: the package names are lower case.\n+ versions[package].append((version, marker))\n+\n+ if self.with_markers:\n+ # simplify\n+ for package, version in versions.items():\n+ if isinstance(version, list) and len(version) == 1:\n+ versions[package] = version[0]\n return versions\n \n def __setitem__(self, package_name, new_version):\n@@ -98,6 +123,14 @@ def __setitem__(self, package_name, new_version):\n contents += "\\n"\n self.path.write_text(contents)\n \n+ if isinstance(new_version, tuple):\n+ new_version, marker = new_version\n+ section = f"[versions:{marker}]"\n+ if marker not in self.markers:\n+ contents = f"{contents}\\n{section}\\n"\n+ self.path.write_text(contents)\n+ else:\n+ section = "[versions]"\n newline = f"{package_name} = {new_version}"\n # Search case insensitively.\n line_reg = re.compile(rf"^{package_name} *=.*", flags=re.I)\n@@ -107,15 +140,22 @@ def line_check(line):\n # no whitespace in front. Maybe whitespace in between.\n return line_reg.match(line)\n \n+ def start_check(line):\n+ # If we see this line, we start trying to match.\n+ return line == section\n+\n def stop_check(line):\n # If we see this line, we should stop trying to match.\n- return line.startswith("[versionannotations]") or line.startswith(\n- "[versions:"\n- )\n+ return line.startswith("[")\n \n # set version in contents.\n new_contents = update_contents(\n- contents, line_check, newline, self.file_location, stop_check=stop_check\n+ contents,\n+ line_check,\n+ newline,\n+ self.file_location,\n+ start_check=start_check,\n+ stop_check=stop_check,\n )\n if contents != new_contents:\n self.path.write_text(new_contents)\n@@ -134,6 +174,7 @@ def data(self):\n # https://github.com/plone/mr.roboto/issues/89\n config["buildout"]["directory"] = os.getcwd()\n sources_dict = OrderedDict()\n+ # I don\'t think we need to support [sources:marker].\n for name, value in config["sources"].items():\n source = Source.create_from_string(value)\n sources_dict[name.lower()] = source\n@@ -150,6 +191,7 @@ def data(self):\n with self.path.open() as f:\n config.read_file(f)\n config["buildout"]["directory"] = os.getcwd()\n+ # I don\'t think we need to support [buildout:marker].\n checkouts = config.get("buildout", "auto-checkout")\n # Map from lower case to actual case, so we can find the package.\n mapping = {}\ndiff --git a/plone/releaser/manage.py b/plone/releaser/manage.py\nindex 9a7749d..d88c80f 100644\n--- a/plone/releaser/manage.py\n+++ b/plone/releaser/manage.py\n@@ -235,6 +235,24 @@ def versions2constraints(path=None):\n """Take a Buildout versions file and create a pip constraints file out of it.\n \n If no path is given, we use versions*.cfg.\n+\n+ Notes:\n+ * This does not handle \'extends\' yet.\n+ * This does not handle [versions:pythonX] yet.\n+\n+ We could parse the file with Buildout. This incorporates the \'extends\',\n+ but you lose versions information for other Python versions.\n+\n+ We could pass an option simple/full.\n+ Maybe if a path is passed, we handle only that file in simple mode.\n+ Without path, we grab versions.cfg and check \'extends\' and other versions.\n+\n+ \'extends = versions-extra.cfg\' could be transformed to \'-c constraints-extra.txt\'\n+\n+ I think I need some more options in VersionsFile first:\n+ - what to do with extends\n+ - what to do with [versions:*]\n+ - whether to turn it into a single constraints file.\n """\n if path:\n paths = [path]\ndiff --git a/plone/releaser/tests/input/versions.cfg b/plone/releaser/tests/input/versions.cfg\nindex 15b43b0..3b01ac1 100644\n--- a/plone/releaser/tests/input/versions.cfg\n+++ b/plone/releaser/tests/input/versions.cfg\n@@ -14,6 +14,7 @@ pyspecific = 1.0\n UPPERCASE = 1.0\n \n [versions:python312]\n+onepython = 2.1\n pyspecific = 2.0\n \n [versionannotations]\ndiff --git a/plone/releaser/tests/test_buildout.py b/plone/releaser/tests/test_buildout.py\nindex b231fbc..87b82c3 100644\n--- a/plone/releaser/tests/test_buildout.py\n+++ b/plone/releaser/tests/test_buildout.py\n@@ -185,6 +185,21 @@ def test_versions_file_versions():\n }\n \n \n+def test_versions_file_versions_with_markers():\n+ vf = VersionsFile(VERSIONS_FILE, with_markers=True)\n+ # All versions are reported lowercased.\n+ assert vf.data == {\n+ "annotated": "1.0",\n+ "camelcase": "1.0",\n+ "duplicate": "1.0",\n+ "lowercase": "1.0",\n+ "onepython": ("2.1", "python312"),\n+ "package": "1.0",\n+ "pyspecific": ["1.0", ("2.0", "python312")],\n+ "uppercase": "1.0",\n+ }\n+\n+\n def test_versions_file_contains():\n vf = VersionsFile(VERSIONS_FILE)\n assert "package" in vf\n@@ -202,6 +217,16 @@ def test_versions_file_contains():\n assert "UPPERCASE" in vf\n \n \n+def test_versions_file_contains_with_markers():\n+ vf = VersionsFile(VERSIONS_FILE, with_markers=True)\n+ assert "package" in vf\n+ assert "nope" not in vf\n+ assert "onepython" in vf\n+ assert "pyspecific" in vf\n+ assert "ONEpython" in vf\n+ assert "pySPECIFIC" in vf\n+\n+\n def test_versions_file_get():\n vf = VersionsFile(VERSIONS_FILE)\n assert vf.get("package") == "1.0"\n@@ -223,6 +248,16 @@ def test_versions_file_get():\n assert vf["UPPERCASE"] == "1.0"\n \n \n+def test_versions_file_get_with_markers():\n+ vf = VersionsFile(VERSIONS_FILE, with_markers=True)\n+ assert vf.get("package") == "1.0"\n+ assert vf["package"] == "1.0"\n+ assert vf.get("onepython") == ("2.1", "python312")\n+ assert vf.get("pyspecific") == ["1.0", ("2.0", "python312")]\n+ assert vf["onepython"] == ("2.1", "python312")\n+ assert vf["pyspecific"] == ["1.0", ("2.0", "python312")]\n+\n+\n def test_versions_file_set_normal(tmp_path):\n # When we set a version, the file changes, so we work on a copy.\n copy_path = tmp_path / "versions.cfg"\n@@ -266,6 +301,44 @@ def test_versions_file_set_ignore_markers(tmp_path):\n assert "pyspecific = 2.0" in copy_path.read_text()\n \n \n+def test_versions_file_set_with_markers(tmp_path):\n+ # [versions:python312] pins \'pyspecific = 2.0\'.\n+ # We do not report or change this section.\n+ copy_path = tmp_path / "versions.cfg"\n+ shutil.copyfile(VERSIONS_FILE, copy_path)\n+ vf = VersionsFile(copy_path, with_markers=True)\n+ assert "pyspecific = 2.0" in copy_path.read_text()\n+ assert vf.get("pyspecific") == ["1.0", ("2.0", "python312")]\n+ vf.set("pyspecific", "1.1")\n+ # Read it fresh, without markers.\n+ vf = VersionsFile(copy_path)\n+ assert vf.get("pyspecific") == "1.1"\n+ # Read it fresh, with markers.\n+ vf = VersionsFile(copy_path, with_markers=True)\n+ assert vf.get("pyspecific") == ["1.1", ("2.0", "python312")]\n+ # Now edit for a specific python version.\n+ vf.set("pyspecific", ("2.1", "python312"))\n+ # Read it fresh, with markers.\n+ vf = VersionsFile(copy_path, with_markers=True)\n+ assert vf.get("pyspecific") == ["1.1", ("2.1", "python312")]\n+ # Add to an unknown marker.\n+ vf.set("pyspecific", ("3.0", "python313"))\n+ # Read it fresh, with markers.\n+ vf = VersionsFile(copy_path, with_markers=True)\n+ assert "[versions:python313]" in copy_path.read_text()\n+ assert vf.get("pyspecific") == ["1.1", ("2.1", "python312"), ("3.0", "python313")]\n+ # Add a new package to a new marker.\n+ vf.set("maconly", ("1.0", "macosx"))\n+ # Read it fresh, with markers.\n+ vf = VersionsFile(copy_path, with_markers=True)\n+ assert "[versions:macosx]" in copy_path.read_text()\n+ assert vf.get("maconly") == ("1.0", "macosx")\n+ # Read it without markers.\n+ vf = VersionsFile(copy_path)\n+ assert vf["pyspecific"] == "1.1"\n+ assert not vf.get("maconly")\n+\n+\n def test_versions_file_set_cleanup_duplicates(tmp_path):\n copy_path = tmp_path / "versions.cfg"\n shutil.copyfile(VERSIONS_FILE, copy_path)\ndiff --git a/plone/releaser/utils.py b/plone/releaser/utils.py\nindex 7d148a2..41f93c7 100644\n--- a/plone/releaser/utils.py\n+++ b/plone/releaser/utils.py\n@@ -1,4 +1,6 @@\n-def update_contents(contents, line_check, newline, filename, stop_check=None):\n+def update_contents(\n+ contents, line_check, newline, filename, start_check=None, stop_check=None\n+):\n """Update contents to have a new line if needed.\n \n * contents is some file contents\n@@ -6,6 +8,8 @@ def update_contents(contents, line_check, newline, filename, stop_check=None):\n * newline is the line with which we replace the matched line.\n This can be None to signal that the old line should be removed\n * filename is used for reporting.\n+ * start_check is an optional function we call to check if we should start\n+ trying to match.\n * stop_check is an optional function we call to check if we should stop\n trying to match.\n \n@@ -17,6 +21,12 @@ def update_contents(contents, line_check, newline, filename, stop_check=None):\n while content_lines:\n line = content_lines.pop(0)\n line = line.rstrip()\n+ if start_check is not None:\n+ if start_check(line):\n+ # We start searching now. Disable the start_check.\n+ start_check = None\n+ lines.append(line)\n+ continue\n if stop_check is not None and stop_check(line):\n # Put this line back. We will handle this line and the other\n # remaining lines outside of this loop.\n' -Repository: plone.volto +Repository: plone.releaser -Branch: refs/heads/main -Date: 2023-11-29T07:36:02-08:00 -Author: David Glick (davisagli) -Commit: https://github.com/plone/plone.volto/commit/c3000d6f98028d87e05db9be23496f6090103c1f +Branch: refs/heads/master +Date: 2023-09-19T00:12:50+02:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/fdad9eb0a2f6092bfb62a58aa5771df7fa66de06 -Merge pull request #135 from plone/narrowtemplatefor5.2 +Read 'extends' lines from versions.cfg. -Add guard for template used in the Volto installed status message that is Plone 6 only +Only local for now. Files changed: -A news/135.bugfix -M src/plone/volto/browser/configure.zcml +A plone/releaser/tests/input/versions2.cfg +A plone/releaser/tests/input/versions3.cfg +A plone/releaser/tests/input/versions4.cfg +M plone/releaser/buildout.py +M plone/releaser/tests/test_buildout.py -b'diff --git a/news/135.bugfix b/news/135.bugfix\nnew file mode 100644\nindex 00000000..74f0e815\n--- /dev/null\n+++ b/news/135.bugfix\n@@ -0,0 +1 @@\n+Add guard for template used in the Volto installed status message that is Plone 6 only @sneridagh\ndiff --git a/src/plone/volto/browser/configure.zcml b/src/plone/volto/browser/configure.zcml\nindex faffda5c..78ce7e6c 100644\n--- a/src/plone/volto/browser/configure.zcml\n+++ b/src/plone/volto/browser/configure.zcml\n@@ -57,6 +57,7 @@\n template="voltobackendwarning.pt"\n permission="zope2.View"\n layer="plone.volto.interfaces.IPloneVoltoCoreLayer"\n+ zcml:condition="have plone-60"\n />\n \n \n' +b'diff --git a/plone/releaser/buildout.py b/plone/releaser/buildout.py\nindex d98bac3..c4fb44f 100644\n--- a/plone/releaser/buildout.py\n+++ b/plone/releaser/buildout.py\n@@ -4,6 +4,7 @@\n from collections import OrderedDict\n from configparser import ConfigParser\n from configparser import ExtendedInterpolation\n+from functools import cached_property\n \n import os\n import pathlib\n@@ -55,11 +56,25 @@ def __eq__(self, other):\n \n \n class VersionsFile(BaseFile):\n- def __init__(self, file_location, with_markers=False):\n+ def __init__(self, file_location, with_markers=False, read_extends=False):\n self.file_location = file_location\n self.path = pathlib.Path(self.file_location).resolve()\n self.with_markers = with_markers\n self.markers = set()\n+ self.read_extends = read_extends\n+\n+ @cached_property\n+ def config(self):\n+ config = ConfigParser(interpolation=ExtendedInterpolation(), strict=False)\n+ with self.path.open() as f:\n+ config.read_file(f)\n+ return config\n+\n+ @property\n+ def extends(self):\n+ if self.config.has_section("buildout"):\n+ return self.config["buildout"].get("extends", "").strip().splitlines()\n+ return []\n \n @property\n def data(self):\n@@ -83,21 +98,31 @@ def data(self):\n Ah, but we *do* need this information when translating to pip.\n For that: set self.with_markers = True.\n """\n- config = ConfigParser(interpolation=ExtendedInterpolation(), strict=False)\n- with self.path.open() as f:\n- config.read_file(f)\n- # https://github.com/plone/plone.releaser/issues/42\n- if config.has_section("buildout"):\n- config["buildout"]["directory"] = os.getcwd()\n- versions = defaultdict(list)\n- for section in config.sections():\n+ versions = defaultdict(dict)\n+ if self.config.has_section("buildout"):\n+ # https://github.com/plone/plone.releaser/issues/42\n+ self.config["buildout"]["directory"] = os.getcwd()\n+ if self.read_extends:\n+ # Recursively read the extended files, and include their versions.\n+ for extend in self.extends:\n+ # TODO: support downloading\n+ assert not extend.startswith("http")\n+ extended = VersionsFile(\n+ self.path.parent / extend,\n+ with_markers=self.with_markers,\n+ read_extends=True,\n+ )\n+ for package, version in extended.data.items():\n+ if not isinstance(version, dict):\n+ versions[package][""] = version\n+ else:\n+ versions[package].update(version)\n+\n+ for section in self.config.sections():\n if section == "versions":\n- for package, version in config[section].items():\n+ for package, version in self.config[section].items():\n # Note: the package names are lower case.\n- if not self.with_markers:\n- versions[package] = version\n- else:\n- versions[package].append(version)\n+ versions[package][""] = version\n if not self.with_markers:\n continue\n parts = section.split(":")\n@@ -105,15 +130,14 @@ def data(self):\n continue\n marker = parts[1]\n self.markers.add(marker)\n- for package, version in config[section].items():\n- # Note: the package names are lower case.\n- versions[package].append((version, marker))\n-\n- if self.with_markers:\n- # simplify\n- for package, version in versions.items():\n- if isinstance(version, list) and len(version) == 1:\n- versions[package] = version[0]\n+ for package, version in self.config[section].items():\n+ versions[package][marker] = version\n+\n+ # simplify\n+ for package, version in versions.items():\n+ if len(version) == 1 and "" in version.keys():\n+ versions[package] = version[""]\n+ continue\n return versions\n \n def __setitem__(self, package_name, new_version):\ndiff --git a/plone/releaser/tests/input/versions2.cfg b/plone/releaser/tests/input/versions2.cfg\nnew file mode 100644\nindex 0000000..a65029b\n--- /dev/null\n+++ b/plone/releaser/tests/input/versions2.cfg\n@@ -0,0 +1,9 @@\n+[buildout]\n+extends = versions3.cfg\n+\n+[versions]\n+one = 1.1\n+two = 2.0\n+\n+[versions:python312]\n+three = 3.2\ndiff --git a/plone/releaser/tests/input/versions3.cfg b/plone/releaser/tests/input/versions3.cfg\nnew file mode 100644\nindex 0000000..293a5d5\n--- /dev/null\n+++ b/plone/releaser/tests/input/versions3.cfg\n@@ -0,0 +1,10 @@\n+[buildout]\n+extends =\n+ versions4.cfg\n+\n+[versions]\n+one = 1.0\n+three = 3.0\n+\n+[versions:python312]\n+three = 3.1\ndiff --git a/plone/releaser/tests/input/versions4.cfg b/plone/releaser/tests/input/versions4.cfg\nnew file mode 100644\nindex 0000000..9fdd35a\n--- /dev/null\n+++ b/plone/releaser/tests/input/versions4.cfg\n@@ -0,0 +1,5 @@\n+[versions]\n+four = 4.0\n+\n+[versions:macosx]\n+five = 5.0\ndiff --git a/plone/releaser/tests/test_buildout.py b/plone/releaser/tests/test_buildout.py\nindex 87b82c3..984498a 100644\n--- a/plone/releaser/tests/test_buildout.py\n+++ b/plone/releaser/tests/test_buildout.py\n@@ -14,6 +14,9 @@\n CHECKOUTS_FILE = INPUT_DIR / "checkouts.cfg"\n SOURCES_FILE = INPUT_DIR / "sources.cfg"\n VERSIONS_FILE = INPUT_DIR / "versions.cfg"\n+VERSIONS_FILE2 = INPUT_DIR / "versions2.cfg"\n+VERSIONS_FILE3 = INPUT_DIR / "versions3.cfg"\n+VERSIONS_FILE4 = INPUT_DIR / "versions4.cfg"\n \n \n def test_checkouts_file_data():\n@@ -185,6 +188,33 @@ def test_versions_file_versions():\n }\n \n \n+def test_versions_file_extends():\n+ vf = VersionsFile(VERSIONS_FILE)\n+ assert vf.extends == [\n+ "https://zopefoundation.github.io/Zope/releases/5.8.3/versions.cfg"\n+ ]\n+ vf = VersionsFile(VERSIONS_FILE2)\n+ assert vf.extends == ["versions3.cfg"]\n+ vf = VersionsFile(VERSIONS_FILE3)\n+ assert vf.extends == ["versions4.cfg"]\n+\n+\n+def test_versions_file_read_extends_without_markers():\n+ vf = VersionsFile(VERSIONS_FILE2, read_extends=True)\n+ assert vf.data == {"four": "4.0", "one": "1.1", "three": "3.0", "two": "2.0"}\n+\n+\n+def test_versions_file_read_extends_with_markers():\n+ vf = VersionsFile(VERSIONS_FILE2, with_markers=True, read_extends=True)\n+ assert vf.data == {\n+ "five": {"macosx": "5.0"},\n+ "four": "4.0",\n+ "one": "1.1",\n+ "three": {"": "3.0", "python312": "3.2"},\n+ "two": "2.0",\n+ }\n+\n+\n def test_versions_file_versions_with_markers():\n vf = VersionsFile(VERSIONS_FILE, with_markers=True)\n # All versions are reported lowercased.\n@@ -193,9 +223,9 @@ def test_versions_file_versions_with_markers():\n "camelcase": "1.0",\n "duplicate": "1.0",\n "lowercase": "1.0",\n- "onepython": ("2.1", "python312"),\n+ "onepython": {"python312": "2.1"},\n "package": "1.0",\n- "pyspecific": ["1.0", ("2.0", "python312")],\n+ "pyspecific": {"": "1.0", "python312": "2.0"},\n "uppercase": "1.0",\n }\n \n@@ -252,10 +282,10 @@ def test_versions_file_get_with_markers():\n vf = VersionsFile(VERSIONS_FILE, with_markers=True)\n assert vf.get("package") == "1.0"\n assert vf["package"] == "1.0"\n- assert vf.get("onepython") == ("2.1", "python312")\n- assert vf.get("pyspecific") == ["1.0", ("2.0", "python312")]\n- assert vf["onepython"] == ("2.1", "python312")\n- assert vf["pyspecific"] == ["1.0", ("2.0", "python312")]\n+ assert vf.get("onepython") == {"python312": "2.1"}\n+ assert vf.get("pyspecific") == {"": "1.0", "python312": "2.0"}\n+ assert vf["onepython"] == {"python312": "2.1"}\n+ assert vf["pyspecific"] == {"": "1.0", "python312": "2.0"}\n \n \n def test_versions_file_set_normal(tmp_path):\n@@ -308,31 +338,31 @@ def test_versions_file_set_with_markers(tmp_path):\n shutil.copyfile(VERSIONS_FILE, copy_path)\n vf = VersionsFile(copy_path, with_markers=True)\n assert "pyspecific = 2.0" in copy_path.read_text()\n- assert vf.get("pyspecific") == ["1.0", ("2.0", "python312")]\n+ assert vf.get("pyspecific") == {"": "1.0", "python312": "2.0"}\n vf.set("pyspecific", "1.1")\n # Read it fresh, without markers.\n vf = VersionsFile(copy_path)\n assert vf.get("pyspecific") == "1.1"\n # Read it fresh, with markers.\n vf = VersionsFile(copy_path, with_markers=True)\n- assert vf.get("pyspecific") == ["1.1", ("2.0", "python312")]\n+ assert vf.get("pyspecific") == {"": "1.1", "python312": "2.0"}\n # Now edit for a specific python version.\n vf.set("pyspecific", ("2.1", "python312"))\n # Read it fresh, with markers.\n vf = VersionsFile(copy_path, with_markers=True)\n- assert vf.get("pyspecific") == ["1.1", ("2.1", "python312")]\n+ assert vf.get("pyspecific") == {"": "1.1", "python312": "2.1"}\n # Add to an unknown marker.\n vf.set("pyspecific", ("3.0", "python313"))\n # Read it fresh, with markers.\n vf = VersionsFile(copy_path, with_markers=True)\n assert "[versions:python313]" in copy_path.read_text()\n- assert vf.get("pyspecific") == ["1.1", ("2.1", "python312"), ("3.0", "python313")]\n+ assert vf.get("pyspecific") == {"": "1.1", "python312": "2.1", "python313": "3.0"}\n # Add a new package to a new marker.\n vf.set("maconly", ("1.0", "macosx"))\n # Read it fresh, with markers.\n vf = VersionsFile(copy_path, with_markers=True)\n assert "[versions:macosx]" in copy_path.read_text()\n- assert vf.get("maconly") == ("1.0", "macosx")\n+ assert vf.get("maconly") == {"macosx": "1.0"}\n # Read it without markers.\n vf = VersionsFile(copy_path)\n assert vf["pyspecific"] == "1.1"\n' + +Repository: plone.releaser + + +Branch: refs/heads/master +Date: 2023-10-07T20:54:02+02:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/3d800c3caa7129eea4e671f8d05e25a1a77e230d + +Support reading extends (-c) in constraints as well. + +Files changed: +A plone/releaser/tests/input/constraints2.txt +A plone/releaser/tests/input/constraints3.txt +A plone/releaser/tests/input/constraints4.txt +M plone/releaser/pip.py +M plone/releaser/tests/input/constraints.txt +M plone/releaser/tests/test_buildout.py +M plone/releaser/tests/test_pip.py + +b'diff --git a/plone/releaser/pip.py b/plone/releaser/pip.py\nindex 2c8cb95..38b52de 100644\n--- a/plone/releaser/pip.py\n+++ b/plone/releaser/pip.py\n@@ -1,9 +1,11 @@\n from .base import BaseFile\n from .utils import update_contents\n+from collections import defaultdict\n from configparser import ConfigParser\n from configparser import ExtendedInterpolation\n from functools import cached_property\n \n+import pathlib\n import re\n \n \n@@ -16,36 +18,74 @@ def to_bool(value):\n \n \n class ConstraintsFile(BaseFile):\n+ def __init__(self, file_location, with_markers=False, read_extends=False):\n+ self.file_location = file_location\n+ self.path = pathlib.Path(self.file_location).resolve()\n+ self.with_markers = with_markers\n+ self.markers = set()\n+ self.read_extends = read_extends\n+ self._extends = []\n+\n+ @property\n+ def extends(self):\n+ # Getting the data fills self._extends.\n+ _ignored = self.data # noqa F841\n+ return self._extends\n+\n @cached_property\n def data(self):\n """Read the constraints."""\n contents = self.path.read_text()\n- constraints = {}\n+ constraints = defaultdict(dict)\n for line in contents.splitlines():\n line = line.strip()\n if line.startswith("#"):\n continue\n+ if line.startswith("-c"):\n+ extend = line[len("-c") :].strip()\n+ self._extends.append(extend)\n+ if self.read_extends:\n+ # TODO: support downloading\n+ assert not extend.startswith("http")\n+ # Recursively read the extended files, and include their versions.\n+ extended = ConstraintsFile(\n+ self.path.parent / extend,\n+ with_markers=self.with_markers,\n+ read_extends=True,\n+ )\n+ for package, version in extended.data.items():\n+ if not isinstance(version, dict):\n+ constraints[package][""] = version\n+ else:\n+ constraints[package].update(version)\n+ continue\n if "==" not in line:\n # We might want to support e.g. \'>=\', but for now keep it simple.\n continue\n package = line.split("==")[0].strip().lower()\n- version = line.split("==")[1].strip()\n+ version = line.split("==", 1)[1].strip()\n # The line could also contain environment markers like this:\n # "; python_version >= \'3.0\'"\n # But currently I think we really only need the package name,\n # and not even the version. Let\'s use the entire rest of the line.\n # Actually, for our purposes, we should ignore lines that have such\n # markers, just like we do in buildout.py:VersionsFile.\n- if ";" in version:\n+ if ";" not in version:\n+ constraints[package][""] = version\n continue\n- if package in constraints:\n- if constraints[package] != version:\n- print(\n- f"ERROR: {package} is in {self.file_location} with two "\n- f"constraints: \'{constraints[package]}\' and \'{version}\'."\n- )\n+ if not self.with_markers:\n continue\n- constraints[package] = version\n+ version, marker = version.split(";")\n+ version = version.strip()\n+ marker = marker.strip()\n+ constraints[package][marker] = version\n+\n+ # simplify\n+ for package, version in constraints.items():\n+ if len(version) == 1 and "" in version.keys():\n+ constraints[package] = version[""]\n+ continue\n+\n return constraints\n \n def __setitem__(self, package_name, new_version):\ndiff --git a/plone/releaser/tests/input/constraints.txt b/plone/releaser/tests/input/constraints.txt\nindex 6c401aa..afff783 100644\n--- a/plone/releaser/tests/input/constraints.txt\n+++ b/plone/releaser/tests/input/constraints.txt\n@@ -9,4 +9,5 @@ lowercase==1.0\n package==1.0\n pyspecific==1.0\n pyspecific==2.0; python_version=="3.12"\n+onepython==2.1; python_version=="3.12"\n UPPERCASE==1.0\ndiff --git a/plone/releaser/tests/input/constraints2.txt b/plone/releaser/tests/input/constraints2.txt\nnew file mode 100644\nindex 0000000..c0a21a0\n--- /dev/null\n+++ b/plone/releaser/tests/input/constraints2.txt\n@@ -0,0 +1,4 @@\n+-c constraints3.txt\n+one==1.1\n+two==2.0\n+three==3.2; python_version=="3.12"\ndiff --git a/plone/releaser/tests/input/constraints3.txt b/plone/releaser/tests/input/constraints3.txt\nnew file mode 100644\nindex 0000000..17353f3\n--- /dev/null\n+++ b/plone/releaser/tests/input/constraints3.txt\n@@ -0,0 +1,4 @@\n+-c constraints4.txt\n+one==1.0\n+three==3.0\n+three==3.1; python_version=="3.12"\ndiff --git a/plone/releaser/tests/input/constraints4.txt b/plone/releaser/tests/input/constraints4.txt\nnew file mode 100644\nindex 0000000..da5b64d\n--- /dev/null\n+++ b/plone/releaser/tests/input/constraints4.txt\n@@ -0,0 +1,2 @@\n+four==4.0\n+five==5.0; platform_system == \'macosx\'\ndiff --git a/plone/releaser/tests/test_buildout.py b/plone/releaser/tests/test_buildout.py\nindex 984498a..4928279 100644\n--- a/plone/releaser/tests/test_buildout.py\n+++ b/plone/releaser/tests/test_buildout.py\n@@ -197,6 +197,8 @@ def test_versions_file_extends():\n assert vf.extends == ["versions3.cfg"]\n vf = VersionsFile(VERSIONS_FILE3)\n assert vf.extends == ["versions4.cfg"]\n+ vf = VersionsFile(VERSIONS_FILE4)\n+ assert vf.extends == []\n \n \n def test_versions_file_read_extends_without_markers():\ndiff --git a/plone/releaser/tests/test_pip.py b/plone/releaser/tests/test_pip.py\nindex 30774c1..78e7766 100644\n--- a/plone/releaser/tests/test_pip.py\n+++ b/plone/releaser/tests/test_pip.py\n@@ -9,6 +9,9 @@\n TESTS_DIR = pathlib.Path(__file__).parent\n INPUT_DIR = TESTS_DIR / "input"\n CONSTRAINTS_FILE = INPUT_DIR / "constraints.txt"\n+CONSTRAINTS_FILE2 = INPUT_DIR / "constraints2.txt"\n+CONSTRAINTS_FILE3 = INPUT_DIR / "constraints3.txt"\n+CONSTRAINTS_FILE4 = INPUT_DIR / "constraints4.txt"\n MXDEV_FILE = INPUT_DIR / "mxdev.ini"\n \n \n@@ -201,3 +204,47 @@ def test_constraints_file_set_cleanup_duplicates(tmp_path):\n assert cf.get("duplicate") == "2.0"\n assert copy_path.read_text().count("duplicate==2.0") == 1\n assert copy_path.read_text().count("duplicate==1.0") == 0\n+\n+\n+def test_constraints_file_extends():\n+ cf = ConstraintsFile(CONSTRAINTS_FILE)\n+ assert cf.extends == [\n+ "https://zopefoundation.github.io/Zope/releases/5.8.3/constraints.txt"\n+ ]\n+ cf = ConstraintsFile(CONSTRAINTS_FILE2)\n+ assert cf.extends == ["constraints3.txt"]\n+ cf = ConstraintsFile(CONSTRAINTS_FILE3)\n+ assert cf.extends == ["constraints4.txt"]\n+ cf = ConstraintsFile(CONSTRAINTS_FILE4)\n+ assert cf.extends == []\n+\n+\n+def test_constraints_file_read_extends_without_markers():\n+ cf = ConstraintsFile(CONSTRAINTS_FILE2, read_extends=True)\n+ assert cf.data == {"four": "4.0", "one": "1.1", "three": "3.0", "two": "2.0"}\n+\n+\n+def test_constraints_file_read_extends_with_markers():\n+ cf = ConstraintsFile(CONSTRAINTS_FILE2, with_markers=True, read_extends=True)\n+ assert cf.data == {\n+ "five": {"platform_system == \'macosx\'": "5.0"},\n+ "four": "4.0",\n+ "one": "1.1",\n+ "three": {"": "3.0", \'python_version=="3.12"\': "3.2"},\n+ "two": "2.0",\n+ }\n+\n+\n+def test_constraints_file_constraints_with_markers():\n+ cf = ConstraintsFile(CONSTRAINTS_FILE, with_markers=True)\n+ # All constraints are reported lowercased.\n+ assert cf.data == {\n+ "annotated": "1.0",\n+ "camelcase": "1.0",\n+ "duplicate": "1.0",\n+ "lowercase": "1.0",\n+ "onepython": {\'python_version=="3.12"\': "2.1"},\n+ "package": "1.0",\n+ "pyspecific": {"": "1.0", \'python_version=="3.12"\': "2.0"},\n+ "uppercase": "1.0",\n+ }\n' + +Repository: plone.releaser + + +Branch: refs/heads/master +Date: 2023-10-07T22:15:36+02:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/990f64eb555805e3c989d932f7f2c1c09e665e24 + +Add VersionsFile.rewrite. + +This takes the parsed 'extends' and 'versions' and writes them back. +This normalizes some stuff, so it will not be exactly the same. + +In the end we want to take versions.cfg and translate it to constraints.txt. +But translating versions.cfg to its own should work as well, and is a good first step. + +Files changed: +M plone/releaser/buildout.py +M plone/releaser/tests/test_buildout.py + +b'diff --git a/plone/releaser/buildout.py b/plone/releaser/buildout.py\nindex c4fb44f..b27235a 100644\n--- a/plone/releaser/buildout.py\n+++ b/plone/releaser/buildout.py\n@@ -184,6 +184,45 @@ def stop_check(line):\n if contents != new_contents:\n self.path.write_text(new_contents)\n \n+ def rewrite(self):\n+ """Rewrite the file based on the parsed data.\n+\n+ This will lose comments, and may change the order.\n+ """\n+ contents = []\n+ if self.extends and not self.read_extends:\n+ # With read_extends=True, we incorporate the versions of the\n+ # extended files in our own, so we no longer need the extends.\n+ contents = ["[buildout]", "extends ="]\n+ for extend in self.extends:\n+ contents.append(f" {extend}")\n+ contents.append("")\n+\n+ contents.append("[versions]")\n+ markers = defaultdict(list)\n+ for package, version in self.data.items():\n+ if isinstance(version, str):\n+ contents.append(f"{package} = {version}")\n+ continue\n+ # version is a dict\n+ for marker, value in version.items():\n+ if not marker:\n+ # add to current [versions]\n+ contents.append(f"{package} = {value}")\n+ continue\n+ # add to markers to write at the end\n+ markers[marker].append((package, value))\n+\n+ for marker, entries in markers.items():\n+ contents.append("")\n+ contents.append(f"[versions:{marker}]")\n+ for package, version in entries:\n+ contents.append(f"{package} = {version}")\n+\n+ contents.append("")\n+ new_contents = "\\n".join(contents)\n+ self.path.write_text(new_contents)\n+\n \n class SourcesFile(BaseFile):\n @property\ndiff --git a/plone/releaser/tests/test_buildout.py b/plone/releaser/tests/test_buildout.py\nindex 4928279..46427cb 100644\n--- a/plone/releaser/tests/test_buildout.py\n+++ b/plone/releaser/tests/test_buildout.py\n@@ -383,3 +383,142 @@ def test_versions_file_set_cleanup_duplicates(tmp_path):\n assert vf.get("duplicate") == "2.0"\n assert copy_path.read_text().count("duplicate = 2.0") == 1\n assert copy_path.read_text().count("duplicate = 1.0") == 0\n+\n+\n+def test_versions_file_rewrite(tmp_path):\n+ copy_path = tmp_path / "versions.cfg"\n+ shutil.copyfile(VERSIONS_FILE, copy_path)\n+ vf = VersionsFile(copy_path)\n+ vf.rewrite()\n+ # Read it fresh and compare\n+ vf2 = VersionsFile(copy_path)\n+ assert vf.extends == vf2.extends\n+ assert vf.data == vf2.data\n+ # Check the entire text.\n+ # Note that there are differences with the original:\n+ # - the extends line is on a separate line\n+ # - all comments are removed\n+ # - the duplicate is removed\n+ # - all package names are lowercased\n+ assert (\n+ copy_path.read_text()\n+ == """[buildout]\n+extends =\n+ https://zopefoundation.github.io/Zope/releases/5.8.3/versions.cfg\n+\n+[versions]\n+annotated = 1.0\n+camelcase = 1.0\n+duplicate = 1.0\n+lowercase = 1.0\n+package = 1.0\n+pyspecific = 1.0\n+uppercase = 1.0\n+"""\n+ )\n+\n+\n+def test_versions_file_rewrite_2(tmp_path):\n+ copy_path = tmp_path / "versions2.cfg"\n+ shutil.copyfile(VERSIONS_FILE2, copy_path)\n+ vf = VersionsFile(copy_path)\n+ vf.rewrite()\n+ # Read it fresh and compare\n+ vf2 = VersionsFile(copy_path)\n+ assert vf.extends == vf2.extends\n+ assert vf.data == vf2.data\n+ # Check the entire text.\n+ assert (\n+ copy_path.read_text()\n+ == """[buildout]\n+extends =\n+ versions3.cfg\n+\n+[versions]\n+one = 1.1\n+two = 2.0\n+"""\n+ )\n+\n+\n+def test_versions_file_rewrite_with_markers(tmp_path):\n+ copy_path = tmp_path / "versions2.cfg"\n+ shutil.copyfile(VERSIONS_FILE2, copy_path)\n+ vf = VersionsFile(copy_path, with_markers=True)\n+ vf.rewrite()\n+ # Read it fresh and compare\n+ vf2 = VersionsFile(copy_path, with_markers=True)\n+ assert vf.extends == vf2.extends\n+ assert vf.data == vf2.data\n+ # Check the entire text.\n+ assert (\n+ copy_path.read_text()\n+ == """[buildout]\n+extends =\n+ versions3.cfg\n+\n+[versions]\n+one = 1.1\n+two = 2.0\n+\n+[versions:python312]\n+three = 3.2\n+"""\n+ )\n+\n+\n+def test_versions_file_rewrite_read_extends_without_markers(tmp_path):\n+ # Note: this combination may not make sense.\n+ copy_path = tmp_path / "versions2.cfg"\n+ shutil.copyfile(VERSIONS_FILE2, copy_path)\n+ # We extend some files and use their versions, so we need to copy them.\n+ shutil.copyfile(VERSIONS_FILE3, tmp_path / "versions3.cfg")\n+ shutil.copyfile(VERSIONS_FILE4, tmp_path / "versions4.cfg")\n+ vf = VersionsFile(copy_path, read_extends=True, with_markers=False)\n+ vf.rewrite()\n+ # Read it fresh and compare\n+ vf2 = VersionsFile(copy_path, read_extends=True, with_markers=False)\n+ assert vf.extends\n+ assert not vf2.extends\n+ assert vf.data == vf2.data\n+ # Check the entire text. Note that packages are alphabetically sorted.\n+ assert (\n+ copy_path.read_text()\n+ == """[versions]\n+four = 4.0\n+one = 1.1\n+three = 3.0\n+two = 2.0\n+"""\n+ )\n+\n+\n+def test_versions_file_rewrite_read_extends_with_markers(tmp_path):\n+ copy_path = tmp_path / "versions2.cfg"\n+ shutil.copyfile(VERSIONS_FILE2, copy_path)\n+ # We extend some files and use their versions, so we need to copy them.\n+ shutil.copyfile(VERSIONS_FILE3, tmp_path / "versions3.cfg")\n+ shutil.copyfile(VERSIONS_FILE4, tmp_path / "versions4.cfg")\n+ vf = VersionsFile(copy_path, read_extends=True, with_markers=True)\n+ vf.rewrite()\n+ # Read it fresh and compare\n+ vf2 = VersionsFile(copy_path, read_extends=True, with_markers=True)\n+ assert vf.extends\n+ assert not vf2.extends\n+ assert vf.data == vf2.data\n+ # Check the entire text. Note that packages are alphabetically sorted.\n+ assert (\n+ copy_path.read_text()\n+ == """[versions]\n+four = 4.0\n+one = 1.1\n+three = 3.0\n+two = 2.0\n+\n+[versions:macosx]\n+five = 5.0\n+\n+[versions:python312]\n+three = 3.2\n+"""\n+ )\n' + +Repository: plone.releaser + + +Branch: refs/heads/master +Date: 2023-10-09T12:05:44+02:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/b41ea5ba8ecc9a9ec5feaf1d25159412d176cc2a + +constraints: platform_system is darwin, not macosx. + +Files changed: +M plone/releaser/tests/input/constraints4.txt +M plone/releaser/tests/test_pip.py + +b'diff --git a/plone/releaser/tests/input/constraints4.txt b/plone/releaser/tests/input/constraints4.txt\nindex da5b64d..f55d689 100644\n--- a/plone/releaser/tests/input/constraints4.txt\n+++ b/plone/releaser/tests/input/constraints4.txt\n@@ -1,2 +1,2 @@\n four==4.0\n-five==5.0; platform_system == \'macosx\'\n+five==5.0; platform_system == \'darwin\'\ndiff --git a/plone/releaser/tests/test_pip.py b/plone/releaser/tests/test_pip.py\nindex 78e7766..97c1209 100644\n--- a/plone/releaser/tests/test_pip.py\n+++ b/plone/releaser/tests/test_pip.py\n@@ -227,7 +227,7 @@ def test_constraints_file_read_extends_without_markers():\n def test_constraints_file_read_extends_with_markers():\n cf = ConstraintsFile(CONSTRAINTS_FILE2, with_markers=True, read_extends=True)\n assert cf.data == {\n- "five": {"platform_system == \'macosx\'": "5.0"},\n+ "five": {"platform_system == \'darwin\'": "5.0"},\n "four": "4.0",\n "one": "1.1",\n "three": {"": "3.0", \'python_version=="3.12"\': "3.2"},\n' + +Repository: plone.releaser + + +Branch: refs/heads/master +Date: 2023-10-12T20:37:44+02:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/fb2968b4000c7ad45fade472e1a48d8d66186b3d + +Add ConstraintsFile.rewrite. + +Files changed: +M plone/releaser/pip.py +M plone/releaser/tests/test_pip.py + +b'diff --git a/plone/releaser/pip.py b/plone/releaser/pip.py\nindex 38b52de..4304ad1 100644\n--- a/plone/releaser/pip.py\n+++ b/plone/releaser/pip.py\n@@ -109,6 +109,33 @@ def line_check(line):\n if contents != new_contents:\n self.path.write_text(new_contents)\n \n+ def rewrite(self):\n+ """Rewrite the file based on the parsed data.\n+\n+ This will lose comments, and may change the order.\n+ """\n+ contents = []\n+ if self.extends and not self.read_extends:\n+ # With read_extends=True, we incorporate the versions of the\n+ # extended files in our own, so we no longer need the extends.\n+ for extend in self.extends:\n+ contents.append(f"-c {extend}")\n+\n+ for package, version in self.data.items():\n+ if isinstance(version, str):\n+ contents.append(f"{package}=={version}")\n+ continue\n+ # version is a dict\n+ for marker, value in version.items():\n+ line = f"{package}=={value}"\n+ if marker:\n+ line += f"; {marker}"\n+ contents.append(line)\n+\n+ contents.append("")\n+ new_contents = "\\n".join(contents)\n+ self.path.write_text(new_contents)\n+\n \n class IniFile(BaseFile):\n """Ini file for mxdev.\ndiff --git a/plone/releaser/tests/test_pip.py b/plone/releaser/tests/test_pip.py\nindex 97c1209..5d2b1a9 100644\n--- a/plone/releaser/tests/test_pip.py\n+++ b/plone/releaser/tests/test_pip.py\n@@ -164,7 +164,7 @@ def test_constraints_file_set_normal(tmp_path):\n # How about packages that are not lowercase?\n # Currently in ConstraintsFile we report all package names as lower case,\n # so we don\'t know what their exact spelling is, which is what ConfigParser\n- # does for the Buildout versions file. So whatever we pass on, should be used.\n+ # does for the Buildout files. So whatever we pass on, should be used.\n assert "CamelCase==1.0" in copy_path.read_text()\n assert copy_path.read_text().lower().count("camelcase") == 1\n cf["CAMELcase"] = "1.1"\n@@ -248,3 +248,122 @@ def test_constraints_file_constraints_with_markers():\n "pyspecific": {"": "1.0", \'python_version=="3.12"\': "2.0"},\n "uppercase": "1.0",\n }\n+\n+\n+def test_constraints_file_rewrite(tmp_path):\n+ copy_path = tmp_path / "constraints.txt"\n+ shutil.copyfile(CONSTRAINTS_FILE, copy_path)\n+ cf = ConstraintsFile(copy_path)\n+ cf.rewrite()\n+ # Read it fresh and compare\n+ cf2 = ConstraintsFile(copy_path)\n+ assert cf.extends == cf2.extends\n+ assert cf.data == cf2.data\n+ # Check the entire text.\n+ # Note that there are differences with the original:\n+ # - the extends line is on a separate line\n+ # - all comments are removed\n+ # - the duplicate is removed\n+ # - all package names are lowercased\n+ assert (\n+ copy_path.read_text()\n+ == """-c https://zopefoundation.github.io/Zope/releases/5.8.3/constraints.txt\n+annotated==1.0\n+camelcase==1.0\n+duplicate==1.0\n+lowercase==1.0\n+package==1.0\n+pyspecific==1.0\n+uppercase==1.0\n+"""\n+ )\n+\n+\n+def test_constraints_file_rewrite_2(tmp_path):\n+ copy_path = tmp_path / "constraints2.txt"\n+ shutil.copyfile(CONSTRAINTS_FILE2, copy_path)\n+ cf = ConstraintsFile(copy_path)\n+ cf.rewrite()\n+ # Read it fresh and compare\n+ cf2 = ConstraintsFile(copy_path)\n+ assert cf.extends == cf2.extends\n+ assert cf.data == cf2.data\n+ # Check the entire text.\n+ assert (\n+ copy_path.read_text()\n+ == """-c constraints3.txt\n+one==1.1\n+two==2.0\n+"""\n+ )\n+\n+\n+def test_constraints_file_rewrite_with_markers(tmp_path):\n+ copy_path = tmp_path / "constraints2.txt"\n+ shutil.copyfile(CONSTRAINTS_FILE2, copy_path)\n+ cf = ConstraintsFile(copy_path, with_markers=True)\n+ cf.rewrite()\n+ # Read it fresh and compare\n+ cf2 = ConstraintsFile(copy_path, with_markers=True)\n+ assert cf.extends == cf2.extends\n+ assert cf.data == cf2.data\n+ # Check the entire text.\n+ assert (\n+ copy_path.read_text()\n+ == """-c constraints3.txt\n+one==1.1\n+two==2.0\n+three==3.2; python_version=="3.12"\n+"""\n+ )\n+\n+\n+def test_constraints_file_rewrite_read_extends_without_markers(tmp_path):\n+ # Note: this combination may not make sense.\n+ copy_path = tmp_path / "constraints2.txt"\n+ shutil.copyfile(CONSTRAINTS_FILE2, copy_path)\n+ # We extend some files and use their constraints, so we need to copy them.\n+ shutil.copyfile(CONSTRAINTS_FILE3, tmp_path / "constraints3.txt")\n+ shutil.copyfile(CONSTRAINTS_FILE4, tmp_path / "constraints4.txt")\n+ cf = ConstraintsFile(copy_path, read_extends=True, with_markers=False)\n+ cf.rewrite()\n+ # Read it fresh and compare\n+ cf2 = ConstraintsFile(copy_path, read_extends=True, with_markers=False)\n+ assert cf.extends\n+ assert not cf2.extends\n+ assert cf.data == cf2.data\n+ # Check the entire text. Note that packages are alphabetically sorted.\n+ assert (\n+ copy_path.read_text()\n+ == """four==4.0\n+one==1.1\n+three==3.0\n+two==2.0\n+"""\n+ )\n+\n+\n+def test_constraints_file_rewrite_read_extends_with_markers(tmp_path):\n+ copy_path = tmp_path / "constraints2.txt"\n+ shutil.copyfile(CONSTRAINTS_FILE2, copy_path)\n+ # We extend some files and use their constraints, so we need to copy them.\n+ shutil.copyfile(CONSTRAINTS_FILE3, tmp_path / "constraints3.txt")\n+ shutil.copyfile(CONSTRAINTS_FILE4, tmp_path / "constraints4.txt")\n+ cf = ConstraintsFile(copy_path, read_extends=True, with_markers=True)\n+ cf.rewrite()\n+ # Read it fresh and compare\n+ cf2 = ConstraintsFile(copy_path, read_extends=True, with_markers=True)\n+ assert cf.extends\n+ assert not cf2.extends\n+ assert cf.data == cf2.data\n+ # Check the entire text. Note that packages are alphabetically sorted.\n+ assert (\n+ copy_path.read_text()\n+ == """four==4.0\n+five==5.0; platform_system == \'darwin\'\n+one==1.1\n+three==3.0\n+three==3.2; python_version=="3.12"\n+two==2.0\n+"""\n+ )\n' + +Repository: plone.releaser + + +Branch: refs/heads/master +Date: 2023-10-12T22:45:33+02:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/a034175da9cfec19ea71132733490ed0d68a81fb + +Add CheckoutsFile.rewrite. + +Files changed: +M plone/releaser/buildout.py +M plone/releaser/tests/test_buildout.py + +b'diff --git a/plone/releaser/buildout.py b/plone/releaser/buildout.py\nindex b27235a..edd6d62 100644\n--- a/plone/releaser/buildout.py\n+++ b/plone/releaser/buildout.py\n@@ -248,14 +248,22 @@ def __setitem__(self, package_name, value):\n \n \n class CheckoutsFile(BaseFile):\n- @property\n- def data(self):\n+ @cached_property\n+ def config(self):\n config = ConfigParser(interpolation=ExtendedInterpolation())\n with self.path.open() as f:\n config.read_file(f)\n config["buildout"]["directory"] = os.getcwd()\n+ return config\n+\n+ @property\n+ def always_checkout(self):\n+ return self.config.get("buildout", "always-checkout")\n+\n+ @property\n+ def data(self):\n # I don\'t think we need to support [buildout:marker].\n- checkouts = config.get("buildout", "auto-checkout")\n+ checkouts = self.config.get("buildout", "auto-checkout")\n # Map from lower case to actual case, so we can find the package.\n mapping = {}\n for package in checkouts.splitlines():\n@@ -288,6 +296,25 @@ def set(self, package_name, new_version):\n # This method makes no sense for this class.\n raise NotImplementedError\n \n+ def rewrite(self):\n+ """Rewrite the file based on the parsed data.\n+\n+ This will lose comments, and may change the order.\n+ """\n+ contents = ["[buildout]"]\n+ if self.always_checkout:\n+ contents.append(f"always-checkout = {self.always_checkout}"),\n+ contents.append("auto-checkout =")\n+ # self.values has the original case.\n+ # We could iterate over \'self\' to get lowercase,\n+ # which is what we get in most other places.\n+ # But for now let\'s use the info we have.\n+ for package in self.values():\n+ contents.append(f" {package}")\n+ contents.append("")\n+ new_contents = "\\n".join(contents)\n+ self.path.write_text(new_contents)\n+\n \n class Buildout:\n def __init__(\ndiff --git a/plone/releaser/tests/test_buildout.py b/plone/releaser/tests/test_buildout.py\nindex 46427cb..37a98e9 100644\n--- a/plone/releaser/tests/test_buildout.py\n+++ b/plone/releaser/tests/test_buildout.py\n@@ -80,6 +80,27 @@ def test_checkouts_file_remove(tmp_path):\n assert "camelcase" not in cf\n \n \n+def test_checkouts_file_rewrite(tmp_path):\n+ copy_path = tmp_path / "checkouts.cfg"\n+ shutil.copyfile(CHECKOUTS_FILE, copy_path)\n+ cf = CheckoutsFile(copy_path)\n+ cf.rewrite()\n+ # Read it fresh and compare\n+ cf2 = CheckoutsFile(copy_path)\n+ assert cf.data == cf2.data\n+ # Check the entire text. Note that packages are alphabetically sorted.\n+ # Currently we get the original case, but we may change this to lowercase.\n+ assert (\n+ copy_path.read_text()\n+ == """[buildout]\n+always-checkout = force\n+auto-checkout =\n+ CamelCase\n+ package\n+"""\n+ )\n+\n+\n def test_source_standard():\n src = Source.create_from_string(\n "git https://github.com/plone/Plone.git pushurl=git@github.com:plone/Plone.git branch=6.0.x"\n' + +Repository: plone.releaser + + +Branch: refs/heads/master +Date: 2023-10-12T23:30:30+02:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/6b56b8f44abcdeb5c39cef5aea3f4fd04057a5ee + +Add Inifile.rewrite. + +Files changed: +M plone/releaser/pip.py +M plone/releaser/tests/test_pip.py + +b'diff --git a/plone/releaser/pip.py b/plone/releaser/pip.py\nindex 4304ad1..9cd0135 100644\n--- a/plone/releaser/pip.py\n+++ b/plone/releaser/pip.py\n@@ -2,7 +2,6 @@\n from .utils import update_contents\n from collections import defaultdict\n from configparser import ConfigParser\n-from configparser import ExtendedInterpolation\n from functools import cached_property\n \n import pathlib\n@@ -150,8 +149,13 @@ def __init__(self, file_location):\n super().__init__(file_location)\n self.config = ConfigParser(\n default_section="settings",\n- interpolation=ExtendedInterpolation(),\n )\n+ # mxdev itself calls ConfigParser with extra option\n+ # interpolation=ExtendedInterpolation().\n+ # This turns a line like \'url = ${settings:plone}/package.git\'\n+ # into \'url = https://github.com/plone/package.git\'.\n+ # In our case we very much want the original line,\n+ # especially when we do a rewrite of the file.\n with self.path.open() as f:\n self.config.read_file(f)\n self.default_use = to_bool(self.config["settings"].get("default-use", True))\n@@ -252,3 +256,25 @@ def __setitem__(self, package_name, enabled=True):\n \n contents = "\\n".join(lines)\n self.path.write_text(contents)\n+\n+ def rewrite(self):\n+ """Rewrite the file based on the parsed data.\n+\n+ This will lose comments, and may change the order.\n+ TODO Can we trust self.config? It won\'t get updated if we change any data\n+ after reading.\n+ """\n+ contents = ["[settings]"]\n+ for key, value in self.config["settings"].items():\n+ contents.append(f"{key} = {value}")\n+\n+ for package in self.sections.values():\n+ contents.append("")\n+ contents.append(f"[{package}]")\n+ for key, value in self.config[package].items():\n+ if self.config["settings"].get(key) != value:\n+ contents.append(f"{key} = {value}")\n+\n+ contents.append("")\n+ new_contents = "\\n".join(contents)\n+ self.path.write_text(new_contents)\ndiff --git a/plone/releaser/tests/test_pip.py b/plone/releaser/tests/test_pip.py\nindex 5d2b1a9..78116e6 100644\n--- a/plone/releaser/tests/test_pip.py\n+++ b/plone/releaser/tests/test_pip.py\n@@ -96,6 +96,42 @@ def test_mxdev_file_remove(tmp_path):\n assert "CamelCase" in mf\n \n \n+def test_mxdev_file_rewrite(tmp_path):\n+ copy_path = tmp_path / "mxdev.ini"\n+ shutil.copyfile(MXDEV_FILE, copy_path)\n+ mf = IniFile(copy_path)\n+ mf.rewrite()\n+ # Read it fresh and compare\n+ mf2 = IniFile(copy_path)\n+ assert mf.data == mf2.data\n+ # Check the entire text. Note that packages are alphabetically sorted.\n+ # Currently we get the original case, but we may change this to lowercase.\n+ assert (\n+ copy_path.read_text()\n+ == """[settings]\n+requirements-in = requirements.txt\n+requirements-out = requirements-mxdev.txt\n+contraints-out = constraints-mxdev.txt\n+default-use = false\n+plone = https://github.com/plone\n+\n+[package]\n+url = ${settings:plone}/package.git\n+branch = main\n+use = true\n+\n+[unused]\n+url = ${settings:plone}/package.git\n+branch = main\n+\n+[CamelCase]\n+url = ${settings:plone}/CamelCase.git\n+branch = main\n+use = true\n+"""\n+ )\n+\n+\n def test_constraints_file_constraints():\n cf = ConstraintsFile(CONSTRAINTS_FILE)\n # All constraints are reported lowercased.\n' + +Repository: plone.releaser + + +Branch: refs/heads/master +Date: 2023-10-27T00:10:23+02:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/667d09c2be62b6e8fd5dbac06c672bd0b4bf0c05 + +Fix manage.py to work with argh 0.30+. + +Full traceback with info: + +``` +$ bin/manage -h +Traceback (most recent call last): + File "/Users/maurits/community/plone-coredev/6.0/bin/manage", line 63, in <module> + sys.exit(plone.releaser.manage.manage()) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/maurits/community/plone-coredev/6.0/src/plone.releaser/plone/releaser/manage.py", line 277, in __call__ + parser.add_commands( + File "/Users/maurits/shared-eggs/cp312/argh-0.30.2-py3.12.egg/argh/helpers.py", line 47, in add_commands + return add_commands(self, *args, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/maurits/shared-eggs/cp312/argh-0.30.2-py3.12.egg/argh/assembling.py", line 592, in add_commands + set_default_command( + File "/Users/maurits/shared-eggs/cp312/argh-0.30.2-py3.12.egg/argh/assembling.py", line 331, in set_default_command + inferred_args: List[ParserAddArgumentSpec] = list( + ^^^^^ + File "/Users/maurits/shared-eggs/cp312/argh-0.30.2-py3.12.egg/argh/assembling.py", line 155, in infer_argspecs_from_function + raise ArgumentNameMappingError( +argh.assembling.ArgumentNameMappingError: Argument "path" in function "add_checkout" +is not keyword-only but has a default value. + +Please note that since Argh v.0.30 the default name mapping +policy has changed. + +More information: +https://argh.readthedocs.io/en/latest/changes.html#version-0-30-0-2023-10-21 + +You need to upgrade your functions so that the arguments +that have default values become keyword-only: + + f(x=1) -> f(*, x=1) + +If you actually want an optional positional argument, +please set the name mapping policy explicitly to `BY_NAME_IF_KWONLY`. + +If you choose to postpone the migration, you have two options: + +a) set the policy explicitly to `BY_NAME_IF_HAS_DEFAULT`; +b) pin Argh version to 0.29 until you are ready to migrate. + +Thank you for understanding! +``` + +Files changed: +M plone/releaser/manage.py + +b'diff --git a/plone/releaser/manage.py b/plone/releaser/manage.py\nindex d88c80f..8837041 100644\n--- a/plone/releaser/manage.py\n+++ b/plone/releaser/manage.py\n@@ -112,7 +112,7 @@ def _get_checkouts(path=None):\n yield checkouts\n \n \n-def check_checkout(package_name, path=None):\n+def check_checkout(package_name, *, path=None):\n """Check if package is in the checkouts.\n \n If no path is given, we try several paths:\n@@ -126,7 +126,7 @@ def check_checkout(package_name, path=None):\n print(f"YES, your package {package_name} is on auto checkout in {loc}.")\n \n \n-def remove_checkout(package_name, path=None):\n+def remove_checkout(package_name, *, path=None):\n """Remove package from auto checkouts.\n \n If no path is given, we try several paths:\n@@ -136,7 +136,7 @@ def remove_checkout(package_name, path=None):\n checkouts.remove(package_name)\n \n \n-def add_checkout(package_name, path=None):\n+def add_checkout(package_name, *, path=None):\n """Add package to auto checkouts.\n \n If no path is given, we try several paths:\n@@ -175,7 +175,7 @@ def _get_constraints(path=None):\n yield constraints\n \n \n-def get_package_version(package_name, path=None):\n+def get_package_version(package_name, *, path=None):\n """Get package version from constraints/versions file.\n \n If no path is given, we try several paths.\n@@ -206,7 +206,7 @@ def get_package_version(package_name, path=None):\n print(f"{constraints.file_location}: {package_name} {version}.")\n \n \n-def set_package_version(package_name, new_version, path=None):\n+def set_package_version(package_name, new_version, *, path=None):\n """Pin package to new version in a versions file.\n \n This can also be a pip constraints file.\n@@ -231,7 +231,7 @@ def set_package_version(package_name, new_version, path=None):\n constraints.set(package_name, new_version)\n \n \n-def versions2constraints(path=None):\n+def versions2constraints(*, path=None):\n """Take a Buildout versions file and create a pip constraints file out of it.\n \n If no path is given, we use versions*.cfg.\n' + +Repository: plone.releaser + + +Branch: refs/heads/master +Date: 2023-10-30T10:56:01+01:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/4f5492cc20279e4ed3a6f76ac4a9afd85ee5aaa9 + +SourcesFile: use config/data and a raw version without interpolation. + +Files changed: +M plone/releaser/buildout.py +M plone/releaser/tests/test_buildout.py + +b'diff --git a/plone/releaser/buildout.py b/plone/releaser/buildout.py\nindex edd6d62..44462d4 100644\n--- a/plone/releaser/buildout.py\n+++ b/plone/releaser/buildout.py\n@@ -51,6 +51,18 @@ def create_from_string(cls, source_string):\n def __repr__(self):\n return f""\n \n+ def __str__(self):\n+ line = f"{self.protocol} {self.url}"\n+ if self.pushurl:\n+ line += f" pushurl={self.pushurl}"\n+ if self.branch:\n+ line += f" branch={self.branch}"\n+ if self.path:\n+ line += f" path={self.path}"\n+ if not self.egg:\n+ line += " egg=false"\n+ return line\n+\n def __eq__(self, other):\n return repr(self) == repr(other)\n \n@@ -225,8 +237,8 @@ def rewrite(self):\n \n \n class SourcesFile(BaseFile):\n- @property\n- def data(self):\n+ @cached_property\n+ def config(self):\n config = ConfigParser(interpolation=ExtendedInterpolation())\n config.optionxform = str\n with self.path.open() as f:\n@@ -236,9 +248,38 @@ def data(self):\n # See this similar issue in mr.roboto:\n # https://github.com/plone/mr.roboto/issues/89\n config["buildout"]["directory"] = os.getcwd()\n+ return config\n+\n+ @cached_property\n+ def raw_config(self):\n+ # Read the same data, but without interpolation.\n+ # So keep a url like \'${settings:plone}/package.git\'\n+ config = ConfigParser()\n+ config.optionxform = str\n+ with self.path.open() as f:\n+ config.read_file(f)\n+ return config\n+\n+ @property\n+ def extends(self):\n+ if self.config.has_section("buildout"):\n+ return self.config["buildout"].get("extends", "").strip().splitlines()\n+ return []\n+\n+ @property\n+ def data(self):\n+ sources_dict = OrderedDict()\n+ # I don\'t think we need to support [sources:marker].\n+ for name, value in self.config["sources"].items():\n+ source = Source.create_from_string(value)\n+ sources_dict[name.lower()] = source\n+ return sources_dict\n+\n+ @property\n+ def raw_data(self):\n sources_dict = OrderedDict()\n # I don\'t think we need to support [sources:marker].\n- for name, value in config["sources"].items():\n+ for name, value in self.raw_config["sources"].items():\n source = Source.create_from_string(value)\n sources_dict[name.lower()] = source\n return sources_dict\n@@ -246,6 +287,26 @@ def data(self):\n def __setitem__(self, package_name, value):\n raise NotImplementedError\n \n+ def rewrite(self):\n+ """Rewrite the file based on the parsed data.\n+\n+ This will lose comments, and may change the order.\n+ """\n+ contents = []\n+ # TODO add [buildout], extends, docs-directory.\n+ # Definitions of remotes could be good.\n+ # But we might skip this all, as it is a one-off exercise.\n+ # Or keep all existing text until [sources].\n+ contents.append("[buildout]")\n+ contents.append("")\n+ contents.append("[sources]")\n+ for name, source in self.raw_data.items():\n+ contents.append(f"{name} = {str(source)}")\n+\n+ contents.append("")\n+ new_contents = "\\n".join(contents)\n+ self.path.write_text(new_contents)\n+\n \n class CheckoutsFile(BaseFile):\n @cached_property\ndiff --git a/plone/releaser/tests/test_buildout.py b/plone/releaser/tests/test_buildout.py\nindex 37a98e9..8d4cc62 100644\n--- a/plone/releaser/tests/test_buildout.py\n+++ b/plone/releaser/tests/test_buildout.py\n@@ -195,6 +195,32 @@ def test_sources_file_get():\n assert base.egg\n \n \n+def test_sources_file_rewrite(tmp_path):\n+ copy_path = tmp_path / "sources.cfg"\n+ shutil.copyfile(SOURCES_FILE, copy_path)\n+ sf = SourcesFile(copy_path)\n+ sf.rewrite()\n+ # Read it fresh and compare\n+ sf2 = SourcesFile(copy_path)\n+ assert sf.raw_data == sf2.raw_data\n+ # TODO re-enable this test after including the remotes:\n+ # assert sf.data == sf2.data\n+ # Some differences compared with the original:\n+ # - We always specify the branch.\n+ # - The order of the options may be different.\n+ assert (\n+ copy_path.read_text()\n+ == """[buildout]\n+\n+[sources]\n+docs = git ${remotes:plone}/documentation.git branch=6.0 path=${buildout:docs-directory} egg=false\n+plone = git ${remotes:plone}/Plone.git pushurl=${remotes:plone_push}/Plone.git branch=6.0.x\n+plone.alterego = git ${remotes:plone}/plone.alterego.git branch=master\n+plone.base = git ${remotes:plone}/plone.base.git branch=main\n+"""\n+ )\n+\n+\n def test_versions_file_versions():\n vf = VersionsFile(VERSIONS_FILE)\n # All versions are reported lowercased.\n' + +Repository: plone.releaser + + +Branch: refs/heads/master +Date: 2023-11-16T18:50:15+01:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/d03ac9a24f9e10058d0df5821255d5f3719d386a + +SourcesFile.rewrite: take all non-sources sections. + +Files changed: +M plone/releaser/buildout.py +M plone/releaser/tests/test_buildout.py + +b'diff --git a/plone/releaser/buildout.py b/plone/releaser/buildout.py\nindex 44462d4..bc93dec 100644\n--- a/plone/releaser/buildout.py\n+++ b/plone/releaser/buildout.py\n@@ -5,6 +5,7 @@\n from configparser import ConfigParser\n from configparser import ExtendedInterpolation\n from functools import cached_property\n+from textwrap import indent\n \n import os\n import pathlib\n@@ -293,12 +294,21 @@ def rewrite(self):\n This will lose comments, and may change the order.\n """\n contents = []\n- # TODO add [buildout], extends, docs-directory.\n- # Definitions of remotes could be good.\n- # But we might skip this all, as it is a one-off exercise.\n- # Or keep all existing text until [sources].\n- contents.append("[buildout]")\n- contents.append("")\n+ # First rewrite all existing sections except [sources].\n+ for part, section in self.raw_config.items():\n+ if part in ("sources", "DEFAULT"):\n+ continue\n+ contents.append(f"[{part}]")\n+ for key, value in section.items():\n+ if value.startswith("\\n"):\n+ contents.append(f"{key} =")\n+ value = indent(value.strip(), " ")\n+ contents.append(value)\n+ else:\n+ contents.append(f"{key} = {value}")\n+ contents.append("")\n+\n+ # Now handle the sources.\n contents.append("[sources]")\n for name, source in self.raw_data.items():\n contents.append(f"{name} = {str(source)}")\ndiff --git a/plone/releaser/tests/test_buildout.py b/plone/releaser/tests/test_buildout.py\nindex 8d4cc62..fcf089c 100644\n--- a/plone/releaser/tests/test_buildout.py\n+++ b/plone/releaser/tests/test_buildout.py\n@@ -203,14 +203,20 @@ def test_sources_file_rewrite(tmp_path):\n # Read it fresh and compare\n sf2 = SourcesFile(copy_path)\n assert sf.raw_data == sf2.raw_data\n- # TODO re-enable this test after including the remotes:\n- # assert sf.data == sf2.data\n+ assert sf.data == sf2.data\n # Some differences compared with the original:\n # - We always specify the branch.\n # - The order of the options may be different.\n assert (\n copy_path.read_text()\n == """[buildout]\n+extends =\n+ https://raw.githubusercontent.com/zopefoundation/Zope/master/sources.cfg\n+docs-directory = ${buildout:directory}/documentation\n+\n+[remotes]\n+plone = https://github.com/plone\n+plone_push = git@github.com:plone\n \n [sources]\n docs = git ${remotes:plone}/documentation.git branch=6.0 path=${buildout:docs-directory} egg=false\n' + +Repository: plone.releaser + + +Branch: refs/heads/master +Date: 2023-11-16T19:34:08+01:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/9a2ba07110ed0d35fe9db4a3617c8373101bb009 + +Get versions2constraints working for simple cases. + +Files changed: +A plone/releaser/tests/test_versions2constraints.py +M plone/releaser/manage.py + +b'diff --git a/plone/releaser/manage.py b/plone/releaser/manage.py\nindex 8837041..2c70272 100644\n--- a/plone/releaser/manage.py\n+++ b/plone/releaser/manage.py\n@@ -260,13 +260,15 @@ def versions2constraints(*, path=None):\n paths = glob.glob("versions*.cfg")\n for path in paths:\n versions = VersionsFile(path)\n- constraints_path = path.replace("versions", "constraints").replace(\n+ # Create path to constraints*.txt instead of versions*.cfg.\n+ filename = str(path)[len(str(path.parent)) + 1:]\n+ filename = filename.replace("versions", "constraints").replace(\n ".cfg", ".txt"\n )\n+ constraints_path = path.parent / filename\n constraints = ConstraintsFile(constraints_path)\n if not constraints.path.exists():\n- with constraints.path.open("w") as myfile:\n- myfile.write("")\n+ constraints.path.touch()\n for package_name, version in versions.items():\n constraints[package_name] = version\n \ndiff --git a/plone/releaser/tests/test_versions2constraints.py b/plone/releaser/tests/test_versions2constraints.py\nnew file mode 100644\nindex 0000000..246a53a\n--- /dev/null\n+++ b/plone/releaser/tests/test_versions2constraints.py\n@@ -0,0 +1,36 @@\n+from plone.releaser.manage import versions2constraints\n+\n+import os\n+import pathlib\n+import pytest\n+import shutil\n+\n+\n+TESTS_DIR = pathlib.Path(__file__).parent\n+INPUT_DIR = TESTS_DIR / "input"\n+VERSIONS_FILE = INPUT_DIR / "versions.cfg"\n+VERSIONS_FILE2 = INPUT_DIR / "versions2.cfg"\n+VERSIONS_FILE3 = INPUT_DIR / "versions3.cfg"\n+VERSIONS_FILE4 = INPUT_DIR / "versions4.cfg"\n+\n+\n+def test_versions2constraints(tmp_path):\n+ copy_path = tmp_path / "versions.cfg"\n+ constraints_file = tmp_path / "constraints.txt"\n+ shutil.copyfile(VERSIONS_FILE, copy_path)\n+ assert not constraints_file.exists()\n+ versions2constraints(path=copy_path)\n+ assert constraints_file.exists()\n+ # TODO: we should include versions with markers:\n+ # pyspecific==2.0; python_version=="3.12"\n+ # onepython==2.1; python_version=="3.12"\n+ assert (\n+ constraints_file.read_text()\n+ == """annotated==1.0\n+camelcase==1.0\n+duplicate==1.0\n+lowercase==1.0\n+package==1.0\n+pyspecific==1.0\n+uppercase==1.0\n+""")\n' + +Repository: plone.releaser + + +Branch: refs/heads/master +Date: 2023-11-16T19:59:26+01:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/b718af04079eff3820bfd2cb1c8a2043379d4d2d + +Add BaseBuildoutFile and let the others inherit from it. + +Then everything has a config, raw_config, and extends. + +Files changed: +M plone/releaser/buildout.py + +b'diff --git a/plone/releaser/buildout.py b/plone/releaser/buildout.py\nindex bc93dec..e9bb1ff 100644\n--- a/plone/releaser/buildout.py\n+++ b/plone/releaser/buildout.py\n@@ -68,7 +68,7 @@ def __eq__(self, other):\n return repr(self) == repr(other)\n \n \n-class VersionsFile(BaseFile):\n+class BaseBuildoutFile(BaseFile):\n def __init__(self, file_location, with_markers=False, read_extends=False):\n self.file_location = file_location\n self.path = pathlib.Path(self.file_location).resolve()\n@@ -78,7 +78,31 @@ def __init__(self, file_location, with_markers=False, read_extends=False):\n \n @cached_property\n def config(self):\n+ # For versions.cfg we had strict=False, for the others not.\n+ # Let\'s use it always.\n config = ConfigParser(interpolation=ExtendedInterpolation(), strict=False)\n+ # In SourcesFile we had this:\n+ # config.optionxform = str\n+ # This seems to make everything lowercase, and makes tests fail.\n+ # TODO: we could use it if we choose.\n+ with self.path.open() as f:\n+ config.read_file(f)\n+ # Especially in sources.cfg we may need to define a few extra variables\n+ # that are in a different buildout file that we do not parse here.\n+ # See this similar issue in mr.roboto:\n+ # https://github.com/plone/mr.roboto/issues/89\n+ if not config.has_section("buildout"):\n+ config.add_section("buildout")\n+ if not config.has_option("buildout", "directory"):\n+ config["buildout"]["directory"] = os.getcwd()\n+ return config\n+\n+ @cached_property\n+ def raw_config(self):\n+ # Read the same data, but without interpolation.\n+ # So keep a url like \'${settings:plone}/package.git\'\n+ config = ConfigParser(strict=False)\n+ # config.optionxform = str\n with self.path.open() as f:\n config.read_file(f)\n return config\n@@ -89,6 +113,8 @@ def extends(self):\n return self.config["buildout"].get("extends", "").strip().splitlines()\n return []\n \n+\n+class VersionsFile(BaseBuildoutFile):\n @property\n def data(self):\n """Read the versions config.\n@@ -237,36 +263,7 @@ def rewrite(self):\n self.path.write_text(new_contents)\n \n \n-class SourcesFile(BaseFile):\n- @cached_property\n- def config(self):\n- config = ConfigParser(interpolation=ExtendedInterpolation())\n- config.optionxform = str\n- with self.path.open() as f:\n- config.read_file(f)\n- # We need to define a few extra variables that are in a different\n- # buildout file that we do not parse here.\n- # See this similar issue in mr.roboto:\n- # https://github.com/plone/mr.roboto/issues/89\n- config["buildout"]["directory"] = os.getcwd()\n- return config\n-\n- @cached_property\n- def raw_config(self):\n- # Read the same data, but without interpolation.\n- # So keep a url like \'${settings:plone}/package.git\'\n- config = ConfigParser()\n- config.optionxform = str\n- with self.path.open() as f:\n- config.read_file(f)\n- return config\n-\n- @property\n- def extends(self):\n- if self.config.has_section("buildout"):\n- return self.config["buildout"].get("extends", "").strip().splitlines()\n- return []\n-\n+class SourcesFile(BaseBuildoutFile):\n @property\n def data(self):\n sources_dict = OrderedDict()\n@@ -318,15 +315,7 @@ def rewrite(self):\n self.path.write_text(new_contents)\n \n \n-class CheckoutsFile(BaseFile):\n- @cached_property\n- def config(self):\n- config = ConfigParser(interpolation=ExtendedInterpolation())\n- with self.path.open() as f:\n- config.read_file(f)\n- config["buildout"]["directory"] = os.getcwd()\n- return config\n-\n+class CheckoutsFile(BaseBuildoutFile):\n @property\n def always_checkout(self):\n return self.config.get("buildout", "always-checkout")\n' + +Repository: plone.releaser + + +Branch: refs/heads/master +Date: 2023-11-16T23:26:00+01:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/28983c6c5194a6fa258ba0feac1ce87cee356f9c + +Get versions2constraints working + +Files changed: +M plone/releaser/buildout.py +M plone/releaser/manage.py +M plone/releaser/pip.py +M plone/releaser/tests/test_utils.py +M plone/releaser/tests/test_versions2constraints.py +M plone/releaser/utils.py + +b'diff --git a/plone/releaser/buildout.py b/plone/releaser/buildout.py\nindex e9bb1ff..5dc5aeb 100644\n--- a/plone/releaser/buildout.py\n+++ b/plone/releaser/buildout.py\n@@ -1,4 +1,5 @@\n from .base import BaseFile\n+from .utils import buildout_marker_to_pip_marker\n from .utils import update_contents\n from collections import defaultdict\n from collections import OrderedDict\n@@ -262,6 +263,88 @@ def rewrite(self):\n new_contents = "\\n".join(contents)\n self.path.write_text(new_contents)\n \n+ def extends_to_pip(self):\n+ """Translate our extends data to pip.\n+\n+ We assume that all \'extends\' lines are files with versions,\n+ and that a constraints file is at the same place.\n+ """\n+ if not self.extends:\n+ return []\n+ if self.read_extends:\n+ # We incorporate the versions of the extended files in our own,\n+ # so we do not need the extends.\n+ return []\n+\n+ new_extends = []\n+ for extend in self.extends:\n+ parts = extend.split("/")\n+ parent = "/".join(parts[:-1])\n+ extend = parts[-1]\n+ extend = extend.replace("versions", "constraints").replace(".cfg", ".txt")\n+ if parent:\n+ extend = "/".join([parent, extend])\n+ new_extends.append(extend)\n+\n+ return new_extends\n+\n+ def pins_to_pip(self):\n+ """Translate our version pins to pip.\n+\n+ There is just one thing to do: translate buildout-specific markers\n+ to ones that pip understands.\n+ Note that the other way around is no problem: Buildout can meanwhile\n+ understand the pip markers.\n+\n+ An option would be to always do this for Buildout as well.\n+ Or have a command to normalize a buildout file, with this and other\n+ small changes like making all package named lower case.\n+ """\n+ new_data = {}\n+ for package, version in self.data.items():\n+ if isinstance(version, str):\n+ new_data[package] = version\n+ continue\n+ # version is a dict\n+ new_version = {}\n+ for marker, value in version.items():\n+ if not marker:\n+ new_version[marker] = value\n+ continue\n+ # If this is a Buildout-specific marker, we need to translate it.\n+ new_marker = buildout_marker_to_pip_marker(marker)\n+ new_version[new_marker] = value\n+ new_data[package] = new_version\n+ return new_data\n+\n+ def to_constraints(self, constraints_path):\n+ """Overwrite constraints file with our data.\n+\n+ The strategy is:\n+\n+ 1. Translate our data to constraints data.\n+ 2. Ask the constraints file to rewrite itself.\n+ """\n+ # Import here to avoid circular imports.\n+ from plone.releaser.pip import ConstraintsFile\n+\n+ constraints = ConstraintsFile(\n+ constraints_path,\n+ with_markers=self.with_markers,\n+ read_extends=self.read_extends,\n+ )\n+ # Create or empty the constraints file.\n+ constraints.path.write_text("")\n+\n+ # Translate our extends to pip.\n+ constraints.extends = self.extends_to_pip()\n+\n+ # Translate our version pins to pip.\n+ constraints.data = self.pins_to_pip()\n+\n+ # Rewrite the file.\n+ constraints.rewrite()\n+\n \n class SourcesFile(BaseBuildoutFile):\n @property\ndiff --git a/plone/releaser/manage.py b/plone/releaser/manage.py\nindex 2c70272..661155a 100644\n--- a/plone/releaser/manage.py\n+++ b/plone/releaser/manage.py\n@@ -259,18 +259,13 @@ def versions2constraints(*, path=None):\n else:\n paths = glob.glob("versions*.cfg")\n for path in paths:\n- versions = VersionsFile(path)\n+ versions = VersionsFile(path, with_markers=True)\n # Create path to constraints*.txt instead of versions*.cfg.\n- filename = str(path)[len(str(path.parent)) + 1:]\n- filename = filename.replace("versions", "constraints").replace(\n- ".cfg", ".txt"\n- )\n- constraints_path = path.parent / filename\n- constraints = ConstraintsFile(constraints_path)\n- if not constraints.path.exists():\n- constraints.path.touch()\n- for package_name, version in versions.items():\n- constraints[package_name] = version\n+ filepath = versions.path\n+ filename = str(filepath)[len(str(filepath.parent)) + 1 :]\n+ filename = filename.replace("versions", "constraints").replace(".cfg", ".txt")\n+ constraints_path = filepath.parent / filename\n+ versions.to_constraints(constraints_path)\n \n \n class Manage:\ndiff --git a/plone/releaser/pip.py b/plone/releaser/pip.py\nindex 9cd0135..0dda487 100644\n--- a/plone/releaser/pip.py\n+++ b/plone/releaser/pip.py\n@@ -21,11 +21,10 @@ def __init__(self, file_location, with_markers=False, read_extends=False):\n self.file_location = file_location\n self.path = pathlib.Path(self.file_location).resolve()\n self.with_markers = with_markers\n- self.markers = set()\n self.read_extends = read_extends\n self._extends = []\n \n- @property\n+ @cached_property\n def extends(self):\n # Getting the data fills self._extends.\n _ignored = self.data # noqa F841\ndiff --git a/plone/releaser/tests/test_utils.py b/plone/releaser/tests/test_utils.py\nindex 0a8927e..bf8af86 100644\n--- a/plone/releaser/tests/test_utils.py\n+++ b/plone/releaser/tests/test_utils.py\n@@ -9,6 +9,24 @@\n VERSIONS = (INPUT_DIR / "versions.cfg").read_text()\n \n \n+def test_buildout_marker_to_pip_marker():\n+ from plone.releaser.utils import buildout_marker_to_pip_marker as trans\n+\n+ assert trans("") == ""\n+ assert trans("unknown") == "unknown"\n+ assert trans(\'python_version == "3.8"\') == \'python_version == "3.8"\'\n+ assert trans(\'platform_system == "Linux"\') == \'platform_system == "Linux"\'\n+ assert trans("python2") == \'python_version < "3"\'\n+ assert trans("python3") == \'python_version >= "3"\'\n+ assert trans("python27") == \'python_version == "2.7"\'\n+ assert trans("python38") == \'python_version == "3.8"\'\n+ assert trans("python313") == \'python_version == "3.13"\'\n+ assert trans("pypy") == \'implementation_name == "pypy"\'\n+ assert trans("linux") == \'platform_system == "Linux"\'\n+ assert trans("macosx") == \'platform_system == "Darwin"\'\n+ assert trans("windows") == \'platform_system == "Windows"\'\n+\n+\n def test_update_contents_empty():\n assert update_contents("\\n", lambda x: True, "", "") == "\\n"\n \ndiff --git a/plone/releaser/tests/test_versions2constraints.py b/plone/releaser/tests/test_versions2constraints.py\nindex 246a53a..2e19522 100644\n--- a/plone/releaser/tests/test_versions2constraints.py\n+++ b/plone/releaser/tests/test_versions2constraints.py\n@@ -1,6 +1,6 @@\n from plone.releaser.manage import versions2constraints\n+from plone.releaser.pip import ConstraintsFile\n \n-import os\n import pathlib\n import pytest\n import shutil\n@@ -14,6 +14,7 @@\n VERSIONS_FILE4 = INPUT_DIR / "versions4.cfg"\n \n \n+@pytest.mark.current\n def test_versions2constraints(tmp_path):\n copy_path = tmp_path / "versions.cfg"\n constraints_file = tmp_path / "constraints.txt"\n@@ -21,16 +22,19 @@ def test_versions2constraints(tmp_path):\n assert not constraints_file.exists()\n versions2constraints(path=copy_path)\n assert constraints_file.exists()\n- # TODO: we should include versions with markers:\n- # pyspecific==2.0; python_version=="3.12"\n- # onepython==2.1; python_version=="3.12"\n+ cf = ConstraintsFile(constraints_file, with_markers=True)\n+ print(cf.data)\n assert (\n constraints_file.read_text()\n- == """annotated==1.0\n+ == """-c https://zopefoundation.github.io/Zope/releases/5.8.3/constraints.txt\n+annotated==1.0\n camelcase==1.0\n duplicate==1.0\n lowercase==1.0\n package==1.0\n pyspecific==1.0\n+pyspecific==2.0; python_version == "3.12"\n uppercase==1.0\n-""")\n+onepython==2.1; python_version == "3.12"\n+"""\n+ )\ndiff --git a/plone/releaser/utils.py b/plone/releaser/utils.py\nindex 41f93c7..324f618 100644\n--- a/plone/releaser/utils.py\n+++ b/plone/releaser/utils.py\n@@ -1,3 +1,77 @@\n+def buildout_marker_to_pip_marker(marker):\n+ """Translate a Buildout marker to a pip marker.\n+\n+ Example:\n+\n+ [versions:python38]\n+ package = 1.0\n+\n+ This translates to:\n+\n+ package==1.0; python_version == "3.8"\n+\n+ The Buildout markers are defined here:\n+ https://github.com/buildout/buildout/blob/3.0.1/src/zc/buildout/buildout.py#L1724\n+\n+ The pip markers are defined in PEP-508:\n+ https://peps.python.org/pep-0508/#environment-markers\n+\n+ It seems hard to translate *all* possible markers.\n+ But we can do the ones used in the Plone core development buildout.\n+ Even with those, I do not see a 100% correct translation between the two.\n+\n+ Buildout supports the pip markers natively since version 3.0.0, so you can write:\n+\n+ [versions:python_version == "3.8"]\n+ package = 1.0\n+\n+ See https://github.com/buildout/buildout/pull/622\n+ """\n+ if not marker:\n+ return marker\n+\n+ # Python versions\n+ if marker.startswith("python"):\n+ if marker.startswith("python_version"):\n+ # already a pip marker\n+ return marker\n+ if marker == "python2":\n+ return \'python_version < "3"\'\n+ if marker == "python3":\n+ return \'python_version >= "3"\'\n+ version = marker[len("python") :]\n+ major = version[0]\n+ minor = version[1:]\n+ return f\'python_version == "{major}.{minor}"\'\n+\n+ # Python implementations\n+ if marker in ("cpython", "pypy", "jython", "ironpython"):\n+ # Buildout checks sys.version.lower().\n+ # pip uses the equivalent of sys.implementation.name.\n+ return f\'implementation_name == "{marker}"\'\n+\n+ # system platforms\n+ # Buildout mostly uses str(sys.platform).lower() and then has a mapping to more\n+ # common names. For pip, platform_system seems best in most cases.\n+ if marker == "linux":\n+ return \'platform_system == "Linux"\'\n+ if marker == "macosx":\n+ return \'platform_system == "Darwin"\'\n+ if marker == "windows":\n+ return \'platform_system == "Windows"\'\n+ if marker == "solaris":\n+ return \'platform_system == "SunOS"\'\n+ if marker == "posix":\n+ return \'os_name == "posix"\'\n+ if marker == "cygwin":\n+ return \'sys_platform == "cygwin"\'\n+\n+ # We are missing a few, like bits64 and big_endian.\n+ # Or this is an invalid marker.\n+ # Or this already is a pip marker.\n+ return marker\n+\n+\n def update_contents(\n contents, line_check, newline, filename, start_check=None, stop_check=None\n ):\n' + +Repository: plone.releaser + + +Branch: refs/heads/master +Date: 2023-11-17T00:24:47+01:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/004aed8ae276f737fb5d14f9999ddc6698775e5e + +Add news snippet: Add bin/manage versions2constraints command. + +Files changed: +A news/3670.feature + +b'diff --git a/news/3670.feature b/news/3670.feature\nnew file mode 100644\nindex 0000000..2bde7d5\n--- /dev/null\n+++ b/news/3670.feature\n@@ -0,0 +1,2 @@\n+Add bin/manage versions2constraints command.\n+[maurits]\n' + +Repository: plone.releaser + + +Branch: refs/heads/master +Date: 2023-11-30T20:12:59+01:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/c59ad3f8766a9d9289f142dd48d637cbc824fca1 + +Merge pull request #62 from plone/maurits-buildout2pip + +Add bin/manage versions2constraints command. + +Files changed: +A news/3670.feature +A plone/releaser/tests/input/constraints2.txt +A plone/releaser/tests/input/constraints3.txt +A plone/releaser/tests/input/constraints4.txt +A plone/releaser/tests/input/versions2.cfg +A plone/releaser/tests/input/versions3.cfg +A plone/releaser/tests/input/versions4.cfg +A plone/releaser/tests/test_versions2constraints.py +M plone/releaser/buildout.py +M plone/releaser/manage.py +M plone/releaser/pip.py +M plone/releaser/tests/input/constraints.txt +M plone/releaser/tests/input/versions.cfg +M plone/releaser/tests/test_buildout.py +M plone/releaser/tests/test_pip.py +M plone/releaser/tests/test_utils.py +M plone/releaser/utils.py + +b'diff --git a/news/3670.feature b/news/3670.feature\nnew file mode 100644\nindex 0000000..2bde7d5\n--- /dev/null\n+++ b/news/3670.feature\n@@ -0,0 +1,2 @@\n+Add bin/manage versions2constraints command.\n+[maurits]\ndiff --git a/plone/releaser/buildout.py b/plone/releaser/buildout.py\nindex 15db5af..5dc5aeb 100644\n--- a/plone/releaser/buildout.py\n+++ b/plone/releaser/buildout.py\n@@ -1,8 +1,12 @@\n from .base import BaseFile\n+from .utils import buildout_marker_to_pip_marker\n from .utils import update_contents\n+from collections import defaultdict\n from collections import OrderedDict\n from configparser import ConfigParser\n from configparser import ExtendedInterpolation\n+from functools import cached_property\n+from textwrap import indent\n \n import os\n import pathlib\n@@ -49,15 +53,69 @@ def create_from_string(cls, source_string):\n def __repr__(self):\n return f""\n \n+ def __str__(self):\n+ line = f"{self.protocol} {self.url}"\n+ if self.pushurl:\n+ line += f" pushurl={self.pushurl}"\n+ if self.branch:\n+ line += f" branch={self.branch}"\n+ if self.path:\n+ line += f" path={self.path}"\n+ if not self.egg:\n+ line += " egg=false"\n+ return line\n+\n def __eq__(self, other):\n return repr(self) == repr(other)\n \n \n-class VersionsFile(BaseFile):\n- def __init__(self, file_location):\n+class BaseBuildoutFile(BaseFile):\n+ def __init__(self, file_location, with_markers=False, read_extends=False):\n self.file_location = file_location\n self.path = pathlib.Path(self.file_location).resolve()\n+ self.with_markers = with_markers\n+ self.markers = set()\n+ self.read_extends = read_extends\n+\n+ @cached_property\n+ def config(self):\n+ # For versions.cfg we had strict=False, for the others not.\n+ # Let\'s use it always.\n+ config = ConfigParser(interpolation=ExtendedInterpolation(), strict=False)\n+ # In SourcesFile we had this:\n+ # config.optionxform = str\n+ # This seems to make everything lowercase, and makes tests fail.\n+ # TODO: we could use it if we choose.\n+ with self.path.open() as f:\n+ config.read_file(f)\n+ # Especially in sources.cfg we may need to define a few extra variables\n+ # that are in a different buildout file that we do not parse here.\n+ # See this similar issue in mr.roboto:\n+ # https://github.com/plone/mr.roboto/issues/89\n+ if not config.has_section("buildout"):\n+ config.add_section("buildout")\n+ if not config.has_option("buildout", "directory"):\n+ config["buildout"]["directory"] = os.getcwd()\n+ return config\n+\n+ @cached_property\n+ def raw_config(self):\n+ # Read the same data, but without interpolation.\n+ # So keep a url like \'${settings:plone}/package.git\'\n+ config = ConfigParser(strict=False)\n+ # config.optionxform = str\n+ with self.path.open() as f:\n+ config.read_file(f)\n+ return config\n \n+ @property\n+ def extends(self):\n+ if self.config.has_section("buildout"):\n+ return self.config["buildout"].get("extends", "").strip().splitlines()\n+ return []\n+\n+\n+class VersionsFile(BaseBuildoutFile):\n @property\n def data(self):\n """Read the versions config.\n@@ -76,19 +134,50 @@ def data(self):\n plone.releaser to support such a corner case.\n \n So we do not want to report or edit anything except the versions section.\n+\n+ Ah, but we *do* need this information when translating to pip.\n+ For that: set self.with_markers = True.\n """\n- config = ConfigParser(interpolation=ExtendedInterpolation(), strict=False)\n- with self.path.open() as f:\n- config.read_file(f)\n- # https://github.com/plone/plone.releaser/issues/42\n- if config.has_section("buildout"):\n- config["buildout"]["directory"] = os.getcwd()\n- versions = {}\n- for section in config.sections():\n+ versions = defaultdict(dict)\n+ if self.config.has_section("buildout"):\n+ # https://github.com/plone/plone.releaser/issues/42\n+ self.config["buildout"]["directory"] = os.getcwd()\n+ if self.read_extends:\n+ # Recursively read the extended files, and include their versions.\n+ for extend in self.extends:\n+ # TODO: support downloading\n+ assert not extend.startswith("http")\n+ extended = VersionsFile(\n+ self.path.parent / extend,\n+ with_markers=self.with_markers,\n+ read_extends=True,\n+ )\n+ for package, version in extended.data.items():\n+ if not isinstance(version, dict):\n+ versions[package][""] = version\n+ else:\n+ versions[package].update(version)\n+\n+ for section in self.config.sections():\n if section == "versions":\n- for package, version in config[section].items():\n+ for package, version in self.config[section].items():\n # Note: the package names are lower case.\n- versions[package] = version\n+ versions[package][""] = version\n+ if not self.with_markers:\n+ continue\n+ parts = section.split(":")\n+ if len(parts) != 2 or parts[0] != "versions":\n+ continue\n+ marker = parts[1]\n+ self.markers.add(marker)\n+ for package, version in self.config[section].items():\n+ versions[package][marker] = version\n+\n+ # simplify\n+ for package, version in versions.items():\n+ if len(version) == 1 and "" in version.keys():\n+ versions[package] = version[""]\n+ continue\n return versions\n \n def __setitem__(self, package_name, new_version):\n@@ -98,6 +187,14 @@ def __setitem__(self, package_name, new_version):\n contents += "\\n"\n self.path.write_text(contents)\n \n+ if isinstance(new_version, tuple):\n+ new_version, marker = new_version\n+ section = f"[versions:{marker}]"\n+ if marker not in self.markers:\n+ contents = f"{contents}\\n{section}\\n"\n+ self.path.write_text(contents)\n+ else:\n+ section = "[versions]"\n newline = f"{package_name} = {new_version}"\n # Search case insensitively.\n line_reg = re.compile(rf"^{package_name} *=.*", flags=re.I)\n@@ -107,34 +204,163 @@ def line_check(line):\n # no whitespace in front. Maybe whitespace in between.\n return line_reg.match(line)\n \n+ def start_check(line):\n+ # If we see this line, we start trying to match.\n+ return line == section\n+\n def stop_check(line):\n # If we see this line, we should stop trying to match.\n- return line.startswith("[versionannotations]") or line.startswith(\n- "[versions:"\n- )\n+ return line.startswith("[")\n \n # set version in contents.\n new_contents = update_contents(\n- contents, line_check, newline, self.file_location, stop_check=stop_check\n+ contents,\n+ line_check,\n+ newline,\n+ self.file_location,\n+ start_check=start_check,\n+ stop_check=stop_check,\n )\n if contents != new_contents:\n self.path.write_text(new_contents)\n \n+ def rewrite(self):\n+ """Rewrite the file based on the parsed data.\n \n-class SourcesFile(BaseFile):\n+ This will lose comments, and may change the order.\n+ """\n+ contents = []\n+ if self.extends and not self.read_extends:\n+ # With read_extends=True, we incorporate the versions of the\n+ # extended files in our own, so we no longer need the extends.\n+ contents = ["[buildout]", "extends ="]\n+ for extend in self.extends:\n+ contents.append(f" {extend}")\n+ contents.append("")\n+\n+ contents.append("[versions]")\n+ markers = defaultdict(list)\n+ for package, version in self.data.items():\n+ if isinstance(version, str):\n+ contents.append(f"{package} = {version}")\n+ continue\n+ # version is a dict\n+ for marker, value in version.items():\n+ if not marker:\n+ # add to current [versions]\n+ contents.append(f"{package} = {value}")\n+ continue\n+ # add to markers to write at the end\n+ markers[marker].append((package, value))\n+\n+ for marker, entries in markers.items():\n+ contents.append("")\n+ contents.append(f"[versions:{marker}]")\n+ for package, version in entries:\n+ contents.append(f"{package} = {version}")\n+\n+ contents.append("")\n+ new_contents = "\\n".join(contents)\n+ self.path.write_text(new_contents)\n+\n+ def extends_to_pip(self):\n+ """Translate our extends data to pip.\n+\n+ We assume that all \'extends\' lines are files with versions,\n+ and that a constraints file is at the same place.\n+ """\n+ if not self.extends:\n+ return []\n+ if self.read_extends:\n+ # We incorporate the versions of the extended files in our own,\n+ # so we do not need the extends.\n+ return []\n+\n+ new_extends = []\n+ for extend in self.extends:\n+ parts = extend.split("/")\n+ parent = "/".join(parts[:-1])\n+ extend = parts[-1]\n+ extend = extend.replace("versions", "constraints").replace(".cfg", ".txt")\n+ if parent:\n+ extend = "/".join([parent, extend])\n+ new_extends.append(extend)\n+\n+ return new_extends\n+\n+ def pins_to_pip(self):\n+ """Translate our version pins to pip.\n+\n+ There is just one thing to do: translate buildout-specific markers\n+ to ones that pip understands.\n+ Note that the other way around is no problem: Buildout can meanwhile\n+ understand the pip markers.\n+\n+ An option would be to always do this for Buildout as well.\n+ Or have a command to normalize a buildout file, with this and other\n+ small changes like making all package named lower case.\n+ """\n+ new_data = {}\n+ for package, version in self.data.items():\n+ if isinstance(version, str):\n+ new_data[package] = version\n+ continue\n+ # version is a dict\n+ new_version = {}\n+ for marker, value in version.items():\n+ if not marker:\n+ new_version[marker] = value\n+ continue\n+ # If this is a Buildout-specific marker, we need to translate it.\n+ new_marker = buildout_marker_to_pip_marker(marker)\n+ new_version[new_marker] = value\n+ new_data[package] = new_version\n+ return new_data\n+\n+ def to_constraints(self, constraints_path):\n+ """Overwrite constraints file with our data.\n+\n+ The strategy is:\n+\n+ 1. Translate our data to constraints data.\n+ 2. Ask the constraints file to rewrite itself.\n+ """\n+ # Import here to avoid circular imports.\n+ from plone.releaser.pip import ConstraintsFile\n+\n+ constraints = ConstraintsFile(\n+ constraints_path,\n+ with_markers=self.with_markers,\n+ read_extends=self.read_extends,\n+ )\n+ # Create or empty the constraints file.\n+ constraints.path.write_text("")\n+\n+ # Translate our extends to pip.\n+ constraints.extends = self.extends_to_pip()\n+\n+ # Translate our version pins to pip.\n+ constraints.data = self.pins_to_pip()\n+\n+ # Rewrite the file.\n+ constraints.rewrite()\n+\n+\n+class SourcesFile(BaseBuildoutFile):\n @property\n def data(self):\n- config = ConfigParser(interpolation=ExtendedInterpolation())\n- config.optionxform = str\n- with self.path.open() as f:\n- config.read_file(f)\n- # We need to define a few extra variables that are in a different\n- # buildout file that we do not parse here.\n- # See this similar issue in mr.roboto:\n- # https://github.com/plone/mr.roboto/issues/89\n- config["buildout"]["directory"] = os.getcwd()\n sources_dict = OrderedDict()\n- for name, value in config["sources"].items():\n+ # I don\'t think we need to support [sources:marker].\n+ for name, value in self.config["sources"].items():\n+ source = Source.create_from_string(value)\n+ sources_dict[name.lower()] = source\n+ return sources_dict\n+\n+ @property\n+ def raw_data(self):\n+ sources_dict = OrderedDict()\n+ # I don\'t think we need to support [sources:marker].\n+ for name, value in self.raw_config["sources"].items():\n source = Source.create_from_string(value)\n sources_dict[name.lower()] = source\n return sources_dict\n@@ -142,15 +368,45 @@ def data(self):\n def __setitem__(self, package_name, value):\n raise NotImplementedError\n \n+ def rewrite(self):\n+ """Rewrite the file based on the parsed data.\n+\n+ This will lose comments, and may change the order.\n+ """\n+ contents = []\n+ # First rewrite all existing sections except [sources].\n+ for part, section in self.raw_config.items():\n+ if part in ("sources", "DEFAULT"):\n+ continue\n+ contents.append(f"[{part}]")\n+ for key, value in section.items():\n+ if value.startswith("\\n"):\n+ contents.append(f"{key} =")\n+ value = indent(value.strip(), " ")\n+ contents.append(value)\n+ else:\n+ contents.append(f"{key} = {value}")\n+ contents.append("")\n+\n+ # Now handle the sources.\n+ contents.append("[sources]")\n+ for name, source in self.raw_data.items():\n+ contents.append(f"{name} = {str(source)}")\n+\n+ contents.append("")\n+ new_contents = "\\n".join(contents)\n+ self.path.write_text(new_contents)\n+\n+\n+class CheckoutsFile(BaseBuildoutFile):\n+ @property\n+ def always_checkout(self):\n+ return self.config.get("buildout", "always-checkout")\n \n-class CheckoutsFile(BaseFile):\n @property\n def data(self):\n- config = ConfigParser(interpolation=ExtendedInterpolation())\n- with self.path.open() as f:\n- config.read_file(f)\n- config["buildout"]["directory"] = os.getcwd()\n- checkouts = config.get("buildout", "auto-checkout")\n+ # I don\'t think we need to support [buildout:marker].\n+ checkouts = self.config.get("buildout", "auto-checkout")\n # Map from lower case to actual case, so we can find the package.\n mapping = {}\n for package in checkouts.splitlines():\n@@ -183,6 +439,25 @@ def set(self, package_name, new_version):\n # This method makes no sense for this class.\n raise NotImplementedError\n \n+ def rewrite(self):\n+ """Rewrite the file based on the parsed data.\n+\n+ This will lose comments, and may change the order.\n+ """\n+ contents = ["[buildout]"]\n+ if self.always_checkout:\n+ contents.append(f"always-checkout = {self.always_checkout}"),\n+ contents.append("auto-checkout =")\n+ # self.values has the original case.\n+ # We could iterate over \'self\' to get lowercase,\n+ # which is what we get in most other places.\n+ # But for now let\'s use the info we have.\n+ for package in self.values():\n+ contents.append(f" {package}")\n+ contents.append("")\n+ new_contents = "\\n".join(contents)\n+ self.path.write_text(new_contents)\n+\n \n class Buildout:\n def __init__(\ndiff --git a/plone/releaser/manage.py b/plone/releaser/manage.py\nindex 2345b53..661155a 100644\n--- a/plone/releaser/manage.py\n+++ b/plone/releaser/manage.py\n@@ -112,7 +112,7 @@ def _get_checkouts(path=None):\n yield checkouts\n \n \n-def check_checkout(package_name, path=None):\n+def check_checkout(package_name, *, path=None):\n """Check if package is in the checkouts.\n \n If no path is given, we try several paths:\n@@ -126,7 +126,7 @@ def check_checkout(package_name, path=None):\n print(f"YES, your package {package_name} is on auto checkout in {loc}.")\n \n \n-def remove_checkout(package_name, path=None):\n+def remove_checkout(package_name, *, path=None):\n """Remove package from auto checkouts.\n \n If no path is given, we try several paths:\n@@ -136,7 +136,7 @@ def remove_checkout(package_name, path=None):\n checkouts.remove(package_name)\n \n \n-def add_checkout(package_name, path=None):\n+def add_checkout(package_name, *, path=None):\n """Add package to auto checkouts.\n \n If no path is given, we try several paths:\n@@ -175,7 +175,7 @@ def _get_constraints(path=None):\n yield constraints\n \n \n-def get_package_version(package_name, path=None):\n+def get_package_version(package_name, *, path=None):\n """Get package version from constraints/versions file.\n \n If no path is given, we try several paths.\n@@ -206,7 +206,7 @@ def get_package_version(package_name, path=None):\n print(f"{constraints.file_location}: {package_name} {version}.")\n \n \n-def set_package_version(package_name, new_version, path=None):\n+def set_package_version(package_name, new_version, *, path=None):\n """Pin package to new version in a versions file.\n \n This can also be a pip constraints file.\n@@ -231,6 +231,43 @@ def set_package_version(package_name, new_version, path=None):\n constraints.set(package_name, new_version)\n \n \n+def versions2constraints(*, path=None):\n+ """Take a Buildout versions file and create a pip constraints file out of it.\n+\n+ If no path is given, we use versions*.cfg.\n+\n+ Notes:\n+ * This does not handle \'extends\' yet.\n+ * This does not handle [versions:pythonX] yet.\n+\n+ We could parse the file with Buildout. This incorporates the \'extends\',\n+ but you lose versions information for other Python versions.\n+\n+ We could pass an option simple/full.\n+ Maybe if a path is passed, we handle only that file in simple mode.\n+ Without path, we grab versions.cfg and check \'extends\' and other versions.\n+\n+ \'extends = versions-extra.cfg\' could be transformed to \'-c constraints-extra.txt\'\n+\n+ I think I need some more options in VersionsFile first:\n+ - what to do with extends\n+ - what to do with [versions:*]\n+ - whether to turn it into a single constraints file.\n+ """\n+ if path:\n+ paths = [path]\n+ else:\n+ paths = glob.glob("versions*.cfg")\n+ for path in paths:\n+ versions = VersionsFile(path, with_markers=True)\n+ # Create path to constraints*.txt instead of versions*.cfg.\n+ filepath = versions.path\n+ filename = str(filepath)[len(str(filepath.parent)) + 1 :]\n+ filename = filename.replace("versions", "constraints").replace(".cfg", ".txt")\n+ constraints_path = filepath.parent / filename\n+ versions.to_constraints(constraints_path)\n+\n+\n class Manage:\n def __call__(self, **kwargs):\n parser = ArghParser()\n@@ -247,6 +284,7 @@ def __call__(self, **kwargs):\n set_package_version,\n get_package_version,\n jenkins_report,\n+ versions2constraints,\n ]\n )\n parser.dispatch()\ndiff --git a/plone/releaser/pip.py b/plone/releaser/pip.py\nindex 2c8cb95..0dda487 100644\n--- a/plone/releaser/pip.py\n+++ b/plone/releaser/pip.py\n@@ -1,9 +1,10 @@\n from .base import BaseFile\n from .utils import update_contents\n+from collections import defaultdict\n from configparser import ConfigParser\n-from configparser import ExtendedInterpolation\n from functools import cached_property\n \n+import pathlib\n import re\n \n \n@@ -16,36 +17,73 @@ def to_bool(value):\n \n \n class ConstraintsFile(BaseFile):\n+ def __init__(self, file_location, with_markers=False, read_extends=False):\n+ self.file_location = file_location\n+ self.path = pathlib.Path(self.file_location).resolve()\n+ self.with_markers = with_markers\n+ self.read_extends = read_extends\n+ self._extends = []\n+\n+ @cached_property\n+ def extends(self):\n+ # Getting the data fills self._extends.\n+ _ignored = self.data # noqa F841\n+ return self._extends\n+\n @cached_property\n def data(self):\n """Read the constraints."""\n contents = self.path.read_text()\n- constraints = {}\n+ constraints = defaultdict(dict)\n for line in contents.splitlines():\n line = line.strip()\n if line.startswith("#"):\n continue\n+ if line.startswith("-c"):\n+ extend = line[len("-c") :].strip()\n+ self._extends.append(extend)\n+ if self.read_extends:\n+ # TODO: support downloading\n+ assert not extend.startswith("http")\n+ # Recursively read the extended files, and include their versions.\n+ extended = ConstraintsFile(\n+ self.path.parent / extend,\n+ with_markers=self.with_markers,\n+ read_extends=True,\n+ )\n+ for package, version in extended.data.items():\n+ if not isinstance(version, dict):\n+ constraints[package][""] = version\n+ else:\n+ constraints[package].update(version)\n+ continue\n if "==" not in line:\n # We might want to support e.g. \'>=\', but for now keep it simple.\n continue\n package = line.split("==")[0].strip().lower()\n- version = line.split("==")[1].strip()\n+ version = line.split("==", 1)[1].strip()\n # The line could also contain environment markers like this:\n # "; python_version >= \'3.0\'"\n # But currently I think we really only need the package name,\n # and not even the version. Let\'s use the entire rest of the line.\n # Actually, for our purposes, we should ignore lines that have such\n # markers, just like we do in buildout.py:VersionsFile.\n- if ";" in version:\n+ if ";" not in version:\n+ constraints[package][""] = version\n continue\n- if package in constraints:\n- if constraints[package] != version:\n- print(\n- f"ERROR: {package} is in {self.file_location} with two "\n- f"constraints: \'{constraints[package]}\' and \'{version}\'."\n- )\n+ if not self.with_markers:\n+ continue\n+ version, marker = version.split(";")\n+ version = version.strip()\n+ marker = marker.strip()\n+ constraints[package][marker] = version\n+\n+ # simplify\n+ for package, version in constraints.items():\n+ if len(version) == 1 and "" in version.keys():\n+ constraints[package] = version[""]\n continue\n- constraints[package] = version\n+\n return constraints\n \n def __setitem__(self, package_name, new_version):\n@@ -69,6 +107,33 @@ def line_check(line):\n if contents != new_contents:\n self.path.write_text(new_contents)\n \n+ def rewrite(self):\n+ """Rewrite the file based on the parsed data.\n+\n+ This will lose comments, and may change the order.\n+ """\n+ contents = []\n+ if self.extends and not self.read_extends:\n+ # With read_extends=True, we incorporate the versions of the\n+ # extended files in our own, so we no longer need the extends.\n+ for extend in self.extends:\n+ contents.append(f"-c {extend}")\n+\n+ for package, version in self.data.items():\n+ if isinstance(version, str):\n+ contents.append(f"{package}=={version}")\n+ continue\n+ # version is a dict\n+ for marker, value in version.items():\n+ line = f"{package}=={value}"\n+ if marker:\n+ line += f"; {marker}"\n+ contents.append(line)\n+\n+ contents.append("")\n+ new_contents = "\\n".join(contents)\n+ self.path.write_text(new_contents)\n+\n \n class IniFile(BaseFile):\n """Ini file for mxdev.\n@@ -83,8 +148,13 @@ def __init__(self, file_location):\n super().__init__(file_location)\n self.config = ConfigParser(\n default_section="settings",\n- interpolation=ExtendedInterpolation(),\n )\n+ # mxdev itself calls ConfigParser with extra option\n+ # interpolation=ExtendedInterpolation().\n+ # This turns a line like \'url = ${settings:plone}/package.git\'\n+ # into \'url = https://github.com/plone/package.git\'.\n+ # In our case we very much want the original line,\n+ # especially when we do a rewrite of the file.\n with self.path.open() as f:\n self.config.read_file(f)\n self.default_use = to_bool(self.config["settings"].get("default-use", True))\n@@ -185,3 +255,25 @@ def __setitem__(self, package_name, enabled=True):\n \n contents = "\\n".join(lines)\n self.path.write_text(contents)\n+\n+ def rewrite(self):\n+ """Rewrite the file based on the parsed data.\n+\n+ This will lose comments, and may change the order.\n+ TODO Can we trust self.config? It won\'t get updated if we change any data\n+ after reading.\n+ """\n+ contents = ["[settings]"]\n+ for key, value in self.config["settings"].items():\n+ contents.append(f"{key} = {value}")\n+\n+ for package in self.sections.values():\n+ contents.append("")\n+ contents.append(f"[{package}]")\n+ for key, value in self.config[package].items():\n+ if self.config["settings"].get(key) != value:\n+ contents.append(f"{key} = {value}")\n+\n+ contents.append("")\n+ new_contents = "\\n".join(contents)\n+ self.path.write_text(new_contents)\ndiff --git a/plone/releaser/tests/input/constraints.txt b/plone/releaser/tests/input/constraints.txt\nindex 6c401aa..afff783 100644\n--- a/plone/releaser/tests/input/constraints.txt\n+++ b/plone/releaser/tests/input/constraints.txt\n@@ -9,4 +9,5 @@ lowercase==1.0\n package==1.0\n pyspecific==1.0\n pyspecific==2.0; python_version=="3.12"\n+onepython==2.1; python_version=="3.12"\n UPPERCASE==1.0\ndiff --git a/plone/releaser/tests/input/constraints2.txt b/plone/releaser/tests/input/constraints2.txt\nnew file mode 100644\nindex 0000000..c0a21a0\n--- /dev/null\n+++ b/plone/releaser/tests/input/constraints2.txt\n@@ -0,0 +1,4 @@\n+-c constraints3.txt\n+one==1.1\n+two==2.0\n+three==3.2; python_version=="3.12"\ndiff --git a/plone/releaser/tests/input/constraints3.txt b/plone/releaser/tests/input/constraints3.txt\nnew file mode 100644\nindex 0000000..17353f3\n--- /dev/null\n+++ b/plone/releaser/tests/input/constraints3.txt\n@@ -0,0 +1,4 @@\n+-c constraints4.txt\n+one==1.0\n+three==3.0\n+three==3.1; python_version=="3.12"\ndiff --git a/plone/releaser/tests/input/constraints4.txt b/plone/releaser/tests/input/constraints4.txt\nnew file mode 100644\nindex 0000000..f55d689\n--- /dev/null\n+++ b/plone/releaser/tests/input/constraints4.txt\n@@ -0,0 +1,2 @@\n+four==4.0\n+five==5.0; platform_system == \'darwin\'\ndiff --git a/plone/releaser/tests/input/versions.cfg b/plone/releaser/tests/input/versions.cfg\nindex 15b43b0..3b01ac1 100644\n--- a/plone/releaser/tests/input/versions.cfg\n+++ b/plone/releaser/tests/input/versions.cfg\n@@ -14,6 +14,7 @@ pyspecific = 1.0\n UPPERCASE = 1.0\n \n [versions:python312]\n+onepython = 2.1\n pyspecific = 2.0\n \n [versionannotations]\ndiff --git a/plone/releaser/tests/input/versions2.cfg b/plone/releaser/tests/input/versions2.cfg\nnew file mode 100644\nindex 0000000..a65029b\n--- /dev/null\n+++ b/plone/releaser/tests/input/versions2.cfg\n@@ -0,0 +1,9 @@\n+[buildout]\n+extends = versions3.cfg\n+\n+[versions]\n+one = 1.1\n+two = 2.0\n+\n+[versions:python312]\n+three = 3.2\ndiff --git a/plone/releaser/tests/input/versions3.cfg b/plone/releaser/tests/input/versions3.cfg\nnew file mode 100644\nindex 0000000..293a5d5\n--- /dev/null\n+++ b/plone/releaser/tests/input/versions3.cfg\n@@ -0,0 +1,10 @@\n+[buildout]\n+extends =\n+ versions4.cfg\n+\n+[versions]\n+one = 1.0\n+three = 3.0\n+\n+[versions:python312]\n+three = 3.1\ndiff --git a/plone/releaser/tests/input/versions4.cfg b/plone/releaser/tests/input/versions4.cfg\nnew file mode 100644\nindex 0000000..9fdd35a\n--- /dev/null\n+++ b/plone/releaser/tests/input/versions4.cfg\n@@ -0,0 +1,5 @@\n+[versions]\n+four = 4.0\n+\n+[versions:macosx]\n+five = 5.0\ndiff --git a/plone/releaser/tests/test_buildout.py b/plone/releaser/tests/test_buildout.py\nindex b231fbc..fcf089c 100644\n--- a/plone/releaser/tests/test_buildout.py\n+++ b/plone/releaser/tests/test_buildout.py\n@@ -14,6 +14,9 @@\n CHECKOUTS_FILE = INPUT_DIR / "checkouts.cfg"\n SOURCES_FILE = INPUT_DIR / "sources.cfg"\n VERSIONS_FILE = INPUT_DIR / "versions.cfg"\n+VERSIONS_FILE2 = INPUT_DIR / "versions2.cfg"\n+VERSIONS_FILE3 = INPUT_DIR / "versions3.cfg"\n+VERSIONS_FILE4 = INPUT_DIR / "versions4.cfg"\n \n \n def test_checkouts_file_data():\n@@ -77,6 +80,27 @@ def test_checkouts_file_remove(tmp_path):\n assert "camelcase" not in cf\n \n \n+def test_checkouts_file_rewrite(tmp_path):\n+ copy_path = tmp_path / "checkouts.cfg"\n+ shutil.copyfile(CHECKOUTS_FILE, copy_path)\n+ cf = CheckoutsFile(copy_path)\n+ cf.rewrite()\n+ # Read it fresh and compare\n+ cf2 = CheckoutsFile(copy_path)\n+ assert cf.data == cf2.data\n+ # Check the entire text. Note that packages are alphabetically sorted.\n+ # Currently we get the original case, but we may change this to lowercase.\n+ assert (\n+ copy_path.read_text()\n+ == """[buildout]\n+always-checkout = force\n+auto-checkout =\n+ CamelCase\n+ package\n+"""\n+ )\n+\n+\n def test_source_standard():\n src = Source.create_from_string(\n "git https://github.com/plone/Plone.git pushurl=git@github.com:plone/Plone.git branch=6.0.x"\n@@ -171,6 +195,38 @@ def test_sources_file_get():\n assert base.egg\n \n \n+def test_sources_file_rewrite(tmp_path):\n+ copy_path = tmp_path / "sources.cfg"\n+ shutil.copyfile(SOURCES_FILE, copy_path)\n+ sf = SourcesFile(copy_path)\n+ sf.rewrite()\n+ # Read it fresh and compare\n+ sf2 = SourcesFile(copy_path)\n+ assert sf.raw_data == sf2.raw_data\n+ assert sf.data == sf2.data\n+ # Some differences compared with the original:\n+ # - We always specify the branch.\n+ # - The order of the options may be different.\n+ assert (\n+ copy_path.read_text()\n+ == """[buildout]\n+extends =\n+ https://raw.githubusercontent.com/zopefoundation/Zope/master/sources.cfg\n+docs-directory = ${buildout:directory}/documentation\n+\n+[remotes]\n+plone = https://github.com/plone\n+plone_push = git@github.com:plone\n+\n+[sources]\n+docs = git ${remotes:plone}/documentation.git branch=6.0 path=${buildout:docs-directory} egg=false\n+plone = git ${remotes:plone}/Plone.git pushurl=${remotes:plone_push}/Plone.git branch=6.0.x\n+plone.alterego = git ${remotes:plone}/plone.alterego.git branch=master\n+plone.base = git ${remotes:plone}/plone.base.git branch=main\n+"""\n+ )\n+\n+\n def test_versions_file_versions():\n vf = VersionsFile(VERSIONS_FILE)\n # All versions are reported lowercased.\n@@ -185,6 +241,50 @@ def test_versions_file_versions():\n }\n \n \n+def test_versions_file_extends():\n+ vf = VersionsFile(VERSIONS_FILE)\n+ assert vf.extends == [\n+ "https://zopefoundation.github.io/Zope/releases/5.8.3/versions.cfg"\n+ ]\n+ vf = VersionsFile(VERSIONS_FILE2)\n+ assert vf.extends == ["versions3.cfg"]\n+ vf = VersionsFile(VERSIONS_FILE3)\n+ assert vf.extends == ["versions4.cfg"]\n+ vf = VersionsFile(VERSIONS_FILE4)\n+ assert vf.extends == []\n+\n+\n+def test_versions_file_read_extends_without_markers():\n+ vf = VersionsFile(VERSIONS_FILE2, read_extends=True)\n+ assert vf.data == {"four": "4.0", "one": "1.1", "three": "3.0", "two": "2.0"}\n+\n+\n+def test_versions_file_read_extends_with_markers():\n+ vf = VersionsFile(VERSIONS_FILE2, with_markers=True, read_extends=True)\n+ assert vf.data == {\n+ "five": {"macosx": "5.0"},\n+ "four": "4.0",\n+ "one": "1.1",\n+ "three": {"": "3.0", "python312": "3.2"},\n+ "two": "2.0",\n+ }\n+\n+\n+def test_versions_file_versions_with_markers():\n+ vf = VersionsFile(VERSIONS_FILE, with_markers=True)\n+ # All versions are reported lowercased.\n+ assert vf.data == {\n+ "annotated": "1.0",\n+ "camelcase": "1.0",\n+ "duplicate": "1.0",\n+ "lowercase": "1.0",\n+ "onepython": {"python312": "2.1"},\n+ "package": "1.0",\n+ "pyspecific": {"": "1.0", "python312": "2.0"},\n+ "uppercase": "1.0",\n+ }\n+\n+\n def test_versions_file_contains():\n vf = VersionsFile(VERSIONS_FILE)\n assert "package" in vf\n@@ -202,6 +302,16 @@ def test_versions_file_contains():\n assert "UPPERCASE" in vf\n \n \n+def test_versions_file_contains_with_markers():\n+ vf = VersionsFile(VERSIONS_FILE, with_markers=True)\n+ assert "package" in vf\n+ assert "nope" not in vf\n+ assert "onepython" in vf\n+ assert "pyspecific" in vf\n+ assert "ONEpython" in vf\n+ assert "pySPECIFIC" in vf\n+\n+\n def test_versions_file_get():\n vf = VersionsFile(VERSIONS_FILE)\n assert vf.get("package") == "1.0"\n@@ -223,6 +333,16 @@ def test_versions_file_get():\n assert vf["UPPERCASE"] == "1.0"\n \n \n+def test_versions_file_get_with_markers():\n+ vf = VersionsFile(VERSIONS_FILE, with_markers=True)\n+ assert vf.get("package") == "1.0"\n+ assert vf["package"] == "1.0"\n+ assert vf.get("onepython") == {"python312": "2.1"}\n+ assert vf.get("pyspecific") == {"": "1.0", "python312": "2.0"}\n+ assert vf["onepython"] == {"python312": "2.1"}\n+ assert vf["pyspecific"] == {"": "1.0", "python312": "2.0"}\n+\n+\n def test_versions_file_set_normal(tmp_path):\n # When we set a version, the file changes, so we work on a copy.\n copy_path = tmp_path / "versions.cfg"\n@@ -266,6 +386,44 @@ def test_versions_file_set_ignore_markers(tmp_path):\n assert "pyspecific = 2.0" in copy_path.read_text()\n \n \n+def test_versions_file_set_with_markers(tmp_path):\n+ # [versions:python312] pins \'pyspecific = 2.0\'.\n+ # We do not report or change this section.\n+ copy_path = tmp_path / "versions.cfg"\n+ shutil.copyfile(VERSIONS_FILE, copy_path)\n+ vf = VersionsFile(copy_path, with_markers=True)\n+ assert "pyspecific = 2.0" in copy_path.read_text()\n+ assert vf.get("pyspecific") == {"": "1.0", "python312": "2.0"}\n+ vf.set("pyspecific", "1.1")\n+ # Read it fresh, without markers.\n+ vf = VersionsFile(copy_path)\n+ assert vf.get("pyspecific") == "1.1"\n+ # Read it fresh, with markers.\n+ vf = VersionsFile(copy_path, with_markers=True)\n+ assert vf.get("pyspecific") == {"": "1.1", "python312": "2.0"}\n+ # Now edit for a specific python version.\n+ vf.set("pyspecific", ("2.1", "python312"))\n+ # Read it fresh, with markers.\n+ vf = VersionsFile(copy_path, with_markers=True)\n+ assert vf.get("pyspecific") == {"": "1.1", "python312": "2.1"}\n+ # Add to an unknown marker.\n+ vf.set("pyspecific", ("3.0", "python313"))\n+ # Read it fresh, with markers.\n+ vf = VersionsFile(copy_path, with_markers=True)\n+ assert "[versions:python313]" in copy_path.read_text()\n+ assert vf.get("pyspecific") == {"": "1.1", "python312": "2.1", "python313": "3.0"}\n+ # Add a new package to a new marker.\n+ vf.set("maconly", ("1.0", "macosx"))\n+ # Read it fresh, with markers.\n+ vf = VersionsFile(copy_path, with_markers=True)\n+ assert "[versions:macosx]" in copy_path.read_text()\n+ assert vf.get("maconly") == {"macosx": "1.0"}\n+ # Read it without markers.\n+ vf = VersionsFile(copy_path)\n+ assert vf["pyspecific"] == "1.1"\n+ assert not vf.get("maconly")\n+\n+\n def test_versions_file_set_cleanup_duplicates(tmp_path):\n copy_path = tmp_path / "versions.cfg"\n shutil.copyfile(VERSIONS_FILE, copy_path)\n@@ -278,3 +436,142 @@ def test_versions_file_set_cleanup_duplicates(tmp_path):\n assert vf.get("duplicate") == "2.0"\n assert copy_path.read_text().count("duplicate = 2.0") == 1\n assert copy_path.read_text().count("duplicate = 1.0") == 0\n+\n+\n+def test_versions_file_rewrite(tmp_path):\n+ copy_path = tmp_path / "versions.cfg"\n+ shutil.copyfile(VERSIONS_FILE, copy_path)\n+ vf = VersionsFile(copy_path)\n+ vf.rewrite()\n+ # Read it fresh and compare\n+ vf2 = VersionsFile(copy_path)\n+ assert vf.extends == vf2.extends\n+ assert vf.data == vf2.data\n+ # Check the entire text.\n+ # Note that there are differences with the original:\n+ # - the extends line is on a separate line\n+ # - all comments are removed\n+ # - the duplicate is removed\n+ # - all package names are lowercased\n+ assert (\n+ copy_path.read_text()\n+ == """[buildout]\n+extends =\n+ https://zopefoundation.github.io/Zope/releases/5.8.3/versions.cfg\n+\n+[versions]\n+annotated = 1.0\n+camelcase = 1.0\n+duplicate = 1.0\n+lowercase = 1.0\n+package = 1.0\n+pyspecific = 1.0\n+uppercase = 1.0\n+"""\n+ )\n+\n+\n+def test_versions_file_rewrite_2(tmp_path):\n+ copy_path = tmp_path / "versions2.cfg"\n+ shutil.copyfile(VERSIONS_FILE2, copy_path)\n+ vf = VersionsFile(copy_path)\n+ vf.rewrite()\n+ # Read it fresh and compare\n+ vf2 = VersionsFile(copy_path)\n+ assert vf.extends == vf2.extends\n+ assert vf.data == vf2.data\n+ # Check the entire text.\n+ assert (\n+ copy_path.read_text()\n+ == """[buildout]\n+extends =\n+ versions3.cfg\n+\n+[versions]\n+one = 1.1\n+two = 2.0\n+"""\n+ )\n+\n+\n+def test_versions_file_rewrite_with_markers(tmp_path):\n+ copy_path = tmp_path / "versions2.cfg"\n+ shutil.copyfile(VERSIONS_FILE2, copy_path)\n+ vf = VersionsFile(copy_path, with_markers=True)\n+ vf.rewrite()\n+ # Read it fresh and compare\n+ vf2 = VersionsFile(copy_path, with_markers=True)\n+ assert vf.extends == vf2.extends\n+ assert vf.data == vf2.data\n+ # Check the entire text.\n+ assert (\n+ copy_path.read_text()\n+ == """[buildout]\n+extends =\n+ versions3.cfg\n+\n+[versions]\n+one = 1.1\n+two = 2.0\n+\n+[versions:python312]\n+three = 3.2\n+"""\n+ )\n+\n+\n+def test_versions_file_rewrite_read_extends_without_markers(tmp_path):\n+ # Note: this combination may not make sense.\n+ copy_path = tmp_path / "versions2.cfg"\n+ shutil.copyfile(VERSIONS_FILE2, copy_path)\n+ # We extend some files and use their versions, so we need to copy them.\n+ shutil.copyfile(VERSIONS_FILE3, tmp_path / "versions3.cfg")\n+ shutil.copyfile(VERSIONS_FILE4, tmp_path / "versions4.cfg")\n+ vf = VersionsFile(copy_path, read_extends=True, with_markers=False)\n+ vf.rewrite()\n+ # Read it fresh and compare\n+ vf2 = VersionsFile(copy_path, read_extends=True, with_markers=False)\n+ assert vf.extends\n+ assert not vf2.extends\n+ assert vf.data == vf2.data\n+ # Check the entire text. Note that packages are alphabetically sorted.\n+ assert (\n+ copy_path.read_text()\n+ == """[versions]\n+four = 4.0\n+one = 1.1\n+three = 3.0\n+two = 2.0\n+"""\n+ )\n+\n+\n+def test_versions_file_rewrite_read_extends_with_markers(tmp_path):\n+ copy_path = tmp_path / "versions2.cfg"\n+ shutil.copyfile(VERSIONS_FILE2, copy_path)\n+ # We extend some files and use their versions, so we need to copy them.\n+ shutil.copyfile(VERSIONS_FILE3, tmp_path / "versions3.cfg")\n+ shutil.copyfile(VERSIONS_FILE4, tmp_path / "versions4.cfg")\n+ vf = VersionsFile(copy_path, read_extends=True, with_markers=True)\n+ vf.rewrite()\n+ # Read it fresh and compare\n+ vf2 = VersionsFile(copy_path, read_extends=True, with_markers=True)\n+ assert vf.extends\n+ assert not vf2.extends\n+ assert vf.data == vf2.data\n+ # Check the entire text. Note that packages are alphabetically sorted.\n+ assert (\n+ copy_path.read_text()\n+ == """[versions]\n+four = 4.0\n+one = 1.1\n+three = 3.0\n+two = 2.0\n+\n+[versions:macosx]\n+five = 5.0\n+\n+[versions:python312]\n+three = 3.2\n+"""\n+ )\ndiff --git a/plone/releaser/tests/test_pip.py b/plone/releaser/tests/test_pip.py\nindex 30774c1..78116e6 100644\n--- a/plone/releaser/tests/test_pip.py\n+++ b/plone/releaser/tests/test_pip.py\n@@ -9,6 +9,9 @@\n TESTS_DIR = pathlib.Path(__file__).parent\n INPUT_DIR = TESTS_DIR / "input"\n CONSTRAINTS_FILE = INPUT_DIR / "constraints.txt"\n+CONSTRAINTS_FILE2 = INPUT_DIR / "constraints2.txt"\n+CONSTRAINTS_FILE3 = INPUT_DIR / "constraints3.txt"\n+CONSTRAINTS_FILE4 = INPUT_DIR / "constraints4.txt"\n MXDEV_FILE = INPUT_DIR / "mxdev.ini"\n \n \n@@ -93,6 +96,42 @@ def test_mxdev_file_remove(tmp_path):\n assert "CamelCase" in mf\n \n \n+def test_mxdev_file_rewrite(tmp_path):\n+ copy_path = tmp_path / "mxdev.ini"\n+ shutil.copyfile(MXDEV_FILE, copy_path)\n+ mf = IniFile(copy_path)\n+ mf.rewrite()\n+ # Read it fresh and compare\n+ mf2 = IniFile(copy_path)\n+ assert mf.data == mf2.data\n+ # Check the entire text. Note that packages are alphabetically sorted.\n+ # Currently we get the original case, but we may change this to lowercase.\n+ assert (\n+ copy_path.read_text()\n+ == """[settings]\n+requirements-in = requirements.txt\n+requirements-out = requirements-mxdev.txt\n+contraints-out = constraints-mxdev.txt\n+default-use = false\n+plone = https://github.com/plone\n+\n+[package]\n+url = ${settings:plone}/package.git\n+branch = main\n+use = true\n+\n+[unused]\n+url = ${settings:plone}/package.git\n+branch = main\n+\n+[CamelCase]\n+url = ${settings:plone}/CamelCase.git\n+branch = main\n+use = true\n+"""\n+ )\n+\n+\n def test_constraints_file_constraints():\n cf = ConstraintsFile(CONSTRAINTS_FILE)\n # All constraints are reported lowercased.\n@@ -161,7 +200,7 @@ def test_constraints_file_set_normal(tmp_path):\n # How about packages that are not lowercase?\n # Currently in ConstraintsFile we report all package names as lower case,\n # so we don\'t know what their exact spelling is, which is what ConfigParser\n- # does for the Buildout versions file. So whatever we pass on, should be used.\n+ # does for the Buildout files. So whatever we pass on, should be used.\n assert "CamelCase==1.0" in copy_path.read_text()\n assert copy_path.read_text().lower().count("camelcase") == 1\n cf["CAMELcase"] = "1.1"\n@@ -201,3 +240,166 @@ def test_constraints_file_set_cleanup_duplicates(tmp_path):\n assert cf.get("duplicate") == "2.0"\n assert copy_path.read_text().count("duplicate==2.0") == 1\n assert copy_path.read_text().count("duplicate==1.0") == 0\n+\n+\n+def test_constraints_file_extends():\n+ cf = ConstraintsFile(CONSTRAINTS_FILE)\n+ assert cf.extends == [\n+ "https://zopefoundation.github.io/Zope/releases/5.8.3/constraints.txt"\n+ ]\n+ cf = ConstraintsFile(CONSTRAINTS_FILE2)\n+ assert cf.extends == ["constraints3.txt"]\n+ cf = ConstraintsFile(CONSTRAINTS_FILE3)\n+ assert cf.extends == ["constraints4.txt"]\n+ cf = ConstraintsFile(CONSTRAINTS_FILE4)\n+ assert cf.extends == []\n+\n+\n+def test_constraints_file_read_extends_without_markers():\n+ cf = ConstraintsFile(CONSTRAINTS_FILE2, read_extends=True)\n+ assert cf.data == {"four": "4.0", "one": "1.1", "three": "3.0", "two": "2.0"}\n+\n+\n+def test_constraints_file_read_extends_with_markers():\n+ cf = ConstraintsFile(CONSTRAINTS_FILE2, with_markers=True, read_extends=True)\n+ assert cf.data == {\n+ "five": {"platform_system == \'darwin\'": "5.0"},\n+ "four": "4.0",\n+ "one": "1.1",\n+ "three": {"": "3.0", \'python_version=="3.12"\': "3.2"},\n+ "two": "2.0",\n+ }\n+\n+\n+def test_constraints_file_constraints_with_markers():\n+ cf = ConstraintsFile(CONSTRAINTS_FILE, with_markers=True)\n+ # All constraints are reported lowercased.\n+ assert cf.data == {\n+ "annotated": "1.0",\n+ "camelcase": "1.0",\n+ "duplicate": "1.0",\n+ "lowercase": "1.0",\n+ "onepython": {\'python_version=="3.12"\': "2.1"},\n+ "package": "1.0",\n+ "pyspecific": {"": "1.0", \'python_version=="3.12"\': "2.0"},\n+ "uppercase": "1.0",\n+ }\n+\n+\n+def test_constraints_file_rewrite(tmp_path):\n+ copy_path = tmp_path / "constraints.txt"\n+ shutil.copyfile(CONSTRAINTS_FILE, copy_path)\n+ cf = ConstraintsFile(copy_path)\n+ cf.rewrite()\n+ # Read it fresh and compare\n+ cf2 = ConstraintsFile(copy_path)\n+ assert cf.extends == cf2.extends\n+ assert cf.data == cf2.data\n+ # Check the entire text.\n+ # Note that there are differences with the original:\n+ # - the extends line is on a separate line\n+ # - all comments are removed\n+ # - the duplicate is removed\n+ # - all package names are lowercased\n+ assert (\n+ copy_path.read_text()\n+ == """-c https://zopefoundation.github.io/Zope/releases/5.8.3/constraints.txt\n+annotated==1.0\n+camelcase==1.0\n+duplicate==1.0\n+lowercase==1.0\n+package==1.0\n+pyspecific==1.0\n+uppercase==1.0\n+"""\n+ )\n+\n+\n+def test_constraints_file_rewrite_2(tmp_path):\n+ copy_path = tmp_path / "constraints2.txt"\n+ shutil.copyfile(CONSTRAINTS_FILE2, copy_path)\n+ cf = ConstraintsFile(copy_path)\n+ cf.rewrite()\n+ # Read it fresh and compare\n+ cf2 = ConstraintsFile(copy_path)\n+ assert cf.extends == cf2.extends\n+ assert cf.data == cf2.data\n+ # Check the entire text.\n+ assert (\n+ copy_path.read_text()\n+ == """-c constraints3.txt\n+one==1.1\n+two==2.0\n+"""\n+ )\n+\n+\n+def test_constraints_file_rewrite_with_markers(tmp_path):\n+ copy_path = tmp_path / "constraints2.txt"\n+ shutil.copyfile(CONSTRAINTS_FILE2, copy_path)\n+ cf = ConstraintsFile(copy_path, with_markers=True)\n+ cf.rewrite()\n+ # Read it fresh and compare\n+ cf2 = ConstraintsFile(copy_path, with_markers=True)\n+ assert cf.extends == cf2.extends\n+ assert cf.data == cf2.data\n+ # Check the entire text.\n+ assert (\n+ copy_path.read_text()\n+ == """-c constraints3.txt\n+one==1.1\n+two==2.0\n+three==3.2; python_version=="3.12"\n+"""\n+ )\n+\n+\n+def test_constraints_file_rewrite_read_extends_without_markers(tmp_path):\n+ # Note: this combination may not make sense.\n+ copy_path = tmp_path / "constraints2.txt"\n+ shutil.copyfile(CONSTRAINTS_FILE2, copy_path)\n+ # We extend some files and use their constraints, so we need to copy them.\n+ shutil.copyfile(CONSTRAINTS_FILE3, tmp_path / "constraints3.txt")\n+ shutil.copyfile(CONSTRAINTS_FILE4, tmp_path / "constraints4.txt")\n+ cf = ConstraintsFile(copy_path, read_extends=True, with_markers=False)\n+ cf.rewrite()\n+ # Read it fresh and compare\n+ cf2 = ConstraintsFile(copy_path, read_extends=True, with_markers=False)\n+ assert cf.extends\n+ assert not cf2.extends\n+ assert cf.data == cf2.data\n+ # Check the entire text. Note that packages are alphabetically sorted.\n+ assert (\n+ copy_path.read_text()\n+ == """four==4.0\n+one==1.1\n+three==3.0\n+two==2.0\n+"""\n+ )\n+\n+\n+def test_constraints_file_rewrite_read_extends_with_markers(tmp_path):\n+ copy_path = tmp_path / "constraints2.txt"\n+ shutil.copyfile(CONSTRAINTS_FILE2, copy_path)\n+ # We extend some files and use their constraints, so we need to copy them.\n+ shutil.copyfile(CONSTRAINTS_FILE3, tmp_path / "constraints3.txt")\n+ shutil.copyfile(CONSTRAINTS_FILE4, tmp_path / "constraints4.txt")\n+ cf = ConstraintsFile(copy_path, read_extends=True, with_markers=True)\n+ cf.rewrite()\n+ # Read it fresh and compare\n+ cf2 = ConstraintsFile(copy_path, read_extends=True, with_markers=True)\n+ assert cf.extends\n+ assert not cf2.extends\n+ assert cf.data == cf2.data\n+ # Check the entire text. Note that packages are alphabetically sorted.\n+ assert (\n+ copy_path.read_text()\n+ == """four==4.0\n+five==5.0; platform_system == \'darwin\'\n+one==1.1\n+three==3.0\n+three==3.2; python_version=="3.12"\n+two==2.0\n+"""\n+ )\ndiff --git a/plone/releaser/tests/test_utils.py b/plone/releaser/tests/test_utils.py\nindex 0a8927e..bf8af86 100644\n--- a/plone/releaser/tests/test_utils.py\n+++ b/plone/releaser/tests/test_utils.py\n@@ -9,6 +9,24 @@\n VERSIONS = (INPUT_DIR / "versions.cfg").read_text()\n \n \n+def test_buildout_marker_to_pip_marker():\n+ from plone.releaser.utils import buildout_marker_to_pip_marker as trans\n+\n+ assert trans("") == ""\n+ assert trans("unknown") == "unknown"\n+ assert trans(\'python_version == "3.8"\') == \'python_version == "3.8"\'\n+ assert trans(\'platform_system == "Linux"\') == \'platform_system == "Linux"\'\n+ assert trans("python2") == \'python_version < "3"\'\n+ assert trans("python3") == \'python_version >= "3"\'\n+ assert trans("python27") == \'python_version == "2.7"\'\n+ assert trans("python38") == \'python_version == "3.8"\'\n+ assert trans("python313") == \'python_version == "3.13"\'\n+ assert trans("pypy") == \'implementation_name == "pypy"\'\n+ assert trans("linux") == \'platform_system == "Linux"\'\n+ assert trans("macosx") == \'platform_system == "Darwin"\'\n+ assert trans("windows") == \'platform_system == "Windows"\'\n+\n+\n def test_update_contents_empty():\n assert update_contents("\\n", lambda x: True, "", "") == "\\n"\n \ndiff --git a/plone/releaser/tests/test_versions2constraints.py b/plone/releaser/tests/test_versions2constraints.py\nnew file mode 100644\nindex 0000000..2e19522\n--- /dev/null\n+++ b/plone/releaser/tests/test_versions2constraints.py\n@@ -0,0 +1,40 @@\n+from plone.releaser.manage import versions2constraints\n+from plone.releaser.pip import ConstraintsFile\n+\n+import pathlib\n+import pytest\n+import shutil\n+\n+\n+TESTS_DIR = pathlib.Path(__file__).parent\n+INPUT_DIR = TESTS_DIR / "input"\n+VERSIONS_FILE = INPUT_DIR / "versions.cfg"\n+VERSIONS_FILE2 = INPUT_DIR / "versions2.cfg"\n+VERSIONS_FILE3 = INPUT_DIR / "versions3.cfg"\n+VERSIONS_FILE4 = INPUT_DIR / "versions4.cfg"\n+\n+\n+@pytest.mark.current\n+def test_versions2constraints(tmp_path):\n+ copy_path = tmp_path / "versions.cfg"\n+ constraints_file = tmp_path / "constraints.txt"\n+ shutil.copyfile(VERSIONS_FILE, copy_path)\n+ assert not constraints_file.exists()\n+ versions2constraints(path=copy_path)\n+ assert constraints_file.exists()\n+ cf = ConstraintsFile(constraints_file, with_markers=True)\n+ print(cf.data)\n+ assert (\n+ constraints_file.read_text()\n+ == """-c https://zopefoundation.github.io/Zope/releases/5.8.3/constraints.txt\n+annotated==1.0\n+camelcase==1.0\n+duplicate==1.0\n+lowercase==1.0\n+package==1.0\n+pyspecific==1.0\n+pyspecific==2.0; python_version == "3.12"\n+uppercase==1.0\n+onepython==2.1; python_version == "3.12"\n+"""\n+ )\ndiff --git a/plone/releaser/utils.py b/plone/releaser/utils.py\nindex 432d943..324f618 100644\n--- a/plone/releaser/utils.py\n+++ b/plone/releaser/utils.py\n@@ -1,4 +1,80 @@\n-def update_contents(contents, line_check, newline, filename, stop_check=None):\n+def buildout_marker_to_pip_marker(marker):\n+ """Translate a Buildout marker to a pip marker.\n+\n+ Example:\n+\n+ [versions:python38]\n+ package = 1.0\n+\n+ This translates to:\n+\n+ package==1.0; python_version == "3.8"\n+\n+ The Buildout markers are defined here:\n+ https://github.com/buildout/buildout/blob/3.0.1/src/zc/buildout/buildout.py#L1724\n+\n+ The pip markers are defined in PEP-508:\n+ https://peps.python.org/pep-0508/#environment-markers\n+\n+ It seems hard to translate *all* possible markers.\n+ But we can do the ones used in the Plone core development buildout.\n+ Even with those, I do not see a 100% correct translation between the two.\n+\n+ Buildout supports the pip markers natively since version 3.0.0, so you can write:\n+\n+ [versions:python_version == "3.8"]\n+ package = 1.0\n+\n+ See https://github.com/buildout/buildout/pull/622\n+ """\n+ if not marker:\n+ return marker\n+\n+ # Python versions\n+ if marker.startswith("python"):\n+ if marker.startswith("python_version"):\n+ # already a pip marker\n+ return marker\n+ if marker == "python2":\n+ return \'python_version < "3"\'\n+ if marker == "python3":\n+ return \'python_version >= "3"\'\n+ version = marker[len("python") :]\n+ major = version[0]\n+ minor = version[1:]\n+ return f\'python_version == "{major}.{minor}"\'\n+\n+ # Python implementations\n+ if marker in ("cpython", "pypy", "jython", "ironpython"):\n+ # Buildout checks sys.version.lower().\n+ # pip uses the equivalent of sys.implementation.name.\n+ return f\'implementation_name == "{marker}"\'\n+\n+ # system platforms\n+ # Buildout mostly uses str(sys.platform).lower() and then has a mapping to more\n+ # common names. For pip, platform_system seems best in most cases.\n+ if marker == "linux":\n+ return \'platform_system == "Linux"\'\n+ if marker == "macosx":\n+ return \'platform_system == "Darwin"\'\n+ if marker == "windows":\n+ return \'platform_system == "Windows"\'\n+ if marker == "solaris":\n+ return \'platform_system == "SunOS"\'\n+ if marker == "posix":\n+ return \'os_name == "posix"\'\n+ if marker == "cygwin":\n+ return \'sys_platform == "cygwin"\'\n+\n+ # We are missing a few, like bits64 and big_endian.\n+ # Or this is an invalid marker.\n+ # Or this already is a pip marker.\n+ return marker\n+\n+\n+def update_contents(\n+ contents, line_check, newline, filename, start_check=None, stop_check=None\n+):\n """Update contents to have a new line if needed.\n \n * contents is some file contents\n@@ -6,6 +82,8 @@ def update_contents(contents, line_check, newline, filename, stop_check=None):\n * newline is the line with which we replace the matched line.\n This can be None to signal that the old line should be removed\n * filename is used for reporting.\n+ * start_check is an optional function we call to check if we should start\n+ trying to match.\n * stop_check is an optional function we call to check if we should stop\n trying to match.\n \n@@ -17,6 +95,12 @@ def update_contents(contents, line_check, newline, filename, stop_check=None):\n while content_lines:\n line = content_lines.pop(0)\n line = line.rstrip()\n+ if start_check is not None:\n+ if start_check(line):\n+ # We start searching now. Disable the start_check.\n+ start_check = None\n+ lines.append(line)\n+ continue\n if stop_check is not None and stop_check(line):\n # Put this line back. We will handle this line and the other\n # remaining lines outside of this loop.\n@@ -54,4 +138,7 @@ def update_contents(contents, line_check, newline, filename, stop_check=None):\n if content_lines:\n lines.extend(content_lines)\n \n- return "\\n".join(lines) + "\\n"\n+ result = "\\n".join(lines)\n+ if not result.endswith("\\n"):\n+ result += "\\n"\n+ return result\n'