From 109fed3fc802dcce1e38cc8a55c02064b74f9be5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez-Mondrag=C3=B3n?= Date: Fri, 6 Sep 2024 00:24:32 -0600 Subject: [PATCH] Unify data classes --- docs/index.md | 37 ++----- src/pep610/__init__.py | 219 ++++++++++++++++------------------------- src/pep610/_types.py | 31 ++---- tests/test_parse.py | 93 +++++++++-------- 4 files changed, 146 insertions(+), 234 deletions(-) diff --git a/docs/index.md b/docs/index.md index 32587be..baf49c6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,7 +16,7 @@ import pep610 dist = metadata.distribution("pep610") match data := pep610.read_from_distribution(dist): - case pep610.DirData(url, pep610.DirInfo(editable=True)): + case pep610.DirectUrl(url, pep610.DirInfo(editable=True)): print("Editable installation, a.k.a. in development mode") case _: print("Not an editable installation") @@ -34,8 +34,9 @@ dist = metadata.distribution("pep610") if ( (data := pep610.read_from_distribution(dist)) - and isinstance(data, pep610.DirData) - and data.dir_info.is_editable() + and isinstance(data, pep610.DirectUrl) + and isinstance(data.info, pep610.DirInfo) + and data.info.is_editable() ): print("Editable installation, a.k.a. in development mode") else: @@ -74,24 +75,14 @@ for package in report["install"]: print(data) ``` -## Supported formats +## Direct URL Data class ```{eval-rst} -.. autoclass:: pep610.ArchiveData +.. autoclass:: pep610.DirectUrl :members: ``` -```{eval-rst} -.. autoclass:: pep610.DirData - :members: -``` - -```{eval-rst} -.. autoclass:: pep610.VCSData - :members: -``` - -## Other classes +## Supported direct URL formats ```{eval-rst} .. autoclass:: pep610.ArchiveInfo @@ -135,27 +126,17 @@ for package in report["install"]: :members: hashes, hash ``` -```{eval-rst} -.. autoclass:: pep610._types.ArchiveDict - :members: url, archive_info -``` - ```{eval-rst} .. autoclass:: pep610._types.DirectoryInfoDict :members: editable ``` -```{eval-rst} -.. autoclass:: pep610._types.DirectoryDict - :members: url, dir_info -``` - ```{eval-rst} .. autoclass:: pep610._types.VCSInfoDict :members: vcs, commit_id, requested_revision, resolved_revision, resolved_revision_type ``` ```{eval-rst} -.. autoclass:: pep610._types.VCSDict - :members: url ,vcs_info +.. autoclass:: pep610._types.DirectUrlDict + :members: url, vcs_info, archive_info, dir_info ``` diff --git a/src/pep610/__init__.py b/src/pep610/__init__.py index cee9e2e..a20b381 100644 --- a/src/pep610/__init__.py +++ b/src/pep610/__init__.py @@ -2,12 +2,10 @@ from __future__ import annotations -import abc import hashlib import json import typing as t from dataclasses import dataclass -from functools import singledispatch from importlib.metadata import distribution, version if t.TYPE_CHECKING: @@ -20,21 +18,16 @@ from typing import Self from pep610._types import ( - ArchiveDict, ArchiveInfoDict, - DirectoryDict, DirectoryInfoDict, - VCSDict, + DirectUrlDict, VCSInfoDict, ) __all__ = [ - "ArchiveData", "ArchiveInfo", - "DirData", "DirInfo", "HashData", - "VCSData", "VCSInfo", "__version__", "read_from_distribution", @@ -44,6 +37,8 @@ __version__ = version(__package__) +T = t.TypeVar("T") + DIRECT_URL_METADATA_NAME = "direct_url.json" @@ -51,6 +46,11 @@ class DirectUrlValidationError(Exception): """Direct URL validation error.""" +def _filter_none(**kwargs: T) -> dict[str, T]: + """Make dict excluding None values.""" + return {k: v for k, v in kwargs.items() if v is not None} + + @dataclass class VCSInfo: """VCS information. @@ -64,6 +64,8 @@ class VCSInfo: compatible with the VCS). """ + key: t.ClassVar[str] = "vcs_info" + vcs: str commit_id: str requested_revision: str | None = None @@ -86,61 +88,13 @@ def to_dict(self) -> VCSInfoDict: >>> vcs_info.to_dict() {'vcs': 'git', 'commit_id': '4f42225e91a0be634625c09e84dd29ea82b85e27', 'requested_revision': 'main'} """ # noqa: E501 - vcs_info: VCSInfoDict = { - "vcs": self.vcs, - "commit_id": self.commit_id, - } - if self.requested_revision is not None: - vcs_info["requested_revision"] = self.requested_revision - if self.resolved_revision is not None: - vcs_info["resolved_revision"] = self.resolved_revision - if self.resolved_revision_type is not None: - vcs_info["resolved_revision_type"] = self.resolved_revision_type - - return vcs_info - - -@dataclass -class _BaseData(abc.ABC): - """Base direct URL data. - - Args: - url: The direct URL. - """ - - url: str - - @abc.abstractmethod - def to_dict(self) -> t.Mapping[str, t.Any]: - """Convert the data to a dictionary.""" - - def to_json(self) -> str: - """Convert the data to a JSON string. - - Returns: - The data as a JSON string. - """ - return json.dumps(self.to_dict(), sort_keys=True) - - -@dataclass -class VCSData(_BaseData): - """VCS direct URL data. - - Args: - url: The VCS URL. - vcs_info: VCS information. - """ - - vcs_info: VCSInfo - - def to_dict(self) -> VCSDict: - """Convert the VCS data to a dictionary. - - Returns: - The VCS data as a dictionary. - """ - return {"url": self.url, "vcs_info": self.vcs_info.to_dict()} + return _filter_none( # type: ignore[return-value] + vcs=self.vcs, + commit_id=self.commit_id, + requested_revision=self.requested_revision, + resolved_revision=self.resolved_revision, + resolved_revision_type=self.resolved_revision_type, + ) class HashData(t.NamedTuple): @@ -171,6 +125,8 @@ class ArchiveInfo: hash: The archive hash (deprecated). """ + key: t.ClassVar[str] = "archive_info" + hashes: dict[str, str] | None = None hash: HashData | None = None @@ -246,32 +202,10 @@ def to_dict(self) -> ArchiveInfoDict: >>> archive_info.to_dict() {'hashes': {'sha256': '1dc6b5a470a1bde68946f263f1af1515a2574a150a30d6ce02c6ff742fcc0db9', 'md5': 'c4e0f0a1e0a5e708c8e3e3c4cbe2e85f'}} """ # noqa: E501 - archive_info: ArchiveInfoDict = {} - if self.hashes is not None: - archive_info["hashes"] = self.hashes - if self.hash is not None: - archive_info["hash"] = f"{self.hash.algorithm}={self.hash.value}" - return archive_info - - -@dataclass -class ArchiveData(_BaseData): - """Archive direct URL data. - - Args: - url: The archive URL. - archive_info: Archive information. - """ - - archive_info: ArchiveInfo - - def to_dict(self) -> ArchiveDict: - """Convert the archive data to a dictionary. - - Returns: - The archive data as a dictionary. - """ - return {"url": self.url, "archive_info": self.archive_info.to_dict()} + return _filter_none( # type: ignore[return-value] + hashes=self.hashes, + hash=self.hash and f"{self.hash.algorithm}={self.hash.value}", + ) @dataclass @@ -284,6 +218,8 @@ class DirInfo: editable: Whether the distribution is installed in editable mode. """ + key: t.ClassVar[str] = "dir_info" + editable: bool | None def is_editable(self: Self) -> bool: @@ -327,63 +263,77 @@ def to_dict(self) -> DirectoryInfoDict: >>> dir_info.to_dict() {'editable': True} """ - dir_info: DirectoryInfoDict = {} - if self.editable is not None: - dir_info["editable"] = self.editable - - return dir_info + return _filter_none(editable=self.editable) # type: ignore[return-value] @dataclass -class DirData(_BaseData): - """Local directory direct URL data. +class DirectUrl: + """Direct URL data. Args: - url: The local directory URL. - dir_info: Local directory information. + url: The direct URL. + info: The direct URL data. + subdirectory: The optional directory path, relative to the root of the VCS repository, + source archive or local directory, to specify where pyproject.toml or setup.py + is located. """ - dir_info: DirInfo + url: str + info: VCSInfo | ArchiveInfo | DirInfo + subdirectory: str | None = None - def to_dict(self) -> DirectoryDict: - """Convert the directory data to a dictionary. + def to_dict(self) -> DirectUrlDict: + """Convert the data to a dictionary. Returns: - The directory data as a dictionary. - """ - return {"url": self.url, "dir_info": self.dir_info.to_dict()} + The data as a dictionary. + .. code-block:: pycon -@singledispatch -def to_dict(data) -> dict[str, t.Any]: # noqa: ANN001 - """Convert the parsed data to a dictionary. + >>> direct_url = DirectUrl( + ... url="file:///home/user/pep610", + ... info=DirInfo(editable=False), + ... subdirectory="app", + ... ) + >>> direct_url.to_dict() + {'url': 'file:///home/user/pep610', 'subdirectory': 'app', 'dir_info': {'editable': False}} + """ # noqa: E501 + res = _filter_none(url=self.url, subdirectory=self.subdirectory) + res[self.info.key] = self.info.to_dict() # type: ignore[assignment] + return res # type: ignore[return-value] - Args: - data: The parsed data. + def to_json(self) -> str: + """Convert the data to a JSON string. - Raises: - NotImplementedError: If the data type is not supported. - """ - message = f"Cannot serialize unknown direct URL data of type {type(data)}" - raise NotImplementedError(message) + Returns: + The data as a JSON string. + .. code-block:: pycon -@to_dict.register(VCSData) -def _(data: VCSData) -> VCSDict: - return data.to_dict() + >>> direct_url = DirectUrl( + ... url="file:///home/user/pep610", + ... info=DirInfo(editable=False), + ... subdirectory="app", + ... ) + >>> direct_url.to_json() + '{"dir_info": {"editable": false}, "subdirectory": "app", "url": "file:///home/user/pep610"}' + """ + return json.dumps(self.to_dict(), sort_keys=True) -@to_dict.register(ArchiveData) -def _(data: ArchiveData) -> ArchiveDict: - return data.to_dict() +def to_dict(data: DirectUrl) -> DirectUrlDict: + """Convert the parsed data to a dictionary. + Args: + data: The parsed data. -@to_dict.register(DirData) -def _(data: DirData) -> DirectoryDict: + Returns: + The data as a dictionary. + """ return data.to_dict() -def parse(data: dict) -> VCSData | ArchiveData | DirData: +def parse(data: dict) -> DirectUrl: """Parse the direct URL data. Args: @@ -407,7 +357,7 @@ def parse(data: dict) -> VCSData | ArchiveData | DirData: ... } ... } ... ) - VCSData(url='https://github.com/pypa/packaging', vcs_info=VCSInfo(vcs='git', commit_id='4f42225e91a0be634625c09e84dd29ea82b85e27', requested_revision='main', resolved_revision=None, resolved_revision_type=None)) + DirectUrl(url='https://github.com/pypa/packaging', info=VCSInfo(vcs='git', commit_id='4f42225e91a0be634625c09e84dd29ea82b85e27', requested_revision='main', resolved_revision=None, resolved_revision_type=None), subdirectory=None) """ # noqa: E501 if "archive_info" in data: hashes = data["archive_info"].get("hashes") @@ -415,36 +365,39 @@ def parse(data: dict) -> VCSData | ArchiveData | DirData: if hash_value := data["archive_info"].get("hash"): hash_data = HashData(*hash_value.split("=", 1)) if hash_value else None - return ArchiveData( + return DirectUrl( url=data["url"], - archive_info=ArchiveInfo(hashes=hashes, hash=hash_data), + info=ArchiveInfo(hashes=hashes, hash=hash_data), + subdirectory=data.get("subdirectory"), ) if "dir_info" in data: - return DirData( + return DirectUrl( url=data["url"], - dir_info=DirInfo( + info=DirInfo( editable=data["dir_info"].get("editable"), ), + subdirectory=data.get("subdirectory"), ) if "vcs_info" in data: - return VCSData( + return DirectUrl( url=data["url"], - vcs_info=VCSInfo( + info=VCSInfo( vcs=data["vcs_info"]["vcs"], commit_id=data["vcs_info"]["commit_id"], requested_revision=data["vcs_info"].get("requested_revision"), resolved_revision=data["vcs_info"].get("resolved_revision"), resolved_revision_type=data["vcs_info"].get("resolved_revision_type"), ), + subdirectory=data.get("subdirectory"), ) msg = "Direct URL data does not contain 'archive_info', 'dir_info', or 'vcs_info'" raise DirectUrlValidationError(msg) -def read_from_distribution(dist: Distribution) -> VCSData | ArchiveData | DirData | None: +def read_from_distribution(dist: Distribution) -> DirectUrl | None: """Read the package data for a given package. Args: @@ -481,10 +434,10 @@ def is_editable(distribution_name: str) -> bool: """ # noqa: DAR402, RUF100 dist = distribution(distribution_name) data = read_from_distribution(dist) - return isinstance(data, DirData) and data.dir_info.is_editable() + return data is not None and isinstance(data.info, DirInfo) and data.info.is_editable() -def write_to_distribution(dist: PathDistribution, data: dict | _BaseData) -> int: +def write_to_distribution(dist: PathDistribution, data: dict | DirectUrl) -> int: """Write the direct URL data to a distribution. Args: diff --git a/src/pep610/_types.py b/src/pep610/_types.py index 8558c63..e2068df 100644 --- a/src/pep610/_types.py +++ b/src/pep610/_types.py @@ -30,16 +30,6 @@ class VCSInfoDict(t.TypedDict, total=False): resolved_revision_type: str -class VCSDict(t.TypedDict): - """VCS direct URL data dictionary.""" - - #: The VCS URL. - url: str - - #: VCS information. - vcs_info: VCSInfoDict - - class ArchiveInfoDict(t.TypedDict, total=False): """Archive information dictionary.""" @@ -50,16 +40,6 @@ class ArchiveInfoDict(t.TypedDict, total=False): hash: str -class ArchiveDict(t.TypedDict): - """Archive direct URL data dictionary.""" - - #: The archive URL. - url: str - - #: Archive information. - archive_info: ArchiveInfoDict - - class DirectoryInfoDict(t.TypedDict, total=False): """Local directory information dictionary.""" @@ -67,11 +47,12 @@ class DirectoryInfoDict(t.TypedDict, total=False): editable: bool -class DirectoryDict(t.TypedDict): - """Local directory direct URL data dictionary.""" +class DirectUrlDict(t.TypedDict): + """Direct URL data dictionary.""" - #: The local directory URL. - url: str + #: The direct URL. + url: Required[str] - #: Directory information. + vcs_info: VCSInfoDict + archive_info: ArchiveInfoDict dir_info: DirectoryInfoDict diff --git a/tests/test_parse.py b/tests/test_parse.py index 5d78bde..d6ddf5d 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -18,25 +18,25 @@ [ pytest.param( {"url": "file:///home/user/project", "dir_info": {"editable": True}}, - pep610.DirData( + pep610.DirectUrl( url="file:///home/user/project", - dir_info=pep610.DirInfo(editable=True), + info=pep610.DirInfo(editable=True), ), id="local_editable", ), pytest.param( {"url": "file:///home/user/project", "dir_info": {"editable": False}}, - pep610.DirData( + pep610.DirectUrl( url="file:///home/user/project", - dir_info=pep610.DirInfo(editable=False), + info=pep610.DirInfo(editable=False), ), id="local_not_editable", ), pytest.param( {"url": "file:///home/user/project", "dir_info": {}}, - pep610.DirData( + pep610.DirectUrl( url="file:///home/user/project", - dir_info=pep610.DirInfo(editable=None), + info=pep610.DirInfo(editable=None), ), id="local_no_editable_info", ), @@ -50,9 +50,9 @@ } }, }, - pep610.ArchiveData( + pep610.DirectUrl( url="https://github.com/pypa/pip/archive/1.3.1.zip", - archive_info=pep610.ArchiveInfo( + info=pep610.ArchiveInfo( hashes={ "md5": "c4e0f0a1e0a5e708c8e3e3c4cbe2e85f", "sha256": "2dc6b5a470a1bde68946f263f1af1515a2574a150a30d6ce02c6ff742fcc0db8", # noqa: E501 @@ -68,9 +68,9 @@ "hash": "sha256=2dc6b5a470a1bde68946f263f1af1515a2574a150a30d6ce02c6ff742fcc0db8", # noqa: E501 }, }, - pep610.ArchiveData( + pep610.DirectUrl( url="https://github.com/pypa/pip/archive/1.3.1.zip", - archive_info=pep610.ArchiveInfo( + info=pep610.ArchiveInfo( hash=pep610.HashData( "sha256", "2dc6b5a470a1bde68946f263f1af1515a2574a150a30d6ce02c6ff742fcc0db8", @@ -84,9 +84,9 @@ "url": "file://path/to/my.whl", "archive_info": {}, }, - pep610.ArchiveData( + pep610.DirectUrl( url="file://path/to/my.whl", - archive_info=pep610.ArchiveInfo(hash=None), + info=pep610.ArchiveInfo(hash=None), ), id="archive_no_hashes", ), @@ -100,9 +100,9 @@ "commit_id": "7921be1537eac1e97bc40179a57f0349c2aee67d", }, }, - pep610.VCSData( + pep610.DirectUrl( url="https://github.com/pypa/pip.git", - vcs_info=pep610.VCSInfo( + info=pep610.VCSInfo( vcs="git", requested_revision="1.3.1", resolved_revision_type="tag", @@ -120,9 +120,9 @@ "commit_id": "7921be1537eac1e97bc40179a57f0349c2aee67d", }, }, - pep610.VCSData( + pep610.DirectUrl( url="https://github.com/pypa/pip.git", - vcs_info=pep610.VCSInfo( + info=pep610.VCSInfo( vcs="git", requested_revision=None, resolved_revision_type="tag", @@ -142,9 +142,9 @@ "commit_id": "7921be1537eac1e97bc40179a57f0349c2aee67d", }, }, - pep610.VCSData( + pep610.DirectUrl( url="https://github.com/pypa/pip.git", - vcs_info=pep610.VCSInfo( + info=pep610.VCSInfo( vcs="git", requested_revision="1.3.1", resolved_revision="1.3.1", @@ -164,9 +164,9 @@ "commit_id": "7921be1537eac1e97bc40179a57f0349c2aee67d", }, }, - pep610.VCSData( + pep610.DirectUrl( url="https://github.com/pypa/pip.git", - vcs_info=pep610.VCSInfo( + info=pep610.VCSInfo( vcs="git", requested_revision="1.3.1", resolved_revision="1.3.1", @@ -191,21 +191,14 @@ def test_parse(data: dict, expected: object, tmp_path: Path): def test_to_json(): """Test the to_json method.""" - data = pep610.DirData( + data = pep610.DirectUrl( url="file:///home/user/project", - dir_info=pep610.DirInfo(editable=True), + info=pep610.DirInfo(editable=True), ) assert data.to_json() == '{"dir_info": {"editable": true}, "url": "file:///home/user/project"}' -def test_unknown_data_type(): - """Test serialization from unknown data fails.""" - data = object() - with pytest.raises(NotImplementedError, match="Cannot serialize unknown"): - pep610.to_dict(data) - - def test_local_directory(tmp_path: Path): """Test that a local directory is read back as a local directory.""" data = { @@ -216,18 +209,19 @@ def test_local_directory(tmp_path: Path): pep610.write_to_distribution(dist, data) result = pep610.read_from_distribution(dist) - assert isinstance(result, pep610.DirData) + assert isinstance(result, pep610.DirectUrl) assert result.url == "file:///home/user/project" - assert result.dir_info.is_editable() + assert isinstance(result.info, pep610.DirInfo) + assert result.info.is_editable() assert pep610.to_dict(result) == data - result.dir_info.editable = False + result.info.editable = False assert pep610.to_dict(result) == { "url": "file:///home/user/project", "dir_info": {"editable": False}, } - result.dir_info.editable = None + result.info.editable = None assert pep610.to_dict(result) == { "url": "file:///home/user/project", "dir_info": {}, @@ -250,17 +244,18 @@ def test_archive_hashes_merged(tmp_path: Path): pep610.write_to_distribution(dist, data) result = pep610.read_from_distribution(dist) - assert isinstance(result, pep610.ArchiveData) + assert isinstance(result, pep610.DirectUrl) assert result.url == "file://path/to/my.whl" - assert result.archive_info.hash == pep610.HashData( + assert isinstance(result.info, pep610.ArchiveInfo) + assert result.info.hash == pep610.HashData( "sha256", "2dc6b5a470a1bde68946f263f1af1515a2574a150a30d6ce02c6ff742fcc0db8", ) - assert result.archive_info.hashes == { + assert result.info.hashes == { "md5": "c4e0f0a1e0a5e708c8e3e3c4cbe2e85f", "sha256": "1dc6b5a470a1bde68946f263f1af1515a2574a150a30d6ce02c6ff742fcc0db9", } - assert result.archive_info.all_hashes == { + assert result.info.all_hashes == { "md5": "c4e0f0a1e0a5e708c8e3e3c4cbe2e85f", "sha256": "1dc6b5a470a1bde68946f263f1af1515a2574a150a30d6ce02c6ff742fcc0db9", } @@ -276,11 +271,12 @@ def test_archive_no_hashes(tmp_path: Path): pep610.write_to_distribution(dist, data) result = pep610.read_from_distribution(dist) - assert isinstance(result, pep610.ArchiveData) + assert isinstance(result, pep610.DirectUrl) assert result.url == "file://path/to/my.whl" - assert result.archive_info.hash is None - assert result.archive_info.hashes is None - assert result.archive_info.all_hashes == {} + assert isinstance(result.info, pep610.ArchiveInfo) + assert result.info.hash is None + assert result.info.hashes is None + assert result.info.all_hashes == {} def test_archive_no_valid_algorithms(tmp_path: Path): @@ -297,12 +293,13 @@ def test_archive_no_valid_algorithms(tmp_path: Path): pep610.write_to_distribution(dist, data) result = pep610.read_from_distribution(dist) - assert isinstance(result, pep610.ArchiveData) + assert isinstance(result, pep610.DirectUrl) assert result.url == "file://path/to/my.whl" - assert result.archive_info.hash is None - assert result.archive_info.hashes == {"notavalidalgo": "1234"} - assert result.archive_info.all_hashes == {"notavalidalgo": "1234"} - assert not result.archive_info.has_valid_algorithms() + assert isinstance(result.info, pep610.ArchiveInfo) + assert result.info.hash is None + assert result.info.hashes == {"notavalidalgo": "1234"} + assert result.info.all_hashes == {"notavalidalgo": "1234"} + assert not result.info.has_valid_algorithms() def test_unknown_url_type(tmp_path: Path): @@ -338,9 +335,9 @@ def test_parse_pip_install_report(pip_install_report: dict): packages = _get_direct_url_packages(pip_install_report) assert packages == { - "packaging": pep610.VCSData( + "packaging": pep610.DirectUrl( url="https://github.com/pypa/packaging", - vcs_info=pep610.VCSInfo( + info=pep610.VCSInfo( vcs="git", requested_revision="main", commit_id="4f42225e91a0be634625c09e84dd29ea82b85e27",