diff --git a/src/python/jw/pkg/lib/Package.py b/src/python/jw/pkg/lib/Package.py index 6fd19e24..7d883a8c 100644 --- a/src/python/jw/pkg/lib/Package.py +++ b/src/python/jw/pkg/lib/Package.py @@ -8,19 +8,25 @@ meta_tags = [ 'packager', 'url', 'maintainer', + 'version', ] class Package: + """A package as reported by a package manager. The version is the + full version string as the PM reports it, for instance + version-release for rpm and the single version for dpkg.""" + name: str vendor: str | None = None packager: str | None = None url: str | None = None maintainer: str | None = None + version: str | None = None @classmethod def parse_spec_str(cls, spec: str, delimiter: str = '|') -> 'Package': tags = spec.split(delimiter) - if len(tags) != 5: + if len(tags) != len(meta_tags): raise ValueError(f'Invalid package spec string "{spec}"') return cls( name = tags[0], @@ -28,6 +34,7 @@ class Package: packager = tags[2], url = tags[3], maintainer = tags[4], + version = tags[5], ) @classmethod @@ -51,12 +58,14 @@ class Package: packager: str | None = None, url: str | None = None, maintainer: str | None = None, + version: str | None = None, ): self.name = name self.vendor = vendor self.packager = packager self.url = url self.maintainer = maintainer + self.version = version @override def __repr__(self) -> str: diff --git a/src/python/jw/pkg/lib/pm/dpkg.py b/src/python/jw/pkg/lib/pm/dpkg.py index 339ddb48..b2ad671e 100644 --- a/src/python/jw/pkg/lib/pm/dpkg.py +++ b/src/python/jw/pkg/lib/pm/dpkg.py @@ -17,11 +17,12 @@ def meta_map() -> dict[str, str]: if _meta_map is None: _meta_map = Package.order_tags( { - 'name': 'binary:Package', + 'name': '${binary:Package}', 'vendor': None, # deb doesn't have vendor field 'packager': None, # -- packager -- - 'url': 'Homepage', - 'maintainer': 'Maintainer', + 'url': '${Homepage}', + 'maintainer': '${Maintainer}', + 'version': '${Version}', } ) return _meta_map @@ -33,7 +34,7 @@ async def _run( **kwargs: Any, ) -> str: return ( - await run_sudo(cmd) if sudo else await + await run_sudo(cmd, ec = ec, **kwargs) if sudo else await run_cmd(cmd, ec = ec, cmd_input = InputMode.NonInteractive, **kwargs) ).stdout_str @@ -50,20 +51,23 @@ async def run_dpkg( # export async def run_dpkg_query( # export args: list[str], sudo: bool = False, - ec: ExecContext | None = None + ec: ExecContext | None = None, + **kwargs: Any, ) -> str: cmd = ['/usr/bin/dpkg-query'] cmd.extend(args) - return await _run(cmd, sudo, ec) + return await _run(cmd, sudo, ec, **kwargs) -async def query_packages(names: Iterable[str] = [], - ec: ExecContext | None = None) -> Iterable[Package]: - fmt_str = ( - '|'.join([(f'${{{tag}}}' if tag else '') - for tag in meta_map().values()]) + r'\n' - ) +async def query_packages( + names: Iterable[str] = [], + ec: ExecContext | None = None, + throw: bool = True +) -> Iterable[Package]: + fmt_str = '|'.join(tag or '' for tag in meta_map().values()) + r'\n' # dpkg-query -W -f='${binary:Package}|${Maintainer}| ... \n' - specs = await run_dpkg_query(['-W', '-f=' + fmt_str, *names], sudo = False, ec = ec) + specs = await run_dpkg_query( + ['-W', '-f=' + fmt_str, *names], sudo = False, throw = throw, ec = ec + ) return cast('Iterable[Package]', Package.parse_specs_str(specs)) async def list_files( diff --git a/src/python/jw/pkg/lib/pm/rpm.py b/src/python/jw/pkg/lib/pm/rpm.py index eef7e794..cb63e26a 100644 --- a/src/python/jw/pkg/lib/pm/rpm.py +++ b/src/python/jw/pkg/lib/pm/rpm.py @@ -19,11 +19,13 @@ def meta_map() -> dict[str, str]: if _meta_map is None: _meta_map = Package.order_tags( { - 'name': 'Name', - 'vendor': 'Vendor', - 'packager': 'Packager', - 'url': 'URL', + 'name': '%{Name}', + 'vendor': '%{Vendor}', + 'packager': '%{Packager}', + 'url': '%{URL}', 'maintainer': None, # RPM doesn't have a maintainer field + # -- the full version identity as one column + 'version': '%{VERSION}-%{RELEASE}', } ) return _meta_map @@ -46,17 +48,15 @@ async def run_rpm( # export async def query_packages( # export names: Collection[str] = [], ec: ExecContext | None = None, + throw: bool = True, ) -> Iterable[Package]: - fmt_str = ( - '|'.join([(f'%{{{tag}}}' if tag else '') - for tag in meta_map().values()]) + r'\n' - ) + fmt_str = '|'.join(tag or '' for tag in meta_map().values()) + r'\n' opts = ['-q', '--queryformat', fmt_str] if not names: opts.append('-a') specs = await run_rpm( [*opts, *names], - throw = True, + throw = throw, sudo = False, mode = InputMode.NonInteractive, ec = ec diff --git a/test/unit/python/jw/pkg/lib/Distro/test.py b/test/unit/python/jw/pkg/lib/Distro/test.py index f1bd8e4d..37902e22 100644 --- a/test/unit/python/jw/pkg/lib/Distro/test.py +++ b/test/unit/python/jw/pkg/lib/Distro/test.py @@ -256,7 +256,7 @@ class CannedRpmLocal(Local): elif '-ql' in cmd: out = '/etc/a/a.conf.jw-tmpl\n/etc/b/b.conf.jw-tmpl\n' else: - out = 'a|v|p|https://jw.dev/a|m\nb|v|p|https://jw.dev/b|m\n' + out = 'a|v|p|https://jw.dev/a|m|1.0.0-1\nb|v|p|https://jw.dev/b|m|1.0.0-1\n' return Result(stdout = out.encode(), status = 0) cl = CannedRpmLocal() diff --git a/test/unit/python/jw/pkg/lib/Package/test.py b/test/unit/python/jw/pkg/lib/Package/test.py index 09269a91..52979cfb 100644 --- a/test/unit/python/jw/pkg/lib/Package/test.py +++ b/test/unit/python/jw/pkg/lib/Package/test.py @@ -3,29 +3,32 @@ from jw.pkg.lib.Package import Package, meta_tags # -- parse_spec_str -- p = Package.parse_spec_str( - 'jw-core|jw|Jan|https://example.com/jw-core|Jan ' + 'jw-core|jw|Jan|https://example.com/jw-core|Jan |1.0.0-1' ) assert p.name == 'jw-core' assert p.vendor == 'jw' assert p.packager == 'Jan' assert p.url == 'https://example.com/jw-core' assert p.maintainer == 'Jan ' +assert p.version == '1.0.0-1' # An empty field is preserved as an empty string -p = Package.parse_spec_str('name||||') +p = Package.parse_spec_str('name|||||') assert p.name == 'name' assert p.vendor == '' assert p.packager == '' assert p.url == '' assert p.maintainer == '' +assert p.version == '' # A custom delimiter works -p = Package.parse_spec_str('a,b,c,d,e', delimiter = ',') +p = Package.parse_spec_str('a,b,c,d,e,f', delimiter = ',') assert p.name == 'a' assert p.maintainer == 'e' +assert p.version == 'f' # Wrong field counts raise -for spec in ('a', 'a|b', 'a|b|c|d', 'a|b|c|d|e|f'): +for spec in ('a', 'a|b', 'a|b|c', 'a|b|c|d|e', 'a|b|c|d|e|f|g'): try: Package.parse_spec_str(spec) assert False, f'Should have raised for "{spec}"' @@ -35,8 +38,8 @@ for spec in ('a', 'a|b', 'a|b|c|d', 'a|b|c|d|e|f'): # -- parse_specs_str -- specs = ( - 'jw-core|jw|Jan|https://example.com/jw-core|Jan\n' - 'jw-base|jw|Jan|https://example.com/jw-base|Jan' + 'jw-core|jw|Jan|https://example.com/jw-core|Jan|1.0.0-1\n' + 'jw-base|jw|Jan|https://example.com/jw-base|Jan|2.0.0-1' ) packages = Package.parse_specs_str(specs) assert [p.name for p in packages] == ['jw-core', 'jw-base'] @@ -64,17 +67,19 @@ assert ordered == { 'packager': '', 'url': 'https://example.com/jw-core', 'maintainer': 'Jan', + 'version': '', } # -- __repr__ -- -p = Package.parse_spec_str('jw-core|jw|Jan|url|Jan') +p = Package.parse_spec_str('jw-core|jw|Jan|url|Jan|1.0.0-1') assert repr(p) == ( 'name : jw-core\n' 'vendor : jw\n' 'packager : Jan\n' 'url : url\n' - 'maintainer : Jan' + 'maintainer : Jan\n' + 'version : 1.0.0-1' ) print('All Package tests passed') diff --git a/test/unit/python/jw/pkg/lib/file_types/Makefile b/test/unit/python/jw/pkg/lib/file_types/Makefile new file mode 100644 index 00000000..3d3138c5 --- /dev/null +++ b/test/unit/python/jw/pkg/lib/file_types/Makefile @@ -0,0 +1,8 @@ +TOPDIR = ../../../../../../.. + +include $(TOPDIR)/make/proj.mk +include $(JWBDIR)/make/py-run.mk + +all: + +test: run diff --git a/test/unit/python/jw/pkg/lib/file_types/test.py b/test/unit/python/jw/pkg/lib/file_types/test.py new file mode 100644 index 00000000..7b77d0bc --- /dev/null +++ b/test/unit/python/jw/pkg/lib/file_types/test.py @@ -0,0 +1,46 @@ +import stat + +from jw.pkg.lib.base import StatResult +from jw.pkg.lib.file_types import ( + FT_COMPOUND, + FT_DIR, + FT_REG, + FT_TOP, + FileType, + file_types, +) + +# -- The compound hangs under the regular files: it is a file whose +# content is gettable, and only its position in the tree makes +# it listable, too +assert FT_COMPOUND.parent is FT_REG +assert FT_COMPOUND.is_subtype(FT_REG) +assert not FT_COMPOUND.is_subtype(FT_DIR) +assert FT_DIR.is_subtype(FT_TOP) +assert FT_REG.is_subtype(FT_TOP) + +def __stat(ftype: FileType) -> StatResult: + return StatResult(0o400, 'u', 'g', 0, 0.0, 0.0, 0.0, ftype = ftype) + +# -- The predicates answer from the type: a directory lists, a file +# is gettable, a compound is both +assert __stat(FT_DIR).is_dir and not __stat(FT_DIR).is_gettable +assert not __stat(FT_REG).is_dir and __stat(FT_REG).is_gettable +st = __stat(FT_COMPOUND) +assert st.is_dir and st.is_gettable + +# -- A mode-resolved stat answers the same way: the host's file +# types are the tree's file types +st = StatResult(stat.S_IFREG | 0o400, 'u', 'g', 0, 0.0, 0.0, 0.0) +assert st.ftype is FT_REG and st.is_gettable and not st.is_dir +st = StatResult(stat.S_IFDIR | 0o755, 'u', 'g', 0, 0.0, 0.0, 0.0) +assert st.ftype is FT_DIR and st.is_dir and not st.is_gettable + +# -- A mode the tree cannot resolve is an error +try: + file_types.from_mode(stat.S_IFLNK | 0o777) + raise AssertionError('expected NotImplementedError') +except NotImplementedError: + pass + +print('All file_types tests passed')