jw-pkg packages install: Add -x/--exact option #159
7 changed files with 104 additions and 32 deletions
lib.Package: Add and fill version field
Add a version field to the Package class and make query_packages() fill it. It's very much distribution and package dependent what shape that version has, so from Package's perspective, it's just a str-typed member. Further improvements and fixes: - Make query_packages() accept throw, defaulting to the previous behaviour, so a caller that wants to be prepared for a missing package / an empty result can pass throw = False. - Make dpkg's _run() and run_dpkg_query() forward ec and the remaining keyword arguments on the sudo path, which dropped them before. Add unit tests for the file type tree and its StatResult predicates. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v1.4.0 Signed-off-by: Jan Lindemann <jan@janware.com>
commit
afbdc2e4b0
|
|
@ -8,19 +8,25 @@ meta_tags = [
|
||||||
'packager',
|
'packager',
|
||||||
'url',
|
'url',
|
||||||
'maintainer',
|
'maintainer',
|
||||||
|
'version',
|
||||||
]
|
]
|
||||||
|
|
||||||
class Package:
|
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
|
name: str
|
||||||
vendor: str | None = None
|
vendor: str | None = None
|
||||||
packager: str | None = None
|
packager: str | None = None
|
||||||
url: str | None = None
|
url: str | None = None
|
||||||
maintainer: str | None = None
|
maintainer: str | None = None
|
||||||
|
version: str | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def parse_spec_str(cls, spec: str, delimiter: str = '|') -> 'Package':
|
def parse_spec_str(cls, spec: str, delimiter: str = '|') -> 'Package':
|
||||||
tags = spec.split(delimiter)
|
tags = spec.split(delimiter)
|
||||||
if len(tags) != 5:
|
if len(tags) != len(meta_tags):
|
||||||
raise ValueError(f'Invalid package spec string "{spec}"')
|
raise ValueError(f'Invalid package spec string "{spec}"')
|
||||||
return cls(
|
return cls(
|
||||||
name = tags[0],
|
name = tags[0],
|
||||||
|
|
@ -28,6 +34,7 @@ class Package:
|
||||||
packager = tags[2],
|
packager = tags[2],
|
||||||
url = tags[3],
|
url = tags[3],
|
||||||
maintainer = tags[4],
|
maintainer = tags[4],
|
||||||
|
version = tags[5],
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -51,12 +58,14 @@ class Package:
|
||||||
packager: str | None = None,
|
packager: str | None = None,
|
||||||
url: str | None = None,
|
url: str | None = None,
|
||||||
maintainer: str | None = None,
|
maintainer: str | None = None,
|
||||||
|
version: str | None = None,
|
||||||
):
|
):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.vendor = vendor
|
self.vendor = vendor
|
||||||
self.packager = packager
|
self.packager = packager
|
||||||
self.url = url
|
self.url = url
|
||||||
self.maintainer = maintainer
|
self.maintainer = maintainer
|
||||||
|
self.version = version
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,12 @@ def meta_map() -> dict[str, str]:
|
||||||
if _meta_map is None:
|
if _meta_map is None:
|
||||||
_meta_map = Package.order_tags(
|
_meta_map = Package.order_tags(
|
||||||
{
|
{
|
||||||
'name': 'binary:Package',
|
'name': '${binary:Package}',
|
||||||
'vendor': None, # deb doesn't have vendor field
|
'vendor': None, # deb doesn't have vendor field
|
||||||
'packager': None, # -- packager --
|
'packager': None, # -- packager --
|
||||||
'url': 'Homepage',
|
'url': '${Homepage}',
|
||||||
'maintainer': 'Maintainer',
|
'maintainer': '${Maintainer}',
|
||||||
|
'version': '${Version}',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return _meta_map
|
return _meta_map
|
||||||
|
|
@ -33,7 +34,7 @@ async def _run(
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> str:
|
) -> str:
|
||||||
return (
|
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)
|
run_cmd(cmd, ec = ec, cmd_input = InputMode.NonInteractive, **kwargs)
|
||||||
).stdout_str
|
).stdout_str
|
||||||
|
|
||||||
|
|
@ -50,20 +51,23 @@ async def run_dpkg( # export
|
||||||
async def run_dpkg_query( # export
|
async def run_dpkg_query( # export
|
||||||
args: list[str],
|
args: list[str],
|
||||||
sudo: bool = False,
|
sudo: bool = False,
|
||||||
ec: ExecContext | None = None
|
ec: ExecContext | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
) -> str:
|
) -> str:
|
||||||
cmd = ['/usr/bin/dpkg-query']
|
cmd = ['/usr/bin/dpkg-query']
|
||||||
cmd.extend(args)
|
cmd.extend(args)
|
||||||
return await _run(cmd, sudo, ec)
|
return await _run(cmd, sudo, ec, **kwargs)
|
||||||
|
|
||||||
async def query_packages(names: Iterable[str] = [],
|
async def query_packages(
|
||||||
ec: ExecContext | None = None) -> Iterable[Package]:
|
names: Iterable[str] = [],
|
||||||
fmt_str = (
|
ec: ExecContext | None = None,
|
||||||
'|'.join([(f'${{{tag}}}' if tag else '')
|
throw: bool = True
|
||||||
for tag in meta_map().values()]) + r'\n'
|
) -> Iterable[Package]:
|
||||||
)
|
fmt_str = '|'.join(tag or '' for tag in meta_map().values()) + r'\n'
|
||||||
# dpkg-query -W -f='${binary:Package}|${Maintainer}| ... \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))
|
return cast('Iterable[Package]', Package.parse_specs_str(specs))
|
||||||
|
|
||||||
async def list_files(
|
async def list_files(
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,13 @@ def meta_map() -> dict[str, str]:
|
||||||
if _meta_map is None:
|
if _meta_map is None:
|
||||||
_meta_map = Package.order_tags(
|
_meta_map = Package.order_tags(
|
||||||
{
|
{
|
||||||
'name': 'Name',
|
'name': '%{Name}',
|
||||||
'vendor': 'Vendor',
|
'vendor': '%{Vendor}',
|
||||||
'packager': 'Packager',
|
'packager': '%{Packager}',
|
||||||
'url': 'URL',
|
'url': '%{URL}',
|
||||||
'maintainer': None, # RPM doesn't have a maintainer field
|
'maintainer': None, # RPM doesn't have a maintainer field
|
||||||
|
# -- the full version identity as one column
|
||||||
|
'version': '%{VERSION}-%{RELEASE}',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return _meta_map
|
return _meta_map
|
||||||
|
|
@ -46,17 +48,15 @@ async def run_rpm( # export
|
||||||
async def query_packages( # export
|
async def query_packages( # export
|
||||||
names: Collection[str] = [],
|
names: Collection[str] = [],
|
||||||
ec: ExecContext | None = None,
|
ec: ExecContext | None = None,
|
||||||
|
throw: bool = True,
|
||||||
) -> Iterable[Package]:
|
) -> Iterable[Package]:
|
||||||
fmt_str = (
|
fmt_str = '|'.join(tag or '' for tag in meta_map().values()) + r'\n'
|
||||||
'|'.join([(f'%{{{tag}}}' if tag else '')
|
|
||||||
for tag in meta_map().values()]) + r'\n'
|
|
||||||
)
|
|
||||||
opts = ['-q', '--queryformat', fmt_str]
|
opts = ['-q', '--queryformat', fmt_str]
|
||||||
if not names:
|
if not names:
|
||||||
opts.append('-a')
|
opts.append('-a')
|
||||||
specs = await run_rpm(
|
specs = await run_rpm(
|
||||||
[*opts, *names],
|
[*opts, *names],
|
||||||
throw = True,
|
throw = throw,
|
||||||
sudo = False,
|
sudo = False,
|
||||||
mode = InputMode.NonInteractive,
|
mode = InputMode.NonInteractive,
|
||||||
ec = ec
|
ec = ec
|
||||||
|
|
|
||||||
|
|
@ -256,7 +256,7 @@ class CannedRpmLocal(Local):
|
||||||
elif '-ql' in cmd:
|
elif '-ql' in cmd:
|
||||||
out = '/etc/a/a.conf.jw-tmpl\n/etc/b/b.conf.jw-tmpl\n'
|
out = '/etc/a/a.conf.jw-tmpl\n/etc/b/b.conf.jw-tmpl\n'
|
||||||
else:
|
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)
|
return Result(stdout = out.encode(), status = 0)
|
||||||
|
|
||||||
cl = CannedRpmLocal()
|
cl = CannedRpmLocal()
|
||||||
|
|
|
||||||
|
|
@ -3,29 +3,32 @@ from jw.pkg.lib.Package import Package, meta_tags
|
||||||
# -- parse_spec_str --
|
# -- parse_spec_str --
|
||||||
|
|
||||||
p = Package.parse_spec_str(
|
p = Package.parse_spec_str(
|
||||||
'jw-core|jw|Jan|https://example.com/jw-core|Jan <jan@example.com>'
|
'jw-core|jw|Jan|https://example.com/jw-core|Jan <jan@example.com>|1.0.0-1'
|
||||||
)
|
)
|
||||||
assert p.name == 'jw-core'
|
assert p.name == 'jw-core'
|
||||||
assert p.vendor == 'jw'
|
assert p.vendor == 'jw'
|
||||||
assert p.packager == 'Jan'
|
assert p.packager == 'Jan'
|
||||||
assert p.url == 'https://example.com/jw-core'
|
assert p.url == 'https://example.com/jw-core'
|
||||||
assert p.maintainer == 'Jan <jan@example.com>'
|
assert p.maintainer == 'Jan <jan@example.com>'
|
||||||
|
assert p.version == '1.0.0-1'
|
||||||
|
|
||||||
# An empty field is preserved as an empty string
|
# 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.name == 'name'
|
||||||
assert p.vendor == ''
|
assert p.vendor == ''
|
||||||
assert p.packager == ''
|
assert p.packager == ''
|
||||||
assert p.url == ''
|
assert p.url == ''
|
||||||
assert p.maintainer == ''
|
assert p.maintainer == ''
|
||||||
|
assert p.version == ''
|
||||||
|
|
||||||
# A custom delimiter works
|
# 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.name == 'a'
|
||||||
assert p.maintainer == 'e'
|
assert p.maintainer == 'e'
|
||||||
|
assert p.version == 'f'
|
||||||
|
|
||||||
# Wrong field counts raise
|
# 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:
|
try:
|
||||||
Package.parse_spec_str(spec)
|
Package.parse_spec_str(spec)
|
||||||
assert False, f'Should have raised for "{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 --
|
# -- parse_specs_str --
|
||||||
|
|
||||||
specs = (
|
specs = (
|
||||||
'jw-core|jw|Jan|https://example.com/jw-core|Jan\n'
|
'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'
|
'jw-base|jw|Jan|https://example.com/jw-base|Jan|2.0.0-1'
|
||||||
)
|
)
|
||||||
packages = Package.parse_specs_str(specs)
|
packages = Package.parse_specs_str(specs)
|
||||||
assert [p.name for p in packages] == ['jw-core', 'jw-base']
|
assert [p.name for p in packages] == ['jw-core', 'jw-base']
|
||||||
|
|
@ -64,17 +67,19 @@ assert ordered == {
|
||||||
'packager': '',
|
'packager': '',
|
||||||
'url': 'https://example.com/jw-core',
|
'url': 'https://example.com/jw-core',
|
||||||
'maintainer': 'Jan',
|
'maintainer': 'Jan',
|
||||||
|
'version': '',
|
||||||
}
|
}
|
||||||
|
|
||||||
# -- __repr__ --
|
# -- __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) == (
|
assert repr(p) == (
|
||||||
'name : jw-core\n'
|
'name : jw-core\n'
|
||||||
'vendor : jw\n'
|
'vendor : jw\n'
|
||||||
'packager : Jan\n'
|
'packager : Jan\n'
|
||||||
'url : url\n'
|
'url : url\n'
|
||||||
'maintainer : Jan'
|
'maintainer : Jan\n'
|
||||||
|
'version : 1.0.0-1'
|
||||||
)
|
)
|
||||||
|
|
||||||
print('All Package tests passed')
|
print('All Package tests passed')
|
||||||
|
|
|
||||||
8
test/unit/python/jw/pkg/lib/file_types/Makefile
Normal file
8
test/unit/python/jw/pkg/lib/file_types/Makefile
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
TOPDIR = ../../../../../../..
|
||||||
|
|
||||||
|
include $(TOPDIR)/make/proj.mk
|
||||||
|
include $(JWBDIR)/make/py-run.mk
|
||||||
|
|
||||||
|
all:
|
||||||
|
|
||||||
|
test: run
|
||||||
46
test/unit/python/jw/pkg/lib/file_types/test.py
Normal file
46
test/unit/python/jw/pkg/lib/file_types/test.py
Normal file
|
|
@ -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')
|
||||||
Loading…
Reference in a new issue