From 1c72f06de0a3610f355c807c659f984807266284 Mon Sep 17 00:00:00 2001 From: Oliver Mohr Date: Sun, 14 Jun 2026 14:08:21 -0400 Subject: [PATCH 01/10] docs: add notification channel abstraction plan --- NOTIFICATION_CHANNELS_PLAN.md | 107 ++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 NOTIFICATION_CHANNELS_PLAN.md diff --git a/NOTIFICATION_CHANNELS_PLAN.md b/NOTIFICATION_CHANNELS_PLAN.md new file mode 100644 index 0000000..70b2bc8 --- /dev/null +++ b/NOTIFICATION_CHANNELS_PLAN.md @@ -0,0 +1,107 @@ +# Plan: Notification Channel abstraction + +Branch: `feature/notification-channels` + +## Goal + +Introduce an internal `NotificationChannel` abstraction that the two existing +delivery mechanisms — the per-task **log file** and the **HTML email** alert — +are expressed through. This refactor enables adding new notification handlers +later (Telegram, Slack, Discord, …) by simply implementing a new +`NotificationChannel` subclass, without touching `Task` internals. + +**In scope (this branch):** +- Define the `NotificationChannel` abstract base class. +- Wrap the two *existing* handlers as channels: `FileChannel` and `EmailChannel`. +- Wire `Task` to build its logger handlers through these channels. +- Expose an optional `channels` parameter on `Task` so extra channels can be + plugged in (the extensibility point). +- Tests, type checking (mypy strict) and lint (ruff) green. + +**Out of scope (explicitly NOT in this branch):** +- No new concrete channels (no Telegram/Slack/Discord). Only the abstraction + + the two current handlers. +- No final merge / PR — the maintainer will open the PR. + +## Design + +### Abstraction + +```python +class NotificationChannel(ABC): + @abstractmethod + def build_handler(self, task_name: str) -> logging.Handler: ... +``` + +A channel knows how to build a fully configured `logging.Handler`. `Task` +iterates over its channels and attaches each handler to its logger. This keeps +the existing `record.task_context` failure-context flow intact — channels reuse +the same formatters (`_TaskLogfileFormatter`, `_HTMLEmailFormatter`). + +### Concrete channels (wrapping current handlers) + +- `FileChannel(log_path, level=logging.INFO)` → builds the `FileHandler` with + `_TaskLogfileFormatter` (current logfile behaviour). +- `EmailChannel(smtp_config, style=None)` → delegates to the existing + `_build_task_email_handler(smtp_config, style, task_name)` (current email + behaviour: `ERROR` level, localized subject). + +### `Task` wiring (backward compatible) + +`Task.__init__` keeps its current signature and behaviour, plus one additive +optional parameter: + +```python +def __init__(self, ..., channels: list[NotificationChannel] | None = None): + ... + self._channels: list[NotificationChannel] = [FileChannel(self.log_path)] + if smtp_config is not None: + self._channels.append(EmailChannel(smtp_config, email_style or HTMLEmailStyle())) + if channels is not None: + self._channels.extend(channels) + for channel in self._channels: + logger.addHandler(channel.build_handler(self.name)) +``` + +- `log_path` / `smtp_config` / `email_style` keep working exactly as before — + they are translated into the file and email channels internally. +- `_frame_filter` (traced-vars frame filter) stays sourced from `email_style`; + it governs how failure context is *built*, independent of delivery. +- New `channels` param is additive and defaults to `None` → zero behaviour + change for existing callers. + +### Public API exports + +Export `NotificationChannel`, `FileChannel`, `EmailChannel` from +`processes/__init__.py`. + +## Files + +- `src/processes/notification_channels.py` — new module (ABC + two channels). +- `src/processes/task.py` — build handlers via channels; add `channels` param. +- `src/processes/__init__.py` — export the new public names. +- `tests/test_notification_channels.py` — new test module. + +## Testing + +- `NotificationChannel` cannot be instantiated directly (is abstract). +- `FileChannel.build_handler` returns a `FileHandler` at the right level with + `_TaskLogfileFormatter`, writing to the given path. +- `EmailChannel.build_handler` returns an `ERROR`-level handler with the + localized subject (mock SMTP, mirroring `test_email_themes.py`). +- Integration: a `Task` with a custom extra channel attaches its handler; + existing file + email behaviour unchanged. +- Full existing suite must stay green (backward-compat guarantee). + +## Commit sequence (conventional commits, no Claude attribution) + +Commit only after the relevant tests + ruff + mypy pass. + +1. `docs: add notification channel abstraction plan` — this file. +2. `feat: add NotificationChannel abstraction with file and email channels` + — new module + unit tests + exports. +3. `refactor: build task handlers through notification channels` + — wire `Task` to channels, add `channels` param, integration tests, + docstring updates. + +No final merge; push the branch so it appears on GitHub for the maintainer's PR. From d0b34854ffef5a0a3b3558e2682849426c2a0d63 Mon Sep 17 00:00:00 2001 From: Oliver Mohr Date: Sun, 14 Jun 2026 14:13:19 -0400 Subject: [PATCH 02/10] feat: add NotificationChannel abstraction with file and email channels --- src/processes/__init__.py | 3 + src/processes/notification_channels.py | 121 +++++++++++++++++++++++++ tests/test_notification_channels.py | 98 ++++++++++++++++++++ 3 files changed, 222 insertions(+) create mode 100644 src/processes/notification_channels.py create mode 100644 tests/test_notification_channels.py diff --git a/src/processes/__init__.py b/src/processes/__init__.py index 119e40b..eb514ff 100644 --- a/src/processes/__init__.py +++ b/src/processes/__init__.py @@ -12,6 +12,9 @@ from .exceptions import ( TaskNotFoundError as TaskNotFoundError, ) +from .notification_channels import EmailChannel as EmailChannel +from .notification_channels import FileChannel as FileChannel +from .notification_channels import NotificationChannel as NotificationChannel from .process import Process as Process from .task import Task as Task from .task import TaskDependency as TaskDependency diff --git a/src/processes/notification_channels.py b/src/processes/notification_channels.py new file mode 100644 index 0000000..64543c5 --- /dev/null +++ b/src/processes/notification_channels.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod + +from ._email_internals import _build_task_email_handler +from ._logfile_formatting import _TaskLogfileFormatter +from .email_config import HTMLEmailStyle, SMTPConfig + + +class NotificationChannel(ABC): + """Base class for task notification channels. + + A notification channel knows how to build a configured + ``logging.Handler`` that delivers a task's log records (and, on + failure, its structured failure context) to some destination. ``Task`` + attaches one handler per configured channel to its logger. + + Concrete channels (e.g. ``FileChannel``, ``EmailChannel``) wrap a + specific delivery mechanism. New channels can be added by subclassing + ``NotificationChannel`` and implementing ``build_handler``. + """ + + @abstractmethod + def build_handler(self, task_name: str) -> logging.Handler: + """Build a configured handler for the given task. + + Parameters + ---------- + task_name : str + Name of the task the handler will be attached to. + + Returns + ------- + logging.Handler + A handler ready to be added to the task's logger. + """ + + +class FileChannel(NotificationChannel): + """Notification channel that writes task log records to a plain-text file. + + Attributes + ---------- + log_path : str + File path the handler writes to. + level : int + Minimum log level handled. Defaults to ``logging.INFO``. + + Parameters + ---------- + log_path : str + File path the handler writes to. + level : int + Minimum log level handled. Defaults to ``logging.INFO``. + """ + + def __init__(self, log_path: str, level: int = logging.INFO): + self.log_path = log_path + self.level = level + + def build_handler(self, task_name: str) -> logging.Handler: + """Build a ``FileHandler`` writing to ``log_path``. + + Parameters + ---------- + task_name : str + Name of the task the handler will be attached to. Unused by + this channel, accepted for interface consistency. + + Returns + ------- + logging.Handler + A ``FileHandler`` at ``level``, formatted with + ``_TaskLogfileFormatter``. + """ + handler = logging.FileHandler(self.log_path) + handler.setLevel(self.level) + handler.setFormatter(_TaskLogfileFormatter()) + return handler + + +class EmailChannel(NotificationChannel): + """Notification channel that sends an HTML email alert on task failure. + + Attributes + ---------- + smtp_config : SMTPConfig + SMTP transport configuration for the alert. + style : HTMLEmailStyle + HTML presentation settings used to render the alert. + + Parameters + ---------- + smtp_config : SMTPConfig + SMTP transport configuration for the alert. + style : HTMLEmailStyle | None + HTML presentation settings used to render the alert. Defaults to + ``HTMLEmailStyle()`` (modern, neutral, English) when ``None``. + """ + + def __init__(self, smtp_config: SMTPConfig, style: HTMLEmailStyle | None = None): + self.smtp_config = smtp_config + self.style = style or HTMLEmailStyle() + + def build_handler(self, task_name: str) -> logging.Handler: + """Build an HTML email handler bound to ``task_name``. + + Parameters + ---------- + task_name : str + Name of the task the handler will be attached to, used in the + email subject. + + Returns + ------- + logging.Handler + A handler at ``logging.ERROR`` level that sends a styled HTML + email for each error log record. + """ + return _build_task_email_handler(self.smtp_config, self.style, task_name) diff --git a/tests/test_notification_channels.py b/tests/test_notification_channels.py new file mode 100644 index 0000000..48508c5 --- /dev/null +++ b/tests/test_notification_channels.py @@ -0,0 +1,98 @@ +"""Tests for the NotificationChannel abstraction. + +Covers: + +* ``NotificationChannel`` cannot be instantiated directly. +* ``FileChannel.build_handler`` returns a ``FileHandler`` at the right + level, formatted with ``_TaskLogfileFormatter``, writing to the given path. +* ``EmailChannel.build_handler`` returns an ``ERROR``-level handler with the + localized subject, mirroring the behaviour of ``_build_task_email_handler``. +""" + +from __future__ import annotations + +import logging + +import pytest + +from processes import EmailChannel, FileChannel, HTMLEmailStyle, NotificationChannel, SMTPConfig +from processes._email_internals import _HTMLEmailFormatter +from processes._logfile_formatting import _TaskLogfileFormatter + +from .base_test import BaseTest + + +class TestNotificationChannelABC: + def test_cannot_instantiate_abstract_base(self) -> None: + with pytest.raises(TypeError): + NotificationChannel() # type: ignore[abstract] + + +class TestFileChannel(BaseTest): + def test_build_handler_returns_configured_file_handler(self) -> None: + log_path = self._log("file_channel.log") + channel = FileChannel(log_path) + handler = channel.build_handler("some_task") + + try: + assert isinstance(handler, logging.FileHandler) + assert handler.level == logging.INFO + assert isinstance(handler.formatter, _TaskLogfileFormatter) + assert handler.baseFilename == log_path + finally: + handler.close() + + def test_build_handler_respects_custom_level(self) -> None: + log_path = self._log("file_channel_level.log") + channel = FileChannel(log_path, level=logging.WARNING) + handler = channel.build_handler("some_task") + + try: + assert handler.level == logging.WARNING + finally: + handler.close() + + def test_handler_writes_log_records_to_path(self) -> None: + log_path = self._log("file_channel_write.log") + channel = FileChannel(log_path) + handler = channel.build_handler("write_task") + + logger = logging.getLogger("test.notification_channels.file_write") + logger.setLevel(logging.DEBUG) + logger.addHandler(handler) + try: + logger.info("hello from file channel") + finally: + handler.close() + logger.removeHandler(handler) + + with open(log_path) as f: + content = f.read() + assert "hello from file channel" in content + + +class TestEmailChannel(BaseTest): + def _smtp_config(self) -> SMTPConfig: + return SMTPConfig( + mailhost=("smtp.example.test", 25), + fromaddr="alerts@example.test", + toaddrs=["oncall@example.test"], + ) + + def test_build_handler_defaults_to_error_level_and_default_style(self) -> None: + channel = EmailChannel(self._smtp_config()) + handler = channel.build_handler("email_task") + + assert handler.level == logging.ERROR + assert isinstance(handler.formatter, _HTMLEmailFormatter) + assert handler.subject == "Error in task email_task" + + def test_build_handler_uses_provided_style_for_subject_language(self) -> None: + channel = EmailChannel(self._smtp_config(), HTMLEmailStyle(language="es")) + handler = channel.build_handler("email_task") + + assert handler.subject == "Error en la tarea email_task" + + def test_default_style_is_modern_neutral_english(self) -> None: + channel = EmailChannel(self._smtp_config()) + assert channel.style == HTMLEmailStyle() From 44eb6253c322d08952018cf56bbe2216354a4fd7 Mon Sep 17 00:00:00 2001 From: Oliver Mohr Date: Sun, 14 Jun 2026 14:15:09 -0400 Subject: [PATCH 03/10] refactor: build task handlers through notification channels --- src/processes/task.py | 42 ++++++++++---- tests/test_notification_channels.py | 85 ++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 11 deletions(-) diff --git a/src/processes/task.py b/src/processes/task.py index 2e879ee..7616fb4 100644 --- a/src/processes/task.py +++ b/src/processes/task.py @@ -9,11 +9,10 @@ import logging -from ._email_internals import _build_task_email_handler -from ._logfile_formatting import _TaskLogfileFormatter from ._tb_utils import _build_traced_vars_html, _build_traced_vars_location, _format_traceback from .email_config import HTMLEmailStyle, SMTPConfig from .exceptions import CircularDependencyError +from .notification_channels import EmailChannel, FileChannel, NotificationChannel class TaskResult: @@ -148,6 +147,9 @@ class Task: Exception types that trigger a retry. When ``retries >= 1`` and ``retry_on`` is ``None``, defaults at call time to ``(ConnectionError, TimeoutError)``. Defaults to ``None``. + channels : list[NotificationChannel] + Additional notification channels attached to this task's logger, + on top of the implicit file and email channels. Defaults to empty list. logger : logging.Logger Logger instance for this task, automatically configured. @@ -187,13 +189,19 @@ class Task: Exception types that trigger a retry. Evaluated only when ``retries >= 1``. When ``None``, defaults at call time to ``(ConnectionError, TimeoutError)``. Defaults to ``None``. + channels : list[NotificationChannel] | None + Additional notification channels whose handlers are attached to this + task's logger, alongside the implicit ``FileChannel`` (and + ``EmailChannel`` when ``smtp_config`` is set). ``None`` is treated as + an empty list. Defaults to ``None``. Raises ------ TypeError If any parameter is not of the expected type, ``timeout`` is not a - positive number, ``retries`` is negative, or ``retry_on`` is not a - tuple of ``Exception`` subclasses. + positive number, ``retries`` is negative, ``retry_on`` is not a + tuple of ``Exception`` subclasses, or ``channels`` is not a list of + ``NotificationChannel`` instances. ValueError If ``name`` contains a space, if the same dependency name is listed more than once, or if the task lists itself as a @@ -216,6 +224,7 @@ def __init__( timeout: float | None = None, retries: int | None = 0, retry_on: tuple[type[Exception], ...] | None = None, + channels: list[NotificationChannel] | None = None, ): self.name = name self.log_path = log_path @@ -233,6 +242,10 @@ def __init__( self.dependencies = [] else: self.dependencies = dependencies + if channels is None: + self.channels = [] + else: + self.channels = channels self._check_input_types(smtp_config, email_style) if " " in self.name: @@ -249,14 +262,14 @@ def __init__( logger = logging.getLogger(f"processes.{self.name}.{id(self)}") logger.setLevel(logging.DEBUG) - file_handler = logging.FileHandler(self.log_path) - file_handler.setLevel(logging.INFO) - file_handler.setFormatter(_TaskLogfileFormatter()) - logger.addHandler(file_handler) - + all_channels: list[NotificationChannel] = [FileChannel(self.log_path)] if smtp_config is not None: style = email_style or HTMLEmailStyle() - logger.addHandler(_build_task_email_handler(smtp_config, style, self.name)) + all_channels.append(EmailChannel(smtp_config, style)) + all_channels.extend(self.channels) + + for channel in all_channels: + logger.addHandler(channel.build_handler(self.name)) self._frame_filter: str | None = ( email_style.traced_vars_frame_filter if email_style is not None else None @@ -306,6 +319,15 @@ def _check_input_types( f"dependency must be of type TaskDependency. Got {type(dependency)}" ) + if not isinstance(self.channels, list): + raise TypeError(f"channels must be list. Got {type(self.channels)}") + + for channel in self.channels: + if not isinstance(channel, NotificationChannel): + raise TypeError( + f"channel must be of type NotificationChannel. Got {type(channel)}" + ) + if self.timeout is not None and ( not isinstance(self.timeout, (int, float)) or self.timeout <= 0 ): diff --git a/tests/test_notification_channels.py b/tests/test_notification_channels.py index 48508c5..8fe5633 100644 --- a/tests/test_notification_channels.py +++ b/tests/test_notification_channels.py @@ -15,7 +15,15 @@ import pytest -from processes import EmailChannel, FileChannel, HTMLEmailStyle, NotificationChannel, SMTPConfig +from processes import ( + EmailChannel, + FileChannel, + HTMLEmailStyle, + NotificationChannel, + Process, + SMTPConfig, + Task, +) from processes._email_internals import _HTMLEmailFormatter from processes._logfile_formatting import _TaskLogfileFormatter @@ -96,3 +104,78 @@ def test_build_handler_uses_provided_style_for_subject_language(self) -> None: def test_default_style_is_modern_neutral_english(self) -> None: channel = EmailChannel(self._smtp_config()) assert channel.style == HTMLEmailStyle() + + +class _RecordingChannel(NotificationChannel): + """Test-only channel that captures every record it receives.""" + + def __init__(self) -> None: + self.records: list[logging.LogRecord] = [] + + def build_handler(self, task_name: str) -> logging.Handler: + records = self.records + + class _Handler(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + records.append(record) + + handler = _Handler() + handler.setLevel(logging.INFO) + return handler + + +class TestTaskChannelsWiring(BaseTest): + def test_extra_channel_receives_log_records(self) -> None: + def task_1() -> int: + return 1 + + channel = _RecordingChannel() + task = Task("task_1", self._log("channels_extra.log"), task_1, channels=[channel]) + + with Process([task]) as process: + process.run() + + messages = [r.getMessage() for r in channel.records] + assert "Starting task_1." in messages + assert "Finished task_1." in messages + + def test_default_channels_remain_when_extra_channel_given(self) -> None: + def task_1() -> int: + return 1 + + log_path = self._log("channels_default_plus_extra.log") + channel = _RecordingChannel() + task = Task("task_1", log_path, task_1, channels=[channel]) + + with Process([task]) as process: + process.run() + + with open(log_path) as f: + lines = f.readlines() + assert "Starting task_1." in lines[0] + assert "Finished task_1." in lines[1] + assert len(channel.records) == 2 + + def test_channels_must_be_list(self) -> None: + def task_1() -> int: + return 1 + + with pytest.raises(TypeError, match="channels must be list"): + Task( + "task_1", + self._log("channels_bad_type.log"), + task_1, + channels="not-a-list", # type: ignore[arg-type] + ) + + def test_channels_entries_must_be_notification_channel(self) -> None: + def task_1() -> int: + return 1 + + with pytest.raises(TypeError, match="channel must be of type NotificationChannel"): + Task( + "task_1", + self._log("channels_bad_entry.log"), + task_1, + channels=["not-a-channel"], # type: ignore[list-item] + ) From f86c377e1fc0c22910a1a0a5c2a332a9b54eef07 Mon Sep 17 00:00:00 2001 From: Oliver Mohr Date: Sun, 14 Jun 2026 15:39:49 -0400 Subject: [PATCH 04/10] refactor: keep file and email channel implementations internal --- src/processes/__init__.py | 2 -- src/processes/notification_channels.py | 8 ++++---- src/processes/task.py | 6 +++--- tests/test_notification_channels.py | 27 ++++++++++---------------- 4 files changed, 17 insertions(+), 26 deletions(-) diff --git a/src/processes/__init__.py b/src/processes/__init__.py index eb514ff..40258c7 100644 --- a/src/processes/__init__.py +++ b/src/processes/__init__.py @@ -12,8 +12,6 @@ from .exceptions import ( TaskNotFoundError as TaskNotFoundError, ) -from .notification_channels import EmailChannel as EmailChannel -from .notification_channels import FileChannel as FileChannel from .notification_channels import NotificationChannel as NotificationChannel from .process import Process as Process from .task import Task as Task diff --git a/src/processes/notification_channels.py b/src/processes/notification_channels.py index 64543c5..c3aa0f7 100644 --- a/src/processes/notification_channels.py +++ b/src/processes/notification_channels.py @@ -16,8 +16,8 @@ class NotificationChannel(ABC): failure, its structured failure context) to some destination. ``Task`` attaches one handler per configured channel to its logger. - Concrete channels (e.g. ``FileChannel``, ``EmailChannel``) wrap a - specific delivery mechanism. New channels can be added by subclassing + Concrete channels wrap a specific delivery mechanism (e.g. a logfile or + an email alert). New channels can be added by subclassing ``NotificationChannel`` and implementing ``build_handler``. """ @@ -37,7 +37,7 @@ def build_handler(self, task_name: str) -> logging.Handler: """ -class FileChannel(NotificationChannel): +class _FileChannel(NotificationChannel): """Notification channel that writes task log records to a plain-text file. Attributes @@ -80,7 +80,7 @@ def build_handler(self, task_name: str) -> logging.Handler: return handler -class EmailChannel(NotificationChannel): +class _EmailChannel(NotificationChannel): """Notification channel that sends an HTML email alert on task failure. Attributes diff --git a/src/processes/task.py b/src/processes/task.py index 7616fb4..5272878 100644 --- a/src/processes/task.py +++ b/src/processes/task.py @@ -12,7 +12,7 @@ from ._tb_utils import _build_traced_vars_html, _build_traced_vars_location, _format_traceback from .email_config import HTMLEmailStyle, SMTPConfig from .exceptions import CircularDependencyError -from .notification_channels import EmailChannel, FileChannel, NotificationChannel +from .notification_channels import NotificationChannel, _EmailChannel, _FileChannel class TaskResult: @@ -262,10 +262,10 @@ def __init__( logger = logging.getLogger(f"processes.{self.name}.{id(self)}") logger.setLevel(logging.DEBUG) - all_channels: list[NotificationChannel] = [FileChannel(self.log_path)] + all_channels: list[NotificationChannel] = [_FileChannel(self.log_path)] if smtp_config is not None: style = email_style or HTMLEmailStyle() - all_channels.append(EmailChannel(smtp_config, style)) + all_channels.append(_EmailChannel(smtp_config, style)) all_channels.extend(self.channels) for channel in all_channels: diff --git a/tests/test_notification_channels.py b/tests/test_notification_channels.py index 8fe5633..09d7af5 100644 --- a/tests/test_notification_channels.py +++ b/tests/test_notification_channels.py @@ -3,9 +3,9 @@ Covers: * ``NotificationChannel`` cannot be instantiated directly. -* ``FileChannel.build_handler`` returns a ``FileHandler`` at the right +* ``_FileChannel.build_handler`` returns a ``FileHandler`` at the right level, formatted with ``_TaskLogfileFormatter``, writing to the given path. -* ``EmailChannel.build_handler`` returns an ``ERROR``-level handler with the +* ``_EmailChannel.build_handler`` returns an ``ERROR``-level handler with the localized subject, mirroring the behaviour of ``_build_task_email_handler``. """ @@ -15,17 +15,10 @@ import pytest -from processes import ( - EmailChannel, - FileChannel, - HTMLEmailStyle, - NotificationChannel, - Process, - SMTPConfig, - Task, -) +from processes import HTMLEmailStyle, NotificationChannel, Process, SMTPConfig, Task from processes._email_internals import _HTMLEmailFormatter from processes._logfile_formatting import _TaskLogfileFormatter +from processes.notification_channels import _EmailChannel, _FileChannel from .base_test import BaseTest @@ -39,7 +32,7 @@ def test_cannot_instantiate_abstract_base(self) -> None: class TestFileChannel(BaseTest): def test_build_handler_returns_configured_file_handler(self) -> None: log_path = self._log("file_channel.log") - channel = FileChannel(log_path) + channel = _FileChannel(log_path) handler = channel.build_handler("some_task") try: @@ -52,7 +45,7 @@ def test_build_handler_returns_configured_file_handler(self) -> None: def test_build_handler_respects_custom_level(self) -> None: log_path = self._log("file_channel_level.log") - channel = FileChannel(log_path, level=logging.WARNING) + channel = _FileChannel(log_path, level=logging.WARNING) handler = channel.build_handler("some_task") try: @@ -62,7 +55,7 @@ def test_build_handler_respects_custom_level(self) -> None: def test_handler_writes_log_records_to_path(self) -> None: log_path = self._log("file_channel_write.log") - channel = FileChannel(log_path) + channel = _FileChannel(log_path) handler = channel.build_handler("write_task") logger = logging.getLogger("test.notification_channels.file_write") @@ -88,7 +81,7 @@ def _smtp_config(self) -> SMTPConfig: ) def test_build_handler_defaults_to_error_level_and_default_style(self) -> None: - channel = EmailChannel(self._smtp_config()) + channel = _EmailChannel(self._smtp_config()) handler = channel.build_handler("email_task") assert handler.level == logging.ERROR @@ -96,13 +89,13 @@ def test_build_handler_defaults_to_error_level_and_default_style(self) -> None: assert handler.subject == "Error in task email_task" def test_build_handler_uses_provided_style_for_subject_language(self) -> None: - channel = EmailChannel(self._smtp_config(), HTMLEmailStyle(language="es")) + channel = _EmailChannel(self._smtp_config(), HTMLEmailStyle(language="es")) handler = channel.build_handler("email_task") assert handler.subject == "Error en la tarea email_task" def test_default_style_is_modern_neutral_english(self) -> None: - channel = EmailChannel(self._smtp_config()) + channel = _EmailChannel(self._smtp_config()) assert channel.style == HTMLEmailStyle() From 6fba03c6e9e11eeb4bc216b53e9932ba7af7ce1b Mon Sep 17 00:00:00 2001 From: Oliver Mohr Date: Sun, 14 Jun 2026 15:52:21 -0400 Subject: [PATCH 05/10] refactor!: configure email alerts via channels instead of Task smtp_config Remove smtp_config and email_style from Task.__init__. log_path remains the only logging-related argument and is converted internally into a file channel; email alerts and any other notification channels are now configured via the channels parameter (list[NotificationChannel]), placed after dependencies and before timeout. EmailChannel is exported publicly as the way to attach email alerts. BREAKING CHANGE: Task no longer accepts smtp_config or email_style. Pass channels=[EmailChannel(smtp_config, style)] instead. --- NOTIFICATION_CHANNELS_PLAN.md | 63 +++++++++-------- README.md | 31 +++++++-- docs/examples/advanced.md | 8 +-- docs/index.md | 16 ++--- src/processes/__init__.py | 1 + src/processes/notification_channels.py | 27 +++++++- src/processes/task.py | 67 +++++-------------- tests/manual_tests/manual_pipeline_inspect.py | 4 +- .../manual_tests/manual_themed_tracebacks.py | 18 +++-- tests/test_complex_dag_failures.py | 4 +- tests/test_email_themes.py | 43 ++++++------ tests/test_notification_channels.py | 20 ++++-- 12 files changed, 167 insertions(+), 135 deletions(-) diff --git a/NOTIFICATION_CHANNELS_PLAN.md b/NOTIFICATION_CHANNELS_PLAN.md index 70b2bc8..afaeaab 100644 --- a/NOTIFICATION_CHANNELS_PLAN.md +++ b/NOTIFICATION_CHANNELS_PLAN.md @@ -12,10 +12,11 @@ later (Telegram, Slack, Discord, …) by simply implementing a new **In scope (this branch):** - Define the `NotificationChannel` abstract base class. -- Wrap the two *existing* handlers as channels: `FileChannel` and `EmailChannel`. +- Wrap the two *existing* handlers as channels: `_FileChannel` (internal) and + `EmailChannel` (public). - Wire `Task` to build its logger handlers through these channels. -- Expose an optional `channels` parameter on `Task` so extra channels can be - plugged in (the extensibility point). +- Expose a `channels` parameter on `Task` so extra channels can be plugged in + (the extensibility point). - Tests, type checking (mypy strict) and lint (ruff) green. **Out of scope (explicitly NOT in this branch):** @@ -40,58 +41,64 @@ the same formatters (`_TaskLogfileFormatter`, `_HTMLEmailFormatter`). ### Concrete channels (wrapping current handlers) -- `FileChannel(log_path, level=logging.INFO)` → builds the `FileHandler` with - `_TaskLogfileFormatter` (current logfile behaviour). -- `EmailChannel(smtp_config, style=None)` → delegates to the existing +- `_FileChannel(log_path, level=logging.INFO)` (internal) → builds the + `FileHandler` with `_TaskLogfileFormatter` (current logfile behaviour). +- `EmailChannel(smtp_config, style=None)` (public) → delegates to the existing `_build_task_email_handler(smtp_config, style, task_name)` (current email behaviour: `ERROR` level, localized subject). -### `Task` wiring (backward compatible) +### `Task` wiring (breaking change) -`Task.__init__` keeps its current signature and behaviour, plus one additive -optional parameter: +`smtp_config` and `email_style` are removed from `Task.__init__` entirely. +`log_path` remains the only logging-related required argument and is +converted internally into a `_FileChannel`. Everything else — including email +alerts — is configured via `channels: list[NotificationChannel]`, positioned +right after `dependencies` and before `timeout`: ```python -def __init__(self, ..., channels: list[NotificationChannel] | None = None): +def __init__( + self, name, log_path, func, args=(), kwargs=None, + dependencies=None, channels: list[NotificationChannel] | None = None, + timeout=None, retries=0, retry_on=None, +): ... - self._channels: list[NotificationChannel] = [FileChannel(self.log_path)] - if smtp_config is not None: - self._channels.append(EmailChannel(smtp_config, email_style or HTMLEmailStyle())) - if channels is not None: - self._channels.extend(channels) - for channel in self._channels: + all_channels: list[NotificationChannel] = [_FileChannel(self.log_path), *self.channels] + for channel in all_channels: logger.addHandler(channel.build_handler(self.name)) + self._frame_filter = next( + (c.frame_filter for c in all_channels if c.frame_filter is not None), None + ) ``` -- `log_path` / `smtp_config` / `email_style` keep working exactly as before — - they are translated into the file and email channels internally. -- `_frame_filter` (traced-vars frame filter) stays sourced from `email_style`; - it governs how failure context is *built*, independent of delivery. -- New `channels` param is additive and defaults to `None` → zero behaviour - change for existing callers. +- `_frame_filter` (traced-vars frame filter) is sourced from the first channel + that defines a non-`None` `frame_filter` property (e.g. `EmailChannel`, + via `style.traced_vars_frame_filter`); it governs how failure context is + *built*, independent of delivery. +- To get email alerts, pass `channels=[EmailChannel(smtp_config, style)]`. ### Public API exports -Export `NotificationChannel`, `FileChannel`, `EmailChannel` from -`processes/__init__.py`. +Export `NotificationChannel` and `EmailChannel` from `processes/__init__.py`. +`_FileChannel` stays internal. ## Files - `src/processes/notification_channels.py` — new module (ABC + two channels). -- `src/processes/task.py` — build handlers via channels; add `channels` param. +- `src/processes/task.py` — build handlers via channels; `channels` param + replaces `smtp_config`/`email_style`. - `src/processes/__init__.py` — export the new public names. - `tests/test_notification_channels.py` — new test module. ## Testing - `NotificationChannel` cannot be instantiated directly (is abstract). -- `FileChannel.build_handler` returns a `FileHandler` at the right level with +- `_FileChannel.build_handler` returns a `FileHandler` at the right level with `_TaskLogfileFormatter`, writing to the given path. - `EmailChannel.build_handler` returns an `ERROR`-level handler with the localized subject (mock SMTP, mirroring `test_email_themes.py`). - Integration: a `Task` with a custom extra channel attaches its handler; - existing file + email behaviour unchanged. -- Full existing suite must stay green (backward-compat guarantee). + the implicit file channel and any `EmailChannel` in `channels` keep working. +- Full existing suite (updated to the new `channels` API) stays green. ## Commit sequence (conventional commits, no Claude attribution) diff --git a/README.md b/README.md index c791ef1..375f294 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ A realistic mini-pipeline: fetch two sources **in parallel**, transform them, ag import logging from pathlib import Path -from processes import HTMLEmailStyle, Process, SMTPConfig, Task, TaskDependency +from processes import EmailChannel, HTMLEmailStyle, Process, SMTPConfig, Task, TaskDependency LOG_DIR = Path("logs") LOG_DIR.mkdir(exist_ok=True) @@ -188,7 +188,7 @@ tasks = [ LOG_DIR / "notify_slack.log", notify_slack, dependencies=[TaskDependency("build_report", use_result_as_additional_args=True)], - smtp_config=smtp, + channels=[EmailChannel(smtp)], ), Task( "archive_report", @@ -230,8 +230,7 @@ Task( args: tuple = (), kwargs: dict | None = None, dependencies: list[TaskDependency] | None = None, - smtp_config: SMTPConfig | None = None, - email_style: HTMLEmailStyle | None = None, + channels: list[NotificationChannel] | None = None, timeout: float | None = None, retries: int | None = 0, retry_on: tuple[type[Exception], ...] | None = None, @@ -239,10 +238,9 @@ Task( ``` - `name` — unique within the `Process`; no spaces. -- `log_path` — the file this task logs to (INFO level, format `%(asctime)s - %(name)s - %(levelname)s - %(message)s`). +- `log_path` — the file this task logs to (INFO level, format `%(asctime)s - %(name)s - %(levelname)s - %(message)s`); wired internally into a file `NotificationChannel`. - `func` — the callable; receives `func(*args, **kwargs)` after result-injection. -- `smtp_config` — when set, fires an HTML email on `logging.ERROR`; body includes `task_name`, `function`, `args`, `kwargs`, and `downstream_impact`. -- `email_style` — optional presentation override; defaults to `HTMLEmailStyle()` (modern, neutral, English) when `smtp_config` is set. +- `channels` — additional `NotificationChannel`s attached to the task's logger. Use `EmailChannel(smtp_config, style=None)` to fire an HTML email on `logging.ERROR`; body includes `task_name`, `function`, `args`, `kwargs`, and `downstream_impact`. `style` defaults to `HTMLEmailStyle()` (modern, neutral, English). - `timeout` — seconds allowed per attempt; `None` means no limit. When the timeout fires the underlying thread is detached (Python threading limitation). - `retries` — additional attempts after the first failure; `0` or `None` means a single attempt. Defaults to `0`. - `retry_on` — tuple of exception types that trigger a retry. When `retries >= 1` and `retry_on` is `None`, defaults to `(ConnectionError, TimeoutError)` at call time. @@ -309,6 +307,25 @@ HTMLEmailStyle( ) ``` +### `NotificationChannel` + +```python +NotificationChannel # ABC: subclass and implement build_handler(task_name) -> logging.Handler +``` + +Every `Task` always attaches an internal file channel built from `log_path`. Extra channels passed via `channels` are attached on top of it. + +### `EmailChannel` + +```python +EmailChannel( + smtp_config: SMTPConfig, + style: HTMLEmailStyle | None = None, # defaults to HTMLEmailStyle() +) +``` + +Fires a styled HTML email on `logging.ERROR` and above. + All fields are optional — omit `HTMLEmailStyle` entirely to use the defaults. #### Traced Variables diff --git a/docs/examples/advanced.md b/docs/examples/advanced.md index ea25dac..b8924f4 100644 --- a/docs/examples/advanced.md +++ b/docs/examples/advanced.md @@ -197,9 +197,9 @@ If a task fails it can notify via email: - The traceback of the error - The tasks that could not be executed in the process due to this failure. -To set this up, pass an `SMTPConfig` to the Task constructor: +To set this up, pass an `EmailChannel` to the Task constructor via `channels`: ```python -from processes import SMTPConfig, HTMLEmailStyle, Task +from processes import SMTPConfig, HTMLEmailStyle, EmailChannel, Task smtp = SMTPConfig( mailhost=('smtp_server', 587), @@ -216,7 +216,7 @@ style = HTMLEmailStyle( language='en', # en | es | pt | fr | de | it ) -t = Task("task_name", "logfile", func_to_run, smtp_config=smtp, email_style=style) +t = Task("task_name", "logfile", func_to_run, channels=[EmailChannel(smtp, style)]) ``` ## ⏱️ Retries & Timeouts @@ -254,4 +254,4 @@ t_fetch = Task( If every attempt fails, the task is marked failed with the **last** exception raised — `retries` only controls how many times `func` is retried, not whether the failure is eventually reported. Combine with -`smtp_config` to be paged only once all attempts are exhausted. \ No newline at end of file +an `EmailChannel` to be paged only once all attempts are exhausted. \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 33e772d..b9233aa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -71,7 +71,7 @@ Define your tasks and their dependencies. **Processes** will handle the executio ```python from datetime import date -from processes import Process, Task, TaskDependency, SMTPConfig, HTMLEmailStyle +from processes import Process, Task, TaskDependency, SMTPConfig, HTMLEmailStyle, EmailChannel # 1. Setup Email Alerts (Optional) smtp_config = SMTPConfig( @@ -100,7 +100,7 @@ def sum_data_from_csv_and_x(x, a=1, b=2): # 3. Create the Task Graph (order is irrelevant, that is handled by Process) tasks = [ Task("t-1", "etl.log", get_previous_working_day), - Task("intependent", "indep.log", indep_task, smtp_config=smtp_config, email_style=email_style), # This task will send email on failure + Task("intependent", "indep.log", indep_task, channels=[EmailChannel(smtp_config, email_style)]), # This task will send email on failure Task("sum_csv", "etl.log", search_and_sum_csv, dependencies= [ TaskDependency("t-1", @@ -127,7 +127,7 @@ with Process(tasks) as process: # Context Manager ensures correct disposal of lo ## 📧 Customizing the HTML email -When a task with an `smtp_config` raises, the alert is a **styled HTML +When a task with an `EmailChannel` raises, the alert is a **styled HTML email** built from a bundled template. The body includes the exception, the traceback (with the user-frame highlighted), the task context, the list of downstream tasks that were skipped because of the failure, and @@ -146,7 +146,7 @@ Email delivery and presentation are configured with two separate dataclasses: | `traced_vars_frame_filter` | any path substring, or `None` | `None` (outermost user frame) | ```python -from processes import SMTPConfig, HTMLEmailStyle, Task +from processes import SMTPConfig, HTMLEmailStyle, EmailChannel, Task smtp = SMTPConfig( mailhost=("smtp.example.com", 587), @@ -162,12 +162,12 @@ style = HTMLEmailStyle( language="es", # en | es | pt | fr | de | it ) -t = Task("task_name", "logfile", func_to_run, smtp_config=smtp, email_style=style) +t = Task("task_name", "logfile", func_to_run, channels=[EmailChannel(smtp, style)]) ``` -If `smtp_config` is set and `email_style` is omitted, `HTMLEmailStyle()` defaults -(modern, neutral, English) are used. If `smtp_config` is `None`, `email_style` is ignored -and no email handler is attached. +If `style` is omitted, `EmailChannel` defaults to `HTMLEmailStyle()` +(modern, neutral, English). If no `EmailChannel` is included in `channels`, +no email handler is attached. All assets ship inside the wheel — the styles are Jinja-style HTML templates at `src/processes/themes/styles/` and the palettes are CSS diff --git a/src/processes/__init__.py b/src/processes/__init__.py index 40258c7..c1fe5a5 100644 --- a/src/processes/__init__.py +++ b/src/processes/__init__.py @@ -12,6 +12,7 @@ from .exceptions import ( TaskNotFoundError as TaskNotFoundError, ) +from .notification_channels import EmailChannel as EmailChannel from .notification_channels import NotificationChannel as NotificationChannel from .process import Process as Process from .task import Task as Task diff --git a/src/processes/notification_channels.py b/src/processes/notification_channels.py index c3aa0f7..c5c1c5a 100644 --- a/src/processes/notification_channels.py +++ b/src/processes/notification_channels.py @@ -36,6 +36,20 @@ def build_handler(self, task_name: str) -> logging.Handler: A handler ready to be added to the task's logger. """ + @property + def frame_filter(self) -> str | None: + """Substring selecting the traceback frame to trace local variables of. + + See ``HTMLEmailStyle.traced_vars_frame_filter``. Channels that don't + influence frame selection return ``None`` (the default). + + Returns + ------- + str | None + ``None`` unless overridden by a subclass. + """ + return None + class _FileChannel(NotificationChannel): """Notification channel that writes task log records to a plain-text file. @@ -80,7 +94,7 @@ def build_handler(self, task_name: str) -> logging.Handler: return handler -class _EmailChannel(NotificationChannel): +class EmailChannel(NotificationChannel): """Notification channel that sends an HTML email alert on task failure. Attributes @@ -119,3 +133,14 @@ def build_handler(self, task_name: str) -> logging.Handler: email for each error log record. """ return _build_task_email_handler(self.smtp_config, self.style, task_name) + + @property + def frame_filter(self) -> str | None: + """Frame filter sourced from ``style.traced_vars_frame_filter``. + + Returns + ------- + str | None + The configured ``traced_vars_frame_filter``, or ``None``. + """ + return self.style.traced_vars_frame_filter diff --git a/src/processes/task.py b/src/processes/task.py index 5272878..fc18555 100644 --- a/src/processes/task.py +++ b/src/processes/task.py @@ -10,9 +10,8 @@ import logging from ._tb_utils import _build_traced_vars_html, _build_traced_vars_location, _format_traceback -from .email_config import HTMLEmailStyle, SMTPConfig from .exceptions import CircularDependencyError -from .notification_channels import NotificationChannel, _EmailChannel, _FileChannel +from .notification_channels import NotificationChannel, _FileChannel class TaskResult: @@ -133,10 +132,10 @@ class Task: Keyword arguments to pass to the function. Defaults to empty dict. dependencies : list[TaskDependency] List of tasks this task depends on. Defaults to empty list. - smtp_config : SMTPConfig, optional - SMTP transport configuration for HTML email error alerts. - email_style : HTMLEmailStyle, optional - HTML presentation settings used alongside ``smtp_config``. + channels : list[NotificationChannel] + Additional notification channels attached to this task's logger, on + top of the implicit file channel built from ``log_path``. Defaults to + empty list. timeout : float | None Seconds allowed per attempt before a ``TimeoutError`` is raised. ``None`` means no limit. Defaults to ``None``. @@ -147,9 +146,6 @@ class Task: Exception types that trigger a retry. When ``retries >= 1`` and ``retry_on`` is ``None``, defaults at call time to ``(ConnectionError, TimeoutError)``. Defaults to ``None``. - channels : list[NotificationChannel] - Additional notification channels attached to this task's logger, - on top of the implicit file and email channels. Defaults to empty list. logger : logging.Logger Logger instance for this task, automatically configured. @@ -170,13 +166,13 @@ class Task: an empty dict. dependencies : list[TaskDependency] | None Tasks this task depends on. ``None`` is treated as an empty list. - smtp_config : SMTPConfig | None - SMTP transport configuration. When provided, a styled HTML email is sent - on ``logging.ERROR`` and above. Defaults to ``None``. - email_style : HTMLEmailStyle | None - HTML presentation settings (style, palette, language, traced-vars filter). - Used only when ``smtp_config`` is set. Defaults to ``HTMLEmailStyle()`` - (modern, neutral, English). + channels : list[NotificationChannel] | None + Additional notification channels whose handlers are attached to this + task's logger, alongside the implicit file channel built from + ``log_path``. Use ``EmailChannel`` for HTML email alerts on + ``logging.ERROR`` and above, or subclass ``NotificationChannel`` for + other destinations. ``None`` is treated as an empty list. Defaults to + ``None``. timeout : float | None Seconds allowed per attempt before ``TimeoutError`` is raised for that attempt. ``None`` means no limit. When a timeout fires, the underlying @@ -189,11 +185,6 @@ class Task: Exception types that trigger a retry. Evaluated only when ``retries >= 1``. When ``None``, defaults at call time to ``(ConnectionError, TimeoutError)``. Defaults to ``None``. - channels : list[NotificationChannel] | None - Additional notification channels whose handlers are attached to this - task's logger, alongside the implicit ``FileChannel`` (and - ``EmailChannel`` when ``smtp_config`` is set). ``None`` is treated as - an empty list. Defaults to ``None``. Raises ------ @@ -219,12 +210,10 @@ def __init__( args: tuple[Any, ...] = (), kwargs: dict[str, Any] | None = None, dependencies: list[TaskDependency] | None = None, - smtp_config: SMTPConfig | None = None, - email_style: HTMLEmailStyle | None = None, + channels: list[NotificationChannel] | None = None, timeout: float | None = None, retries: int | None = 0, retry_on: tuple[type[Exception], ...] | None = None, - channels: list[NotificationChannel] | None = None, ): self.name = name self.log_path = log_path @@ -247,7 +236,7 @@ def __init__( else: self.channels = channels - self._check_input_types(smtp_config, email_style) + self._check_input_types() if " " in self.name: raise ValueError(f"Task name cannot contain spaces. Got {self.name}") @@ -262,35 +251,20 @@ def __init__( logger = logging.getLogger(f"processes.{self.name}.{id(self)}") logger.setLevel(logging.DEBUG) - all_channels: list[NotificationChannel] = [_FileChannel(self.log_path)] - if smtp_config is not None: - style = email_style or HTMLEmailStyle() - all_channels.append(_EmailChannel(smtp_config, style)) - all_channels.extend(self.channels) + all_channels: list[NotificationChannel] = [_FileChannel(self.log_path), *self.channels] for channel in all_channels: logger.addHandler(channel.build_handler(self.name)) - self._frame_filter: str | None = ( - email_style.traced_vars_frame_filter if email_style is not None else None + self._frame_filter: str | None = next( + (c.frame_filter for c in all_channels if c.frame_filter is not None), None ) self.logger = logger - def _check_input_types( - self, - smtp_config: SMTPConfig | None, - email_style: HTMLEmailStyle | None, - ) -> None: + def _check_input_types(self) -> None: """ Validates all input parameter types. - Parameters - ---------- - smtp_config : SMTPConfig | None - SMTP transport configuration passed to the constructor, if any. - email_style : HTMLEmailStyle | None - HTML presentation settings passed to the constructor, if any. - Raises ------ TypeError @@ -305,11 +279,6 @@ def _check_input_types( if not isinstance(self.kwargs, dict): raise TypeError(f"kwargs must be dict. Got {type(self.kwargs)}") - if smtp_config is not None and not isinstance(smtp_config, SMTPConfig): - raise TypeError(f"smtp_config must be of type SMTPConfig. Got {type(smtp_config)}") - if email_style is not None and not isinstance(email_style, HTMLEmailStyle): - raise TypeError(f"email_style must be of type HTMLEmailStyle. Got {type(email_style)}") - if not isinstance(self.dependencies, list): raise TypeError(f"dependencies must be list. Got {type(self.dependencies)}") diff --git a/tests/manual_tests/manual_pipeline_inspect.py b/tests/manual_tests/manual_pipeline_inspect.py index 9b4431c..ebc4a21 100644 --- a/tests/manual_tests/manual_pipeline_inspect.py +++ b/tests/manual_tests/manual_pipeline_inspect.py @@ -79,7 +79,7 @@ if _PROJECT_ROOT not in sys.path: sys.path.insert(0, _PROJECT_ROOT) -from processes import Process, SMTPConfig, Task, TaskDependency # noqa: E402 +from processes import EmailChannel, Process, SMTPConfig, Task, TaskDependency # noqa: E402 # --------------------------------------------------------------------------- # # Maildev wiring # @@ -268,7 +268,7 @@ def _task( args=args, kwargs=kwargs or {}, dependencies=deps or [], - smtp_config=smtp, + channels=[EmailChannel(smtp)], ) return [ diff --git a/tests/manual_tests/manual_themed_tracebacks.py b/tests/manual_tests/manual_themed_tracebacks.py index 2188966..7a2c9de 100644 --- a/tests/manual_tests/manual_themed_tracebacks.py +++ b/tests/manual_tests/manual_themed_tracebacks.py @@ -68,7 +68,14 @@ if _PROJECT_ROOT not in sys.path: sys.path.insert(0, _PROJECT_ROOT) -from processes import HTMLEmailStyle, Process, SMTPConfig, Task, TaskDependency # noqa: E402 +from processes import ( # noqa: E402 + EmailChannel, + HTMLEmailStyle, + Process, + SMTPConfig, + Task, + TaskDependency, +) # --------------------------------------------------------------------------- # # Constants # @@ -169,24 +176,21 @@ def build_tasks(logs_dir: str, smtp: SMTPConfig, style: HTMLEmailStyle) -> list[ "timeout_seconds": 15, "dry_run": True, }, - smtp_config=smtp, - email_style=style, + channels=[EmailChannel(smtp, style)], ), Task( name="child_a", log_path=_log_path(logs_dir, "child_a"), func=child_a, dependencies=[dep("risky_step")], - smtp_config=smtp, - email_style=style, + channels=[EmailChannel(smtp, style)], ), Task( name="child_b", log_path=_log_path(logs_dir, "child_b"), func=child_b, dependencies=[dep("risky_step")], - smtp_config=smtp, - email_style=style, + channels=[EmailChannel(smtp, style)], ), ] diff --git a/tests/test_complex_dag_failures.py b/tests/test_complex_dag_failures.py index 6f25d2d..f78089f 100644 --- a/tests/test_complex_dag_failures.py +++ b/tests/test_complex_dag_failures.py @@ -30,7 +30,7 @@ from collections.abc import Iterable from unittest.mock import patch -from processes import Process, SMTPConfig, Task, TaskDependency +from processes import EmailChannel, Process, SMTPConfig, Task, TaskDependency from .base_test import BaseTest @@ -87,7 +87,7 @@ def make_task(name: str, deps, fail: bool = False) -> Task: log_path=os.path.join(self._CURDIR, f"{name}.log"), func=make_func(name, fail=fail), dependencies=deps, - smtp_config=smtp_config, + channels=[EmailChannel(smtp_config)], ) dep = TaskDependency diff --git a/tests/test_email_themes.py b/tests/test_email_themes.py index fa65248..3795756 100644 --- a/tests/test_email_themes.py +++ b/tests/test_email_themes.py @@ -10,9 +10,9 @@ in the output. * Constructor validation on ``HTMLEmailStyle`` — unknown style/palette/language names must raise ``ValueError`` at construction time. -* The wiring inside ``Task.__init__``: setting ``email_style`` on a Task must - propagate through the runtime formatter so the rendered email body and subject - carry the chosen options. +* The wiring inside ``Task.__init__``: an ``EmailChannel`` passed via + ``channels`` must propagate its style through the runtime formatter so the + rendered email body and subject carry the chosen options. """ from __future__ import annotations @@ -24,7 +24,7 @@ import pytest -from processes import HTMLEmailStyle, Process, SMTPConfig, Task +from processes import EmailChannel, HTMLEmailStyle, Process, SMTPConfig, Task from processes._email_internals import _HTMLEmailFormatter from processes._tb_utils import ( _build_traced_vars_html, @@ -356,9 +356,9 @@ def _inner() -> None: class TestTaskEmailWiring(BaseTest): def test_task_wiring_propagates_style_palette_language(self) -> None: - """smtp_config + non-default email_style attached to a Task must produce an - email body that carries the chosen style, palette and language, and a subject - carrying the language prefix.""" + """An EmailChannel with a non-default style attached to a Task must produce + an email body that carries the chosen style, palette and language, and a + subject carrying the language prefix.""" smtp_cfg = SMTPConfig( mailhost=("smtp.enterprise.test", 25), fromaddr="alerts@enterprise.test", @@ -373,8 +373,7 @@ def boom() -> None: name="wired", log_path=self._log("wired_task.log"), func=boom, - smtp_config=smtp_cfg, - email_style=style_cfg, + channels=[EmailChannel(smtp_cfg, style_cfg)], ) with patch("processes._email_internals.smtplib.SMTP") as mock_smtp_class: @@ -417,8 +416,7 @@ def boom() -> None: name="subject_de", log_path=self._log("subject_task.log"), func=boom, - smtp_config=smtp_cfg, - email_style=style_cfg, + channels=[EmailChannel(smtp_cfg, style_cfg)], ) with patch("processes._email_internals.smtplib.SMTP") as mock_smtp_class: @@ -436,7 +434,7 @@ def boom() -> None: ) def test_task_without_email_style_uses_defaults(self) -> None: - """When smtp_config is provided but email_style is None, the handler uses + """When EmailChannel is constructed without a style, the handler uses the HTMLEmailStyle defaults (modern/neutral/en).""" smtp_cfg = SMTPConfig( mailhost=("smtp.test", 25), @@ -451,7 +449,7 @@ def boom() -> None: name="default_style", log_path=self._log("default_style_task.log"), func=boom, - smtp_config=smtp_cfg, + channels=[EmailChannel(smtp_cfg)], ) with patch("processes._email_internals.smtplib.SMTP") as mock_smtp_class: @@ -480,10 +478,16 @@ def boom() -> None: raise RuntimeError("boom") task_a = Task( - name="iso_task_a", log_path=self._log("iso_task_a.log"), func=boom, smtp_config=smtp_cfg + name="iso_task_a", + log_path=self._log("iso_task_a.log"), + func=boom, + channels=[EmailChannel(smtp_cfg)], ) task_b = Task( - name="iso_task_b", log_path=self._log("iso_task_b.log"), func=boom, smtp_config=smtp_cfg + name="iso_task_b", + log_path=self._log("iso_task_b.log"), + func=boom, + channels=[EmailChannel(smtp_cfg)], ) try: email_handlers_a = [ @@ -509,22 +513,19 @@ def boom() -> None: finally: self._close_handlers(task_a, task_b) - def test_no_smtp_config_attaches_no_email_handler(self) -> None: - """When smtp_config is None, no email handler must be attached even if - email_style is provided.""" + def test_no_channels_attaches_no_email_handler(self) -> None: + """When no channels are given, no email handler must be attached.""" task = Task( name="no_smtp", log_path=self._log("no_smtp_task.log"), func=lambda: None, - smtp_config=None, - email_style=HTMLEmailStyle(style="classic"), ) try: email_handlers = [ h for h in task.logger.handlers if isinstance(h, logging.handlers.SMTPHandler) ] assert len(email_handlers) == 0, ( - f"No email handler should be attached when smtp_config is None, " + f"No email handler should be attached when no email channel is given, " f"got {len(email_handlers)}" ) finally: diff --git a/tests/test_notification_channels.py b/tests/test_notification_channels.py index 09d7af5..78466a4 100644 --- a/tests/test_notification_channels.py +++ b/tests/test_notification_channels.py @@ -5,7 +5,7 @@ * ``NotificationChannel`` cannot be instantiated directly. * ``_FileChannel.build_handler`` returns a ``FileHandler`` at the right level, formatted with ``_TaskLogfileFormatter``, writing to the given path. -* ``_EmailChannel.build_handler`` returns an ``ERROR``-level handler with the +* ``EmailChannel.build_handler`` returns an ``ERROR``-level handler with the localized subject, mirroring the behaviour of ``_build_task_email_handler``. """ @@ -15,10 +15,10 @@ import pytest -from processes import HTMLEmailStyle, NotificationChannel, Process, SMTPConfig, Task +from processes import EmailChannel, HTMLEmailStyle, NotificationChannel, Process, SMTPConfig, Task from processes._email_internals import _HTMLEmailFormatter from processes._logfile_formatting import _TaskLogfileFormatter -from processes.notification_channels import _EmailChannel, _FileChannel +from processes.notification_channels import _FileChannel from .base_test import BaseTest @@ -81,7 +81,7 @@ def _smtp_config(self) -> SMTPConfig: ) def test_build_handler_defaults_to_error_level_and_default_style(self) -> None: - channel = _EmailChannel(self._smtp_config()) + channel = EmailChannel(self._smtp_config()) handler = channel.build_handler("email_task") assert handler.level == logging.ERROR @@ -89,15 +89,23 @@ def test_build_handler_defaults_to_error_level_and_default_style(self) -> None: assert handler.subject == "Error in task email_task" def test_build_handler_uses_provided_style_for_subject_language(self) -> None: - channel = _EmailChannel(self._smtp_config(), HTMLEmailStyle(language="es")) + channel = EmailChannel(self._smtp_config(), HTMLEmailStyle(language="es")) handler = channel.build_handler("email_task") assert handler.subject == "Error en la tarea email_task" def test_default_style_is_modern_neutral_english(self) -> None: - channel = _EmailChannel(self._smtp_config()) + channel = EmailChannel(self._smtp_config()) assert channel.style == HTMLEmailStyle() + def test_frame_filter_sourced_from_style(self) -> None: + channel = EmailChannel(self._smtp_config(), HTMLEmailStyle(traced_vars_frame_filter="json")) + assert channel.frame_filter == "json" + + def test_frame_filter_defaults_to_none(self) -> None: + channel = EmailChannel(self._smtp_config()) + assert channel.frame_filter is None + class _RecordingChannel(NotificationChannel): """Test-only channel that captures every record it receives.""" From 7fa1d862a777660aef8d964a1383c1011fe3ffca Mon Sep 17 00:00:00 2001 From: Oliver Mohr Date: Sun, 14 Jun 2026 15:53:33 -0400 Subject: [PATCH 06/10] docs: remove notification channels planning doc --- NOTIFICATION_CHANNELS_PLAN.md | 114 ---------------------------------- 1 file changed, 114 deletions(-) delete mode 100644 NOTIFICATION_CHANNELS_PLAN.md diff --git a/NOTIFICATION_CHANNELS_PLAN.md b/NOTIFICATION_CHANNELS_PLAN.md deleted file mode 100644 index afaeaab..0000000 --- a/NOTIFICATION_CHANNELS_PLAN.md +++ /dev/null @@ -1,114 +0,0 @@ -# Plan: Notification Channel abstraction - -Branch: `feature/notification-channels` - -## Goal - -Introduce an internal `NotificationChannel` abstraction that the two existing -delivery mechanisms — the per-task **log file** and the **HTML email** alert — -are expressed through. This refactor enables adding new notification handlers -later (Telegram, Slack, Discord, …) by simply implementing a new -`NotificationChannel` subclass, without touching `Task` internals. - -**In scope (this branch):** -- Define the `NotificationChannel` abstract base class. -- Wrap the two *existing* handlers as channels: `_FileChannel` (internal) and - `EmailChannel` (public). -- Wire `Task` to build its logger handlers through these channels. -- Expose a `channels` parameter on `Task` so extra channels can be plugged in - (the extensibility point). -- Tests, type checking (mypy strict) and lint (ruff) green. - -**Out of scope (explicitly NOT in this branch):** -- No new concrete channels (no Telegram/Slack/Discord). Only the abstraction + - the two current handlers. -- No final merge / PR — the maintainer will open the PR. - -## Design - -### Abstraction - -```python -class NotificationChannel(ABC): - @abstractmethod - def build_handler(self, task_name: str) -> logging.Handler: ... -``` - -A channel knows how to build a fully configured `logging.Handler`. `Task` -iterates over its channels and attaches each handler to its logger. This keeps -the existing `record.task_context` failure-context flow intact — channels reuse -the same formatters (`_TaskLogfileFormatter`, `_HTMLEmailFormatter`). - -### Concrete channels (wrapping current handlers) - -- `_FileChannel(log_path, level=logging.INFO)` (internal) → builds the - `FileHandler` with `_TaskLogfileFormatter` (current logfile behaviour). -- `EmailChannel(smtp_config, style=None)` (public) → delegates to the existing - `_build_task_email_handler(smtp_config, style, task_name)` (current email - behaviour: `ERROR` level, localized subject). - -### `Task` wiring (breaking change) - -`smtp_config` and `email_style` are removed from `Task.__init__` entirely. -`log_path` remains the only logging-related required argument and is -converted internally into a `_FileChannel`. Everything else — including email -alerts — is configured via `channels: list[NotificationChannel]`, positioned -right after `dependencies` and before `timeout`: - -```python -def __init__( - self, name, log_path, func, args=(), kwargs=None, - dependencies=None, channels: list[NotificationChannel] | None = None, - timeout=None, retries=0, retry_on=None, -): - ... - all_channels: list[NotificationChannel] = [_FileChannel(self.log_path), *self.channels] - for channel in all_channels: - logger.addHandler(channel.build_handler(self.name)) - self._frame_filter = next( - (c.frame_filter for c in all_channels if c.frame_filter is not None), None - ) -``` - -- `_frame_filter` (traced-vars frame filter) is sourced from the first channel - that defines a non-`None` `frame_filter` property (e.g. `EmailChannel`, - via `style.traced_vars_frame_filter`); it governs how failure context is - *built*, independent of delivery. -- To get email alerts, pass `channels=[EmailChannel(smtp_config, style)]`. - -### Public API exports - -Export `NotificationChannel` and `EmailChannel` from `processes/__init__.py`. -`_FileChannel` stays internal. - -## Files - -- `src/processes/notification_channels.py` — new module (ABC + two channels). -- `src/processes/task.py` — build handlers via channels; `channels` param - replaces `smtp_config`/`email_style`. -- `src/processes/__init__.py` — export the new public names. -- `tests/test_notification_channels.py` — new test module. - -## Testing - -- `NotificationChannel` cannot be instantiated directly (is abstract). -- `_FileChannel.build_handler` returns a `FileHandler` at the right level with - `_TaskLogfileFormatter`, writing to the given path. -- `EmailChannel.build_handler` returns an `ERROR`-level handler with the - localized subject (mock SMTP, mirroring `test_email_themes.py`). -- Integration: a `Task` with a custom extra channel attaches its handler; - the implicit file channel and any `EmailChannel` in `channels` keep working. -- Full existing suite (updated to the new `channels` API) stays green. - -## Commit sequence (conventional commits, no Claude attribution) - -Commit only after the relevant tests + ruff + mypy pass. - -1. `docs: add notification channel abstraction plan` — this file. -2. `feat: add NotificationChannel abstraction with file and email channels` - — new module + unit tests + exports. -3. `refactor: build task handlers through notification channels` - — wire `Task` to channels, add `channels` param, integration tests, - docstring updates. - -No final merge; push the branch so it appears on GitHub for the maintainer's PR. From e06a349caf717ebbc77569787a39430f45dba54c Mon Sep 17 00:00:00 2001 From: Oliver Mohr Date: Sun, 14 Jun 2026 16:01:42 -0400 Subject: [PATCH 07/10] style: apply ruff formatting to task.py --- src/processes/task.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/processes/task.py b/src/processes/task.py index fc18555..2485e23 100644 --- a/src/processes/task.py +++ b/src/processes/task.py @@ -293,9 +293,7 @@ def _check_input_types(self) -> None: for channel in self.channels: if not isinstance(channel, NotificationChannel): - raise TypeError( - f"channel must be of type NotificationChannel. Got {type(channel)}" - ) + raise TypeError(f"channel must be of type NotificationChannel. Got {type(channel)}") if self.timeout is not None and ( not isinstance(self.timeout, (int, float)) or self.timeout <= 0 From efc6764eb141986bb8552381868de3955b8054a9 Mon Sep 17 00:00:00 2001 From: Oliver Mohr Date: Sun, 14 Jun 2026 16:04:04 -0400 Subject: [PATCH 08/10] build: require Python 3.11+, drop 3.10 from CI matrix typing.Self (used in process.py) is only available from Python 3.11. --- .github/workflows/tests.yml | 2 +- README.md | 8 +- docs/index.md | 2 +- pyproject.toml | 3 +- uv.lock | 172 +----------------------------------- 5 files changed, 8 insertions(+), 179 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 50e5a86..d709171 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - name: Checkout code diff --git a/README.md b/README.md index 375f294..10cf367 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Processes: Smart Task Orchestration -[![Python Version](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/) +[![Python Version](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/) ![Fast & Lightweight](https://img.shields.io/badge/Library-Pure%20Python-green.svg) [![Documentation](https://img.shields.io/badge/docs-GitHub%20Pages-blue.svg)](https://oliverm91.github.io/processes/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) @@ -16,7 +16,7 @@ --- -**Run a list of Python callables that depend on each other — in parallel when possible, with per-task log files and optional HTML email notification on failure. Zero dependencies. Pure Python 3.10+.** +**Run a list of Python callables that depend on each other — in parallel when possible, with per-task log files and optional HTML email notification on failure. Zero dependencies. Pure Python 3.11+.** --- @@ -27,7 +27,7 @@ - 🛡️ **One failure doesn't stop the rest** — a failed task skips only the jobs that depend on it, and **every other part of the workflow keeps running**. - 📝 **One log file per task** — share a single log across the whole run, or keep them separate for easier debugging. - 📧 **Email alerts when something breaks** — pass an `SMTPConfig` to a task and get a styled HTML email (with traceback, task context, and the list of jobs that were skipped) the instant it raises. -- 🧰 **Modern, strictly-typed Python 3.10+** — `from __future__ import annotations`, full `mypy --strict` clean, `dict[str, TaskResult]`, `set[str]`, `|` unions. +- 🧰 **Modern, strictly-typed Python 3.11+** — `from __future__ import annotations`, full `mypy --strict` clean, `dict[str, TaskResult]`, `set[str]`, `|` unions. --- @@ -403,7 +403,7 @@ Or straight from the repository (pure Python, no build step): pip install git+https://github.com/oliverm91/processes.git ``` -Requires **Python 3.10+**. +Requires **Python 3.11+**. --- diff --git a/docs/index.md b/docs/index.md index b9233aa..0050101 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ # 🚀 Processes: Robust Routines Management -[![Python Version](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/) +[![Python Version](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/) ![Fast & Lightweight](https://img.shields.io/badge/Library-Pure%20Python-green.svg) diff --git a/pyproject.toml b/pyproject.toml index 3fba04f..9e0d001 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "processes" dynamic = ["version"] description = "Orchestrate graphs of callables in Python with automatic dependency resolution, parallel execution, retries, timeouts, and HTML email alerts on failure — zero dependencies" readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" license = "MIT" authors = [ { name = "Oliver Mohr Bonometti", email = "oliver.mohr.b@gmail.com" } @@ -17,7 +17,6 @@ classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", diff --git a/uv.lock b/uv.lock index 4eba71c..aa6a3dc 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.10" +requires-python = ">=3.11" [[package]] name = "argcomplete" @@ -80,18 +80,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, - { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, - { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, @@ -159,22 +147,6 @@ version = "3.4.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, - { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, - { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, - { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, - { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, - { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, - { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, - { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, - { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, - { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, - { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, - { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, @@ -280,7 +252,6 @@ dependencies = [ { name = "questionary" }, { name = "termcolor" }, { name = "tomlkit" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d1/28/8ff988952b3c322baf79297e2d38544b90b8e053883d69bf7db51685607b/commitizen-4.11.6.tar.gz", hash = "sha256:ed8aec7eba95eaa9c6c83958396e4c8ec831926cab26f80840f70afaf539c5f2", size = 62949, upload-time = "2026-01-15T17:00:05.248Z" } wheels = [ @@ -330,18 +301,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, -] - [[package]] name = "ghp-import" version = "2.1.0" @@ -402,16 +361,6 @@ version = "0.7.8" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/13/57b06758a13550c5f09563893b004f98e9537ee6ec67b7df85c3571c8832/librt-0.7.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b45306a1fc5f53c9330fbee134d8b3227fe5da2ab09813b892790400aa49352d", size = 56521, upload-time = "2026-01-14T12:54:40.066Z" }, - { url = "https://files.pythonhosted.org/packages/c2/24/bbea34d1452a10612fb45ac8356f95351ba40c2517e429602160a49d1fd0/librt-0.7.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:864c4b7083eeee250ed55135d2127b260d7eb4b5e953a9e5df09c852e327961b", size = 58456, upload-time = "2026-01-14T12:54:41.471Z" }, - { url = "https://files.pythonhosted.org/packages/04/72/a168808f92253ec3a810beb1eceebc465701197dbc7e865a1c9ceb3c22c7/librt-0.7.8-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6938cc2de153bc927ed8d71c7d2f2ae01b4e96359126c602721340eb7ce1a92d", size = 164392, upload-time = "2026-01-14T12:54:42.843Z" }, - { url = "https://files.pythonhosted.org/packages/14/5c/4c0d406f1b02735c2e7af8ff1ff03a6577b1369b91aa934a9fa2cc42c7ce/librt-0.7.8-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66daa6ac5de4288a5bbfbe55b4caa7bf0cd26b3269c7a476ffe8ce45f837f87d", size = 172959, upload-time = "2026-01-14T12:54:44.602Z" }, - { url = "https://files.pythonhosted.org/packages/82/5f/3e85351c523f73ad8d938989e9a58c7f59fb9c17f761b9981b43f0025ce7/librt-0.7.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4864045f49dc9c974dadb942ac56a74cd0479a2aafa51ce272c490a82322ea3c", size = 186717, upload-time = "2026-01-14T12:54:45.986Z" }, - { url = "https://files.pythonhosted.org/packages/08/f8/18bfe092e402d00fe00d33aa1e01dda1bd583ca100b393b4373847eade6d/librt-0.7.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a36515b1328dc5b3ffce79fe204985ca8572525452eacabee2166f44bb387b2c", size = 184585, upload-time = "2026-01-14T12:54:47.139Z" }, - { url = "https://files.pythonhosted.org/packages/4e/fc/f43972ff56fd790a9fa55028a52ccea1875100edbb856b705bd393b601e3/librt-0.7.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b7e7f140c5169798f90b80d6e607ed2ba5059784968a004107c88ad61fb3641d", size = 180497, upload-time = "2026-01-14T12:54:48.946Z" }, - { url = "https://files.pythonhosted.org/packages/e1/3a/25e36030315a410d3ad0b7d0f19f5f188e88d1613d7d3fd8150523ea1093/librt-0.7.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff71447cb778a4f772ddc4ce360e6ba9c95527ed84a52096bd1bbf9fee2ec7c0", size = 200052, upload-time = "2026-01-14T12:54:50.382Z" }, - { url = "https://files.pythonhosted.org/packages/fc/b8/f3a5a1931ae2a6ad92bf6893b9ef44325b88641d58723529e2c2935e8abe/librt-0.7.8-cp310-cp310-win32.whl", hash = "sha256:047164e5f68b7a8ebdf9fae91a3c2161d3192418aadd61ddd3a86a56cbe3dc85", size = 43477, upload-time = "2026-01-14T12:54:51.815Z" }, - { url = "https://files.pythonhosted.org/packages/fe/91/c4202779366bc19f871b4ad25db10fcfa1e313c7893feb942f32668e8597/librt-0.7.8-cp310-cp310-win_amd64.whl", hash = "sha256:d6f254d096d84156a46a84861183c183d30734e52383602443292644d895047c", size = 49806, upload-time = "2026-01-14T12:54:53.149Z" }, { url = "https://files.pythonhosted.org/packages/1b/a3/87ea9c1049f2c781177496ebee29430e4631f439b8553a4969c88747d5d8/librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f", size = 56507, upload-time = "2026-01-14T12:54:54.156Z" }, { url = "https://files.pythonhosted.org/packages/5e/4a/23bcef149f37f771ad30203d561fcfd45b02bc54947b91f7a9ac34815747/librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac", size = 58455, upload-time = "2026-01-14T12:54:55.978Z" }, { url = "https://files.pythonhosted.org/packages/22/6e/46eb9b85c1b9761e0f42b6e6311e1cc544843ac897457062b9d5d0b21df4/librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c", size = 164956, upload-time = "2026-01-14T12:54:57.311Z" }, @@ -484,17 +433,6 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, - { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, - { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, - { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, - { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, - { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, - { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, @@ -691,7 +629,6 @@ dependencies = [ { name = "griffe" }, { name = "mkdocs-autorefs" }, { name = "mkdocstrings" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/24/75/d30af27a2906f00eb90143470272376d728521997800f5dce5b340ba35bc/mkdocstrings_python-2.0.1.tar.gz", hash = "sha256:843a562221e6a471fefdd4b45cc6c22d2607ccbad632879234fa9692e9cf7732", size = 199345, upload-time = "2025-12-03T14:26:11.755Z" } wheels = [ @@ -706,17 +643,10 @@ dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, - { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, - { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, @@ -786,17 +716,6 @@ version = "11.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/5d/45a3553a253ac8763f3561371432a90bdbe6000fbdcf1397ffe502aa206c/pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860", size = 5316554, upload-time = "2025-07-01T09:13:39.342Z" }, - { url = "https://files.pythonhosted.org/packages/7c/c8/67c12ab069ef586a25a4a79ced553586748fad100c77c0ce59bb4983ac98/pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad", size = 4686548, upload-time = "2025-07-01T09:13:41.835Z" }, - { url = "https://files.pythonhosted.org/packages/2f/bd/6741ebd56263390b382ae4c5de02979af7f8bd9807346d068700dd6d5cf9/pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0", size = 5859742, upload-time = "2025-07-03T13:09:47.439Z" }, - { url = "https://files.pythonhosted.org/packages/ca/0b/c412a9e27e1e6a829e6ab6c2dca52dd563efbedf4c9c6aa453d9a9b77359/pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b", size = 7633087, upload-time = "2025-07-03T13:09:51.796Z" }, - { url = "https://files.pythonhosted.org/packages/59/9d/9b7076aaf30f5dd17e5e5589b2d2f5a5d7e30ff67a171eb686e4eecc2adf/pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50", size = 5963350, upload-time = "2025-07-01T09:13:43.865Z" }, - { url = "https://files.pythonhosted.org/packages/f0/16/1a6bf01fb622fb9cf5c91683823f073f053005c849b1f52ed613afcf8dae/pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae", size = 6631840, upload-time = "2025-07-01T09:13:46.161Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e6/6ff7077077eb47fde78739e7d570bdcd7c10495666b6afcd23ab56b19a43/pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9", size = 6074005, upload-time = "2025-07-01T09:13:47.829Z" }, - { url = "https://files.pythonhosted.org/packages/c3/3a/b13f36832ea6d279a697231658199e0a03cd87ef12048016bdcc84131601/pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e", size = 6708372, upload-time = "2025-07-01T09:13:52.145Z" }, - { url = "https://files.pythonhosted.org/packages/6c/e4/61b2e1a7528740efbc70b3d581f33937e38e98ef3d50b05007267a55bcb2/pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6", size = 6277090, upload-time = "2025-07-01T09:13:53.915Z" }, - { url = "https://files.pythonhosted.org/packages/a9/d3/60c781c83a785d6afbd6a326ed4d759d141de43aa7365725cbcd65ce5e54/pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f", size = 6985988, upload-time = "2025-07-01T09:13:55.699Z" }, - { url = "https://files.pythonhosted.org/packages/9f/28/4f4a0203165eefb3763939c6789ba31013a2e90adffb456610f30f613850/pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f", size = 2422899, upload-time = "2025-07-01T09:13:57.497Z" }, { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" }, { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" }, { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" }, @@ -866,13 +785,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, - { url = "https://files.pythonhosted.org/packages/6f/8b/209bd6b62ce8367f47e68a218bffac88888fdf2c9fcf1ecadc6c3ec1ebc7/pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967", size = 5270556, upload-time = "2025-07-01T09:16:09.961Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e6/231a0b76070c2cfd9e260a7a5b504fb72da0a95279410fa7afd99d9751d6/pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe", size = 4654625, upload-time = "2025-07-01T09:16:11.913Z" }, - { url = "https://files.pythonhosted.org/packages/13/f4/10cf94fda33cb12765f2397fc285fa6d8eb9c29de7f3185165b702fc7386/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c", size = 4874207, upload-time = "2025-07-03T13:11:10.201Z" }, - { url = "https://files.pythonhosted.org/packages/72/c9/583821097dc691880c92892e8e2d41fe0a5a3d6021f4963371d2f6d57250/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25", size = 6583939, upload-time = "2025-07-03T13:11:15.68Z" }, - { url = "https://files.pythonhosted.org/packages/3b/8e/5c9d410f9217b12320efc7c413e72693f48468979a013ad17fd690397b9a/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27", size = 4957166, upload-time = "2025-07-01T09:16:13.74Z" }, - { url = "https://files.pythonhosted.org/packages/62/bb/78347dbe13219991877ffb3a91bf09da8317fbfcd4b5f9140aeae020ad71/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a", size = 5581482, upload-time = "2025-07-01T09:16:16.107Z" }, - { url = "https://files.pythonhosted.org/packages/d9/28/1000353d5e61498aaeaaf7f1e4b49ddb05f2c6575f9d4f9f914a3538b6e1/pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f", size = 6984596, upload-time = "2025-07-01T09:16:18.07Z" }, { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" }, { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" }, { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" }, @@ -977,12 +889,10 @@ version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ @@ -1007,15 +917,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, @@ -1160,60 +1061,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, ] -[[package]] -name = "tomli" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, - { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, - { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, - { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, - { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, - { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, - { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, - { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, - { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, - { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, - { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, - { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, - { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, - { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, - { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, - { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, - { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, - { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, - { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, - { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, - { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, -] - [[package]] name = "tomlkit" version = "0.14.0" @@ -1247,9 +1094,6 @@ version = "6.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, - { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, - { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, @@ -1259,8 +1103,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, - { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, - { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, @@ -1297,18 +1139,6 @@ version = "2.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/49/2a/6de8a50cb435b7f42c46126cf1a54b2aab81784e74c8595c8e025e8f36d3/wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f", size = 82040, upload-time = "2025-11-07T00:45:33.312Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/0d/12d8c803ed2ce4e5e7d5b9f5f602721f9dfef82c95959f3ce97fa584bb5c/wrapt-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:64b103acdaa53b7caf409e8d45d39a8442fe6dcfec6ba3f3d141e0cc2b5b4dbd", size = 77481, upload-time = "2025-11-07T00:43:11.103Z" }, - { url = "https://files.pythonhosted.org/packages/05/3e/4364ebe221ebf2a44d9fc8695a19324692f7dd2795e64bd59090856ebf12/wrapt-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:91bcc576260a274b169c3098e9a3519fb01f2989f6d3d386ef9cbf8653de1374", size = 60692, upload-time = "2025-11-07T00:43:13.697Z" }, - { url = "https://files.pythonhosted.org/packages/1f/ff/ae2a210022b521f86a8ddcdd6058d137c051003812b0388a5e9a03d3fe10/wrapt-2.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ab594f346517010050126fcd822697b25a7031d815bb4fbc238ccbe568216489", size = 61574, upload-time = "2025-11-07T00:43:14.967Z" }, - { url = "https://files.pythonhosted.org/packages/c6/93/5cf92edd99617095592af919cb81d4bff61c5dbbb70d3c92099425a8ec34/wrapt-2.0.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:36982b26f190f4d737f04a492a68accbfc6fa042c3f42326fdfbb6c5b7a20a31", size = 113688, upload-time = "2025-11-07T00:43:18.275Z" }, - { url = "https://files.pythonhosted.org/packages/a0/0a/e38fc0cee1f146c9fb266d8ef96ca39fb14a9eef165383004019aa53f88a/wrapt-2.0.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23097ed8bc4c93b7bf36fa2113c6c733c976316ce0ee2c816f64ca06102034ef", size = 115698, upload-time = "2025-11-07T00:43:19.407Z" }, - { url = "https://files.pythonhosted.org/packages/b0/85/bef44ea018b3925fb0bcbe9112715f665e4d5309bd945191da814c314fd1/wrapt-2.0.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bacfe6e001749a3b64db47bcf0341da757c95959f592823a93931a422395013", size = 112096, upload-time = "2025-11-07T00:43:16.5Z" }, - { url = "https://files.pythonhosted.org/packages/7c/0b/733a2376e413117e497aa1a5b1b78e8f3a28c0e9537d26569f67d724c7c5/wrapt-2.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8ec3303e8a81932171f455f792f8df500fc1a09f20069e5c16bd7049ab4e8e38", size = 114878, upload-time = "2025-11-07T00:43:20.81Z" }, - { url = "https://files.pythonhosted.org/packages/da/03/d81dcb21bbf678fcda656495792b059f9d56677d119ca022169a12542bd0/wrapt-2.0.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:3f373a4ab5dbc528a94334f9fe444395b23c2f5332adab9ff4ea82f5a9e33bc1", size = 111298, upload-time = "2025-11-07T00:43:22.229Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d5/5e623040e8056e1108b787020d56b9be93dbbf083bf2324d42cde80f3a19/wrapt-2.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f49027b0b9503bf6c8cdc297ca55006b80c2f5dd36cecc72c6835ab6e10e8a25", size = 113361, upload-time = "2025-11-07T00:43:24.301Z" }, - { url = "https://files.pythonhosted.org/packages/a1/f3/de535ccecede6960e28c7b722e5744846258111d6c9f071aa7578ea37ad3/wrapt-2.0.1-cp310-cp310-win32.whl", hash = "sha256:8330b42d769965e96e01fa14034b28a2a7600fbf7e8f0cc90ebb36d492c993e4", size = 58035, upload-time = "2025-11-07T00:43:28.96Z" }, - { url = "https://files.pythonhosted.org/packages/21/15/39d3ca5428a70032c2ec8b1f1c9d24c32e497e7ed81aed887a4998905fcc/wrapt-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:1218573502a8235bb8a7ecaed12736213b22dcde9feab115fa2989d42b5ded45", size = 60383, upload-time = "2025-11-07T00:43:25.804Z" }, - { url = "https://files.pythonhosted.org/packages/43/c2/dfd23754b7f7a4dce07e08f4309c4e10a40046a83e9ae1800f2e6b18d7c1/wrapt-2.0.1-cp310-cp310-win_arm64.whl", hash = "sha256:eda8e4ecd662d48c28bb86be9e837c13e45c58b8300e43ba3c9b4fa9900302f7", size = 58894, upload-time = "2025-11-07T00:43:27.074Z" }, { url = "https://files.pythonhosted.org/packages/98/60/553997acf3939079dab022e37b67b1904b5b0cc235503226898ba573b10c/wrapt-2.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e17283f533a0d24d6e5429a7d11f250a58d28b4ae5186f8f47853e3e70d2590", size = 77480, upload-time = "2025-11-07T00:43:30.573Z" }, { url = "https://files.pythonhosted.org/packages/2d/50/e5b3d30895d77c52105c6d5cbf94d5b38e2a3dd4a53d22d246670da98f7c/wrapt-2.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85df8d92158cb8f3965aecc27cf821461bb5f40b450b03facc5d9f0d4d6ddec6", size = 60690, upload-time = "2025-11-07T00:43:31.594Z" }, { url = "https://files.pythonhosted.org/packages/f0/40/660b2898703e5cbbb43db10cdefcc294274458c3ca4c68637c2b99371507/wrapt-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1be685ac7700c966b8610ccc63c3187a72e33cab53526a27b2a285a662cd4f7", size = 61578, upload-time = "2025-11-07T00:43:32.918Z" }, From ccc34cf96e0c0c66fc8f12a1c64ed968bec0db0d Mon Sep 17 00:00:00 2001 From: Oliver Mohr Date: Sun, 14 Jun 2026 16:11:10 -0400 Subject: [PATCH 09/10] test: tolerate CI scheduling jitter in sequential timing assertion The exact round-to-4 check left no margin for runner overhead, causing intermittent failures on loaded CI runners. --- tests/test_normal_run_no_errors.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_normal_run_no_errors.py b/tests/test_normal_run_no_errors.py index 22611f5..9344db7 100644 --- a/tests/test_normal_run_no_errors.py +++ b/tests/test_normal_run_no_errors.py @@ -112,9 +112,8 @@ def test_run_dependent_tasks_sequential(self) -> None: assert len(process_result.failed_tasks) == 0, ( f"Expected 0 failed tasks. Got {len(process_result.failed_tasks)}" ) - assert int(round(t_end - t_start, 0)) == 4, ( - f"Sequential run took {t_end - t_start} seconds. Expected 4 seconds." - ) + elapsed = t_end - t_start + assert 4.0 <= elapsed < 4.8, f"Sequential run took {elapsed} seconds. Expected ~4 seconds." def test_run_dependent_tasks_parallel(self) -> None: import os From e1d87c29e61cc951f31e1d045ea4b935cc8f3773 Mon Sep 17 00:00:00 2001 From: Oliver Mohr Date: Sun, 14 Jun 2026 16:17:44 -0400 Subject: [PATCH 10/10] ci: fix PR title type list in semantic-pull-request config Inline comments after each type were being treated as part of the allowed type strings, so no PR title type ever matched. --- .github/workflows/lint-pr.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml index 29cb5fe..e681617 100644 --- a/.github/workflows/lint-pr.yml +++ b/.github/workflows/lint-pr.yml @@ -20,12 +20,12 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: types: | - fix # A bug fix for the user, not a fix to a build script - feat # A new feature for the user, not a new feature for builds - docs # Changes to the documentation - style # Formatting, missing semi colons, etc; no production code change - refactor # Refactoring production code, eg. renaming a variable - perf # Code changes that improve performance - test # Adding missing tests, refactoring tests; no production code change - build # Changes that affect the build system or external dependencies - ci # Changes to our CI configuration files and scripts \ No newline at end of file + fix + feat + docs + style + refactor + perf + test + build + ci \ No newline at end of file