diff --git a/src/sysd_example.py b/src/sysd_example.py index 0ddb2c4..4d43d11 100644 --- a/src/sysd_example.py +++ b/src/sysd_example.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from sysdi import TimedUnit, UnitManager +from sysdi import ServiceUnit, TimedUnit, UnitManager from sysdi.contrib import cronitor @@ -53,9 +53,30 @@ class Starship(TimedUnit): ), ) + +# Service-only (no timer) unit for chaining +@dataclass +class SvcStarship(ServiceUnit): + exec_bin: str = '/bin/starship' + + +# Chain: A runs on a schedule; on success triggers B; on success triggers C +um_chain = UnitManager(unit_prefix='utm-chain-') +alpha = Starship( + 'Diagnostics Head', + 'diagnostics run', + start_delay='30s', + run_every='15m', +) +beta = SvcStarship('Diagnostics Beta', 'beta stage') +gamma = SvcStarship('Diagnostics Gamma', 'gamma stage') +um_chain.chain('Diagnostics Chain', alpha, beta, gamma) + + # Call this in a cli command (or something) to: # - Write units to disk # - Reload systemd daemon # - Enable timer units # - Enable login linger: which indicates timers should run even when the user is logged out # um.sync(linger='enable') +# um_chain.sync(linger=None) diff --git a/src/sysdi/__init__.py b/src/sysdi/__init__.py index 879f4bb..19b43eb 100644 --- a/src/sysdi/__init__.py +++ b/src/sysdi/__init__.py @@ -1,4 +1,5 @@ from .core import ExecWrap as ExecWrap +from .core import ServiceUnit as ServiceUnit from .core import TimedUnit as TimedUnit from .core import UnitManager as UnitManager from .core import WebPing as WebPing diff --git a/src/sysdi/core.py b/src/sysdi/core.py index 7d5bcae..617a07d 100644 --- a/src/sysdi/core.py +++ b/src/sysdi/core.py @@ -1,3 +1,4 @@ +from collections.abc import Iterable, Sequence from dataclasses import dataclass import logging import os @@ -84,9 +85,10 @@ def sync(self, *, linger: str | None, install_dpath: str | os.PathLike | None = linger_disable() def unit_names(self): - return [u.unit_name('service') for u in self.units] + [ - u.unit_name('timer') for u in self.units - ] + names: list[str] = [] + for u in self.units: + names.extend(u.managed_unit_names()) + return names def stale(self): managed_names = set(self.unit_names()) @@ -111,12 +113,53 @@ def remove_all(self): """Remove all unit files, services, and timers that match the prefix.""" self.remove_stale() for unit in self.units: - # Timer first to avoid systemd warning about timer being able to start service. - self.remove_unit(unit.unit_name('timer')) - self.remove_unit(unit.unit_name('service')) + for name in unit.managed_unit_names(): + self.remove_unit(name) daemon_reload() + def chain(self, chain_name: str, *units): + if not units: + raise ValueError('chain must include at least one unit') + if len({id(u) for u in units}) != len(units): + raise ValueError('chain units must be unique instances') + + self.register(*units) + + def _append_success(unit: object, next_unit: object): + try: + curr = unit.on_success # type: ignore[attr-defined] + except AttributeError: + curr = None + items: list[object] + if curr is None: + items = [] + elif isinstance(curr, str | bytes): + items = [curr] + else: + items = list(curr) # type: ignore[arg-type] + items.append(next_unit) + unit.on_success = items # type: ignore[attr-defined] + + for i in range(len(units) - 1): + _append_success(units[i], units[i + 1]) + + # Create a target that wants the first trigger (timer or service) + first = units[0] + wants: list[str] = [] + try: + wants.append(first.unit_name('timer')) # type: ignore[arg-type] + except AssertionError: + wants.append(first.unit_name('service')) # type: ignore[arg-type] + + tgt = TargetUnit( + description=f'Chain: {chain_name}', + unit_basename=slugify(chain_name), + wants=wants, + ) + tgt.unit_prefix = self.unit_prefix + self.units.append(tgt) + def remove_stale(self): """ Remove any unit files, services, or timers that match the prefix but aren't being @@ -266,6 +309,10 @@ class TimedUnit: # Exec Pre/Post support exec_wrap: ExecWrap | None = None + # Chain/Dependency support (Unit options) + on_success: str | object | Sequence[str | object] | None = None + on_failure: str | object | Sequence[str | object] | None = None + # Other Unit Config service_extra: list[str] | None = None timer_extra: list[str] | None = None @@ -333,6 +380,39 @@ def option(self, lines, opt_name): lines.append(f'{opt_name}={value}') + def _normalize_refs(self, refs: str | object | Sequence[str | object] | None) -> list[str]: + if refs is None: + return [] + if isinstance(refs, str | bytes): + items: Iterable[str | object] = [refs] + else: + items = refs # type: ignore[assignment] + names: list[str] = [] + for r in items: + if isinstance(r, str | bytes): + names.append(r) + else: + try: + rpfx = getattr(r, 'unit_prefix', None) + spfx = getattr(self, 'unit_prefix', None) + if rpfx is None and spfx is not None and hasattr(r, 'unit_basename'): + names.append(f'{spfx}{r.unit_basename}.service') # type: ignore[attr-defined] + else: + names.append(r.unit_name('service')) # type: ignore[attr-defined] + except Exception as e: # pragma: no cover - defensive + raise TypeError('Invalid unit reference for OnSuccess/OnFailure') from e + return names + + def _unit_dependency_lines(self) -> list[str]: + lines: list[str] = [] + succ = self._normalize_refs(self.on_success) + fail = self._normalize_refs(self.on_failure) + if succ: + lines.append('OnSuccess=' + ' '.join(succ)) + if fail: + lines.append('OnFailure=' + ' '.join(fail)) + return lines + def timer(self): lines = [] lines.extend( @@ -374,6 +454,9 @@ def service(self): f'Description={self.description}', ), ) + # Add chain dependencies if configured + lines.extend(self._unit_dependency_lines()) + if self.retry_max_tries and self.retry_interval_seconds: # limit interval must be set to more than (tries * interval) to contain the burst limit_interval = (self.retry_max_tries * self.retry_interval_seconds) + 15 @@ -433,3 +516,137 @@ def install(self, install_dpath): def unit_name(self, type_): assert type_ in ('service', 'timer') return f'{self.unit_prefix}{self.unit_basename}.{type_}' + + def managed_unit_names(self) -> list[str]: + return [self.unit_name('timer'), self.unit_name('service')] + + +@dataclass +class ServiceUnit: + description: str + exec_args: str = '' + exec_bin: str = '' + + service_type: str = 'oneshot' + + retry_interval_seconds: int | None = None + retry_max_tries: int | None = None + + exec_wrap: ExecWrap | None = None + + on_success: str | object | Sequence[str | object] | None = None + on_failure: str | object | Sequence[str | object] | None = None + + service_extra: list[str] | None = None + + unit_basename: str | None = None + unit_prefix: str | None = None + + def __post_init__(self): + if not self.exec_bin: + raise ValueError('exec_bin must be set') + self.unit_basename = self.unit_basename or slugify(self.description) + + @property + def exec_start(self): + return f'{self.exec_bin} {self.exec_args}'.strip() + + def _normalize_refs(self, refs: str | object | Sequence[str | object] | None) -> list[str]: + if refs is None: + return [] + if isinstance(refs, str | bytes): + items: Iterable[str | object] = [refs] + else: + items = refs # type: ignore[assignment] + names: list[str] = [] + for r in items: + if isinstance(r, str | bytes): + names.append(r) + else: + try: + rpfx = getattr(r, 'unit_prefix', None) + spfx = getattr(self, 'unit_prefix', None) + if rpfx is None and spfx is not None and hasattr(r, 'unit_basename'): + names.append(f'{spfx}{r.unit_basename}.service') # type: ignore[attr-defined] + else: + names.append(r.unit_name('service')) # type: ignore[attr-defined] + except Exception as e: # pragma: no cover + raise TypeError('Invalid unit reference for OnSuccess/OnFailure') from e + return names + + def _unit_dependency_lines(self) -> list[str]: + lines: list[str] = [] + succ = self._normalize_refs(self.on_success) + fail = self._normalize_refs(self.on_failure) + if succ: + lines.append('OnSuccess=' + ' '.join(succ)) + if fail: + lines.append('OnFailure=' + ' '.join(fail)) + return lines + + def service(self): + lines: list[str] = [] + lines.extend(('[Unit]', f'Description={self.description}')) + lines.extend(self._unit_dependency_lines()) + + if self.retry_max_tries and self.retry_interval_seconds: + limit_interval = (self.retry_max_tries * self.retry_interval_seconds) + 15 + lines.extend( + ( + f'StartLimitInterval={limit_interval}', + f'StartLimitBurst={self.retry_max_tries}', + ), + ) + + lines.extend(('', '[Service]', f'Type={self.service_type}')) + if self.retry_interval_seconds: + lines.extend(('Restart=on-failure', f'RestartSec={self.retry_interval_seconds}')) + + lines.append(f'ExecStart={self.exec_start}') + + if self.exec_wrap: + lines.append(f'ExecStartPre={self.exec_wrap.pre()}') + lines.append(f'ExecStopPost={self.exec_wrap.post()}') + + lines.extend(self.service_extra or ()) + return '\n'.join(lines) + '\n' + + def install(self, install_dpath: Path): + install_dpath.mkdir(parents=True, exist_ok=True) + service_fname = self.unit_name('service') + service_fpath = install_dpath.joinpath(service_fname) + service_fpath.write_text(self.service()) + log.info(f'(Re)installed {service_fname}') + daemon_reload() + + def unit_name(self, type_: str): + assert type_ == 'service' + return f'{self.unit_prefix}{self.unit_basename}.{type_}' + + def managed_unit_names(self) -> list[str]: + return [self.unit_name('service')] + + +@dataclass +class TargetUnit: + description: str + unit_basename: str + wants: list[str] | None = None + unit_prefix: str | None = None + + def install(self, install_dpath: Path): + install_dpath.mkdir(parents=True, exist_ok=True) + fname = self.unit_name() + fpath = install_dpath.joinpath(fname) + lines = ['[Unit]', f'Description={self.description}'] + if self.wants: + lines.append('Wants=' + ' '.join(self.wants)) + fpath.write_text('\n'.join(lines) + '\n') + log.info(f'(Re)installed {fname}') + daemon_reload() + + def unit_name(self) -> str: + return f'{self.unit_prefix}{self.unit_basename}.target' + + def managed_unit_names(self) -> list[str]: + return [self.unit_name()] diff --git a/src/sysdi_tests/test_integration.py b/src/sysdi_tests/test_integration.py index 92de896..47cf25e 100644 --- a/src/sysdi_tests/test_integration.py +++ b/src/sysdi_tests/test_integration.py @@ -44,6 +44,7 @@ def test_ok(self, um: UnitManager, tmp_path: Path): 'Check exec wrap', exec_bin='/usr/bin/true', exec_wrap=FileWrap(tmp_path), + on_active_sec='1000s', ), ) um.sync(linger=None) @@ -59,6 +60,7 @@ def test_fail(self, um: UnitManager, tmp_path: Path): 'Check exec wrap', exec_bin='/usr/bin/false', exec_wrap=FileWrap(tmp_path), + on_active_sec='1000s', ), ) um.sync(linger=None) diff --git a/src/sysdi_tests/test_sequential.py b/src/sysdi_tests/test_sequential.py new file mode 100644 index 0000000..cf3b658 --- /dev/null +++ b/src/sysdi_tests/test_sequential.py @@ -0,0 +1,138 @@ +from dataclasses import dataclass +from pathlib import Path +import re + +import pytest + +from sysdi import ServiceUnit, TimedUnit, UnitManager + +from .libs import testing + + +@pytest.fixture(autouse=True) +def m_core(): + with testing.mock_core() as core_mocks: + yield core_mocks + + +def read(path: Path) -> str: + return path.read_text() + + +def assert_has_line(text: str, pattern: str): + escaped = re.escape(pattern) + assert re.search(rf'^\s*{escaped}\s*$', text, re.MULTILINE), f'Missing line: {pattern}' + + +def assert_not_has_line(text: str, pattern: str): + escaped = re.escape(pattern) + assert not re.search(rf'^\s*{escaped}\s*$', text, re.MULTILINE), f'Unexpected line: {pattern}' + + +class TestSequentialChains: + def test_chain_basic(self, tmp_path: Path): + @dataclass + class TimedPicard(TimedUnit): + exec_bin: str = '/bin/picard' + + @dataclass + class SvcPicard(ServiceUnit): + exec_bin: str = '/bin/picard' + + um = UnitManager(unit_prefix='abc-') + a = TimedPicard('Alpha', 'alpha', on_active_sec='1000s') + b = SvcPicard('Beta', 'beta') + c = SvcPicard('Gamma', 'gamma') + + um.chain('Test Chain', a, b, c) + um.sync(linger=None, install_dpath=tmp_path) + + names = sorted(p.name for p in tmp_path.iterdir()) + assert names == [ + 'abc-alpha.service', + 'abc-alpha.timer', + 'abc-beta.service', + 'abc-gamma.service', + 'abc-test-chain.target', + ] + + a_svc = read(tmp_path / 'abc-alpha.service') + b_svc = read(tmp_path / 'abc-beta.service') + c_svc = read(tmp_path / 'abc-gamma.service') + tgt = read(tmp_path / 'abc-test-chain.target') + + assert_has_line(a_svc, '[Unit]') + assert_has_line(a_svc, 'OnSuccess=abc-beta.service') + assert_not_has_line(a_svc, 'OnFailure=') + + assert_has_line(b_svc, 'OnSuccess=abc-gamma.service') + assert_not_has_line(b_svc, 'OnFailure=') + + assert_not_has_line(c_svc, 'OnSuccess=') + assert_not_has_line(c_svc, 'OnFailure=') + + assert_has_line(tgt, '[Unit]') + assert_has_line(tgt, 'Wants=abc-alpha.timer') + + def test_empty_chain_errors(self): + um = UnitManager(unit_prefix='abc-') + with pytest.raises(ValueError): + um.chain('Empty', *[]) # type: ignore[arg-type] + + def test_single_chain_ok(self, tmp_path: Path): + @dataclass + class TimedPicard(TimedUnit): + exec_bin: str = '/bin/picard' + + um = UnitManager(unit_prefix='abc-') + a = TimedPicard('Alpha', 'alpha', on_active_sec='1000s') + um.chain('Single', a) + um.sync(linger=None, install_dpath=tmp_path) + + names = sorted(p.name for p in tmp_path.iterdir()) + assert names == [ + 'abc-alpha.service', + 'abc-alpha.timer', + 'abc-single.target', + ] + + a_svc = read(tmp_path / 'abc-alpha.service') + assert_not_has_line(a_svc, 'OnSuccess=') + assert_not_has_line(a_svc, 'OnFailure=') + + def test_duplicate_instances_error(self): + @dataclass + class TimedPicard(TimedUnit): + exec_bin: str = '/bin/picard' + + @dataclass + class SvcPicard(ServiceUnit): + exec_bin: str = '/bin/picard' + + um = UnitManager(unit_prefix='abc-') + a = TimedPicard('Alpha', 'alpha', on_active_sec='1000s') + b = SvcPicard('Beta', 'beta') + with pytest.raises(ValueError): + um.chain('Dupes', a, b, b) + + def test_on_failure_directive_supported(self, tmp_path: Path): + @dataclass + class TimedPicard(TimedUnit): + exec_bin: str = '/bin/picard' + + @dataclass + class SvcPicard(ServiceUnit): + exec_bin: str = '/bin/picard' + + um = UnitManager(unit_prefix='abc-') + a = TimedPicard('Alpha', 'alpha', on_active_sec='1000s') + b = SvcPicard('Beta', 'beta') + fail = SvcPicard('Failed', 'failed') + # Explicitly assign OnFailure to Beta + b.on_failure = [fail] + + um.chain('With Failure', a, b) + um.sync(linger=None, install_dpath=tmp_path) + + b_svc = read(tmp_path / 'abc-beta.service') + assert_has_line(b_svc, 'OnFailure=abc-failed.service') diff --git a/uv.lock b/uv.lock index e586a20..05fa7ad 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 -revision = 2 -requires-python = ">=3.12.0, <3.13" +revision = 3 +requires-python = "==3.12.*" [[package]] name = "anyio" @@ -264,11 +264,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.18.0" +version = "3.20.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, + { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, ] [[package]] @@ -600,11 +600,11 @@ wheels = [ [[package]] name = "pip" -version = "25.1.1" +version = "25.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/59/de/241caa0ca606f2ec5fe0c1f4261b0465df78d786a38da693864a116c37f4/pip-25.1.1.tar.gz", hash = "sha256:3de45d411d308d5054c2168185d8da7f9a2cd753dbac8acbfa88a8909ecd9077", size = 1940155, upload-time = "2025-05-02T15:14:02.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/6e/74a3f0179a4a73a53d66ce57fdb4de0080a8baa1de0063de206d6167acc2/pip-25.3.tar.gz", hash = "sha256:8d0538dbbd7babbd207f261ed969c65de439f6bc9e5dbd3b3b9a77f25d95f343", size = 1803014, upload-time = "2025-10-25T00:55:41.394Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/a2/d40fb2460e883eca5199c62cfc2463fd261f760556ae6290f88488c362c0/pip-25.1.1-py3-none-any.whl", hash = "sha256:2913a38a2abf4ea6b64ab507bd9e967f3b53dc1ede74b01b0931e1ce548751af", size = 1825227, upload-time = "2025-05-02T15:13:59.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl", hash = "sha256:9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd", size = 1778622, upload-time = "2025-10-25T00:55:39.247Z" }, ] [[package]] @@ -1016,11 +1016,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.5.0" +version = "2.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] [[package]] @@ -1037,41 +1037,42 @@ wheels = [ [[package]] name = "uv" -version = "0.7.13" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/08/1bcafa9077965de397d927f291827a77a915d75567b42c3ad6bb6a2e0bcd/uv-0.7.13.tar.gz", hash = "sha256:05f3c03c4ea55d294f3da725b6c2c2ff544754c18552da7594def4ec3889dcfb", size = 3308772, upload-time = "2025-06-12T22:23:10.377Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/4e/cdf97c831be960e13c7db28b6ba226e5bdbfee9a38f6099687c7a395ec52/uv-0.7.13-py3-none-linux_armv6l.whl", hash = "sha256:59915aec9fd2b845708a76ddc6c0639cfc99b6e2811854ea2425ee7552aff0e9", size = 17073615, upload-time = "2025-06-12T20:58:46.197Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8f/27217e8a7a457bc9c068d99f2d860706649130755fa377306d75a326ce0b/uv-0.7.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9c457a84cfbe2019ba301e14edd3e1c950472abd0b87fc77622ab3fc475ba012", size = 17099887, upload-time = "2025-06-12T20:58:50.272Z" }, - { url = "https://files.pythonhosted.org/packages/46/c7/1d7ec2211732512ae43d7176242fea3eea1915c83565953014bbafcb6be2/uv-0.7.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4f828174e15a557d3bc0f809de76135c3b66bcbf524657f8ced9d22fc978b89c", size = 15800953, upload-time = "2025-06-12T20:58:52.897Z" }, - { url = "https://files.pythonhosted.org/packages/d8/5b/81ea6ec50890a064b37d8f8dc097901768f73c747d965ffd96f1ebdfacea/uv-0.7.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:88fcf2bfbb53309531a850af50d2ea75874099b19d4159625d0b4f88c53494b9", size = 16355391, upload-time = "2025-06-12T20:58:55.146Z" }, - { url = "https://files.pythonhosted.org/packages/64/24/92a30049a74bf17c9c4ffbf36462c5ff593617c2d0b78efb3c9d55293746/uv-0.7.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:721b058064150fc1c6d88e277af093d1b4f8bb7a59546fe9969d9ff7dbe3f6fd", size = 16819352, upload-time = "2025-06-12T20:58:57.299Z" }, - { url = "https://files.pythonhosted.org/packages/74/fe/8b4de3addc375ba00bd1a515a79aaccbb3a600bc66c03e5fd159d6928066/uv-0.7.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f28e70baadfebe71dcc2d9505059b988d75e903fc62258b102eb87dc4b6994a3", size = 17518852, upload-time = "2025-06-12T20:58:59.538Z" }, - { url = "https://files.pythonhosted.org/packages/09/73/e9c14c6aba0316da7fe30b0dac4f8f6d1155d0422dcff1138b85f4eb4c08/uv-0.7.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:9d2952a1e74c7027347c74cee1cb2be09121a5290db38498b8b17ff585f73748", size = 18405034, upload-time = "2025-06-12T20:59:01.747Z" }, - { url = "https://files.pythonhosted.org/packages/9d/62/a2f4147fa2714ce765104e2984abcdaa0605725b10ca70bee7de4a1ba88c/uv-0.7.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a51006c7574e819308d92a3452b22d5bd45ef8593a4983b5856aa7cb8220885f", size = 18120055, upload-time = "2025-06-12T20:59:03.997Z" }, - { url = "https://files.pythonhosted.org/packages/9c/b2/f4381c1aa4d3d13ff36359e4176cd34d1da1548ba2a6c763a953b282ede0/uv-0.7.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33837aca7bdf02d47554d5d44f9e71756ee17c97073b07b4afead25309855bc7", size = 18283737, upload-time = "2025-06-12T20:59:06.437Z" }, - { url = "https://files.pythonhosted.org/packages/d8/ef/f2e96cec5e4cf65d7fde89b5dcf9540ddacf42e8e39de2fa0332614e55a8/uv-0.7.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5786a29e286f2cc3cbda13a357fd9a4dd5bf1d7448a9d3d842b26b4f784a3a86", size = 17755308, upload-time = "2025-06-12T20:59:08.837Z" }, - { url = "https://files.pythonhosted.org/packages/34/6d/d7a1af8ece6d5cac5287d00e15b9650eb9d3203606add4cd035009d52de6/uv-0.7.13-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:1afdbfcabc3425b383141ba42d413841c0a48b9ee0f4da65459313275e3cea84", size = 16611463, upload-time = "2025-06-12T20:59:10.971Z" }, - { url = "https://files.pythonhosted.org/packages/b4/e8/27294e3067295db8f54dbe8a1f64b6e3000adc1cba29f953c440bc184a5d/uv-0.7.13-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:866cad0d04a7de1aaa3c5cbef203f9d3feef9655972dcccc3283d60122db743b", size = 16759459, upload-time = "2025-06-12T22:22:44.278Z" }, - { url = "https://files.pythonhosted.org/packages/94/6a/36f055eb1b9a44d60eed9a5aa93cf0f23660a19ab07a5ef085331dd9fc0a/uv-0.7.13-py3-none-musllinux_1_1_i686.whl", hash = "sha256:527a12d0c2f4d15f72b275b6f4561ae92af76dd59b4624796fddd45867f13c33", size = 17108780, upload-time = "2025-06-12T22:22:48.412Z" }, - { url = "https://files.pythonhosted.org/packages/11/c1/0f09c0de0896d04b4bb81bdd7833643f055e8a5c2c04f8a2ddf3a74453d8/uv-0.7.13-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:4efa555b217e15767f0691a51d435f7bb2b0bf473fdfd59f173aeda8a93b8d17", size = 17900498, upload-time = "2025-06-12T22:22:50.93Z" }, - { url = "https://files.pythonhosted.org/packages/ce/6f/ee435b4ec3903617b5f592c0077ef0c1e22c41e2ab872be2134b223aabb2/uv-0.7.13-py3-none-win32.whl", hash = "sha256:b1af81e57d098b21b28f42ec756f0e26dce2341d59ba4e4f11759bc3ca2c0a99", size = 17329841, upload-time = "2025-06-12T22:22:57.517Z" }, - { url = "https://files.pythonhosted.org/packages/af/05/c16e2b9369d440e3c85439257bd679c3a92bdd248015238a8848941828f6/uv-0.7.13-py3-none-win_amd64.whl", hash = "sha256:8c0c29a2089ff9011d6c3abccd272f3ee6d0e166dae9e5232099fd83d26104d9", size = 18820166, upload-time = "2025-06-12T22:23:05.224Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ac/68fd18d5190515f9ddb31cc2f14e21d1b38bee721ece2d43c42e13646fa3/uv-0.7.13-py3-none-win_arm64.whl", hash = "sha256:e077dcac19e564cae8b4223b7807c2f617a59938f8142ca77fc6348ae9c6d0aa", size = 17456260, upload-time = "2025-06-12T22:23:08.227Z" }, +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/b3/c2a6afd3d8f8f9f5d9c65fcdff1b80fb5bdaba21c8b0e99dd196e71d311f/uv-0.9.25.tar.gz", hash = "sha256:8625de8f40e7b669713e293ab4f7044bca9aa7f7c739f17dc1fd0cb765e69f28", size = 3863318, upload-time = "2026-01-13T23:20:16.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/e1/9284199aed638643a4feadf8b3283c1d43b3c3adcbdac367f26a8f5e398f/uv-0.9.25-py3-none-linux_armv6l.whl", hash = "sha256:db51f37b3f6c94f4371d8e26ee8adeb9b1b1447c5fda8cc47608694e49ea5031", size = 21479938, upload-time = "2026-01-13T23:21:13.011Z" }, + { url = "https://files.pythonhosted.org/packages/6d/5c/79dc42e1abf0afc021823c688ff04e4283f9e72d20ca4af0027aa7ed29df/uv-0.9.25-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e47a9da2ddd33b5e7efb8068a24de24e24fd0d88a99e0c4a7e2328424783eab8", size = 20681034, upload-time = "2026-01-13T23:20:19.269Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0b/997f279db671fe4b1cf87ad252719c1b7c47a9546efd6c2594b5648ea983/uv-0.9.25-py3-none-macosx_11_0_arm64.whl", hash = "sha256:79af8c9b885b507a82087e45161a4bda7f2382682867dc95f7e6d22514ac844d", size = 19096089, upload-time = "2026-01-13T23:20:55.021Z" }, + { url = "https://files.pythonhosted.org/packages/d5/60/a7682177fe76501b403d464b4fee25c1ee4089fe56caf7cb87c2e6741375/uv-0.9.25-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:6ca6bdd3fe4b1730d1e3d10a4ce23b269915a60712379d3318ecea9a4ff861fd", size = 20848810, upload-time = "2026-01-13T23:20:13.916Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c1/01d5df4cbec33da51fc85868f129562cbd1488290465107c03bed90d8ca4/uv-0.9.25-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9d993b9c590ac76f805e17441125d67c7774b1ba05340dc987d3de01852226b6", size = 21095071, upload-time = "2026-01-13T23:20:44.488Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fe/f7cd2f02b0e0974dd95f732efd12bd36a3e8419d53f4d1d49744d2e3d979/uv-0.9.25-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4b72881d0c66ad77844451dbdbcada87242c0d39c6bfd0f89ac30b917a3cfc3", size = 22070541, upload-time = "2026-01-13T23:21:16.936Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e6/ef53b6d69b303eca6aa56ad97eb322f6cc5b9571c403e4e64313f1ccfb81/uv-0.9.25-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ac0dfb6191e91723a69be533102f98ffa5739cba57c3dfc5f78940c27cf0d7e8", size = 23663768, upload-time = "2026-01-13T23:20:29.808Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/f0e01ddfc62cb4b8ec5c6d94e46fc77035c0cd77865d7958144caadf8ad9/uv-0.9.25-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41ae0f2df7c931b72949345134070efa919174321c5bd403954db960fa4c2d7d", size = 23235860, upload-time = "2026-01-13T23:20:58.724Z" }, + { url = "https://files.pythonhosted.org/packages/6a/56/905257af2c63ffaec9add9cce5d34f851f418d42e6f4e73fee18adecd499/uv-0.9.25-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3bf02fcea14b8bec42b9c04094cc5b527c2cd53b606c06e7bdabfbd943b4512c", size = 22236426, upload-time = "2026-01-13T23:20:40.995Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/909feee469647b7929967397dcb1b6b317cfca07dc3fc0699b3cab700daf/uv-0.9.25-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:642f993d8c74ecd52b192d5f3168433c4efa81b8bb19c5ac97c25f27a44557cb", size = 22294538, upload-time = "2026-01-13T23:21:09.521Z" }, + { url = "https://files.pythonhosted.org/packages/82/be/ac7cd3c45c6baf0d5181133d3bda13f843f76799809374095b6fc7122a96/uv-0.9.25-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:564b5db5e148670fdbcfd962ee8292c0764c1be0c765f63b620600a3c81087d1", size = 20963345, upload-time = "2026-01-13T23:20:25.706Z" }, + { url = "https://files.pythonhosted.org/packages/19/fd/7b6191cef8da4ad451209dde083123b1ac9d10d6c2c1554a1de64aa41ad8/uv-0.9.25-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:991cfb872ef3bc0cc5e88f4d3f68adf181218a3a57860f523ff25279e4cf6657", size = 22205573, upload-time = "2026-01-13T23:20:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/15/80/8d6809df5e5ddf862f963fbfc8b2a25c286dc36724e50c7536e429d718be/uv-0.9.25-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:e1b4ab678c6816fe41e3090777393cf57a0f4ef122f99e9447d789ab83863a78", size = 21036715, upload-time = "2026-01-13T23:20:51.413Z" }, + { url = "https://files.pythonhosted.org/packages/bb/78/e3cb00bf90a359fa8106e2446bad07e49922b41e096e4d3b335b0065117a/uv-0.9.25-py3-none-musllinux_1_1_i686.whl", hash = "sha256:aa7db0ab689c3df34bdd46f83d2281d268161677ccd204804a87172150a654ef", size = 21505379, upload-time = "2026-01-13T23:21:06.045Z" }, + { url = "https://files.pythonhosted.org/packages/86/36/07f69f45878175d2907110858e5c6631a1b712420d229012296c1462b133/uv-0.9.25-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:a658e47e54f11dac9b2751fba4ad966a15db46c386497cf51c1c02f656508358", size = 22520308, upload-time = "2026-01-13T23:20:09.704Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1b/2d457ee7e2dd35fc22ae6f656bb45b781b33083d4f0a40901b9ae59e0b10/uv-0.9.25-py3-none-win32.whl", hash = "sha256:4df14479f034f6d4dca9f52230f912772f56ceead3354c7b186a34927c22188a", size = 20263705, upload-time = "2026-01-13T23:20:47.814Z" }, + { url = "https://files.pythonhosted.org/packages/5c/0b/05ad2dc53dab2c8aa2e112ef1f9227a7b625ba3507bedd7b31153d73aa5f/uv-0.9.25-py3-none-win_amd64.whl", hash = "sha256:001629fbc2a955c35f373311591c6952be010a935b0bc6244dc61da108e4593d", size = 22311694, upload-time = "2026-01-13T23:21:02.562Z" }, + { url = "https://files.pythonhosted.org/packages/54/4e/99788924989082356d6aa79d8bfdba1a2e495efaeae346fd8fec83d3f078/uv-0.9.25-py3-none-win_arm64.whl", hash = "sha256:ea26319abf9f5e302af0d230c0f13f02591313e5ffadac34931f963ef4d7833d", size = 20645549, upload-time = "2026-01-13T23:20:37.201Z" }, ] [[package]] name = "virtualenv" -version = "20.31.2" +version = "20.36.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c3a104fd0c495184c4f2336d65baf398e3c75d72ea94/virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af", size = 6076316, upload-time = "2025-05-08T17:58:23.811Z" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982, upload-time = "2025-05-08T17:58:21.15Z" }, + { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, ] [[package]]