diff --git a/pyarn/lockfile.py b/pyarn/lockfile.py index 5dfbbfd..7646fba 100644 --- a/pyarn/lockfile.py +++ b/pyarn/lockfile.py @@ -18,7 +18,8 @@ import json import logging import re -from typing import Pattern +from pathlib import Path +from typing import Any, Dict, Optional, Pattern from ply import lex, yacc @@ -34,8 +35,16 @@ class Package: def __init__( - self, name, version, url=None, checksum=None, relpath=None, dependencies=None, alias=None - ): + self, + name: str, + version: str, + url: Optional[str] = None, + checksum: Optional[str] = None, + path: Optional[str] = None, + relpath: Optional[str] = None, + dependencies: Optional[Dict[str, str]] = None, + alias: Optional[str] = None, + ) -> None: if not name: raise ValueError("Package name was not provided") @@ -46,12 +55,32 @@ def __init__( self.version = version self.url = url self.checksum = checksum - self.relpath = relpath + self.path = path if path is not None else relpath self.dependencies = dependencies or {} self.alias = alias + @property + def relpath(self) -> Optional[str]: + """ + Return the path to the package. + + This is strictly kept for backwards compatibility and path should be used directly + instead. The path is not always relative and may be absolute. + """ + return self.path + + @relpath.setter + def relpath(self, path: Optional[str]) -> None: + """ + Set the path to the package. + + This is strictly kept for backwards compatibility and path should be used directly + instead. The path is not always relative and may be absolute. + """ + self.path = path + @classmethod - def from_dict(cls, raw_name, data): + def from_dict(cls, raw_name: str, data: Dict[str, Any]) -> "Package": name_at_version = re.compile(r"(?P@?[^@]+)(?:@(?P[^,]*))?") # _version is the version as declared in package.json, not the resolved version @@ -63,19 +92,40 @@ def from_dict(cls, raw_name, data): alias = name name, _version = _must_match(name_at_version, _remove_prefix(_version, "npm:")).groups() - if _version and _version.startswith("file:"): - path = _remove_prefix(_version, "file:") + if _version: + path = cls.get_path_from_version_specifier(_version) + + # Ensure the resolved version key exists to appease mypy + version = data.get("version") + if not version: + raise ValueError("Package version was not provided") return cls( name=name, - version=data.get("version"), + version=version, url=data.get("resolved"), checksum=data.get("integrity"), - relpath=path, + path=path, dependencies=data.get("dependencies", {}), alias=alias, ) + @staticmethod + def get_path_from_version_specifier(version: str) -> Optional[str]: + """Return the path from a package.json file dependency version specifier.""" + version_path = Path(version) + + if version.startswith("file:"): + return _remove_prefix(version, "file:") + elif version.startswith("link:"): + return _remove_prefix(version, "link:") + elif version_path.is_absolute() or version.startswith(("./", "../")): + return str(version_path) + else: + # Some non-path version specifier, (e.g. "1.0.0" or a web link) + # See https://docs.npmjs.com/cli/v10/configuring-npm/package-json#dependencies + return None + def _remove_prefix(s: str, prefix: str) -> str: return s[len(prefix) :] diff --git a/tests/test_lockfile.py b/tests/test_lockfile.py index 55f8335..6393f75 100644 --- a/tests/test_lockfile.py +++ b/tests/test_lockfile.py @@ -120,19 +120,24 @@ def test_packages(): assert packages[0].version == "2.0.0" assert packages[0].checksum is None assert packages[0].url is None - assert packages[0].relpath is None + assert packages[0].path is None -def test_packages_no_version(): +def test_lock_packages_no_version(): data = "breakfast@^1.1.1:\n eggs bacon" lock = lockfile.Lockfile.from_str(data) with pytest.raises(ValueError, match="Package version was not provided"): lock.packages() +def test_package_no_version(): + with pytest.raises(ValueError, match="Package version was not provided"): + lockfile.Package("breakfast", None) # type: ignore[arg-type] + + def test_packages_no_name(): with pytest.raises(ValueError, match="Package name was not provided"): - lockfile.Package(None, "1.0.0") + lockfile.Package(None, "1.0.0") # type: ignore[arg-type] def test_packages_url(): @@ -145,7 +150,7 @@ def test_packages_url(): assert packages[0].version == "2.0.0" assert packages[0].checksum is None assert packages[0].url == url - assert packages[0].relpath is None + assert packages[0].path is None def test_packages_checksum(): @@ -158,11 +163,40 @@ def test_packages_checksum(): assert packages[0].version == "2.0.0" assert packages[0].checksum == "someHash" assert packages[0].url == url - assert packages[0].relpath is None + assert packages[0].path is None -def test_relpath(): - data = '"breakfast@file:some/relative/path":\n version "0.0.0"' +@pytest.mark.parametrize( + "data, expected_path", + [ + pytest.param( + '"breakfast@file:some/relative/path":\n version "0.0.0"', + "some/relative/path", + id="relpath_with_file_prefix", + ), + pytest.param( + '"breakfast@link:some/relative/path":\n version "0.0.0"', + "some/relative/path", + id="relpath_with_link_prefix", + ), + pytest.param( + '"breakfast@./some/relative/path":\n version "0.0.0"', + "some/relative/path", + id="relpath_with_dot_prefix", + ), + pytest.param( + '"breakfast@../some/relative/path":\n version "0.0.0"', + "../some/relative/path", + id="relpath_to_parent_dir", + ), + pytest.param( + '"breakfast@/some/absolute/path":\n version "0.0.0"', + "/some/absolute/path", + id="absolute_path", + ), + ], +) +def test_package_with_path(data: str, expected_path: str) -> None: lock = lockfile.Lockfile.from_str(data) packages = lock.packages() assert len(packages) == 1 @@ -170,7 +204,8 @@ def test_relpath(): assert packages[0].version == "0.0.0" assert packages[0].checksum is None assert packages[0].url is None - assert packages[0].relpath == "some/relative/path" + assert packages[0].path == expected_path + assert packages[0].relpath == expected_path # test backwards compatibility def test_package_with_comma(): @@ -182,7 +217,7 @@ def test_package_with_comma(): assert packages[0].version == "1.1.7" assert packages[0].checksum is None assert packages[0].url is None - assert packages[0].relpath is None + assert packages[0].path is None DATA_TO_DUMP = { @@ -298,19 +333,19 @@ def test_aliased_packages(test_data_dir: Path): assert babel.name == "babel-plugin-add-module-exports" assert babel.alias == "babel7-plugin-add-module-exports" assert babel.version == "1.0.0" - assert babel.relpath is None + assert babel.path is None assert lodash.name == "@elastic/lodash" assert lodash.alias == "lodash" assert lodash.version == "3.10.1-kibana1" - assert lodash.relpath is None + assert lodash.path is None assert fecha.name == "fecha" assert fecha.alias == "date-in-spanish" assert fecha.version == "4.2.3" - assert fecha.relpath is None + assert fecha.path is None assert nonsense.name == "what" assert nonsense.alias == "probably-nonsense" assert nonsense.version == "0.0.1" - assert nonsense.relpath == "how" + assert nonsense.path == "how"