diff --git a/last_commit.txt b/last_commit.txt index 6cd01305dc..6c12b374ad 100644 --- a/last_commit.txt +++ b/last_commit.txt @@ -1,42 +1,247 @@ -Repository: plone.namedfile +Repository: plone.releaser Branch: refs/heads/master -Date: 2023-09-11T10:57:01-04:00 -Author: Mathias Leimgruber (maethu) -Commit: https://github.com/plone/plone.namedfile/commit/056ad6ae630a9576198966b38b5e26786e573842 +Date: 2023-09-01T18:48:27+02:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/5d85a1fe667c69153cf12e2267d31df407f9f572 -Add internal modification timestamp with fallback to _p_mtime. +Test buildout CheckoutsFile. Files changed: -A news/149.internal -M plone/namedfile/file.py -M plone/namedfile/tests/__init__.py -M plone/namedfile/tests/test_blobfile.py -M plone/namedfile/tests/test_image.py -M plone/namedfile/tests/test_scaling.py +A plone/releaser/tests/input/checkouts.cfg +M plone/releaser/buildout.py +M plone/releaser/tests/test_buildout.py -b'diff --git a/news/149.internal b/news/149.internal\nnew file mode 100644\nindex 0000000..aa24e6a\n--- /dev/null\n+++ b/news/149.internal\n@@ -0,0 +1,2 @@\n+Add internal modification timestamp with fallback to _p_mtime.\n+[mathias.leimgruber]\ndiff --git a/plone/namedfile/file.py b/plone/namedfile/file.py\nindex f527aa1..36dd30b 100644\n--- a/plone/namedfile/file.py\n+++ b/plone/namedfile/file.py\n@@ -1,6 +1,7 @@\n # The implementations in this file are largely borrowed\n # from zope.app.file and z3c.blobfile\n # and are licensed under the ZPL.\n+from DateTime import DateTime\n from logging import getLogger\n from persistent import Persistent\n from plone.namedfile.interfaces import INamedBlobFile\n@@ -70,8 +71,17 @@ def _get_contents(self):\n pass\n \n \n+class ModifiedPropertyMixin:\n+ @property\n+ def modified(self):\n+ if hasattr(self, "_modified"):\n+ return self._modified\n+ # Fall back to modification time in database.\n+ return self._p_mtime\n+\n+\n @implementer(INamedFile)\n-class NamedFile(Persistent):\n+class NamedFile(Persistent, ModifiedPropertyMixin):\n """A non-BLOB file that stores a filename\n \n Let\'s test the constructor:\n@@ -169,6 +179,7 @@ def __init__(self, data=b"", contentType="", filename=None):\n self.data = data\n self.contentType = contentType\n self.filename = filename\n+ self._modified = DateTime().millis()\n \n def _getData(self):\n if isinstance(self._data, tuple(FILECHUNK_CLASSES)):\n@@ -177,6 +188,7 @@ def _getData(self):\n return self._data\n \n def _setData(self, data):\n+ self._modified = DateTime().millis()\n \n # Handle case when data is a string\n if isinstance(data, str):\n@@ -310,7 +322,7 @@ def getImageSize(self):\n \n \n @implementer(INamedBlobFile, HTTPRangeSupport.HTTPRangeInterface)\n-class NamedBlobFile(Persistent):\n+class NamedBlobFile(Persistent, ModifiedPropertyMixin):\n """A file stored in a ZODB BLOB, with a filename"""\n \n filename = FieldProperty(INamedFile["filename"])\n@@ -325,6 +337,7 @@ def __init__(self, data=b"", contentType="", filename=None):\n f.close()\n self._setData(data)\n self.filename = filename\n+ self._modified = DateTime().millis()\n \n def open(self, mode="r"):\n if mode != "r" and "size" in self.__dict__:\n@@ -342,6 +355,7 @@ def _setData(self, data):\n log.debug("Storage selected for data: %s", dottedName)\n storable = getUtility(IStorage, name=dottedName)\n storable.store(data, self._blob)\n+ self._modified = DateTime().millis()\n \n def _getData(self):\n fp = self._blob.open("r")\ndiff --git a/plone/namedfile/tests/__init__.py b/plone/namedfile/tests/__init__.py\nindex 16d30e5..b0aa490 100644\n--- a/plone/namedfile/tests/__init__.py\n+++ b/plone/namedfile/tests/__init__.py\n@@ -1,3 +1,8 @@\n+from DateTime import DateTime\n+from plone.namedfile.file import NamedBlobImage\n+from plone.namedfile.file import NamedImage\n+\n+\n import os\n \n \n@@ -6,3 +11,11 @@ def getFile(filename, length=None):\n filename = os.path.join(os.path.dirname(__file__), filename)\n with open(filename, "rb") as data_file:\n return data_file.read(length)\n+\n+\n+class MockNamedImage(NamedImage):\n+ _p_mtime = DateTime().millis()\n+\n+\n+class MockNamedBlobImage(NamedBlobImage):\n+ _p_mtime = DateTime().millis()\ndiff --git a/plone/namedfile/tests/test_blobfile.py b/plone/namedfile/tests/test_blobfile.py\nindex d748246..67f7107 100644\n--- a/plone/namedfile/tests/test_blobfile.py\n+++ b/plone/namedfile/tests/test_blobfile.py\n@@ -15,6 +15,7 @@\n #\n ##############################################################################\n \n+from DateTime import DateTime\n from plone.namedfile import storages\n from plone.namedfile.file import NamedBlobFile\n from plone.namedfile.file import NamedBlobImage\n@@ -24,11 +25,12 @@\n from plone.namedfile.testing import PLONE_NAMEDFILE_FUNCTIONAL_TESTING\n from plone.namedfile.testing import PLONE_NAMEDFILE_INTEGRATION_TESTING\n from plone.namedfile.tests.test_image import zptlogo\n+from plone.namedfile.tests import MockNamedBlobImage\n from zope.component import provideUtility\n from zope.interface.verify import verifyClass\n \n-import os\n import struct\n+import time\n import transaction\n import unittest\n \n@@ -56,11 +58,13 @@ def testEmpty(self):\n file = self._makeImage()\n self.assertEqual(file.contentType, "")\n self.assertEqual(file.data, b"")\n+ self.assertIsNotNone(file.modified)\n \n def testConstructor(self):\n file = self._makeImage(b"Data")\n self.assertEqual(file.contentType, "")\n self.assertEqual(file.data, b"Data")\n+ self.assertIsNotNone(file.modified)\n \n def testMutators(self):\n image = self._makeImage()\n@@ -73,6 +77,24 @@ def testMutators(self):\n self.assertEqual(image.contentType, "image/gif")\n self.assertEqual(image.getImageSize(), (16, 16))\n \n+ def testModifiedTimeStamp(self):\n+ image = self._makeImage()\n+ old_timestamp = image.modified\n+ time.sleep(1/1000) # make sure at least 1ms passes\n+ image._setData(zptlogo)\n+ self.assertNotEqual(image.modified, old_timestamp)\n+\n+ def testFallBackToDatabaseModifiedTimeStamp(self):\n+ dt = DateTime()\n+ image = MockNamedBlobImage()\n+ image._p_mtime = dt.millis()\n+ image._modified = (dt + 1).millis()\n+\n+ delattr(image, "_modified")\n+ marker = object()\n+ self.assertEqual(marker, getattr(image, "_modified", marker))\n+ self.assertEqual(dt.millis(), image._p_mtime)\n+\n def testInterface(self):\n self.assertTrue(INamedBlobImage.implementedBy(NamedBlobImage))\n self.assertTrue(verifyClass(INamedBlobImage, NamedBlobImage))\ndiff --git a/plone/namedfile/tests/test_image.py b/plone/namedfile/tests/test_image.py\nindex 4629207..8360a97 100644\n--- a/plone/namedfile/tests/test_image.py\n+++ b/plone/namedfile/tests/test_image.py\n@@ -1,12 +1,15 @@\n # This file is borrowed from zope.app.file and licensed ZPL.\n \n+from DateTime import DateTime\n from plone.namedfile.file import NamedImage\n from plone.namedfile.interfaces import INamedImage\n from plone.namedfile.testing import PLONE_NAMEDFILE_INTEGRATION_TESTING\n from plone.namedfile.tests import getFile\n+from plone.namedfile.tests import MockNamedImage\n from plone.namedfile.utils import get_contenttype\n from zope.interface.verify import verifyClass\n \n+import time\n import unittest\n \n \n@@ -39,15 +42,16 @@ def testEmpty(self):\n file_img = self._makeImage()\n self.assertEqual(file_img.contentType, "")\n self.assertEqual(bytes(file_img.data), b"")\n+ self.assertIsNotNone(file_img.modified)\n \n def testConstructor(self):\n file_img = self._makeImage(b"Data")\n self.assertEqual(file_img.contentType, "")\n self.assertEqual(bytes(file_img.data), b"Data")\n+ self.assertIsNotNone(file_img.modified)\n \n def testMutators(self):\n image = self._makeImage()\n-\n image.contentType = "image/jpeg"\n self.assertEqual(image.contentType, "image/jpeg")\n \n@@ -56,6 +60,24 @@ def testMutators(self):\n self.assertEqual(image.contentType, "image/gif")\n self.assertEqual(image.getImageSize(), (16, 16))\n \n+ def testModifiedTimeStamp(self):\n+ image = self._makeImage()\n+ old_timestamp = image.modified\n+ time.sleep(1/1000) # make sure at least 1ms passes\n+ image._setData(zptlogo)\n+ self.assertNotEqual(image.modified, old_timestamp)\n+\n+ def testFallBackToDatabaseModifiedTimeStamp(self):\n+ dt = DateTime()\n+ image = MockNamedImage()\n+ image._p_mtime = dt.millis()\n+ image._modified = (dt + 1).millis()\n+\n+ delattr(image, "_modified")\n+ marker = object()\n+ self.assertEqual(marker, getattr(image, "_modified", marker))\n+ self.assertEqual(dt.millis(), image._p_mtime)\n+\n def testInterface(self):\n self.assertTrue(INamedImage.implementedBy(NamedImage))\n self.assertTrue(verifyClass(INamedImage, NamedImage))\ndiff --git a/plone/namedfile/tests/test_scaling.py b/plone/namedfile/tests/test_scaling.py\nindex 7934763..4159ebf 100644\n--- a/plone/namedfile/tests/test_scaling.py\n+++ b/plone/namedfile/tests/test_scaling.py\n@@ -11,6 +11,7 @@\n from plone.namedfile.testing import PLONE_NAMEDFILE_FUNCTIONAL_TESTING\n from plone.namedfile.testing import PLONE_NAMEDFILE_INTEGRATION_TESTING\n from plone.namedfile.tests import getFile\n+from plone.namedfile.tests import MockNamedImage\n from plone.rfc822.interfaces import IPrimaryFieldInfo\n from plone.scale.interfaces import IScaledImageQuality\n from plone.scale.storage import IImageScaleStorage\n@@ -143,10 +144,6 @@ def value(self):\n return self.field\n \n \n-class MockNamedImage(NamedImage):\n- _p_mtime = DateTime().millis()\n-\n-\n @implementer(IScaledImageQuality)\n class DummyQualitySupplier:\n """fake utility for image quality setting from imaging control panel."""\n' +b'diff --git a/plone/releaser/buildout.py b/plone/releaser/buildout.py\nindex 0e3742a..5b63e3b 100644\n--- a/plone/releaser/buildout.py\n+++ b/plone/releaser/buildout.py\n@@ -181,12 +181,19 @@ def data(self):\n # Map from lower case to actual case, so we can find the package.\n mapping = {}\n for package in checkouts.splitlines():\n+ if not package:\n+ continue\n mapping[package.lower()] = package\n return mapping\n \n def __contains__(self, package_name):\n return package_name.lower() in self.data\n \n+ def __getitem__(self, package_name):\n+ if package_name in self:\n+ return self.data.get(package_name.lower())\n+ raise KeyError\n+\n def __setitem__(self, package_name, enabled=True):\n contents = self.path.read_text()\n if not contents.endswith("\\n"):\n@@ -210,6 +217,15 @@ def line_check(line):\n def __delitem__(self, package_name):\n return self.__setitem__(package_name, False)\n \n+ def get(self, package_name, default=None):\n+ if package_name in self:\n+ return self.__getitem__(package_name)\n+ return default\n+\n+ def set(self, package_name, new_version):\n+ # This method makes no sense for this class.\n+ raise NotImplementedError\n+\n def add(self, package_name):\n return self.__setitem__(package_name, True)\n \ndiff --git a/plone/releaser/tests/input/checkouts.cfg b/plone/releaser/tests/input/checkouts.cfg\nnew file mode 100644\nindex 0000000..317719d\n--- /dev/null\n+++ b/plone/releaser/tests/input/checkouts.cfg\n@@ -0,0 +1,7 @@\n+[buildout]\n+always-checkout = force\n+# always-checkout = false\n+auto-checkout =\n+# These packages are always checkout out:\n+ CamelCase\n+ package\ndiff --git a/plone/releaser/tests/test_buildout.py b/plone/releaser/tests/test_buildout.py\nindex 03f4f4e..45aa8ee 100644\n--- a/plone/releaser/tests/test_buildout.py\n+++ b/plone/releaser/tests/test_buildout.py\n@@ -1,3 +1,4 @@\n+from plone.releaser.buildout import CheckoutsFile\n from plone.releaser.buildout import VersionsFile\n \n import pathlib\n@@ -8,6 +9,68 @@\n TESTS_DIR = pathlib.Path(__file__).parent\n INPUT_DIR = TESTS_DIR / "input"\n VERSIONS_FILE = INPUT_DIR / "versions.cfg"\n+CHECKOUTS_FILE = INPUT_DIR / "checkouts.cfg"\n+\n+\n+def test_checkouts_file_data():\n+ cf = CheckoutsFile(CHECKOUTS_FILE)\n+ # The data maps lower case to actual case.\n+ assert cf.data == {\n+ "camelcase": "CamelCase",\n+ "package": "package",\n+ }\n+\n+\n+def test_checkouts_file_contains():\n+ cf = CheckoutsFile(CHECKOUTS_FILE)\n+ assert "package" in cf\n+ assert "nope" not in cf\n+ # We compare case insensitively.\n+ assert "camelcase" in cf\n+ assert "CamelCase" in cf\n+ assert "CAMELCASE" in cf\n+\n+\n+def test_checkouts_file_get():\n+ cf = CheckoutsFile(CHECKOUTS_FILE)\n+ # The data maps lower case to actual case.\n+ assert cf["package"] == "package"\n+ assert cf.get("package") == "package"\n+ assert cf["camelcase"] == "CamelCase"\n+ assert cf["CAMELCASE"] == "CamelCase"\n+ assert cf["CamelCase"] == "CamelCase"\n+ with pytest.raises(KeyError):\n+ cf["nope"]\n+\n+\n+def test_checkouts_file_add(tmp_path):\n+ # When we add or remove a checkout, the file changes, so we work on a copy.\n+ copy_path = tmp_path / "checkouts.cfg"\n+ shutil.copyfile(CHECKOUTS_FILE, copy_path)\n+ cf = CheckoutsFile(copy_path)\n+ assert "Extra" not in cf\n+ cf.add("Extra")\n+ # Let\'s read it fresh, for good measure.\n+ cf = CheckoutsFile(copy_path)\n+ assert "Extra" in cf\n+ assert cf.get("extra") == "Extra"\n+\n+\n+def test_checkouts_file_remove(tmp_path):\n+ copy_path = tmp_path / "checkouts.cfg"\n+ shutil.copyfile(CHECKOUTS_FILE, copy_path)\n+ cf = CheckoutsFile(copy_path)\n+ assert "package" in cf\n+ cf.remove("package")\n+ # Let\'s read it fresh, for good measure.\n+ cf = CheckoutsFile(copy_path)\n+ assert "package" not in cf\n+ assert "CAMELCASE" in cf\n+ cf.remove("CAMELCASE")\n+ cf = CheckoutsFile(copy_path)\n+ assert "CAMELCASE" not in cf\n+ assert "CamelCase" not in cf\n+ assert "camelcase" not in cf\n \n \n def test_versions_file_versions():\n' -Repository: plone.namedfile +Repository: plone.releaser Branch: refs/heads/master -Date: 2023-09-11T22:22:10-07:00 -Author: David Glick (davisagli) -Commit: https://github.com/plone/plone.namedfile/commit/28044bd2801a29a0460f7374252b9c65381a5c31 +Date: 2023-09-01T18:48:27+02:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/7b546ebd7c467d26c8365acadfa1c0812a595591 -Merge pull request #150 from plone/mle-internal-modification-timestamp +Test updating mxdev.ini, fixing some corner cases. -Add internal modification timestamp. +Files changed: +A plone/releaser/tests/input/mxdev.ini +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 4c290f3..e770036 100644\n--- a/plone/releaser/pip.py\n+++ b/plone/releaser/pip.py\n@@ -121,9 +121,24 @@ def data(self):\n checkouts[package.lower()] = package\n return checkouts\n \n+ @property\n+ def sections(self):\n+ # If we want to use a package, we must first know that it exists.\n+ sections = {}\n+ for package in self.config.sections():\n+ # Map from lower case to actual case, so we can find the package.\n+ sections[package.lower()] = package\n+ return sections\n+\n def __contains__(self, package_name):\n+ # Is the package defined AND is it used?\n return package_name.lower() in self.data\n \n+ def __getitem__(self, package_name):\n+ if package_name in self:\n+ return self.data.get(package_name.lower())\n+ raise KeyError\n+\n def __setitem__(self, package_name, enabled=True):\n """Enable or disable a checkout.\n \n@@ -133,10 +148,19 @@ def __setitem__(self, package_name, enabled=True):\n \n But let\'s support the other way around as well:\n when default-use is true, we set \'use = false\'.\n+\n+ Note that in our Buildout setup, we have sources.cfg separately.\n+ In mxdev.ini the source definition and \'use = false/true\' is combined.\n+ So if the package we want to enable is not defined, meaning it has no\n+ section, then we should fail loudly.\n """\n- stored_package_name = self.data.get(package_name.lower())\n- if stored_package_name:\n- package_name = stored_package_name\n+ stored_package_name = self.sections.get(package_name.lower())\n+ if not stored_package_name:\n+ raise KeyError(\n+ f"{self.file_location}: There is no definition for {package_name}"\n+ )\n+ package_name = stored_package_name\n+ if package_name in self:\n use = to_bool(self.config[package_name].get("use", self.default_use))\n else:\n use = False\n@@ -154,7 +178,9 @@ def __setitem__(self, package_name, enabled=True):\n \n lines = []\n found_package = False\n- for line in contents.splitlines():\n+ # Add extra line at the end. This eases parsing and editing the final section.\n+ orig_lines = contents.splitlines() + ["\\n"]\n+ for line in orig_lines:\n line = line.rstrip()\n if line == f"[{package_name}]":\n found_package = True\n@@ -188,7 +214,7 @@ def __setitem__(self, package_name, enabled=True):\n # Just a regular line.\n lines.append(line)\n \n- contents = "\\n".join(lines) + "\\n"\n+ contents = "\\n".join(lines)\n self.path.write_text(contents)\n \n def __delitem__(self, package_name):\ndiff --git a/plone/releaser/tests/input/mxdev.ini b/plone/releaser/tests/input/mxdev.ini\nnew file mode 100644\nindex 0000000..a0c15fd\n--- /dev/null\n+++ b/plone/releaser/tests/input/mxdev.ini\n@@ -0,0 +1,22 @@\n+[settings]\n+requirements-in = requirements.txt\n+requirements-out = requirements-mxdev.txt\n+contraints-out = constraints-mxdev.txt\n+# The packages are defined, but not used by default.\n+default-use = false\n+# custom variables\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\ndiff --git a/plone/releaser/tests/test_pip.py b/plone/releaser/tests/test_pip.py\nindex 4ef3d6d..5b72eb4 100644\n--- a/plone/releaser/tests/test_pip.py\n+++ b/plone/releaser/tests/test_pip.py\n@@ -1,4 +1,5 @@\n from plone.releaser.pip import ConstraintsFile\n+from plone.releaser.pip import IniFile\n \n import pathlib\n import pytest\n@@ -8,6 +9,88 @@\n TESTS_DIR = pathlib.Path(__file__).parent\n INPUT_DIR = TESTS_DIR / "input"\n CONSTRAINTS_FILE = INPUT_DIR / "constraints.txt"\n+MXDEV_FILE = INPUT_DIR / "mxdev.ini"\n+\n+\n+def test_mxdev_file_data():\n+ mf = IniFile(MXDEV_FILE)\n+ # The data maps lower case to actual case.\n+ assert mf.data == {\n+ "camelcase": "CamelCase",\n+ "package": "package",\n+ }\n+\n+\n+def test_mxdev_file_contains():\n+ mf = IniFile(MXDEV_FILE)\n+ assert "package" in mf\n+ assert "unused" not in mf\n+ # We compare case insensitively.\n+ assert "camelcase" in mf\n+ assert "CamelCase" in mf\n+ assert "CAMELCASE" in mf\n+\n+\n+def test_mxdev_file_get():\n+ mf = IniFile(MXDEV_FILE)\n+ # The data maps lower case to actual case.\n+ assert mf["package"] == "package"\n+ assert mf.get("package") == "package"\n+ assert mf["camelcase"] == "CamelCase"\n+ assert mf["CAMELCASE"] == "CamelCase"\n+ assert mf["CamelCase"] == "CamelCase"\n+ with pytest.raises(KeyError):\n+ mf["unused"]\n+ assert mf.get("unused") is None\n+\n+\n+def test_mxdev_file_add_known(tmp_path):\n+ # When we add or remove a checkout, the file changes, so we work on a copy.\n+ copy_path = tmp_path / "mxdev.ini"\n+ shutil.copyfile(MXDEV_FILE, copy_path)\n+ mf = IniFile(copy_path)\n+ assert "unused" not in mf\n+ mf.add("unused")\n+ # Let\'s read it fresh, for good measure.\n+ mf = IniFile(copy_path)\n+ assert "unused" in mf\n+ assert mf["unused"] == "unused"\n+\n+\n+def test_mxdev_file_add_unknown(tmp_path):\n+ # We cannot edit mxdev.ini to use a package when it is not defined.\n+ copy_path = tmp_path / "mxdev.ini"\n+ shutil.copyfile(MXDEV_FILE, copy_path)\n+ mf = IniFile(copy_path)\n+ assert "unknown" not in mf\n+ with pytest.raises(KeyError):\n+ mf.add("unknown")\n+\n+\n+def test_mxdev_file_remove(tmp_path):\n+ copy_path = tmp_path / "mxdev.ini"\n+ shutil.copyfile(MXDEV_FILE, copy_path)\n+ mf = IniFile(copy_path)\n+ assert "package" in mf\n+ mf.remove("package")\n+ # Let\'s read it fresh, for good measure.\n+ mf = IniFile(copy_path)\n+ assert "package" not in mf\n+ assert "CAMELCASE" in mf\n+ mf.remove("CAMELCASE")\n+ mf = IniFile(copy_path)\n+ assert "CAMELCASE" not in mf\n+ assert "CamelCase" not in mf\n+ assert "camelcase" not in mf\n+ # Check that we can re-enable a package:\n+ # editing should not remove the entire section.\n+ mf.add("package")\n+ mf = IniFile(copy_path)\n+ assert "package" in mf\n+ # This should work for the last section as well.\n+ mf.add("CamelCase")\n+ mf = IniFile(copy_path)\n+ assert "CamelCase" in mf\n \n \n def test_constraints_file_constraints():\n' + +Repository: plone.releaser + + +Branch: refs/heads/master +Date: 2023-09-01T22:17:45+02:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/75594aa5cabdee207f7af9d09a0371b2165c1a86 + +Make buildout.Source.create_from_string a classmethod. + +Test it. + +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 5b63e3b..4f077cc 100644\n--- a/plone/releaser/buildout.py\n+++ b/plone/releaser/buildout.py\n@@ -15,29 +15,24 @@\n \n \n class Source:\n- def __init__(self, protocol=None, url=None, push_url=None, branch=None):\n+ def __init__(self, protocol=None, url=None, pushurl=None, branch=None):\n self.protocol = protocol\n self.url = url\n- self.push_url = push_url\n+ self.pushurl = pushurl\n self.branch = branch\n \n- def create_from_string(self, source_string):\n+ @classmethod\n+ def create_from_string(cls, source_string):\n protocol, url, extra_1, extra_2, extra_3 = (\n lambda a, b, c=None, d=None, e=None: (a, b, c, d, e)\n )(*source_string.split())\n+ # September 2023: mr.developer defaults to master, mxdev to main.\n+ options = {"protocol": protocol, "url": url, "branch": "master"}\n for param in [extra_1, extra_2, extra_3]:\n if param is not None:\n key, value = param.split("=")\n- setattr(self, key, value)\n- self.protocol = protocol\n- self.url = url\n- if self.push_url is not None:\n- self.push_url = self.push_url.split("=")[-1]\n- if self.branch is None:\n- self.branch = "master"\n- else:\n- self.branch = self.branch.split("=")[-1]\n- return self\n+ options[key] = value\n+ return cls(**options)\n \n @property\n def path(self):\n@@ -151,7 +146,7 @@ def data(self):\n sources_dict = OrderedDict()\n for name, value in config["sources"].items():\n try:\n- source = Source().create_from_string(value)\n+ source = Source.create_from_string(value)\n except TypeError:\n # Happens now for the documentation items in coredev 6.0.\n # We could print, but this gets printed a lot.\ndiff --git a/plone/releaser/tests/test_buildout.py b/plone/releaser/tests/test_buildout.py\nindex 45aa8ee..e34ed68 100644\n--- a/plone/releaser/tests/test_buildout.py\n+++ b/plone/releaser/tests/test_buildout.py\n@@ -1,4 +1,5 @@\n from plone.releaser.buildout import CheckoutsFile\n+from plone.releaser.buildout import Source\n from plone.releaser.buildout import VersionsFile\n \n import pathlib\n@@ -73,6 +74,32 @@ def test_checkouts_file_remove(tmp_path):\n assert "camelcase" not in cf\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+ )\n+ assert src.protocol == "git"\n+ assert src.url == "https://github.com/plone/Plone.git"\n+ assert src.pushurl == "git@github.com:plone/Plone.git"\n+ assert src.branch == "6.0.x"\n+\n+\n+def test_source_not_enough_parameters():\n+ with pytest.raises(TypeError):\n+ Source.create_from_string("")\n+ with pytest.raises(TypeError):\n+ Source.create_from_string("git")\n+\n+\n+def test_source_just_enough_parameters():\n+ # protocol and url are enough\n+ src = Source.create_from_string("git https://github.com/plone/Plone.git")\n+ assert src.protocol == "git"\n+ assert src.url == "https://github.com/plone/Plone.git"\n+ assert src.pushurl is None\n+ assert src.branch == "master"\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-09-01T22:34:54+02:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/b1e7e3d6fe1c32117e33b7d0c8c9da60bce59600 + +Removed no longer working pulls command that I have never used. + +Removed `PyGithub` and `keyring` dependencies that was only used for this. +Removed `Source.path` property that was only used for this. + +Traceback when you try the `pulls` command: + +``` +$ bin/manage pulls +Traceback (most recent call last): + File "/Users/maurits/community/plone-coredev/6.0/bin/manage", line 69, in <module> + sys.exit(plone.releaser.manage.manage()) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/maurits/community/plone-coredev/6.0/src/plone.releaser/plone/releaser/manage.py", line 271, in __call__ + parser.dispatch() + File "/Users/maurits/shared-eggs/cp311/argh-0.28.1-py3.11.egg/argh/helpers.py", line 54, in dispatch + return dispatch(self, *args, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/maurits/shared-eggs/cp311/argh-0.28.1-py3.11.egg/argh/dispatching.py", line 179, in dispatch + for line in lines: + File "/Users/maurits/shared-eggs/cp311/argh-0.28.1-py3.11.egg/argh/dispatching.py", line 290, in _execute_command + for line in result: + File "/Users/maurits/shared-eggs/cp311/argh-0.28.1-py3.11.egg/argh/dispatching.py", line 273, in _call + result = function(*positional, **keywords) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/maurits/community/plone-coredev/6.0/src/plone.releaser/plone/releaser/manage.py", line 94, in pulls + g = Github(client_id=client_id, client_secret=client_secret) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +TypeError: Github.__init__() got an unexpected keyword argument 'client_id' +``` + +Files changed: +A news/56.bugfix +M plone/releaser/buildout.py +M plone/releaser/manage.py +M setup.py + +b'diff --git a/news/56.bugfix b/news/56.bugfix\nnew file mode 100644\nindex 0000000..b6d4e63\n--- /dev/null\n+++ b/news/56.bugfix\n@@ -0,0 +1,4 @@\n+Removed no longer working ``pulls`` command that I have never used.\n+Removed ``PyGithub`` and ``keyring`` dependencies that was only used for this.\n+Removed ``Source.path`` property that was only used for this.\n+[maurits]\ndiff --git a/plone/releaser/buildout.py b/plone/releaser/buildout.py\nindex 4f077cc..948f7a6 100644\n--- a/plone/releaser/buildout.py\n+++ b/plone/releaser/buildout.py\n@@ -9,11 +9,6 @@\n import re\n \n \n-PATH_RE = re.compile(\n- r"(\\w+://)(.+@)*([\\w\\d\\.]+)(:[\\d]+){0,1}/(?P.+(?=\\.git))(\\.git)"\n-)\n-\n-\n class Source:\n def __init__(self, protocol=None, url=None, pushurl=None, branch=None):\n self.protocol = protocol\n@@ -34,14 +29,6 @@ def create_from_string(cls, source_string):\n options[key] = value\n return cls(**options)\n \n- @property\n- def path(self):\n- if self.url:\n- match = PATH_RE.match(self.url)\n- if match:\n- return match.groupdict()["path"]\n- return None\n-\n \n class VersionsFile:\n def __init__(self, file_location):\ndiff --git a/plone/releaser/manage.py b/plone/releaser/manage.py\nindex 89e79a9..2345b53 100644\n--- a/plone/releaser/manage.py\n+++ b/plone/releaser/manage.py\n@@ -1,7 +1,6 @@\n from argh import arg\n from argh import ArghParser\n from argh.decorators import named\n-from github import Github\n from plone.releaser import ACTION_BATCH\n from plone.releaser import ACTION_INTERACTIVE\n from plone.releaser import ACTION_REPORT\n@@ -16,7 +15,6 @@\n from progress.bar import Bar\n \n import glob\n-import keyring\n import time\n \n \n@@ -87,22 +85,6 @@ def checkAllPackagesForUpdates(**kwargs):\n time.sleep(sleep)\n \n \n-def pulls():\n- client_id = "b9f6639835b8c9cf462a"\n- client_secret = keyring.get_password("plone.releaser", client_id)\n-\n- g = Github(client_id=client_id, client_secret=client_secret)\n-\n- for package_name, source in buildout.sources.items():\n- if source.path:\n- repo = g.get_repo(source.path)\n- pulls = [a for a in repo.get_pulls("open") if a.head.ref == source.branch]\n- if pulls:\n- print(package_name)\n- for pull in pulls:\n- print(f" {pull.user.login}: {pull.title} ({pull.url})")\n-\n-\n @named("changelog")\n @arg("--start")\n @arg("--end", default="here")\n@@ -257,7 +239,6 @@ def __call__(self, **kwargs):\n checkPypi,\n checkPackageForUpdates,\n checkAllPackagesForUpdates,\n- pulls,\n changelog,\n check_checkout,\n remove_checkout,\ndiff --git a/setup.py b/setup.py\nindex f5230c8..fe3c0fd 100644\n--- a/setup.py\n+++ b/setup.py\n@@ -41,8 +41,6 @@\n "gitpython>=3.0.0",\n "configparser",\n "progress",\n- "PyGithub",\n- "keyring",\n "zest.releaser[recommended]>=7.2.0",\n "zestreleaser.towncrier>=1.3.0",\n "docutils",\n' + +Repository: plone.releaser + + +Branch: refs/heads/master +Date: 2023-09-01T23:37:51+02:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/01adec7cf2aed968b2288f4f049b33eed7eac9a1 + +Source: support path and egg options. + +Then the docs and mockup sources can get parsed. + +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 948f7a6..82eb946 100644\n--- a/plone/releaser/buildout.py\n+++ b/plone/releaser/buildout.py\n@@ -10,22 +10,39 @@\n \n \n class Source:\n- def __init__(self, protocol=None, url=None, pushurl=None, branch=None):\n+ """Source definition for mr.developer"""\n+\n+ def __init__(\n+ self, protocol=None, url=None, pushurl=None, branch=None, path=None, egg=True\n+ ):\n+ # I think mxdev only supports git as protocol.\n self.protocol = protocol\n self.url = url\n self.pushurl = pushurl\n self.branch = branch\n+ # mxdev has target (default: sources) instead of path (default: src).\n+ self.path = path\n+ # egg=True: mxdev install-mode="direct"\n+ # egg=False: mxdev install-mode="skip"\n+ self.egg = egg\n \n @classmethod\n def create_from_string(cls, source_string):\n- protocol, url, extra_1, extra_2, extra_3 = (\n- lambda a, b, c=None, d=None, e=None: (a, b, c, d, e)\n- )(*source_string.split())\n+ line_options = source_string.split()\n+ protocol = line_options.pop(0)\n+ url = line_options.pop(0)\n # September 2023: mr.developer defaults to master, mxdev to main.\n options = {"protocol": protocol, "url": url, "branch": "master"}\n- for param in [extra_1, extra_2, extra_3]:\n+\n+ # The rest of the line options are key/value pairs.\n+ for param in line_options:\n if param is not None:\n key, value = param.split("=")\n+ if key == "egg":\n+ if value.lower() in ("true", "yes", "on"):\n+ value = True\n+ elif value.lower() in ("false", "no", "off"):\n+ value = False\n options[key] = value\n return cls(**options)\n \n@@ -132,12 +149,7 @@ def data(self):\n config["buildout"]["docs-directory"] = os.path.join(os.getcwd(), "docs")\n sources_dict = OrderedDict()\n for name, value in config["sources"].items():\n- try:\n- source = Source.create_from_string(value)\n- except TypeError:\n- # Happens now for the documentation items in coredev 6.0.\n- # We could print, but this gets printed a lot.\n- continue\n+ source = Source.create_from_string(value)\n sources_dict[name] = source\n return sources_dict\n \ndiff --git a/plone/releaser/tests/test_buildout.py b/plone/releaser/tests/test_buildout.py\nindex e34ed68..52ce5b2 100644\n--- a/plone/releaser/tests/test_buildout.py\n+++ b/plone/releaser/tests/test_buildout.py\n@@ -82,12 +82,14 @@ def test_source_standard():\n assert src.url == "https://github.com/plone/Plone.git"\n assert src.pushurl == "git@github.com:plone/Plone.git"\n assert src.branch == "6.0.x"\n+ assert src.egg is True\n+ assert src.path is None\n \n \n def test_source_not_enough_parameters():\n- with pytest.raises(TypeError):\n+ with pytest.raises(IndexError):\n Source.create_from_string("")\n- with pytest.raises(TypeError):\n+ with pytest.raises(IndexError):\n Source.create_from_string("git")\n \n \n@@ -98,6 +100,21 @@ def test_source_just_enough_parameters():\n assert src.url == "https://github.com/plone/Plone.git"\n assert src.pushurl is None\n assert src.branch == "master"\n+ assert src.egg is True\n+ assert src.path is None\n+\n+\n+def test_source_docs():\n+ # Plone has a docs source with some extra options.\n+ src = Source.create_from_string(\n+ "git https://github.com/plone/documentation.git pushurl=git@github.com:plone/documentation.git egg=false branch=6.0 path=docs"\n+ )\n+ assert src.protocol == "git"\n+ assert src.url == "https://github.com/plone/documentation.git"\n+ assert src.pushurl == "git@github.com:plone/documentation.git"\n+ assert src.branch == "6.0"\n+ assert src.egg is False\n+ assert src.path == "docs"\n \n \n def test_versions_file_versions():\n' + +Repository: plone.releaser + + +Branch: refs/heads/master +Date: 2023-09-07T22:13:36+02:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/1fd616bec66ae43b4abcded02b06ec068533d9fa + +Test SourcesFile, add contains and get, and repr/eq on Source. + +Files changed: +A plone/releaser/tests/input/sources.cfg +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 82eb946..04adee5 100644\n--- a/plone/releaser/buildout.py\n+++ b/plone/releaser/buildout.py\n@@ -46,6 +46,12 @@ def create_from_string(cls, source_string):\n options[key] = value\n return cls(**options)\n \n+ def __repr__(self):\n+ return f""\n+\n+ def __eq__(self, other):\n+ return repr(self) == repr(other)\n+\n \n class VersionsFile:\n def __init__(self, file_location):\n@@ -146,11 +152,10 @@ 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- config["buildout"]["docs-directory"] = os.path.join(os.getcwd(), "docs")\n sources_dict = OrderedDict()\n for name, value in config["sources"].items():\n source = Source.create_from_string(value)\n- sources_dict[name] = source\n+ sources_dict[name.lower()] = source\n return sources_dict\n \n def __setitem__(self, package_name, value):\n@@ -159,6 +164,14 @@ def __setitem__(self, package_name, value):\n def __iter__(self):\n return self.data.__iter__()\n \n+ def __contains__(self, package_name):\n+ return package_name.lower() in self.data\n+\n+ def __getitem__(self, package_name):\n+ if package_name in self:\n+ return self.data.get(package_name.lower())\n+ raise KeyError\n+\n \n class CheckoutsFile(UserDict):\n def __init__(self, file_location):\ndiff --git a/plone/releaser/tests/input/sources.cfg b/plone/releaser/tests/input/sources.cfg\nnew file mode 100644\nindex 0000000..bd3e54e\n--- /dev/null\n+++ b/plone/releaser/tests/input/sources.cfg\n@@ -0,0 +1,16 @@\n+[buildout]\n+extends =\n+ https://raw.githubusercontent.com/zopefoundation/Zope/master/sources.cfg\n+# Define a docs directory.\n+# Must be defined in this file, otherwise mr.roboto fails when it parses only 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 egg=false branch=6.0 path=${buildout:docs-directory}\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\n+plone.base = git ${remotes:plone}/plone.base.git branch=main\ndiff --git a/plone/releaser/tests/test_buildout.py b/plone/releaser/tests/test_buildout.py\nindex 52ce5b2..2d0d4f6 100644\n--- a/plone/releaser/tests/test_buildout.py\n+++ b/plone/releaser/tests/test_buildout.py\n@@ -1,7 +1,9 @@\n from plone.releaser.buildout import CheckoutsFile\n from plone.releaser.buildout import Source\n+from plone.releaser.buildout import SourcesFile\n from plone.releaser.buildout import VersionsFile\n \n+import os\n import pathlib\n import pytest\n import shutil\n@@ -9,8 +11,9 @@\n \n TESTS_DIR = pathlib.Path(__file__).parent\n INPUT_DIR = TESTS_DIR / "input"\n-VERSIONS_FILE = INPUT_DIR / "versions.cfg"\n CHECKOUTS_FILE = INPUT_DIR / "checkouts.cfg"\n+SOURCES_FILE = INPUT_DIR / "sources.cfg"\n+VERSIONS_FILE = INPUT_DIR / "versions.cfg"\n \n \n def test_checkouts_file_data():\n@@ -117,6 +120,57 @@ def test_source_docs():\n assert src.path == "docs"\n \n \n+def test_sources_file_data():\n+ sf = SourcesFile(SOURCES_FILE)\n+ # Note that the keys are lowercase.\n+ assert sorted(sf.data.keys()) == ["docs", "plone", "plone.alterego", "plone.base"]\n+\n+\n+def test_sources_file_contains():\n+ sf = SourcesFile(SOURCES_FILE)\n+ assert "docs" in sf\n+ assert "plone.base" in sf\n+ assert "nope" not in sf\n+ # We compare case insensitively.\n+ assert "Plone" in sf\n+ assert "plone" in sf\n+ assert "PLONE" in sf\n+ assert "PLONE.BASE" in sf\n+\n+\n+def test_sources_file_get():\n+ sf = SourcesFile(SOURCES_FILE)\n+ with pytest.raises(KeyError):\n+ assert sf["nope"]\n+ assert sf["plone"] == sf["PLONE"]\n+ assert sf["plone"] == sf["Plone"]\n+ assert sf["plone"] != sf["plone.base"]\n+ plone = sf["plone"]\n+ assert plone.url == "https://github.com/plone/Plone.git"\n+ assert plone.pushurl == "git@github.com:plone/Plone.git"\n+ assert plone.branch == "6.0.x"\n+ assert plone.path is None\n+ assert plone.egg\n+ docs = sf["docs"]\n+ assert docs.url == "https://github.com/plone/documentation.git"\n+ assert docs.pushurl is None\n+ assert docs.branch == "6.0"\n+ assert docs.path == f"{os.getcwd()}/documentation"\n+ assert not docs.egg\n+ alterego = sf["plone.alterego"]\n+ assert alterego.url == "https://github.com/plone/plone.alterego.git"\n+ assert alterego.pushurl is None\n+ assert alterego.branch == "master"\n+ assert alterego.path is None\n+ assert alterego.egg\n+ base = sf["plone.base"]\n+ assert base.url == "https://github.com/plone/plone.base.git"\n+ assert base.pushurl is None\n+ assert base.branch == "main"\n+ assert base.path is None\n+ assert base.egg\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-09-07T22:22:06+02:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/fecdf8ad3cf2da80a143c8ead1506c44b9c63fd2 + +Let VersionsFile inherit from UserDict, and use data instead of versions. + +Closer to the others. + +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 04adee5..ab3c87c 100644\n--- a/plone/releaser/buildout.py\n+++ b/plone/releaser/buildout.py\n@@ -53,13 +53,13 @@ def __eq__(self, other):\n return repr(self) == repr(other)\n \n \n-class VersionsFile:\n+class VersionsFile(UserDict):\n def __init__(self, file_location):\n self.file_location = file_location\n self.path = pathlib.Path(self.file_location).resolve()\n \n @property\n- def versions(self):\n+ def data(self):\n """Read the versions config.\n \n We use strict=False to avoid a DuplicateOptionError.\n@@ -92,11 +92,11 @@ def versions(self):\n return versions\n \n def __contains__(self, package_name):\n- return package_name.lower() in self.versions\n+ return package_name.lower() in self.data\n \n def __getitem__(self, package_name):\n if package_name in self:\n- return self.versions.get(package_name.lower())\n+ return self.data.get(package_name.lower())\n raise KeyError\n \n def __setitem__(self, package_name, new_version):\ndiff --git a/plone/releaser/tests/test_buildout.py b/plone/releaser/tests/test_buildout.py\nindex 2d0d4f6..eb479e8 100644\n--- a/plone/releaser/tests/test_buildout.py\n+++ b/plone/releaser/tests/test_buildout.py\n@@ -174,7 +174,7 @@ def test_sources_file_get():\n def test_versions_file_versions():\n vf = VersionsFile(VERSIONS_FILE)\n # All versions are reported lowercased.\n- assert vf.versions == {\n+ assert vf.data == {\n "annotated": "1.0",\n "camelcase": "1.0",\n "duplicate": "1.0",\n' + +Repository: plone.releaser + + +Branch: refs/heads/master +Date: 2023-09-07T22:24:14+02:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/92a57599c5d0d7f2be82caa26cd45d532a479083 + +Let ConstraintsFile inherit from UserDict, and use data instead of constraints. + +Closer to the others. + +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 e770036..9f86b2d 100644\n--- a/plone/releaser/pip.py\n+++ b/plone/releaser/pip.py\n@@ -16,13 +16,13 @@ def to_bool(value):\n return False\n \n \n-class ConstraintsFile:\n+class ConstraintsFile(UserDict):\n def __init__(self, file_location):\n self.file_location = file_location\n self.path = pathlib.Path(self.file_location).resolve()\n \n @cached_property\n- def constraints(self):\n+ def data(self):\n """Read the constraints."""\n contents = self.path.read_text()\n constraints = {}\n@@ -54,11 +54,11 @@ def constraints(self):\n return constraints\n \n def __contains__(self, package_name):\n- return package_name.lower() in self.constraints\n+ return package_name.lower() in self.data\n \n def __getitem__(self, package_name):\n if package_name in self:\n- return self.constraints.get(package_name.lower())\n+ return self.data.get(package_name.lower())\n raise KeyError\n \n def __setitem__(self, package_name, new_version):\ndiff --git a/plone/releaser/tests/test_pip.py b/plone/releaser/tests/test_pip.py\nindex 5b72eb4..5e256bf 100644\n--- a/plone/releaser/tests/test_pip.py\n+++ b/plone/releaser/tests/test_pip.py\n@@ -96,7 +96,7 @@ def test_mxdev_file_remove(tmp_path):\n def test_constraints_file_constraints():\n cf = ConstraintsFile(CONSTRAINTS_FILE)\n # All constraints are reported lowercased.\n- assert cf.constraints == {\n+ assert cf.data == {\n "annotated": "1.0",\n "camelcase": "1.0",\n "duplicate": "1.0",\n' + +Repository: plone.releaser + + +Branch: refs/heads/master +Date: 2023-09-07T22:49:08+02:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/6d0eda4ce593dbc2db30b3be14e8cdcac8a5e44f + +Add base.BaseFile as basis for most pip/buildout files. + +These classes were using lots of very similar methods. + +Files changed: +A plone/releaser/base.py +M plone/releaser/buildout.py +M plone/releaser/pip.py + +b'diff --git a/plone/releaser/base.py b/plone/releaser/base.py\nnew file mode 100644\nindex 0000000..536710f\n--- /dev/null\n+++ b/plone/releaser/base.py\n@@ -0,0 +1,45 @@\n+from collections import UserDict\n+\n+import pathlib\n+\n+\n+class BaseFile(UserDict):\n+ def __init__(self, file_location):\n+ self.file_location = file_location\n+ self.path = pathlib.Path(self.file_location).resolve()\n+\n+ @property\n+ def data(self):\n+ raise NotImplementedError\n+\n+ def __iter__(self):\n+ return self.data.__iter__()\n+\n+ def __contains__(self, package_name):\n+ return package_name.lower() in self.data\n+\n+ def __getitem__(self, package_name):\n+ if package_name in self:\n+ return self.data.get(package_name.lower())\n+ raise KeyError\n+\n+ def __setitem__(self, package_name, value):\n+ raise NotImplementedError\n+\n+ def __delitem__(self, package_name):\n+ return self.__setitem__(package_name, False)\n+\n+ def get(self, package_name, default=None):\n+ if package_name in self:\n+ return self.__getitem__(package_name)\n+ return default\n+\n+ def set(self, package_name, value):\n+ return self.__setitem__(package_name, value)\n+\n+ def add(self, package_name):\n+ # This only makes sense for files where package_name maps to True or False.\n+ return self.__setitem__(package_name, True)\n+\n+ def remove(self, package_name):\n+ return self.__delitem__(package_name)\ndiff --git a/plone/releaser/buildout.py b/plone/releaser/buildout.py\nindex ab3c87c..9ccf02d 100644\n--- a/plone/releaser/buildout.py\n+++ b/plone/releaser/buildout.py\n@@ -1,6 +1,6 @@\n+from .base import BaseFile\n from .utils import update_contents\n from collections import OrderedDict\n-from collections import UserDict\n from configparser import ConfigParser\n from configparser import ExtendedInterpolation\n \n@@ -53,7 +53,7 @@ def __eq__(self, other):\n return repr(self) == repr(other)\n \n \n-class VersionsFile(UserDict):\n+class VersionsFile(BaseFile):\n def __init__(self, file_location):\n self.file_location = file_location\n self.path = pathlib.Path(self.file_location).resolve()\n@@ -91,14 +91,6 @@ def data(self):\n versions[package] = version\n return versions\n \n- def __contains__(self, package_name):\n- return package_name.lower() in self.data\n-\n- def __getitem__(self, package_name):\n- if package_name in self:\n- return self.data.get(package_name.lower())\n- raise KeyError\n-\n def __setitem__(self, package_name, new_version):\n contents = self.path.read_text()\n if not contents.endswith("\\n"):\n@@ -127,20 +119,8 @@ def stop_check(line):\n if contents != new_contents:\n self.path.write_text(new_contents)\n \n- def get(self, package_name, default=None):\n- if package_name in self:\n- return self.__getitem__(package_name)\n- return default\n-\n- def set(self, package_name, new_version):\n- return self.__setitem__(package_name, new_version)\n-\n-\n-class SourcesFile(UserDict):\n- def __init__(self, file_location):\n- self.file_location = file_location\n- self.path = pathlib.Path(self.file_location).resolve()\n \n+class SourcesFile(BaseFile):\n @property\n def data(self):\n config = ConfigParser(interpolation=ExtendedInterpolation())\n@@ -161,23 +141,8 @@ def data(self):\n def __setitem__(self, package_name, value):\n raise NotImplementedError\n \n- def __iter__(self):\n- return self.data.__iter__()\n-\n- def __contains__(self, package_name):\n- return package_name.lower() in self.data\n-\n- def __getitem__(self, package_name):\n- if package_name in self:\n- return self.data.get(package_name.lower())\n- raise KeyError\n-\n-\n-class CheckoutsFile(UserDict):\n- def __init__(self, file_location):\n- self.file_location = file_location\n- self.path = pathlib.Path(self.file_location).resolve()\n \n+class CheckoutsFile(BaseFile):\n @property\n def data(self):\n config = ConfigParser(interpolation=ExtendedInterpolation())\n@@ -193,14 +158,6 @@ def data(self):\n mapping[package.lower()] = package\n return mapping\n \n- def __contains__(self, package_name):\n- return package_name.lower() in self.data\n-\n- def __getitem__(self, package_name):\n- if package_name in self:\n- return self.data.get(package_name.lower())\n- raise KeyError\n-\n def __setitem__(self, package_name, enabled=True):\n contents = self.path.read_text()\n if not contents.endswith("\\n"):\n@@ -221,25 +178,10 @@ def line_check(line):\n if contents != new_contents:\n self.path.write_text(new_contents)\n \n- def __delitem__(self, package_name):\n- return self.__setitem__(package_name, False)\n-\n- def get(self, package_name, default=None):\n- if package_name in self:\n- return self.__getitem__(package_name)\n- return default\n-\n def set(self, package_name, new_version):\n # This method makes no sense for this class.\n raise NotImplementedError\n \n- def add(self, package_name):\n- return self.__setitem__(package_name, True)\n-\n- def remove(self, package_name):\n- # Remove from checkouts.cfg\n- return self.__delitem__(package_name)\n-\n \n class Buildout:\n def __init__(\ndiff --git a/plone/releaser/pip.py b/plone/releaser/pip.py\nindex 9f86b2d..4024d48 100644\n--- a/plone/releaser/pip.py\n+++ b/plone/releaser/pip.py\n@@ -1,5 +1,5 @@\n+from .base import BaseFile\n from .utils import update_contents\n-from collections import UserDict\n from configparser import ConfigParser\n from configparser import ExtendedInterpolation\n from functools import cached_property\n@@ -16,11 +16,7 @@ def to_bool(value):\n return False\n \n \n-class ConstraintsFile(UserDict):\n- def __init__(self, file_location):\n- self.file_location = file_location\n- self.path = pathlib.Path(self.file_location).resolve()\n-\n+class ConstraintsFile(BaseFile):\n @cached_property\n def data(self):\n """Read the constraints."""\n@@ -53,14 +49,6 @@ def data(self):\n constraints[package] = version\n return constraints\n \n- def __contains__(self, package_name):\n- return package_name.lower() in self.data\n-\n- def __getitem__(self, package_name):\n- if package_name in self:\n- return self.data.get(package_name.lower())\n- raise KeyError\n-\n def __setitem__(self, package_name, new_version):\n contents = self.path.read_text()\n if not contents.endswith("\\n"):\n@@ -82,16 +70,8 @@ def line_check(line):\n if contents != new_contents:\n self.path.write_text(new_contents)\n \n- def get(self, package_name, default=None):\n- if package_name in self:\n- return self.__getitem__(package_name)\n- return default\n-\n- def set(self, package_name, new_version):\n- return self.__setitem__(package_name, new_version)\n-\n \n-class IniFile(UserDict):\n+class IniFile(BaseFile):\n """Ini file for mxdev.\n \n What we want to do here is similar to what we have in buildout.py\n@@ -130,15 +110,6 @@ def sections(self):\n sections[package.lower()] = package\n return sections\n \n- def __contains__(self, package_name):\n- # Is the package defined AND is it used?\n- return package_name.lower() in self.data\n-\n- def __getitem__(self, package_name):\n- if package_name in self:\n- return self.data.get(package_name.lower())\n- raise KeyError\n-\n def __setitem__(self, package_name, enabled=True):\n """Enable or disable a checkout.\n \n@@ -216,13 +187,3 @@ def __setitem__(self, package_name, enabled=True):\n \n contents = "\\n".join(lines)\n self.path.write_text(contents)\n-\n- def __delitem__(self, package_name):\n- return self.__setitem__(package_name, False)\n-\n- def add(self, package_name):\n- return self.__setitem__(package_name, True)\n-\n- def remove(self, package_name):\n- # Remove from checkouts.\n- return self.__delitem__(package_name)\n' + +Repository: plone.releaser + + +Branch: refs/heads/master +Date: 2023-09-07T22:51:27+02:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/7101bc9321e5073d8921e89b6ab0e98e14a40e99 + +news snippet + +Files changed: +M news/56.bugfix + +b'diff --git a/news/56.bugfix b/news/56.bugfix\nindex b6d4e63..a362c51 100644\n--- a/news/56.bugfix\n+++ b/news/56.bugfix\n@@ -1,4 +1,4 @@\n Removed no longer working ``pulls`` command that I have never used.\n-Removed ``PyGithub`` and ``keyring`` dependencies that was only used for this.\n+Removed ``PyGithub`` and ``keyring`` dependencies that were only used for this.\n Removed ``Source.path`` property that was only used for this.\n [maurits]\n' + +Repository: plone.releaser + + +Branch: refs/heads/master +Date: 2023-09-07T22:58:10+02:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/42a5b36f3ebe44974b922c259076e1751e2190e8 + +Use self.path more, instead of self.file_location. + +Files changed: +M plone/releaser/buildout.py +M plone/releaser/pip.py + +b'diff --git a/plone/releaser/buildout.py b/plone/releaser/buildout.py\nindex 9ccf02d..b1fca5c 100644\n--- a/plone/releaser/buildout.py\n+++ b/plone/releaser/buildout.py\n@@ -78,7 +78,7 @@ def data(self):\n So we do not want to report or edit anything except the versions section.\n """\n config = ConfigParser(interpolation=ExtendedInterpolation(), strict=False)\n- with open(self.file_location) as f:\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@@ -125,7 +125,7 @@ class SourcesFile(BaseFile):\n def data(self):\n config = ConfigParser(interpolation=ExtendedInterpolation())\n config.optionxform = str\n- with open(self.file_location) as f:\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@@ -146,7 +146,7 @@ class CheckoutsFile(BaseFile):\n @property\n def data(self):\n config = ConfigParser(interpolation=ExtendedInterpolation())\n- with open(self.file_location) as f:\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")\ndiff --git a/plone/releaser/pip.py b/plone/releaser/pip.py\nindex 4024d48..9e59b43 100644\n--- a/plone/releaser/pip.py\n+++ b/plone/releaser/pip.py\n@@ -4,7 +4,6 @@\n from configparser import ExtendedInterpolation\n from functools import cached_property\n \n-import pathlib\n import re\n \n \n@@ -81,13 +80,12 @@ class IniFile(BaseFile):\n """\n \n def __init__(self, file_location):\n- self.file_location = file_location\n- self.path = pathlib.Path(self.file_location).resolve()\n+ super().__init__(file_location)\n self.config = ConfigParser(\n default_section="settings",\n interpolation=ExtendedInterpolation(),\n )\n- with open(self.file_location) as f:\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 \n' + +Repository: plone.releaser + + +Branch: refs/heads/master +Date: 2023-09-14T00:48:41+02:00 +Author: Maurits van Rees (mauritsvanrees) +Commit: https://github.com/plone/plone.releaser/commit/58d0a22181b598bcee8e7c509bcebea0804b81be + +Merge pull request #56 from plone/maurits-more-tests + +Improve and test sources. Share code. Files changed: -A news/149.internal -M plone/namedfile/file.py -M plone/namedfile/tests/__init__.py -M plone/namedfile/tests/test_blobfile.py -M plone/namedfile/tests/test_image.py -M plone/namedfile/tests/test_scaling.py +A news/56.bugfix +A plone/releaser/base.py +A plone/releaser/tests/input/checkouts.cfg +A plone/releaser/tests/input/mxdev.ini +A plone/releaser/tests/input/sources.cfg +M plone/releaser/buildout.py +M plone/releaser/manage.py +M plone/releaser/pip.py +M plone/releaser/tests/test_buildout.py +M plone/releaser/tests/test_pip.py +M setup.py -b'diff --git a/news/149.internal b/news/149.internal\nnew file mode 100644\nindex 0000000..aa24e6a\n--- /dev/null\n+++ b/news/149.internal\n@@ -0,0 +1,2 @@\n+Add internal modification timestamp with fallback to _p_mtime.\n+[mathias.leimgruber]\ndiff --git a/plone/namedfile/file.py b/plone/namedfile/file.py\nindex f527aa1..36dd30b 100644\n--- a/plone/namedfile/file.py\n+++ b/plone/namedfile/file.py\n@@ -1,6 +1,7 @@\n # The implementations in this file are largely borrowed\n # from zope.app.file and z3c.blobfile\n # and are licensed under the ZPL.\n+from DateTime import DateTime\n from logging import getLogger\n from persistent import Persistent\n from plone.namedfile.interfaces import INamedBlobFile\n@@ -70,8 +71,17 @@ def _get_contents(self):\n pass\n \n \n+class ModifiedPropertyMixin:\n+ @property\n+ def modified(self):\n+ if hasattr(self, "_modified"):\n+ return self._modified\n+ # Fall back to modification time in database.\n+ return self._p_mtime\n+\n+\n @implementer(INamedFile)\n-class NamedFile(Persistent):\n+class NamedFile(Persistent, ModifiedPropertyMixin):\n """A non-BLOB file that stores a filename\n \n Let\'s test the constructor:\n@@ -169,6 +179,7 @@ def __init__(self, data=b"", contentType="", filename=None):\n self.data = data\n self.contentType = contentType\n self.filename = filename\n+ self._modified = DateTime().millis()\n \n def _getData(self):\n if isinstance(self._data, tuple(FILECHUNK_CLASSES)):\n@@ -177,6 +188,7 @@ def _getData(self):\n return self._data\n \n def _setData(self, data):\n+ self._modified = DateTime().millis()\n \n # Handle case when data is a string\n if isinstance(data, str):\n@@ -310,7 +322,7 @@ def getImageSize(self):\n \n \n @implementer(INamedBlobFile, HTTPRangeSupport.HTTPRangeInterface)\n-class NamedBlobFile(Persistent):\n+class NamedBlobFile(Persistent, ModifiedPropertyMixin):\n """A file stored in a ZODB BLOB, with a filename"""\n \n filename = FieldProperty(INamedFile["filename"])\n@@ -325,6 +337,7 @@ def __init__(self, data=b"", contentType="", filename=None):\n f.close()\n self._setData(data)\n self.filename = filename\n+ self._modified = DateTime().millis()\n \n def open(self, mode="r"):\n if mode != "r" and "size" in self.__dict__:\n@@ -342,6 +355,7 @@ def _setData(self, data):\n log.debug("Storage selected for data: %s", dottedName)\n storable = getUtility(IStorage, name=dottedName)\n storable.store(data, self._blob)\n+ self._modified = DateTime().millis()\n \n def _getData(self):\n fp = self._blob.open("r")\ndiff --git a/plone/namedfile/tests/__init__.py b/plone/namedfile/tests/__init__.py\nindex 16d30e5..b0aa490 100644\n--- a/plone/namedfile/tests/__init__.py\n+++ b/plone/namedfile/tests/__init__.py\n@@ -1,3 +1,8 @@\n+from DateTime import DateTime\n+from plone.namedfile.file import NamedBlobImage\n+from plone.namedfile.file import NamedImage\n+\n+\n import os\n \n \n@@ -6,3 +11,11 @@ def getFile(filename, length=None):\n filename = os.path.join(os.path.dirname(__file__), filename)\n with open(filename, "rb") as data_file:\n return data_file.read(length)\n+\n+\n+class MockNamedImage(NamedImage):\n+ _p_mtime = DateTime().millis()\n+\n+\n+class MockNamedBlobImage(NamedBlobImage):\n+ _p_mtime = DateTime().millis()\ndiff --git a/plone/namedfile/tests/test_blobfile.py b/plone/namedfile/tests/test_blobfile.py\nindex d748246..67f7107 100644\n--- a/plone/namedfile/tests/test_blobfile.py\n+++ b/plone/namedfile/tests/test_blobfile.py\n@@ -15,6 +15,7 @@\n #\n ##############################################################################\n \n+from DateTime import DateTime\n from plone.namedfile import storages\n from plone.namedfile.file import NamedBlobFile\n from plone.namedfile.file import NamedBlobImage\n@@ -24,11 +25,12 @@\n from plone.namedfile.testing import PLONE_NAMEDFILE_FUNCTIONAL_TESTING\n from plone.namedfile.testing import PLONE_NAMEDFILE_INTEGRATION_TESTING\n from plone.namedfile.tests.test_image import zptlogo\n+from plone.namedfile.tests import MockNamedBlobImage\n from zope.component import provideUtility\n from zope.interface.verify import verifyClass\n \n-import os\n import struct\n+import time\n import transaction\n import unittest\n \n@@ -56,11 +58,13 @@ def testEmpty(self):\n file = self._makeImage()\n self.assertEqual(file.contentType, "")\n self.assertEqual(file.data, b"")\n+ self.assertIsNotNone(file.modified)\n \n def testConstructor(self):\n file = self._makeImage(b"Data")\n self.assertEqual(file.contentType, "")\n self.assertEqual(file.data, b"Data")\n+ self.assertIsNotNone(file.modified)\n \n def testMutators(self):\n image = self._makeImage()\n@@ -73,6 +77,24 @@ def testMutators(self):\n self.assertEqual(image.contentType, "image/gif")\n self.assertEqual(image.getImageSize(), (16, 16))\n \n+ def testModifiedTimeStamp(self):\n+ image = self._makeImage()\n+ old_timestamp = image.modified\n+ time.sleep(1/1000) # make sure at least 1ms passes\n+ image._setData(zptlogo)\n+ self.assertNotEqual(image.modified, old_timestamp)\n+\n+ def testFallBackToDatabaseModifiedTimeStamp(self):\n+ dt = DateTime()\n+ image = MockNamedBlobImage()\n+ image._p_mtime = dt.millis()\n+ image._modified = (dt + 1).millis()\n+\n+ delattr(image, "_modified")\n+ marker = object()\n+ self.assertEqual(marker, getattr(image, "_modified", marker))\n+ self.assertEqual(dt.millis(), image._p_mtime)\n+\n def testInterface(self):\n self.assertTrue(INamedBlobImage.implementedBy(NamedBlobImage))\n self.assertTrue(verifyClass(INamedBlobImage, NamedBlobImage))\ndiff --git a/plone/namedfile/tests/test_image.py b/plone/namedfile/tests/test_image.py\nindex 4629207..8360a97 100644\n--- a/plone/namedfile/tests/test_image.py\n+++ b/plone/namedfile/tests/test_image.py\n@@ -1,12 +1,15 @@\n # This file is borrowed from zope.app.file and licensed ZPL.\n \n+from DateTime import DateTime\n from plone.namedfile.file import NamedImage\n from plone.namedfile.interfaces import INamedImage\n from plone.namedfile.testing import PLONE_NAMEDFILE_INTEGRATION_TESTING\n from plone.namedfile.tests import getFile\n+from plone.namedfile.tests import MockNamedImage\n from plone.namedfile.utils import get_contenttype\n from zope.interface.verify import verifyClass\n \n+import time\n import unittest\n \n \n@@ -39,15 +42,16 @@ def testEmpty(self):\n file_img = self._makeImage()\n self.assertEqual(file_img.contentType, "")\n self.assertEqual(bytes(file_img.data), b"")\n+ self.assertIsNotNone(file_img.modified)\n \n def testConstructor(self):\n file_img = self._makeImage(b"Data")\n self.assertEqual(file_img.contentType, "")\n self.assertEqual(bytes(file_img.data), b"Data")\n+ self.assertIsNotNone(file_img.modified)\n \n def testMutators(self):\n image = self._makeImage()\n-\n image.contentType = "image/jpeg"\n self.assertEqual(image.contentType, "image/jpeg")\n \n@@ -56,6 +60,24 @@ def testMutators(self):\n self.assertEqual(image.contentType, "image/gif")\n self.assertEqual(image.getImageSize(), (16, 16))\n \n+ def testModifiedTimeStamp(self):\n+ image = self._makeImage()\n+ old_timestamp = image.modified\n+ time.sleep(1/1000) # make sure at least 1ms passes\n+ image._setData(zptlogo)\n+ self.assertNotEqual(image.modified, old_timestamp)\n+\n+ def testFallBackToDatabaseModifiedTimeStamp(self):\n+ dt = DateTime()\n+ image = MockNamedImage()\n+ image._p_mtime = dt.millis()\n+ image._modified = (dt + 1).millis()\n+\n+ delattr(image, "_modified")\n+ marker = object()\n+ self.assertEqual(marker, getattr(image, "_modified", marker))\n+ self.assertEqual(dt.millis(), image._p_mtime)\n+\n def testInterface(self):\n self.assertTrue(INamedImage.implementedBy(NamedImage))\n self.assertTrue(verifyClass(INamedImage, NamedImage))\ndiff --git a/plone/namedfile/tests/test_scaling.py b/plone/namedfile/tests/test_scaling.py\nindex 7934763..4159ebf 100644\n--- a/plone/namedfile/tests/test_scaling.py\n+++ b/plone/namedfile/tests/test_scaling.py\n@@ -11,6 +11,7 @@\n from plone.namedfile.testing import PLONE_NAMEDFILE_FUNCTIONAL_TESTING\n from plone.namedfile.testing import PLONE_NAMEDFILE_INTEGRATION_TESTING\n from plone.namedfile.tests import getFile\n+from plone.namedfile.tests import MockNamedImage\n from plone.rfc822.interfaces import IPrimaryFieldInfo\n from plone.scale.interfaces import IScaledImageQuality\n from plone.scale.storage import IImageScaleStorage\n@@ -143,10 +144,6 @@ def value(self):\n return self.field\n \n \n-class MockNamedImage(NamedImage):\n- _p_mtime = DateTime().millis()\n-\n-\n @implementer(IScaledImageQuality)\n class DummyQualitySupplier:\n """fake utility for image quality setting from imaging control panel."""\n' +b'diff --git a/news/56.bugfix b/news/56.bugfix\nnew file mode 100644\nindex 0000000..a362c51\n--- /dev/null\n+++ b/news/56.bugfix\n@@ -0,0 +1,4 @@\n+Removed no longer working ``pulls`` command that I have never used.\n+Removed ``PyGithub`` and ``keyring`` dependencies that were only used for this.\n+Removed ``Source.path`` property that was only used for this.\n+[maurits]\ndiff --git a/plone/releaser/base.py b/plone/releaser/base.py\nnew file mode 100644\nindex 0000000..536710f\n--- /dev/null\n+++ b/plone/releaser/base.py\n@@ -0,0 +1,45 @@\n+from collections import UserDict\n+\n+import pathlib\n+\n+\n+class BaseFile(UserDict):\n+ def __init__(self, file_location):\n+ self.file_location = file_location\n+ self.path = pathlib.Path(self.file_location).resolve()\n+\n+ @property\n+ def data(self):\n+ raise NotImplementedError\n+\n+ def __iter__(self):\n+ return self.data.__iter__()\n+\n+ def __contains__(self, package_name):\n+ return package_name.lower() in self.data\n+\n+ def __getitem__(self, package_name):\n+ if package_name in self:\n+ return self.data.get(package_name.lower())\n+ raise KeyError\n+\n+ def __setitem__(self, package_name, value):\n+ raise NotImplementedError\n+\n+ def __delitem__(self, package_name):\n+ return self.__setitem__(package_name, False)\n+\n+ def get(self, package_name, default=None):\n+ if package_name in self:\n+ return self.__getitem__(package_name)\n+ return default\n+\n+ def set(self, package_name, value):\n+ return self.__setitem__(package_name, value)\n+\n+ def add(self, package_name):\n+ # This only makes sense for files where package_name maps to True or False.\n+ return self.__setitem__(package_name, True)\n+\n+ def remove(self, package_name):\n+ return self.__delitem__(package_name)\ndiff --git a/plone/releaser/buildout.py b/plone/releaser/buildout.py\nindex 0e3742a..b1fca5c 100644\n--- a/plone/releaser/buildout.py\n+++ b/plone/releaser/buildout.py\n@@ -1,6 +1,6 @@\n+from .base import BaseFile\n from .utils import update_contents\n from collections import OrderedDict\n-from collections import UserDict\n from configparser import ConfigParser\n from configparser import ExtendedInterpolation\n \n@@ -9,52 +9,57 @@\n import re\n \n \n-PATH_RE = re.compile(\n- r"(\\w+://)(.+@)*([\\w\\d\\.]+)(:[\\d]+){0,1}/(?P.+(?=\\.git))(\\.git)"\n-)\n-\n-\n class Source:\n- def __init__(self, protocol=None, url=None, push_url=None, branch=None):\n+ """Source definition for mr.developer"""\n+\n+ def __init__(\n+ self, protocol=None, url=None, pushurl=None, branch=None, path=None, egg=True\n+ ):\n+ # I think mxdev only supports git as protocol.\n self.protocol = protocol\n self.url = url\n- self.push_url = push_url\n+ self.pushurl = pushurl\n self.branch = branch\n-\n- def create_from_string(self, source_string):\n- protocol, url, extra_1, extra_2, extra_3 = (\n- lambda a, b, c=None, d=None, e=None: (a, b, c, d, e)\n- )(*source_string.split())\n- for param in [extra_1, extra_2, extra_3]:\n+ # mxdev has target (default: sources) instead of path (default: src).\n+ self.path = path\n+ # egg=True: mxdev install-mode="direct"\n+ # egg=False: mxdev install-mode="skip"\n+ self.egg = egg\n+\n+ @classmethod\n+ def create_from_string(cls, source_string):\n+ line_options = source_string.split()\n+ protocol = line_options.pop(0)\n+ url = line_options.pop(0)\n+ # September 2023: mr.developer defaults to master, mxdev to main.\n+ options = {"protocol": protocol, "url": url, "branch": "master"}\n+\n+ # The rest of the line options are key/value pairs.\n+ for param in line_options:\n if param is not None:\n key, value = param.split("=")\n- setattr(self, key, value)\n- self.protocol = protocol\n- self.url = url\n- if self.push_url is not None:\n- self.push_url = self.push_url.split("=")[-1]\n- if self.branch is None:\n- self.branch = "master"\n- else:\n- self.branch = self.branch.split("=")[-1]\n- return self\n+ if key == "egg":\n+ if value.lower() in ("true", "yes", "on"):\n+ value = True\n+ elif value.lower() in ("false", "no", "off"):\n+ value = False\n+ options[key] = value\n+ return cls(**options)\n \n- @property\n- def path(self):\n- if self.url:\n- match = PATH_RE.match(self.url)\n- if match:\n- return match.groupdict()["path"]\n- return None\n+ def __repr__(self):\n+ return f""\n+\n+ def __eq__(self, other):\n+ return repr(self) == repr(other)\n \n \n-class VersionsFile:\n+class VersionsFile(BaseFile):\n def __init__(self, file_location):\n self.file_location = file_location\n self.path = pathlib.Path(self.file_location).resolve()\n \n @property\n- def versions(self):\n+ def data(self):\n """Read the versions config.\n \n We use strict=False to avoid a DuplicateOptionError.\n@@ -73,7 +78,7 @@ def versions(self):\n So we do not want to report or edit anything except the versions section.\n """\n config = ConfigParser(interpolation=ExtendedInterpolation(), strict=False)\n- with open(self.file_location) as f:\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@@ -86,14 +91,6 @@ def versions(self):\n versions[package] = version\n return versions\n \n- def __contains__(self, package_name):\n- return package_name.lower() in self.versions\n-\n- def __getitem__(self, package_name):\n- if package_name in self:\n- return self.versions.get(package_name.lower())\n- raise KeyError\n-\n def __setitem__(self, package_name, new_version):\n contents = self.path.read_text()\n if not contents.endswith("\\n"):\n@@ -122,71 +119,45 @@ def stop_check(line):\n if contents != new_contents:\n self.path.write_text(new_contents)\n \n- def get(self, package_name, default=None):\n- if package_name in self:\n- return self.__getitem__(package_name)\n- return default\n-\n- def set(self, package_name, new_version):\n- return self.__setitem__(package_name, new_version)\n-\n-\n-class SourcesFile(UserDict):\n- def __init__(self, file_location):\n- self.file_location = file_location\n- self.path = pathlib.Path(self.file_location).resolve()\n \n+class SourcesFile(BaseFile):\n @property\n def data(self):\n config = ConfigParser(interpolation=ExtendedInterpolation())\n config.optionxform = str\n- with open(self.file_location) as f:\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- config["buildout"]["docs-directory"] = os.path.join(os.getcwd(), "docs")\n sources_dict = OrderedDict()\n for name, value in config["sources"].items():\n- try:\n- source = Source().create_from_string(value)\n- except TypeError:\n- # Happens now for the documentation items in coredev 6.0.\n- # We could print, but this gets printed a lot.\n- continue\n- sources_dict[name] = source\n+ source = Source.create_from_string(value)\n+ sources_dict[name.lower()] = source\n return sources_dict\n \n def __setitem__(self, package_name, value):\n raise NotImplementedError\n \n- def __iter__(self):\n- return self.data.__iter__()\n-\n-\n-class CheckoutsFile(UserDict):\n- def __init__(self, file_location):\n- self.file_location = file_location\n- self.path = pathlib.Path(self.file_location).resolve()\n \n+class CheckoutsFile(BaseFile):\n @property\n def data(self):\n config = ConfigParser(interpolation=ExtendedInterpolation())\n- with open(self.file_location) as f:\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 # Map from lower case to actual case, so we can find the package.\n mapping = {}\n for package in checkouts.splitlines():\n+ if not package:\n+ continue\n mapping[package.lower()] = package\n return mapping\n \n- def __contains__(self, package_name):\n- return package_name.lower() in self.data\n-\n def __setitem__(self, package_name, enabled=True):\n contents = self.path.read_text()\n if not contents.endswith("\\n"):\n@@ -207,15 +178,9 @@ def line_check(line):\n if contents != new_contents:\n self.path.write_text(new_contents)\n \n- def __delitem__(self, package_name):\n- return self.__setitem__(package_name, False)\n-\n- def add(self, package_name):\n- return self.__setitem__(package_name, True)\n-\n- def remove(self, package_name):\n- # Remove from checkouts.cfg\n- return self.__delitem__(package_name)\n+ def set(self, package_name, new_version):\n+ # This method makes no sense for this class.\n+ raise NotImplementedError\n \n \n class Buildout:\ndiff --git a/plone/releaser/manage.py b/plone/releaser/manage.py\nindex 89e79a9..2345b53 100644\n--- a/plone/releaser/manage.py\n+++ b/plone/releaser/manage.py\n@@ -1,7 +1,6 @@\n from argh import arg\n from argh import ArghParser\n from argh.decorators import named\n-from github import Github\n from plone.releaser import ACTION_BATCH\n from plone.releaser import ACTION_INTERACTIVE\n from plone.releaser import ACTION_REPORT\n@@ -16,7 +15,6 @@\n from progress.bar import Bar\n \n import glob\n-import keyring\n import time\n \n \n@@ -87,22 +85,6 @@ def checkAllPackagesForUpdates(**kwargs):\n time.sleep(sleep)\n \n \n-def pulls():\n- client_id = "b9f6639835b8c9cf462a"\n- client_secret = keyring.get_password("plone.releaser", client_id)\n-\n- g = Github(client_id=client_id, client_secret=client_secret)\n-\n- for package_name, source in buildout.sources.items():\n- if source.path:\n- repo = g.get_repo(source.path)\n- pulls = [a for a in repo.get_pulls("open") if a.head.ref == source.branch]\n- if pulls:\n- print(package_name)\n- for pull in pulls:\n- print(f" {pull.user.login}: {pull.title} ({pull.url})")\n-\n-\n @named("changelog")\n @arg("--start")\n @arg("--end", default="here")\n@@ -257,7 +239,6 @@ def __call__(self, **kwargs):\n checkPypi,\n checkPackageForUpdates,\n checkAllPackagesForUpdates,\n- pulls,\n changelog,\n check_checkout,\n remove_checkout,\ndiff --git a/plone/releaser/pip.py b/plone/releaser/pip.py\nindex 4c290f3..9e59b43 100644\n--- a/plone/releaser/pip.py\n+++ b/plone/releaser/pip.py\n@@ -1,10 +1,9 @@\n+from .base import BaseFile\n from .utils import update_contents\n-from collections import UserDict\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,13 +15,9 @@ def to_bool(value):\n return False\n \n \n-class ConstraintsFile:\n- def __init__(self, file_location):\n- self.file_location = file_location\n- self.path = pathlib.Path(self.file_location).resolve()\n-\n+class ConstraintsFile(BaseFile):\n @cached_property\n- def constraints(self):\n+ def data(self):\n """Read the constraints."""\n contents = self.path.read_text()\n constraints = {}\n@@ -53,14 +48,6 @@ def constraints(self):\n constraints[package] = version\n return constraints\n \n- def __contains__(self, package_name):\n- return package_name.lower() in self.constraints\n-\n- def __getitem__(self, package_name):\n- if package_name in self:\n- return self.constraints.get(package_name.lower())\n- raise KeyError\n-\n def __setitem__(self, package_name, new_version):\n contents = self.path.read_text()\n if not contents.endswith("\\n"):\n@@ -82,16 +69,8 @@ def line_check(line):\n if contents != new_contents:\n self.path.write_text(new_contents)\n \n- def get(self, package_name, default=None):\n- if package_name in self:\n- return self.__getitem__(package_name)\n- return default\n-\n- def set(self, package_name, new_version):\n- return self.__setitem__(package_name, new_version)\n-\n \n-class IniFile(UserDict):\n+class IniFile(BaseFile):\n """Ini file for mxdev.\n \n What we want to do here is similar to what we have in buildout.py\n@@ -101,13 +80,12 @@ class IniFile(UserDict):\n """\n \n def __init__(self, file_location):\n- self.file_location = file_location\n- self.path = pathlib.Path(self.file_location).resolve()\n+ super().__init__(file_location)\n self.config = ConfigParser(\n default_section="settings",\n interpolation=ExtendedInterpolation(),\n )\n- with open(self.file_location) as f:\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 \n@@ -121,8 +99,14 @@ def data(self):\n checkouts[package.lower()] = package\n return checkouts\n \n- def __contains__(self, package_name):\n- return package_name.lower() in self.data\n+ @property\n+ def sections(self):\n+ # If we want to use a package, we must first know that it exists.\n+ sections = {}\n+ for package in self.config.sections():\n+ # Map from lower case to actual case, so we can find the package.\n+ sections[package.lower()] = package\n+ return sections\n \n def __setitem__(self, package_name, enabled=True):\n """Enable or disable a checkout.\n@@ -133,10 +117,19 @@ def __setitem__(self, package_name, enabled=True):\n \n But let\'s support the other way around as well:\n when default-use is true, we set \'use = false\'.\n+\n+ Note that in our Buildout setup, we have sources.cfg separately.\n+ In mxdev.ini the source definition and \'use = false/true\' is combined.\n+ So if the package we want to enable is not defined, meaning it has no\n+ section, then we should fail loudly.\n """\n- stored_package_name = self.data.get(package_name.lower())\n- if stored_package_name:\n- package_name = stored_package_name\n+ stored_package_name = self.sections.get(package_name.lower())\n+ if not stored_package_name:\n+ raise KeyError(\n+ f"{self.file_location}: There is no definition for {package_name}"\n+ )\n+ package_name = stored_package_name\n+ if package_name in self:\n use = to_bool(self.config[package_name].get("use", self.default_use))\n else:\n use = False\n@@ -154,7 +147,9 @@ def __setitem__(self, package_name, enabled=True):\n \n lines = []\n found_package = False\n- for line in contents.splitlines():\n+ # Add extra line at the end. This eases parsing and editing the final section.\n+ orig_lines = contents.splitlines() + ["\\n"]\n+ for line in orig_lines:\n line = line.rstrip()\n if line == f"[{package_name}]":\n found_package = True\n@@ -188,15 +183,5 @@ def __setitem__(self, package_name, enabled=True):\n # Just a regular line.\n lines.append(line)\n \n- contents = "\\n".join(lines) + "\\n"\n+ contents = "\\n".join(lines)\n self.path.write_text(contents)\n-\n- def __delitem__(self, package_name):\n- return self.__setitem__(package_name, False)\n-\n- def add(self, package_name):\n- return self.__setitem__(package_name, True)\n-\n- def remove(self, package_name):\n- # Remove from checkouts.\n- return self.__delitem__(package_name)\ndiff --git a/plone/releaser/tests/input/checkouts.cfg b/plone/releaser/tests/input/checkouts.cfg\nnew file mode 100644\nindex 0000000..317719d\n--- /dev/null\n+++ b/plone/releaser/tests/input/checkouts.cfg\n@@ -0,0 +1,7 @@\n+[buildout]\n+always-checkout = force\n+# always-checkout = false\n+auto-checkout =\n+# These packages are always checkout out:\n+ CamelCase\n+ package\ndiff --git a/plone/releaser/tests/input/mxdev.ini b/plone/releaser/tests/input/mxdev.ini\nnew file mode 100644\nindex 0000000..a0c15fd\n--- /dev/null\n+++ b/plone/releaser/tests/input/mxdev.ini\n@@ -0,0 +1,22 @@\n+[settings]\n+requirements-in = requirements.txt\n+requirements-out = requirements-mxdev.txt\n+contraints-out = constraints-mxdev.txt\n+# The packages are defined, but not used by default.\n+default-use = false\n+# custom variables\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\ndiff --git a/plone/releaser/tests/input/sources.cfg b/plone/releaser/tests/input/sources.cfg\nnew file mode 100644\nindex 0000000..bd3e54e\n--- /dev/null\n+++ b/plone/releaser/tests/input/sources.cfg\n@@ -0,0 +1,16 @@\n+[buildout]\n+extends =\n+ https://raw.githubusercontent.com/zopefoundation/Zope/master/sources.cfg\n+# Define a docs directory.\n+# Must be defined in this file, otherwise mr.roboto fails when it parses only 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 egg=false branch=6.0 path=${buildout:docs-directory}\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\n+plone.base = git ${remotes:plone}/plone.base.git branch=main\ndiff --git a/plone/releaser/tests/test_buildout.py b/plone/releaser/tests/test_buildout.py\nindex 03f4f4e..eb479e8 100644\n--- a/plone/releaser/tests/test_buildout.py\n+++ b/plone/releaser/tests/test_buildout.py\n@@ -1,5 +1,9 @@\n+from plone.releaser.buildout import CheckoutsFile\n+from plone.releaser.buildout import Source\n+from plone.releaser.buildout import SourcesFile\n from plone.releaser.buildout import VersionsFile\n \n+import os\n import pathlib\n import pytest\n import shutil\n@@ -7,13 +11,170 @@\n \n TESTS_DIR = pathlib.Path(__file__).parent\n INPUT_DIR = TESTS_DIR / "input"\n+CHECKOUTS_FILE = INPUT_DIR / "checkouts.cfg"\n+SOURCES_FILE = INPUT_DIR / "sources.cfg"\n VERSIONS_FILE = INPUT_DIR / "versions.cfg"\n \n \n+def test_checkouts_file_data():\n+ cf = CheckoutsFile(CHECKOUTS_FILE)\n+ # The data maps lower case to actual case.\n+ assert cf.data == {\n+ "camelcase": "CamelCase",\n+ "package": "package",\n+ }\n+\n+\n+def test_checkouts_file_contains():\n+ cf = CheckoutsFile(CHECKOUTS_FILE)\n+ assert "package" in cf\n+ assert "nope" not in cf\n+ # We compare case insensitively.\n+ assert "camelcase" in cf\n+ assert "CamelCase" in cf\n+ assert "CAMELCASE" in cf\n+\n+\n+def test_checkouts_file_get():\n+ cf = CheckoutsFile(CHECKOUTS_FILE)\n+ # The data maps lower case to actual case.\n+ assert cf["package"] == "package"\n+ assert cf.get("package") == "package"\n+ assert cf["camelcase"] == "CamelCase"\n+ assert cf["CAMELCASE"] == "CamelCase"\n+ assert cf["CamelCase"] == "CamelCase"\n+ with pytest.raises(KeyError):\n+ cf["nope"]\n+\n+\n+def test_checkouts_file_add(tmp_path):\n+ # When we add or remove a checkout, the file changes, so we work on a copy.\n+ copy_path = tmp_path / "checkouts.cfg"\n+ shutil.copyfile(CHECKOUTS_FILE, copy_path)\n+ cf = CheckoutsFile(copy_path)\n+ assert "Extra" not in cf\n+ cf.add("Extra")\n+ # Let\'s read it fresh, for good measure.\n+ cf = CheckoutsFile(copy_path)\n+ assert "Extra" in cf\n+ assert cf.get("extra") == "Extra"\n+\n+\n+def test_checkouts_file_remove(tmp_path):\n+ copy_path = tmp_path / "checkouts.cfg"\n+ shutil.copyfile(CHECKOUTS_FILE, copy_path)\n+ cf = CheckoutsFile(copy_path)\n+ assert "package" in cf\n+ cf.remove("package")\n+ # Let\'s read it fresh, for good measure.\n+ cf = CheckoutsFile(copy_path)\n+ assert "package" not in cf\n+ assert "CAMELCASE" in cf\n+ cf.remove("CAMELCASE")\n+ cf = CheckoutsFile(copy_path)\n+ assert "CAMELCASE" not in cf\n+ assert "CamelCase" not in cf\n+ assert "camelcase" not in cf\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+ )\n+ assert src.protocol == "git"\n+ assert src.url == "https://github.com/plone/Plone.git"\n+ assert src.pushurl == "git@github.com:plone/Plone.git"\n+ assert src.branch == "6.0.x"\n+ assert src.egg is True\n+ assert src.path is None\n+\n+\n+def test_source_not_enough_parameters():\n+ with pytest.raises(IndexError):\n+ Source.create_from_string("")\n+ with pytest.raises(IndexError):\n+ Source.create_from_string("git")\n+\n+\n+def test_source_just_enough_parameters():\n+ # protocol and url are enough\n+ src = Source.create_from_string("git https://github.com/plone/Plone.git")\n+ assert src.protocol == "git"\n+ assert src.url == "https://github.com/plone/Plone.git"\n+ assert src.pushurl is None\n+ assert src.branch == "master"\n+ assert src.egg is True\n+ assert src.path is None\n+\n+\n+def test_source_docs():\n+ # Plone has a docs source with some extra options.\n+ src = Source.create_from_string(\n+ "git https://github.com/plone/documentation.git pushurl=git@github.com:plone/documentation.git egg=false branch=6.0 path=docs"\n+ )\n+ assert src.protocol == "git"\n+ assert src.url == "https://github.com/plone/documentation.git"\n+ assert src.pushurl == "git@github.com:plone/documentation.git"\n+ assert src.branch == "6.0"\n+ assert src.egg is False\n+ assert src.path == "docs"\n+\n+\n+def test_sources_file_data():\n+ sf = SourcesFile(SOURCES_FILE)\n+ # Note that the keys are lowercase.\n+ assert sorted(sf.data.keys()) == ["docs", "plone", "plone.alterego", "plone.base"]\n+\n+\n+def test_sources_file_contains():\n+ sf = SourcesFile(SOURCES_FILE)\n+ assert "docs" in sf\n+ assert "plone.base" in sf\n+ assert "nope" not in sf\n+ # We compare case insensitively.\n+ assert "Plone" in sf\n+ assert "plone" in sf\n+ assert "PLONE" in sf\n+ assert "PLONE.BASE" in sf\n+\n+\n+def test_sources_file_get():\n+ sf = SourcesFile(SOURCES_FILE)\n+ with pytest.raises(KeyError):\n+ assert sf["nope"]\n+ assert sf["plone"] == sf["PLONE"]\n+ assert sf["plone"] == sf["Plone"]\n+ assert sf["plone"] != sf["plone.base"]\n+ plone = sf["plone"]\n+ assert plone.url == "https://github.com/plone/Plone.git"\n+ assert plone.pushurl == "git@github.com:plone/Plone.git"\n+ assert plone.branch == "6.0.x"\n+ assert plone.path is None\n+ assert plone.egg\n+ docs = sf["docs"]\n+ assert docs.url == "https://github.com/plone/documentation.git"\n+ assert docs.pushurl is None\n+ assert docs.branch == "6.0"\n+ assert docs.path == f"{os.getcwd()}/documentation"\n+ assert not docs.egg\n+ alterego = sf["plone.alterego"]\n+ assert alterego.url == "https://github.com/plone/plone.alterego.git"\n+ assert alterego.pushurl is None\n+ assert alterego.branch == "master"\n+ assert alterego.path is None\n+ assert alterego.egg\n+ base = sf["plone.base"]\n+ assert base.url == "https://github.com/plone/plone.base.git"\n+ assert base.pushurl is None\n+ assert base.branch == "main"\n+ assert base.path is None\n+ assert base.egg\n+\n+\n def test_versions_file_versions():\n vf = VersionsFile(VERSIONS_FILE)\n # All versions are reported lowercased.\n- assert vf.versions == {\n+ assert vf.data == {\n "annotated": "1.0",\n "camelcase": "1.0",\n "duplicate": "1.0",\ndiff --git a/plone/releaser/tests/test_pip.py b/plone/releaser/tests/test_pip.py\nindex 4ef3d6d..5e256bf 100644\n--- a/plone/releaser/tests/test_pip.py\n+++ b/plone/releaser/tests/test_pip.py\n@@ -1,4 +1,5 @@\n from plone.releaser.pip import ConstraintsFile\n+from plone.releaser.pip import IniFile\n \n import pathlib\n import pytest\n@@ -8,12 +9,94 @@\n TESTS_DIR = pathlib.Path(__file__).parent\n INPUT_DIR = TESTS_DIR / "input"\n CONSTRAINTS_FILE = INPUT_DIR / "constraints.txt"\n+MXDEV_FILE = INPUT_DIR / "mxdev.ini"\n+\n+\n+def test_mxdev_file_data():\n+ mf = IniFile(MXDEV_FILE)\n+ # The data maps lower case to actual case.\n+ assert mf.data == {\n+ "camelcase": "CamelCase",\n+ "package": "package",\n+ }\n+\n+\n+def test_mxdev_file_contains():\n+ mf = IniFile(MXDEV_FILE)\n+ assert "package" in mf\n+ assert "unused" not in mf\n+ # We compare case insensitively.\n+ assert "camelcase" in mf\n+ assert "CamelCase" in mf\n+ assert "CAMELCASE" in mf\n+\n+\n+def test_mxdev_file_get():\n+ mf = IniFile(MXDEV_FILE)\n+ # The data maps lower case to actual case.\n+ assert mf["package"] == "package"\n+ assert mf.get("package") == "package"\n+ assert mf["camelcase"] == "CamelCase"\n+ assert mf["CAMELCASE"] == "CamelCase"\n+ assert mf["CamelCase"] == "CamelCase"\n+ with pytest.raises(KeyError):\n+ mf["unused"]\n+ assert mf.get("unused") is None\n+\n+\n+def test_mxdev_file_add_known(tmp_path):\n+ # When we add or remove a checkout, the file changes, so we work on a copy.\n+ copy_path = tmp_path / "mxdev.ini"\n+ shutil.copyfile(MXDEV_FILE, copy_path)\n+ mf = IniFile(copy_path)\n+ assert "unused" not in mf\n+ mf.add("unused")\n+ # Let\'s read it fresh, for good measure.\n+ mf = IniFile(copy_path)\n+ assert "unused" in mf\n+ assert mf["unused"] == "unused"\n+\n+\n+def test_mxdev_file_add_unknown(tmp_path):\n+ # We cannot edit mxdev.ini to use a package when it is not defined.\n+ copy_path = tmp_path / "mxdev.ini"\n+ shutil.copyfile(MXDEV_FILE, copy_path)\n+ mf = IniFile(copy_path)\n+ assert "unknown" not in mf\n+ with pytest.raises(KeyError):\n+ mf.add("unknown")\n+\n+\n+def test_mxdev_file_remove(tmp_path):\n+ copy_path = tmp_path / "mxdev.ini"\n+ shutil.copyfile(MXDEV_FILE, copy_path)\n+ mf = IniFile(copy_path)\n+ assert "package" in mf\n+ mf.remove("package")\n+ # Let\'s read it fresh, for good measure.\n+ mf = IniFile(copy_path)\n+ assert "package" not in mf\n+ assert "CAMELCASE" in mf\n+ mf.remove("CAMELCASE")\n+ mf = IniFile(copy_path)\n+ assert "CAMELCASE" not in mf\n+ assert "CamelCase" not in mf\n+ assert "camelcase" not in mf\n+ # Check that we can re-enable a package:\n+ # editing should not remove the entire section.\n+ mf.add("package")\n+ mf = IniFile(copy_path)\n+ assert "package" in mf\n+ # This should work for the last section as well.\n+ mf.add("CamelCase")\n+ mf = IniFile(copy_path)\n+ assert "CamelCase" in mf\n \n \n def test_constraints_file_constraints():\n cf = ConstraintsFile(CONSTRAINTS_FILE)\n # All constraints are reported lowercased.\n- assert cf.constraints == {\n+ assert cf.data == {\n "annotated": "1.0",\n "camelcase": "1.0",\n "duplicate": "1.0",\ndiff --git a/setup.py b/setup.py\nindex f5230c8..fe3c0fd 100644\n--- a/setup.py\n+++ b/setup.py\n@@ -41,8 +41,6 @@\n "gitpython>=3.0.0",\n "configparser",\n "progress",\n- "PyGithub",\n- "keyring",\n "zest.releaser[recommended]>=7.2.0",\n "zestreleaser.towncrier>=1.3.0",\n "docutils",\n'