diff --git a/.github/requirements/integration.in b/.github/requirements/integration.in index 4582849..c50cc7e 100644 --- a/.github/requirements/integration.in +++ b/.github/requirements/integration.in @@ -17,7 +17,6 @@ PyYAML>=6.0.3 tzdata>=2026.5 opentelemetry-api>=1.45.1 opentelemetry-sdk>=1.45.1 -tomli>=2.0.1; python_version<"3.11" boto3>=1.43.109 azure-storage-blob>=12.19.0 google-api-python-client>=2.100.0 diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index be311d4..0bc4abe 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -41,7 +41,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [ "3.10", "3.11", "3.12", "3.13", "3.14" ] + python-version: [ "3.11", "3.12", "3.13", "3.14" ] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/ci-stable.yml b/.github/workflows/ci-stable.yml index d1bbc88..d9d5d70 100644 --- a/.github/workflows/ci-stable.yml +++ b/.github/workflows/ci-stable.yml @@ -41,7 +41,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [ "3.10", "3.11", "3.12", "3.13", "3.14" ] + python-version: [ "3.11", "3.12", "3.13", "3.14" ] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/CLAUDE.md b/CLAUDE.md index 9ee92f3..687bc90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,7 +107,7 @@ automation_file/ - Keep `dependencies` and `[project.optional-dependencies]` (`dev`) in sync across both TOMLs; `tests/test_dev_toml_parity.py` fails when those, the entry points, `requires-python`, `[build-system]` or `[tool.setuptools]` differ. The base `dependencies` carry no cloud SDK and no GUI toolkit: each backend's SDK, `pyarrow` and `PySide6` live in an extra (`s3`, `azure`, `gdrive`, `dropbox`, `sftp`, `smb`, `fsspec`, `onedrive`, `box`, `parquet`, `gui`; `ftp` and `webdav` are empty; `all` lists every one). Do not move one into `dependencies`, and do not import one at module level: code asks for it at the moment of use with `automation_file.core.optional.require_module(name, extra=...)`, which raises `OptionalDependencyException` naming the extra. `tests/test_optional_dependencies.py` fails when the package cannot be imported without them, when importing it loads one, or when the extras and `all` drift apart. A new optional package needs its extra in both TOMLs, its line in `all`, and its entry in `core.optional.EXTRAS`. - **Patch releases bump themselves.** The publish workflow runs `scripts/stable_release.py bump`, which raises the patch in both `stable.toml` and `dev.toml`, then builds, uploads to PyPI and commits the bump back to `main` tagged as `vX.Y.Z`. Do not hand-bump the patch before merging to `main`. The next publish run is skipped via a commit-message guard (`chore: bump version`), so the bump itself never re-triggers publishing. The dev channel takes its number from PyPI, so never hand-bump `dev.toml` for a patch either. - **A MINOR or MAJOR release is written in the pull request.** Set the version to `X.Y.0` in both `stable.toml` and `dev.toml` in the pull request that goes to `main`. When a file's `MAJOR.MINOR` is above the newest `vX.Y.Z` tag's, the script publishes the version as written instead of adding a patch; it stops the job when the stable version would not be above the newest tag. What counts as MINOR and MAJOR is in `docs/source/Eng/usage/api_policy.rst`. -- CI: GitHub Actions — a `lint` job on Ubuntu (Python 3.12), then `pytest` on Windows across Python 3.10 / 3.11 / 3.12 / 3.13 / 3.14. One workflow per branch: `.github/workflows/ci-dev.yml`, `.github/workflows/ci-stable.yml`. +- CI: GitHub Actions — a `lint` job on Ubuntu (Python 3.12), then `pytest` on Windows across Python 3.11 / 3.12 / 3.13 / 3.14. One workflow per branch: `.github/workflows/ci-dev.yml`, `.github/workflows/ci-stable.yml`. - CI steps: `lint` (ruff check + ruff format --check + mypy) → `pytest` with coverage, installed with `.[all,test]` → uploads `coverage.xml` as an artifact. Two more jobs follow `lint`: `minimal` installs `.[test]` only and runs the whole suite (the tests of a missing extra skip), and `extras` installs each extra on its own. `publish-dev` needs all four. A `package` job builds the sdist and the wheel and runs `twine check`; it gates nothing. `.github/workflows/integration.yml` (§ Testing) runs next to them and gates nothing either. - Stable publishing lives in a separate workflow (`.github/workflows/publish.yml`) that runs on push to `main`: picks the version in both TOMLs (`scripts/stable_release.py`), copies `stable.toml` to `pyproject.toml`, builds the sdist + wheel, `twine upload` via `PYPI_API_TOKEN`, then commits + tags + pushes and creates `gh release create v --generate-notes`. - Dev publishing is the `publish-dev` job at the end of `ci-dev.yml`. It runs only on a push to `dev`, after `lint` and `pytest` pass: `scripts/dev_release.py prepare` writes `pyproject.toml` from `dev.toml` with one patch above the newest `automation_file_dev` on PyPI, the job builds and runs `twine check`, and it uploads (same `PYPI_API_TOKEN`) only when the commit is still the tip of `dev` and the wheel differs from the newest published one. Nothing is committed back. @@ -136,7 +136,7 @@ python -m automation_file --help ## Conventions -- Python 3.10+ — use `X | Y` union syntax, not `Union[X, Y]`. +- Python 3.11+ — use `X | Y` union syntax, not `Union[X, Y]`. - Use `from __future__ import annotations` at the top of every module for deferred type evaluation. - Exception hierarchy: all custom exceptions inherit from `FileAutomationException`; never `raise Exception(...)` directly. - Logging: use `file_automation_logger` from `automation_file.logging_config`. Never `print()` for diagnostics. diff --git a/README.md b/README.md index 06e2ab6..6a5e78c 100644 --- a/README.md +++ b/README.md @@ -372,7 +372,7 @@ Upgrading from a release that bundled everything: install `automation_file[all]` what you had. Requirements: -- Python 3.10+ +- Python 3.11+ - Base dependencies: `requests`, `tqdm`, `watchdog`, `cryptography`, `prometheus_client`, `defusedxml`, `PyYAML`, `opentelemetry-api`, `opentelemetry-sdk`, `je_action_core` (the action executor shared with APITestka, LoadDensity and MailThunder) diff --git a/README.zh-CN.md b/README.zh-CN.md index 7fc7280..0fefe20 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -368,7 +368,7 @@ pip install "automation_file[all,dev]" # 另含 ruff、mypy、pre-commit、pyt 从先前包含所有包的版本升级时:安装 `automation_file[all]` 即可保留原有的全部功能。 要求: -- Python 3.10+ +- Python 3.11+ - 基础依赖:`requests`、`tqdm`、`watchdog`、`cryptography`、`prometheus_client`、`defusedxml`、 `PyYAML`、`opentelemetry-api`、`opentelemetry-sdk`、`je_action_core`(与 APITestka、 LoadDensity、MailThunder 共用的 action 执行器) diff --git a/README.zh-TW.md b/README.zh-TW.md index 87b2234..de5c0a3 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -368,7 +368,7 @@ pip install "automation_file[all,dev]" # 另含 ruff、mypy、pre-commit、pyt 從先前內含所有套件的版本升級時:安裝 `automation_file[all]` 即可保留原有的全部功能。 需求: -- Python 3.10+ +- Python 3.11+ - 基礎相依套件:`requests`、`tqdm`、`watchdog`、`cryptography`、`prometheus_client`、`defusedxml`、 `PyYAML`、`opentelemetry-api`、`opentelemetry-sdk`、`je_action_core`(與 APITestka、 LoadDensity、MailThunder 共用的 action 執行器) diff --git a/architecture.md b/architecture.md index 5bffabc..6e6cc5d 100644 --- a/architecture.md +++ b/architecture.md @@ -277,7 +277,7 @@ storage.observe → events.storage_bridge → StorageError (only for a failing b warns, the name stays for at least two minor releases, a major release removes it (`docs/source/Eng/usage/api_policy.rst`; CLAUDE.md § Conventions). - Only the three action shapes in §3. Extend through the registry, not by subclassing the executor. - Python 3.10+, `X | Y` unions, `from __future__ import annotations` (CLAUDE.md § Conventions). + Python 3.11+, `X | Y` unions, `from __future__ import annotations` (CLAUDE.md § Conventions). - Exceptions derive from `FileAutomationException`. Log through `file_automation_logger`; no `print()` diagnostics and no runtime `assert` (§ Conventions; § Code quality › Logging, printing, assertions). - Every user-supplied URL goes through `validate_http_url`. Never `verify=False`. Downloads have size diff --git a/automation_file/app/dashboard_service.py b/automation_file/app/dashboard_service.py index f632535..a5829a0 100644 --- a/automation_file/app/dashboard_service.py +++ b/automation_file/app/dashboard_service.py @@ -22,7 +22,7 @@ from collections import Counter from collections.abc import Callable from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any from automation_file.app.audit_service import AuditService @@ -111,7 +111,7 @@ def brief_run(view: dict[str, Any]) -> dict[str, Any]: def _now() -> str: - return datetime.now(timezone.utc).isoformat(timespec="seconds") + return datetime.now(UTC).isoformat(timespec="seconds") class DashboardService: diff --git a/automation_file/audit/record.py b/automation_file/audit/record.py index 48e8b72..25f4fab 100644 --- a/automation_file/audit/record.py +++ b/automation_file/audit/record.py @@ -17,7 +17,7 @@ import uuid from collections.abc import Mapping from dataclasses import dataclass, field, fields -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any from automation_file.core.audit import AuditException @@ -34,7 +34,7 @@ #: The ``source`` of the records made from storage operations. STORAGE_SOURCE = "storage" -_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) +_EPOCH = datetime(1970, 1, 1, tzinfo=UTC) _MICROSECOND = timedelta(microseconds=1) _ZULU = "Z" _UTC_OFFSET = "+00:00" @@ -56,7 +56,7 @@ def _new_id() -> str: def _now() -> datetime: - return datetime.now(timezone.utc) + return datetime.now(UTC) def to_microseconds(moment: datetime) -> int: @@ -95,7 +95,7 @@ def parse_time(value: object, name: str = "timestamp") -> datetime: raise AuditException(f"{name} must be a time, got {value!r}") if value.utcoffset() is None: raise AuditException(f"{name} needs a time zone, got {value.isoformat()!r}") - return value.astimezone(timezone.utc) + return value.astimezone(UTC) def _check_text(name: str, value: object, *, optional: bool) -> None: diff --git a/automation_file/audit/store.py b/automation_file/audit/store.py index 9fb8696..fb4e9a4 100644 --- a/automation_file/audit/store.py +++ b/automation_file/audit/store.py @@ -35,7 +35,7 @@ from abc import ABC, abstractmethod from collections.abc import Mapping from dataclasses import dataclass, fields -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from types import TracebackType from typing import Any, TypeVar @@ -207,7 +207,7 @@ def purge_cutoff(older_than_seconds: float) -> datetime: if not older_than_seconds > 0: raise AuditException("older_than_seconds must be positive") try: - return datetime.now(timezone.utc) - timedelta(seconds=older_than_seconds) + return datetime.now(UTC) - timedelta(seconds=older_than_seconds) except (OverflowError, ValueError) as err: raise AuditException(f"older_than_seconds is out of range: {older_than_seconds!r}") from err diff --git a/automation_file/core/config.py b/automation_file/core/config.py index 41db9fa..d2d8ceb 100644 --- a/automation_file/core/config.py +++ b/automation_file/core/config.py @@ -44,7 +44,6 @@ from __future__ import annotations -import sys from collections.abc import Iterable from pathlib import Path from typing import Any @@ -68,14 +67,9 @@ def _load_tomllib() -> Any: - if sys.version_info >= (3, 11): - import tomllib + import tomllib - return tomllib - # pragma: no cover - exercised only on Python 3.10 runners - import tomli # pylint: disable=import-error # declared in *.toml for Python<3.11 - - return tomli + return tomllib class ConfigException(FileAutomationException): diff --git a/automation_file/core/manifest.py b/automation_file/core/manifest.py index e44b1e1..9a0d206 100644 --- a/automation_file/core/manifest.py +++ b/automation_file/core/manifest.py @@ -67,7 +67,7 @@ def write_manifest( "version": _MANIFEST_VERSION, "algorithm": algorithm, "root": str(root_path.resolve()), - "created_at": dt.datetime.now(dt.timezone.utc).isoformat(), + "created_at": dt.datetime.now(dt.UTC).isoformat(), "files": files, } Path(manifest_path).parent.mkdir(parents=True, exist_ok=True) diff --git a/automation_file/core/plugins.py b/automation_file/core/plugins.py index c614489..74e832e 100644 --- a/automation_file/core/plugins.py +++ b/automation_file/core/plugins.py @@ -82,5 +82,5 @@ def _iter_entry_points() -> list[EntryPoint]: return list(entry_points(group=ENTRY_POINT_GROUP)) except TypeError: # importlib.metadata before 3.10 used a different API; the project - # targets 3.10+, so this branch exists only as defensive padding. + # targets 3.11+, so this branch exists only as defensive padding. return list(entry_points().get(ENTRY_POINT_GROUP, [])) # pylint: disable=no-member diff --git a/automation_file/events/model.py b/automation_file/events/model.py index ba01a26..f391e60 100644 --- a/automation_file/events/model.py +++ b/automation_file/events/model.py @@ -16,7 +16,7 @@ import uuid from collections.abc import Mapping from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from enum import Enum from typing import Any, ClassVar @@ -64,7 +64,7 @@ def at_least(self, other: Severity) -> bool: def _now() -> datetime: - return datetime.now(timezone.utc) + return datetime.now(UTC) def _event_id() -> str: diff --git a/automation_file/integrity/manifest.py b/automation_file/integrity/manifest.py index e9d0b18..3c221ec 100644 --- a/automation_file/integrity/manifest.py +++ b/automation_file/integrity/manifest.py @@ -25,7 +25,7 @@ import json from collections.abc import Mapping -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any from automation_file.exceptions import StorageURIException @@ -131,7 +131,7 @@ def _from_legacy(document: Mapping[str, Any], origin: str) -> Snapshot: root=_legacy_root(document.get("root")), backend=LOCAL_SCHEME, algorithm=name, - created_at=created_at or datetime.now(timezone.utc), + created_at=created_at or datetime.now(UTC), entries=tuple(_legacy_entry(path, meta, name) for path, meta in files.items()), ) except IntegrityException as error: diff --git a/automation_file/integrity/monitor.py b/automation_file/integrity/monitor.py index 1758c0b..115769f 100644 --- a/automation_file/integrity/monitor.py +++ b/automation_file/integrity/monitor.py @@ -34,7 +34,7 @@ import threading from collections.abc import Callable, Iterable, Mapping from dataclasses import dataclass, fields -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import TYPE_CHECKING, Any, TypedDict from automation_file.events.bus import EventBus, event_bus @@ -551,7 +551,7 @@ def _record_failure(self, error: Exception, *, repeat: bool) -> dict[str, Any]: signature: _Signature = (("error", message),) announce = repeat or signature != self._announced self._last_summary, self._last_error = summary, message - self._last_run = datetime.now(timezone.utc) + self._last_run = datetime.now(UTC) self._announced = signature if announce: with correlation_scope(): diff --git a/automation_file/integrity/remediation.py b/automation_file/integrity/remediation.py index 25d4620..c037e0e 100644 --- a/automation_file/integrity/remediation.py +++ b/automation_file/integrity/remediation.py @@ -23,7 +23,7 @@ from collections.abc import Iterable from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from automation_file.exceptions import FileAutomationException from automation_file.integrity.detector import Change, ChangeKind @@ -119,7 +119,7 @@ def apply(self, changes: Iterable[Change], engine: HashEngine) -> list[Remediati """ if not self._policy.active: return [] - stamp = datetime.now(timezone.utc).strftime(_STAMP_FORMAT) + stamp = datetime.now(UTC).strftime(_STAMP_FORMAT) steps: list[RemediationStep] = [] for change in changes: steps.extend(self._handle(change, stamp, engine)) diff --git a/automation_file/integrity/report.py b/automation_file/integrity/report.py index 3f014d2..71e3413 100644 --- a/automation_file/integrity/report.py +++ b/automation_file/integrity/report.py @@ -8,7 +8,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any from automation_file.integrity.detector import Change, ChangeKind @@ -17,7 +17,7 @@ def _now() -> datetime: - return datetime.now(timezone.utc) + return datetime.now(UTC) @dataclass(frozen=True) diff --git a/automation_file/integrity/snapshot.py b/automation_file/integrity/snapshot.py index 2c5dd01..87c2cdb 100644 --- a/automation_file/integrity/snapshot.py +++ b/automation_file/integrity/snapshot.py @@ -14,7 +14,7 @@ from bisect import bisect_left from collections.abc import Iterable, Iterator, Mapping, Sequence from dataclasses import dataclass, field, replace -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any from automation_file.exceptions import FileNotExistsException, StorageURIException @@ -29,7 +29,7 @@ def _now() -> datetime: - return datetime.now(timezone.utc) + return datetime.now(UTC) def as_utc(moment: datetime | None) -> datetime | None: @@ -37,8 +37,8 @@ def as_utc(moment: datetime | None) -> datetime | None: if moment is None: return None if moment.tzinfo is None: - return moment.replace(tzinfo=timezone.utc) - return moment.astimezone(timezone.utc) + return moment.replace(tzinfo=UTC) + return moment.astimezone(UTC) def parse_timestamp(value: object, name: str) -> datetime | None: diff --git a/automation_file/pipeline/model.py b/automation_file/pipeline/model.py index b89aa77..fc38bb8 100644 --- a/automation_file/pipeline/model.py +++ b/automation_file/pipeline/model.py @@ -12,7 +12,7 @@ import threading from collections.abc import Callable, Mapping from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from enum import Enum from typing import Any @@ -75,7 +75,7 @@ class RunStatus(str, Enum): def utc_now() -> datetime: """Return the current time as a timezone-aware UTC ``datetime``.""" - return datetime.now(timezone.utc) + return datetime.now(UTC) def _iso(moment: datetime | None) -> str | None: diff --git a/automation_file/scheduler/cron.py b/automation_file/scheduler/cron.py index 0fcb2d4..6c623c6 100644 --- a/automation_file/scheduler/cron.py +++ b/automation_file/scheduler/cron.py @@ -44,7 +44,7 @@ def resolve_timezone(name: str | None) -> dt.tzinfo | None: ) key = name.strip() if key.upper() == _UTC: - return dt.timezone.utc + return dt.UTC try: return zoneinfo.ZoneInfo(key) except zoneinfo.ZoneInfoNotFoundError as error: diff --git a/automation_file/scheduler/manager.py b/automation_file/scheduler/manager.py index 08a3241..5058511 100644 --- a/automation_file/scheduler/manager.py +++ b/automation_file/scheduler/manager.py @@ -60,7 +60,7 @@ def _utc_now() -> dt.datetime: - return dt.datetime.now(dt.timezone.utc) + return dt.datetime.now(dt.UTC) def _checked_name(name: Any) -> str: diff --git a/automation_file/scheduler/runs.py b/automation_file/scheduler/runs.py index c1f467b..2eef423 100644 --- a/automation_file/scheduler/runs.py +++ b/automation_file/scheduler/runs.py @@ -10,7 +10,7 @@ import threading from collections import deque from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from enum import Enum from typing import Any @@ -59,7 +59,7 @@ class TriggerKind(str, Enum): def as_utc(moment: datetime) -> datetime: """Return ``moment`` as an aware UTC ``datetime``; a naive one is taken as local time.""" - return moment.astimezone(timezone.utc) + return moment.astimezone(UTC) def _iso(moment: datetime | None) -> str | None: diff --git a/automation_file/storage/dropbox_storage.py b/automation_file/storage/dropbox_storage.py index 0b46387..b2ee255 100644 --- a/automation_file/storage/dropbox_storage.py +++ b/automation_file/storage/dropbox_storage.py @@ -22,7 +22,7 @@ import contextlib from collections.abc import Callable, Hashable, Iterable, Iterator -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any, BinaryIO @@ -148,8 +148,8 @@ def _utc(moment: Any) -> datetime | None: if not isinstance(moment, datetime): return None if moment.tzinfo is None: - return moment.replace(tzinfo=timezone.utc) - return moment.astimezone(timezone.utc) + return moment.replace(tzinfo=UTC) + return moment.astimezone(UTC) def _file_info(path: str, metadata: Any, files: Any) -> FileInfo | None: diff --git a/automation_file/storage/fsspec_storage.py b/automation_file/storage/fsspec_storage.py index defc043..f9e43c0 100644 --- a/automation_file/storage/fsspec_storage.py +++ b/automation_file/storage/fsspec_storage.py @@ -25,7 +25,7 @@ import contextlib from collections.abc import Hashable, Iterable, Iterator, Mapping from dataclasses import replace -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any @@ -49,7 +49,7 @@ # The keys under which filesystems report a modification time in info() and ls(). _MODIFIED_KEYS = ("mtime", "LastModified", "last_modified", "modified", "updated") _GLOB_CHARACTERS = frozenset("*?[") -_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) +_EPOCH = datetime(1970, 1, 1, tzinfo=UTC) @contextlib.contextmanager @@ -86,8 +86,8 @@ def _utc(value: Any) -> datetime | None: return None if isinstance(value, datetime): if value.tzinfo is None: - return value.replace(tzinfo=timezone.utc) - return value.astimezone(timezone.utc) + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) if isinstance(value, (int, float)) and not isinstance(value, bool): try: return _EPOCH + timedelta(seconds=value) diff --git a/automation_file/storage/ftp_storage.py b/automation_file/storage/ftp_storage.py index 7ef2dd4..f6541d9 100644 --- a/automation_file/storage/ftp_storage.py +++ b/automation_file/storage/ftp_storage.py @@ -26,7 +26,7 @@ import weakref from collections.abc import Iterable, Iterator from contextlib import AbstractContextManager -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import TYPE_CHECKING, Any @@ -124,7 +124,7 @@ def _timestamp(text: str | None) -> datetime | None: microsecond = int(fraction[:6].ljust(6, "0")) if fraction else 0 except ValueError: return None - return moment.replace(microsecond=microsecond, tzinfo=timezone.utc) + return moment.replace(microsecond=microsecond, tzinfo=UTC) def _fact_info(path: str, facts: dict[str, str]) -> FileInfo: diff --git a/automation_file/storage/local_storage.py b/automation_file/storage/local_storage.py index 4d41f7f..5ae7274 100644 --- a/automation_file/storage/local_storage.py +++ b/automation_file/storage/local_storage.py @@ -25,7 +25,7 @@ import stat import uuid from collections.abc import Hashable, Iterable, Iterator -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import BinaryIO @@ -81,7 +81,7 @@ def _file_info(path: str, result: os.stat_result) -> FileInfo: path=path, is_dir=is_dir, size=None if is_dir else result.st_size, - modified_at=datetime.fromtimestamp(result.st_mtime, tz=timezone.utc), + modified_at=datetime.fromtimestamp(result.st_mtime, tz=UTC), content_type=None if is_dir else guess_content_type(path), ) diff --git a/automation_file/storage/memory_storage.py b/automation_file/storage/memory_storage.py index 7dc7d55..411120a 100644 --- a/automation_file/storage/memory_storage.py +++ b/automation_file/storage/memory_storage.py @@ -10,7 +10,7 @@ import threading from collections.abc import Iterable from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from automation_file.storage.backend import StorageBackend, parent_of @@ -65,7 +65,7 @@ def _list_dir(self, path: str) -> Iterable[FileInfo]: return [info for info in map(self._stat, names) if info is not None] def _upload(self, source: Path, path: str) -> None: - blob = _Blob(source.read_bytes(), datetime.now(timezone.utc)) + blob = _Blob(source.read_bytes(), datetime.now(UTC)) with self._lock: self._files[path] = blob diff --git a/automation_file/storage/sftp_storage.py b/automation_file/storage/sftp_storage.py index ad37de3..6cad55b 100644 --- a/automation_file/storage/sftp_storage.py +++ b/automation_file/storage/sftp_storage.py @@ -20,7 +20,7 @@ import stat from collections.abc import Iterable, Iterator from contextlib import AbstractContextManager -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import TYPE_CHECKING, Any @@ -102,7 +102,7 @@ def _file_info(path: str, attributes: Any) -> FileInfo: path=path, is_dir=is_dir, size=None if is_dir else attributes.st_size, - modified_at=None if modified is None else datetime.fromtimestamp(modified, timezone.utc), + modified_at=None if modified is None else datetime.fromtimestamp(modified, UTC), ) diff --git a/automation_file/storage/smb_storage.py b/automation_file/storage/smb_storage.py index 925fcb0..090023e 100644 --- a/automation_file/storage/smb_storage.py +++ b/automation_file/storage/smb_storage.py @@ -21,7 +21,7 @@ import contextlib import errno from collections.abc import Hashable, Iterable, Iterator -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path from typing import TYPE_CHECKING @@ -41,7 +41,7 @@ from automation_file.remote.smb.client import SMBClient, SMBEntry SMB_SCHEME = "smb" -_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) +_EPOCH = datetime(1970, 1, 1, tzinfo=UTC) _MISSING_ERRNO = frozenset({errno.ENOENT, errno.ENOTDIR}) # NTSTATUS STATUS_ACCESS_DENIED, which smbprotocol passes on without an errno. _STATUS_ACCESS_DENIED = 0xC0000022 diff --git a/automation_file/storage/timestamps.py b/automation_file/storage/timestamps.py index 968986b..08487a1 100644 --- a/automation_file/storage/timestamps.py +++ b/automation_file/storage/timestamps.py @@ -3,7 +3,7 @@ from __future__ import annotations import re -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta, timezone _RFC3339 = re.compile( r"(\d{4})-(\d{2})-(\d{2})[Tt ](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?([Zz]|[+-]\d{2}:\d{2})" @@ -34,7 +34,7 @@ def parse_rfc3339(value: object) -> datetime | None: try: zone = timezone(_utc_offset(match.group(8))) moment = datetime(year, month, day, hour, minute, second, int(fraction), tzinfo=zone) - return moment.astimezone(timezone.utc) + return moment.astimezone(UTC) except (ValueError, OverflowError): # Not a real date (month 13, second 60), or one that leaves the calendar in UTC. return None diff --git a/automation_file/storage/webdav_storage.py b/automation_file/storage/webdav_storage.py index 80af1bb..d1de59f 100644 --- a/automation_file/storage/webdav_storage.py +++ b/automation_file/storage/webdav_storage.py @@ -21,7 +21,7 @@ import contextlib from collections.abc import Hashable, Iterable, Iterator -from datetime import datetime, timezone +from datetime import UTC, datetime from email.utils import parsedate_to_datetime from pathlib import Path from typing import TYPE_CHECKING @@ -113,8 +113,8 @@ def _modified(text: str | None) -> datetime | None: except (TypeError, ValueError): return None if moment.tzinfo is None: - return moment.replace(tzinfo=timezone.utc) - return moment.astimezone(timezone.utc) + return moment.replace(tzinfo=UTC) + return moment.astimezone(UTC) def _etag(text: str | None) -> str | None: diff --git a/automation_file/utils/rotate.py b/automation_file/utils/rotate.py index 1f28f9c..4707e6f 100644 --- a/automation_file/utils/rotate.py +++ b/automation_file/utils/rotate.py @@ -13,7 +13,7 @@ import fnmatch from collections.abc import Callable -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from automation_file.exceptions import FileAutomationException @@ -155,21 +155,21 @@ def _select_kept( def _daily_key(mtime: float) -> str: - dt = datetime.fromtimestamp(mtime, tz=timezone.utc) + dt = datetime.fromtimestamp(mtime, tz=UTC) return dt.strftime("%Y-%m-%d") def _weekly_key(mtime: float) -> str: - dt = datetime.fromtimestamp(mtime, tz=timezone.utc) + dt = datetime.fromtimestamp(mtime, tz=UTC) year, week, _weekday = dt.isocalendar() return f"{year}-W{week:02d}" def _monthly_key(mtime: float) -> str: - dt = datetime.fromtimestamp(mtime, tz=timezone.utc) + dt = datetime.fromtimestamp(mtime, tz=UTC) return dt.strftime("%Y-%m") def _yearly_key(mtime: float) -> str: - dt = datetime.fromtimestamp(mtime, tz=timezone.utc) + dt = datetime.fromtimestamp(mtime, tz=UTC) return dt.strftime("%Y") diff --git a/dev.toml b/dev.toml index e8ca03c..3b16dde 100644 --- a/dev.toml +++ b/dev.toml @@ -17,7 +17,7 @@ keywords = [ "file-integrity", "audit", "automation", "mcp", ] readme = { file = "README.md", content-type = "text/markdown" } -requires-python = ">=3.10" +requires-python = ">=3.11" license = "MIT" license-files = ["LICENSE"] # The base install: the action engine, local operations, HTTP, the storage layer's local and @@ -39,7 +39,6 @@ dependencies = [ "tomli>=2.0.1; python_version<\"3.11\"" ] classifiers = [ - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", diff --git a/docs/updates/2026-11.md b/docs/updates/2026-11.md index 0bec148..28c163e 100644 --- a/docs/updates/2026-11.md +++ b/docs/updates/2026-11.md @@ -14,3 +14,11 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **Files**: `.github/requirements/integration.in`, `.github/requirements/integration.txt`, `.github/requirements/publish.txt`. - **Evidence**: `tests/test_workflow_actions.py`, `tests/test_dev_toml_parity.py`. - **Open items**: none. + +## U-20261102-01 · 2026-11-02 · Require Python 3.11; stop supporting 3.10 · #migration #deps #ci + +- **Why**: every package in the workspace moves its floor to Python 3.11 together. Security releases such as Pillow 13.0.0 need 3.11, and supporting 3.10 kept older, vulnerable versions in the locks. +- **What**: `requires-python` is `>=3.11` and the 3.10 classifier is gone; the CI matrices start at 3.11; the docs that gave 3.10 as the floor now give 3.11. +- **Also**: `tomli` is no longer a dependency; it was needed on 3.10 only. ruff and mypy target 3.11. +- **Lint**: ruff's py311 target turns on rules that 3.10 kept off. `datetime.UTC` replaces `timezone.utc` (UP017), and the `tomli` fallbacks that only ran on 3.10 are gone (UP036). UP042 (`StrEnum`) is ignored, because `StrEnum` changes what `str()` returns. +- **Open items**: none. diff --git a/docs/updates/README.md b/docs/updates/README.md index efdc26e..2921456 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261102-01 | 2026-11-02 | Require Python 3.11; stop supporting 3.10 | #migration #deps #ci | [2026-11](2026-11.md) | | U-20261101-01 | 2026-11-01 | Patch the dependencies named in Dependabot security alerts | #bugfix #deps #security #ci | [2026-11](2026-11.md) | | U-20261022-03 | 2026-10-22 | Preserve complete dependency locks | #bugfix #deps #ci | [2026-10](2026-10.md) | | U-20261022-02 | 2026-10-22 | Preserve complete dependency locks | #bugfix #deps #ci | [2026-10](2026-10.md) | @@ -141,6 +142,6 @@ In the same commit: delete the item from `progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-11.md](2026-11.md) | 2026-11 | 1 | +| [2026-11.md](2026-11.md) | 2026-11 | 2 | | [2026-10.md](2026-10.md) | 2026-10 | 53 | | [2026-09.md](2026-09.md) | 2026-09 | 21 | diff --git a/mypy.ini b/mypy.ini index de47b99..69a2279 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,5 +1,5 @@ [mypy] -python_version = 3.10 +python_version = 3.11 ignore_missing_imports = True disable_error_code = import-untyped warn_unused_ignores = True diff --git a/requirements.txt b/requirements.txt index 8d396d5..a66151a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,4 +14,3 @@ opentelemetry-api>=1.45.1 opentelemetry-sdk>=1.45.1 msal>=1.39.0 boxsdk>=10.17.1,<11 -tomli; python_version<"3.11" diff --git a/ruff.toml b/ruff.toml index 12ba12b..ad60503 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,6 +1,6 @@ # Ruff configuration matching the rules documented in CLAUDE.md. line-length = 100 -target-version = "py310" +target-version = "py311" extend-exclude = ["docs/_build", "build", "dist", ".venv"] [lint] @@ -29,6 +29,9 @@ ignore = [ "RUF022", # __all__ is grouped thematically, not alphabetically "PLW0108", # tests occasionally use single-line lambdas for clarity "PLR0911", # download_file legitimately has >6 return points (SSRF guards) + # UP042 wants StrEnum, whose str() is the value rather than Class.MEMBER. That changes + # runtime output, so it is a separate decision from the 3.11 floor that enabled the rule. + "UP042", ] [lint.per-file-ignores] diff --git a/stable.toml b/stable.toml index 9303623..e03d01f 100644 --- a/stable.toml +++ b/stable.toml @@ -15,7 +15,7 @@ keywords = [ "file-integrity", "audit", "automation", "mcp", ] readme = { file = "README.md", content-type = "text/markdown" } -requires-python = ">=3.10" +requires-python = ">=3.11" license = "MIT" license-files = ["LICENSE"] # The base install: the action engine, local operations, HTTP, the storage layer's local and @@ -37,7 +37,6 @@ dependencies = [ "tomli>=2.0.1; python_version<\"3.11\"" ] classifiers = [ - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", diff --git a/tests/drive_stand_in.py b/tests/drive_stand_in.py index 4ec05fb..0fa4890 100644 --- a/tests/drive_stand_in.py +++ b/tests/drive_stand_in.py @@ -35,7 +35,7 @@ import json import re from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any from urllib.parse import parse_qsl, urlsplit @@ -109,7 +109,7 @@ class _Entry: data: bytes | None = None version: int = 1 trashed: bool = False - modified: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + modified: datetime = field(default_factory=lambda: datetime.now(UTC)) def resource(self, *, digests: bool = True) -> dict[str, Any]: stamp = self.modified.strftime("%Y-%m-%dT%H:%M:%S") @@ -423,5 +423,5 @@ def _stored(self, upload: _Upload) -> _Entry: entry.data = content entry.mime_type = upload.mime_type entry.version += 1 - entry.modified = datetime.now(timezone.utc) + entry.modified = datetime.now(UTC) return entry diff --git a/tests/ftp_stand_in.py b/tests/ftp_stand_in.py index da9ea8e..e0bc4a1 100644 --- a/tests/ftp_stand_in.py +++ b/tests/ftp_stand_in.py @@ -18,10 +18,10 @@ from collections import deque from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any -MODIFIED = datetime(2026, 10, 8, 2, 30, 15, 250000, tzinfo=timezone.utc) +MODIFIED = datetime(2026, 10, 8, 2, 30, 15, 250000, tzinfo=UTC) NO_SUCH_FILE = "550 No such file or directory." @@ -310,7 +310,7 @@ def _do_stor(self, argument: str) -> None: def stored(data: bytes) -> None: kept = data if self.transfer_error is None else data[: len(data) // 2] - self.entries[real] = _Entry(data=kept, modified=datetime.now(timezone.utc)) + self.entries[real] = _Entry(data=kept, modified=datetime.now(UTC)) self._reply(self.transfer_error or "226 Transfer complete.") self._transfer(b"", stored) @@ -328,7 +328,7 @@ def _do_mkd(self, argument: str) -> None: if entry is not None or not self._holds(real): self._reply("550 Create directory operation failed.") return - self.entries[real] = _Entry(modified=datetime.now(timezone.utc)) + self.entries[real] = _Entry(modified=datetime.now(UTC)) self._reply(f'257 "{real}" created') def _do_rmd(self, argument: str) -> None: diff --git a/tests/graph_stand_in.py b/tests/graph_stand_in.py index 52951fb..45191ba 100644 --- a/tests/graph_stand_in.py +++ b/tests/graph_stand_in.py @@ -27,7 +27,7 @@ import json import mimetypes from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any from urllib.parse import parse_qsl, quote, unquote, urlsplit @@ -93,7 +93,7 @@ class _Item: parent: str | None data: bytes | None = None revision: int = 1 - modified: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + modified: datetime = field(default_factory=lambda: datetime.now(UTC)) def resource(self, child_count: int) -> dict[str, Any]: stamp = self.modified.strftime("%Y-%m-%dT%H:%M:%S") @@ -311,7 +311,7 @@ def _store(self, path: str, data: bytes) -> tuple[_Item, bool]: raise _Failure(409) existing.data = data existing.revision += 1 - existing.modified = datetime.now(timezone.utc) + existing.modified = datetime.now(UTC) return existing, False # ------------------------------------------------------------------ Graph calls diff --git a/tests/scheduler_kit.py b/tests/scheduler_kit.py index d9328bf..2d0b537 100644 --- a/tests/scheduler_kit.py +++ b/tests/scheduler_kit.py @@ -10,7 +10,7 @@ import threading import time from collections.abc import Callable -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from types import TracebackType from typing import Any @@ -18,7 +18,7 @@ from automation_file.events import Event, EventBus from automation_file.scheduler import JobRun, Scheduler -START = datetime(2026, 10, 8, 2, 0, tzinfo=timezone.utc) +START = datetime(2026, 10, 8, 2, 0, tzinfo=UTC) WAIT = 5.0 _POLL = 0.005 _PREFIX = "scheduler_test_" diff --git a/tests/test_audit_v2.py b/tests/test_audit_v2.py index 37c898b..3fa030c 100644 --- a/tests/test_audit_v2.py +++ b/tests/test_audit_v2.py @@ -15,7 +15,7 @@ import threading from collections.abc import Iterator from contextlib import closing -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta, timezone from pathlib import Path from typing import Any @@ -63,7 +63,7 @@ from automation_file.storage import MemoryStorage, observe from automation_file.storage.observe import StorageOperation -_T0 = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc) +_T0 = datetime(2026, 10, 1, 12, 0, 0, tzinfo=UTC) _FIELDS = ( "id timestamp actor source pipeline task action resource backend status duration_ms error " "metadata correlation_id" @@ -437,7 +437,7 @@ def test_an_id_is_stored_once(store: AuditStore) -> None: def test_purge_removes_what_is_older(store: AuditStore) -> None: - now = datetime.now(timezone.utc) + now = datetime.now(UTC) old = AuditRecord(action="old", timestamp=now - timedelta(days=2)) recent = AuditRecord(action="recent", timestamp=now - timedelta(minutes=1)) store.append(old) diff --git a/tests/test_dev_toml_parity.py b/tests/test_dev_toml_parity.py index d662417..e1894ba 100644 --- a/tests/test_dev_toml_parity.py +++ b/tests/test_dev_toml_parity.py @@ -7,16 +7,11 @@ from __future__ import annotations -import sys +import tomllib from pathlib import Path import pytest -if sys.version_info >= (3, 11): - import tomllib -else: - import tomli as tomllib # declared in *.toml for Python<3.11 - REPO_ROOT = Path(__file__).resolve().parents[1] diff --git a/tests/test_integrity_detector.py b/tests/test_integrity_detector.py index c37d2a4..e31eb44 100644 --- a/tests/test_integrity_detector.py +++ b/tests/test_integrity_detector.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from typing import Any import pytest @@ -20,7 +20,7 @@ ) ROOT = "memory://detect/tree" -MOMENT = datetime(2026, 10, 8, 10, 15, 30, tzinfo=timezone.utc) +MOMENT = datetime(2026, 10, 8, 10, 15, 30, tzinfo=UTC) LATER = MOMENT + timedelta(hours=1) diff --git a/tests/test_integrity_object_store.py b/tests/test_integrity_object_store.py index 4673b1b..3f64648 100644 --- a/tests/test_integrity_object_store.py +++ b/tests/test_integrity_object_store.py @@ -20,7 +20,7 @@ import threading from collections.abc import Iterable, Iterator from dataclasses import dataclass -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path import pytest @@ -60,7 +60,7 @@ def __init__(self) -> None: self.downloads: list[str] = [] self.heads: list[str] = [] self.listings = threading.Semaphore(0) - self._clock = datetime(2026, 10, 8, tzinfo=timezone.utc) + self._clock = datetime(2026, 10, 8, tzinfo=UTC) self._lock = threading.Lock() def uri_for(self, path: str = "") -> str: diff --git a/tests/test_integrity_snapshot.py b/tests/test_integrity_snapshot.py index c024d42..ce48623 100644 --- a/tests/test_integrity_snapshot.py +++ b/tests/test_integrity_snapshot.py @@ -12,7 +12,7 @@ import stat import threading from collections.abc import Iterator -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta, timezone from pathlib import Path from typing import Any @@ -38,7 +38,7 @@ TREE = "memory://snap/tree" FILES = {"a.txt": b"alpha", "sub/b.bin": b"bravo!", "sub/deep/c.txt": b""} -MOMENT = datetime(2026, 10, 8, 10, 15, 30, 123456, tzinfo=timezone.utc) +MOMENT = datetime(2026, 10, 8, 10, 15, 30, 123456, tzinfo=UTC) @pytest.fixture(autouse=True) @@ -70,7 +70,7 @@ def _entry(path: str = "a.txt", **fields: Any) -> SnapshotEntry: def test_a_snapshot_lists_every_file_with_what_the_backend_reports() -> None: storage = _write(TREE, FILES) storage.mkdir("empty") - before = datetime.now(timezone.utc) - timedelta(seconds=5) + before = datetime.now(UTC) - timedelta(seconds=5) snapshot = _snapshot() assert snapshot.root == TREE assert snapshot.backend == "memory" @@ -206,7 +206,7 @@ def test_from_dict_fills_the_gaps_and_reads_a_z_suffix() -> None: backend="memory", ) assert (entry.algorithm, entry.backend, entry.checksum) == ("blake2b", "memory", "abc") - assert entry.modified_at == datetime(2026, 10, 8, 10, 15, 30, tzinfo=timezone.utc) + assert entry.modified_at == datetime(2026, 10, 8, 10, 15, 30, tzinfo=UTC) assert (entry.size, entry.mode, entry.etag) == (None, None, None) diff --git a/tests/test_optional_dependencies.py b/tests/test_optional_dependencies.py index 9b311ef..ea1942d 100644 --- a/tests/test_optional_dependencies.py +++ b/tests/test_optional_dependencies.py @@ -17,6 +17,7 @@ import re import subprocess # nosec B404 # the test starts this interpreter with a fixed argument list import sys +import tomllib from pathlib import Path import pytest @@ -24,11 +25,6 @@ from automation_file.core.optional import EXTRAS, install_hint, require_module from automation_file.exceptions import FileAutomationException, OptionalDependencyException -if sys.version_info >= (3, 11): - import tomllib -else: - import tomli as tomllib # declared in *.toml for Python<3.11 - REPO_ROOT = Path(__file__).resolve().parents[1] #: Top-level import names of every package that only an extra installs. diff --git a/tests/test_pipeline_store.py b/tests/test_pipeline_store.py index 8e2b992..375d242 100644 --- a/tests/test_pipeline_store.py +++ b/tests/test_pipeline_store.py @@ -12,7 +12,7 @@ import time from collections.abc import Callable, Iterator from contextlib import closing -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any @@ -38,7 +38,7 @@ ) WAIT = 5.0 -START = datetime(2026, 10, 8, 2, 0, tzinfo=timezone.utc) +START = datetime(2026, 10, 8, 2, 0, tzinfo=UTC) @pytest.fixture(params=["memory", "sqlite"]) diff --git a/tests/test_rotate.py b/tests/test_rotate.py index 43b781a..fb3bd30 100644 --- a/tests/test_rotate.py +++ b/tests/test_rotate.py @@ -1,7 +1,7 @@ from __future__ import annotations import os -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path import pytest @@ -11,7 +11,7 @@ def _make_file(path: Path, days_ago: int) -> None: path.write_text("data", encoding="utf-8") - when = (datetime.now(tz=timezone.utc) - timedelta(days=days_ago)).timestamp() + when = (datetime.now(tz=UTC) - timedelta(days=days_ago)).timestamp() os.utime(path, (when, when)) diff --git a/tests/test_scheduler_pipeline.py b/tests/test_scheduler_pipeline.py index 0264946..0546902 100644 --- a/tests/test_scheduler_pipeline.py +++ b/tests/test_scheduler_pipeline.py @@ -8,7 +8,7 @@ import time import zoneinfo from collections.abc import Iterator -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -33,7 +33,6 @@ from automation_file.scheduler.targets import fill_params from tests.scheduler_kit import START, WAIT, Rig, wait_until -UTC = timezone.utc NIGHTLY = Schedule("0 2 * * *", "UTC") diff --git a/tests/test_scheduler_runs.py b/tests/test_scheduler_runs.py index dfffc08..f3df646 100644 --- a/tests/test_scheduler_runs.py +++ b/tests/test_scheduler_runs.py @@ -9,7 +9,7 @@ import json import threading from collections.abc import Iterator -from datetime import timedelta, timezone +from datetime import UTC, timedelta from typing import Any import pytest @@ -125,7 +125,7 @@ def test_the_times_of_a_record_are_aware_utc(rig: Rig) -> None: assert run.wait(WAIT) for moment in (run.scheduled_at, run.started_at, run.finished_at): assert moment is not None and moment.utcoffset() == timedelta(0) - assert moment.tzinfo is timezone.utc + assert moment.tzinfo is UTC def test_a_record_is_json_friendly(rig: Rig) -> None: diff --git a/tests/test_scheduler_triggers.py b/tests/test_scheduler_triggers.py index fd8dda6..7e721a0 100644 --- a/tests/test_scheduler_triggers.py +++ b/tests/test_scheduler_triggers.py @@ -8,7 +8,7 @@ import zoneinfo from collections.abc import Iterator -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any @@ -36,8 +36,6 @@ from automation_file.trigger.manager import FileWatcher, TriggerException from tests.scheduler_kit import START, WAIT, Rig, wait_until -UTC = timezone.utc - @pytest.fixture def rig() -> Iterator[Rig]: diff --git a/tests/test_stable_release.py b/tests/test_stable_release.py index 48e590c..c5508f3 100644 --- a/tests/test_stable_release.py +++ b/tests/test_stable_release.py @@ -25,7 +25,7 @@ def _load_script(): def _metadata(root: Path, stable: str, dev: str) -> None: (root / "stable.toml").write_text( - f'[project]\nname = "automation_file"\nversion = "{stable}"\nrequires-python = ">=3.10"\n', + f'[project]\nname = "automation_file"\nversion = "{stable}"\nrequires-python = ">=3.11"\n', encoding="utf-8", ) (root / "dev.toml").write_text( @@ -74,7 +74,7 @@ def test_bump_writes_a_patch_release_into_both_files(tmp_path): _metadata(tmp_path, "0.0.51", "0.0.33") assert stable_release.bump(tmp_path, (0, 0, 51)) == ((0, 0, 52), (0, 0, 34)) assert _versions(tmp_path) == ("0.0.52", "0.0.34") - assert 'requires-python = ">=3.10"' in (tmp_path / "stable.toml").read_text(encoding="utf-8") + assert 'requires-python = ">=3.11"' in (tmp_path / "stable.toml").read_text(encoding="utf-8") def test_bump_keeps_a_raised_major_or_minor_as_written(tmp_path): diff --git a/tests/test_storage_azure.py b/tests/test_storage_azure.py index 9c8b794..21e4195 100644 --- a/tests/test_storage_azure.py +++ b/tests/test_storage_azure.py @@ -23,7 +23,7 @@ import inspect from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from types import SimpleNamespace from typing import Any, BinaryIO @@ -121,7 +121,7 @@ def upload_blob( if self._name in blobs and not overwrite: raise ResourceExistsError("The specified blob already exists.") content_type = getattr(content_settings, "content_type", None) - blobs[self._name] = _Blob(data.read(), content_type, datetime.now(timezone.utc)) + blobs[self._name] = _Blob(data.read(), content_type, datetime.now(UTC)) def download_blob(self) -> _Download: return _Download(self._existing().data) @@ -214,7 +214,7 @@ class TestPrefixedAzureStorageContract(TestAzureStorageContract): def backend(self) -> StorageBackend: service = FakeBlobService() service.containers["container"]["other-tenant/keep.txt"] = _Blob( - b"keep", "text/plain", datetime.now(timezone.utc) + b"keep", "text/plain", datetime.now(UTC) ) return AzureStorage("container", service=service, prefix="tenant/a") @@ -246,9 +246,7 @@ def test_a_blob_without_a_known_suffix_has_no_content_type(storage: AzureStorage def test_a_prefix_confines_the_backend_to_its_blobs(service: FakeBlobService) -> None: - service.containers["container"]["other/keep.txt"] = _Blob( - b"k", None, datetime.now(timezone.utc) - ) + service.containers["container"]["other/keep.txt"] = _Blob(b"k", None, datetime.now(UTC)) tenant = AzureStorage("container", service=service, prefix="tenant/a") tenant.write_bytes("docs/a.txt", b"x") assert sorted(service.containers["container"]) == ["other/keep.txt", "tenant/a/docs/a.txt"] @@ -261,7 +259,7 @@ def test_a_prefix_confines_the_backend_to_its_blobs(service: FakeBlobService) -> def test_a_folder_placeholder_is_a_directory_not_a_file( storage: AzureStorage, service: FakeBlobService ) -> None: - service.containers["container"]["folder/"] = _Blob(b"", None, datetime.now(timezone.utc)) + service.containers["container"]["folder/"] = _Blob(b"", None, datetime.now(UTC)) assert storage.stat("folder").is_dir is True assert storage.list_dir("folder") == [] assert [(info.path, info.is_dir) for info in storage.list_dir()] == [("folder", True)] diff --git a/tests/test_storage_dropbox.py b/tests/test_storage_dropbox.py index 0dd6ad5..8d8caca 100644 --- a/tests/test_storage_dropbox.py +++ b/tests/test_storage_dropbox.py @@ -19,7 +19,7 @@ import inspect import io from dataclasses import dataclass -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any @@ -165,7 +165,7 @@ def _store(self, path: str, data: bytes, mode: Any) -> files.FileMetadata: raise _api_error(files.UploadError.path(failed)) self._make_parents(path) self._revision += 1 - moment = datetime.now(timezone.utc).replace(tzinfo=None, microsecond=0) + moment = datetime.now(UTC).replace(tzinfo=None, microsecond=0) self.stored[path] = _Stored(data, self._revision, moment) return self.stored[path].metadata(path) @@ -343,7 +343,7 @@ def test_stat_reports_what_the_metadata_carries( stored = client.stored["/reports/q1.json"] assert info.path == "reports/q1.json" assert info.size == 2 - assert info.modified_at == stored.modified.replace(tzinfo=timezone.utc) + assert info.modified_at == stored.modified.replace(tzinfo=UTC) assert info.modified_at.utcoffset() == timedelta(0) assert info.version == f"{stored.revision:016x}" assert info.etag == _content_hash(b"{}") @@ -715,7 +715,7 @@ def test_the_sdk_decodes_metadata_the_way_the_adapter_reads_it() -> None: assert decoded.server_modified.tzinfo is None info = dropbox_storage_module._file_info("a.txt", decoded, files) assert info is not None - assert info.modified_at == datetime(2026, 10, 8, 2, 30, 5, tzinfo=timezone.utc) + assert info.modified_at == datetime(2026, 10, 8, 2, 30, 5, tzinfo=UTC) assert (info.size, info.version, info.etag) == (3, "0123456789abcdef", "a" * 64) folder = json_compat_obj_decode( files.Metadata_validator, diff --git a/tests/test_storage_fsspec.py b/tests/test_storage_fsspec.py index 7d6a7b3..836a5bb 100644 --- a/tests/test_storage_fsspec.py +++ b/tests/test_storage_fsspec.py @@ -19,7 +19,7 @@ import secrets import sys from collections.abc import Iterator -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta, timezone from pathlib import Path from typing import Any @@ -99,7 +99,7 @@ def ls(self, path: str, detail: bool = True, **kwargs: Any) -> list[Any]: def put_file(self, lpath: str, rpath: str, callback: Any = None, **kwargs: Any) -> None: self.calls.append("put_file") - self.objects[self._key(rpath)] = (Path(lpath).read_bytes(), datetime.now(timezone.utc)) + self.objects[self._key(rpath)] = (Path(lpath).read_bytes(), datetime.now(UTC)) def get_file(self, rpath: str, lpath: str, callback: Any = None, **kwargs: Any) -> None: self.calls.append("get_file") @@ -120,7 +120,7 @@ def rm_file(self, path: str) -> None: def cp_file(self, path1: str, path2: str, **kwargs: Any) -> None: self.calls.append("cp_file") - self.objects[self._key(path2)] = (self.cat_file(path1), datetime.now(timezone.utc)) + self.objects[self._key(path2)] = (self.cat_file(path1), datetime.now(UTC)) def mkdir(self, path: str, create_parents: bool = True, **kwargs: Any) -> None: """A prefix needs no creating.""" @@ -183,7 +183,7 @@ class TestKeyValueFsspecStorageContract(StorageContract): @pytest.fixture def backend(self) -> StorageBackend: filesystem = KeyValueFileSystem() - filesystem.objects["bucket/other-tenant/keep.txt"] = (b"keep", datetime.now(timezone.utc)) + filesystem.objects["bucket/other-tenant/keep.txt"] = (b"keep", datetime.now(UTC)) return FsspecStorage(filesystem, root="bucket/tenant/a", directories=False) @@ -207,7 +207,7 @@ def _stored() -> dict[str, bytes]: def test_stat_takes_the_time_from_info_when_it_is_there(tmp_path: Path) -> None: storage = FsspecStorage(LocalFileSystem(), root=str(tmp_path)) info = storage.write_bytes("dir/a.txt", b"xyz") - expected = datetime.fromtimestamp(os.stat(tmp_path / "dir" / "a.txt").st_mtime, timezone.utc) + expected = datetime.fromtimestamp(os.stat(tmp_path / "dir" / "a.txt").st_mtime, UTC) assert info.size == 3 assert abs(info.modified_at - expected) < CLOSE_ENOUGH assert info.modified_at.utcoffset() == timedelta(0) @@ -249,7 +249,7 @@ def test_capabilities_and_scheme_belong_to_the_instance(tmp_path: Path) -> None: FsspecStorage(KeyValueFileSystem(), scheme="not a scheme") -AWARE = datetime(2026, 10, 8, 2, 30, tzinfo=timezone.utc) +AWARE = datetime(2026, 10, 8, 2, 30, tzinfo=UTC) @pytest.mark.parametrize( diff --git a/tests/test_storage_gdrive.py b/tests/test_storage_gdrive.py index e91779a..803a4ee 100644 --- a/tests/test_storage_gdrive.py +++ b/tests/test_storage_gdrive.py @@ -21,7 +21,7 @@ import hashlib import inspect import ssl -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any @@ -124,7 +124,7 @@ def test_stat_reports_what_drive_holds(storage: GoogleDriveStorage, drive: FakeD assert info.content_type == "application/json" assert info.modified_at is not None assert info.modified_at.utcoffset() == timedelta(0) - assert datetime.now(timezone.utc) - info.modified_at < timedelta(minutes=1) + assert datetime.now(UTC) - info.modified_at < timedelta(minutes=1) assert dict(info.metadata) == {} assert drive.only("q1.json").mime_type == "application/json" diff --git a/tests/test_storage_s3.py b/tests/test_storage_s3.py index a540927..69bcb61 100644 --- a/tests/test_storage_s3.py +++ b/tests/test_storage_s3.py @@ -24,7 +24,7 @@ import inspect from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -153,7 +153,7 @@ def upload_file(self, filename: str, bucket: str, key: str, **options: Any) -> N self.objects(bucket, "PutObject")[key] = _Object( Path(filename).read_bytes(), extra.get("ContentType", "binary/octet-stream"), - datetime.now(timezone.utc), + datetime.now(UTC), ) def download_file(self, bucket: str, key: str, filename: str) -> None: @@ -170,9 +170,7 @@ def copy(self, copy_source: dict[str, str], bucket: str, key: str) -> None: if copy_source["Key"] not in source: raise _client_error("NoSuchKey", "CopyObject") item = source[copy_source["Key"]] - self.buckets[bucket][key] = _Object( - item.data, item.content_type, datetime.now(timezone.utc) - ) + self.buckets[bucket][key] = _Object(item.data, item.content_type, datetime.now(UTC)) _FAILURES = { @@ -205,7 +203,7 @@ class TestPrefixedS3StorageContract(TestS3StorageContract): def backend(self) -> StorageBackend: client = FakeS3Client() client.buckets["bucket"]["other-tenant/keep.txt"] = _Object( - b"keep", "text/plain", datetime.now(timezone.utc) + b"keep", "text/plain", datetime.now(UTC) ) return S3Storage("bucket", client=client, prefix="tenant/a") @@ -245,9 +243,7 @@ def test_listing_reads_every_page(storage: S3Storage, client: FakeS3Client) -> N def test_a_prefix_confines_the_backend_to_its_keys(client: FakeS3Client) -> None: - client.buckets["bucket"]["other/keep.txt"] = _Object( - b"k", "text/plain", datetime.now(timezone.utc) - ) + client.buckets["bucket"]["other/keep.txt"] = _Object(b"k", "text/plain", datetime.now(UTC)) tenant = S3Storage("bucket", client=client, prefix="/tenant//a/") assert tenant.prefix == "tenant/a" tenant.write_bytes("docs/a.txt", b"x") @@ -261,9 +257,7 @@ def test_a_prefix_confines_the_backend_to_its_keys(client: FakeS3Client) -> None def test_a_folder_placeholder_is_a_directory_not_a_file( storage: S3Storage, client: FakeS3Client ) -> None: - client.buckets["bucket"]["folder/"] = _Object( - b"", "application/x-directory", datetime.now(timezone.utc) - ) + client.buckets["bucket"]["folder/"] = _Object(b"", "application/x-directory", datetime.now(UTC)) assert storage.stat("folder").is_dir is True assert storage.list_dir("folder") == [] assert [(info.path, info.is_dir) for info in storage.list_dir()] == [("folder", True)] @@ -381,7 +375,7 @@ def real_client() -> Any: def test_requests_and_responses_fit_the_service_model(real_client: Any) -> None: """Stubber checks every request against botocore's model of S3, parameter by parameter.""" storage = S3Storage("bucket", client=real_client) - modified = datetime(2026, 10, 8, 2, 30, tzinfo=timezone.utc) + modified = datetime(2026, 10, 8, 2, 30, tzinfo=UTC) listing = { "KeyCount": 2, "IsTruncated": False, diff --git a/tests/test_storage_sftp.py b/tests/test_storage_sftp.py index ece274c..945acc8 100644 --- a/tests/test_storage_sftp.py +++ b/tests/test_storage_sftp.py @@ -23,7 +23,7 @@ import time from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path import pytest @@ -342,7 +342,7 @@ def test_stat_reports_the_size_and_the_modification_time( session.write("/reports/q1.csv", b"a,b\n") info = storage.stat("reports/q1.csv") assert (info.path, info.is_dir, info.size) == ("reports/q1.csv", False, 4) - assert info.modified_at == datetime.fromtimestamp(MODIFIED, timezone.utc) + assert info.modified_at == datetime.fromtimestamp(MODIFIED, UTC) assert (info.etag, info.version, info.content_type, dict(info.metadata)) == ( None, None, diff --git a/tests/test_storage_smb.py b/tests/test_storage_smb.py index 333606e..e1d8b45 100644 --- a/tests/test_storage_smb.py +++ b/tests/test_storage_smb.py @@ -20,7 +20,7 @@ import stat import sys from collections.abc import Iterator -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path from types import ModuleType, SimpleNamespace, TracebackType from typing import Any @@ -288,7 +288,7 @@ def test_stat_reports_size_and_modification_time(storage: SMBStorage, share: Fak path = f"{SHARE_ROOT}\\reports\\q1.json" assert info.path == "reports/q1.json" assert info.size == 2 - assert info.modified_at == datetime.fromtimestamp(share.modified[path], tz=timezone.utc) + assert info.modified_at == datetime.fromtimestamp(share.modified[path], tz=UTC) assert info.modified_at.utcoffset() == timedelta(0) assert (info.etag, info.version, info.content_type) == (None, None, None) folder = storage.stat("reports") @@ -300,7 +300,7 @@ def test_a_time_before_1970_is_reported(storage: SMBStorage, share: FakeShare) - storage.write_bytes("old.txt", b"x") # A server that keeps no time sends the FILETIME epoch, the year 1601. share.modified[f"{SHARE_ROOT}\\old.txt"] = -11_644_473_600.0 - assert storage.stat("old.txt").modified_at == datetime(1601, 1, 1, tzinfo=timezone.utc) + assert storage.stat("old.txt").modified_at == datetime(1601, 1, 1, tzinfo=UTC) share.modified[f"{SHARE_ROOT}\\old.txt"] = 1e20 assert storage.stat("old.txt").modified_at is None diff --git a/tests/test_storage_timestamps.py b/tests/test_storage_timestamps.py index f69f30c..33d36e7 100644 --- a/tests/test_storage_timestamps.py +++ b/tests/test_storage_timestamps.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import pytest @@ -12,22 +12,22 @@ @pytest.mark.parametrize( "text,expected", [ - ("2026-10-08T02:30:00Z", datetime(2026, 10, 8, 2, 30, tzinfo=timezone.utc)), - ("2026-10-08T02:30:00.123Z", datetime(2026, 10, 8, 2, 30, 0, 123000, tzinfo=timezone.utc)), + ("2026-10-08T02:30:00Z", datetime(2026, 10, 8, 2, 30, tzinfo=UTC)), + ("2026-10-08T02:30:00.123Z", datetime(2026, 10, 8, 2, 30, 0, 123000, tzinfo=UTC)), # Seven digits, as Microsoft Graph sends: cut to microseconds, not rounded. ( "2026-10-08T02:30:00.1234567Z", - datetime(2026, 10, 8, 2, 30, 0, 123456, tzinfo=timezone.utc), + datetime(2026, 10, 8, 2, 30, 0, 123456, tzinfo=UTC), ), - ("2026-10-08T02:30:00.5z", datetime(2026, 10, 8, 2, 30, 0, 500000, tzinfo=timezone.utc)), - ("2026-10-08T10:30:00+08:00", datetime(2026, 10, 8, 2, 30, tzinfo=timezone.utc)), + ("2026-10-08T02:30:00.5z", datetime(2026, 10, 8, 2, 30, 0, 500000, tzinfo=UTC)), + ("2026-10-08T10:30:00+08:00", datetime(2026, 10, 8, 2, 30, tzinfo=UTC)), ( "2026-10-07T21:00:00.25-05:30", - datetime(2026, 10, 8, 2, 30, 0, 250000, tzinfo=timezone.utc), + datetime(2026, 10, 8, 2, 30, 0, 250000, tzinfo=UTC), ), - ("2026-10-08t02:30:00+00:00", datetime(2026, 10, 8, 2, 30, tzinfo=timezone.utc)), - (" 2026-10-08 02:30:00Z ", datetime(2026, 10, 8, 2, 30, tzinfo=timezone.utc)), - ("0001-01-01T00:00:00Z", datetime(1, 1, 1, tzinfo=timezone.utc)), + ("2026-10-08t02:30:00+00:00", datetime(2026, 10, 8, 2, 30, tzinfo=UTC)), + (" 2026-10-08 02:30:00Z ", datetime(2026, 10, 8, 2, 30, tzinfo=UTC)), + ("0001-01-01T00:00:00Z", datetime(1, 1, 1, tzinfo=UTC)), ], ) def test_a_timestamp_becomes_an_aware_utc_datetime(text: str, expected: datetime) -> None: diff --git a/tests/test_storage_types.py b/tests/test_storage_types.py index 4f449e5..b7e91d5 100644 --- a/tests/test_storage_types.py +++ b/tests/test_storage_types.py @@ -6,7 +6,7 @@ import dataclasses import json -from datetime import datetime, timezone +from datetime import UTC, datetime import pytest @@ -80,7 +80,7 @@ def test_file_info_to_dict_is_json_serialisable() -> None: info = FileInfo( "dir/report.csv", size=12, - modified_at=datetime(2026, 10, 8, 2, 30, tzinfo=timezone.utc), + modified_at=datetime(2026, 10, 8, 2, 30, tzinfo=UTC), etag="abc", version="7", content_type="text/csv", diff --git a/tests/test_storage_webdav.py b/tests/test_storage_webdav.py index 233dc7c..f2fcf4a 100644 --- a/tests/test_storage_webdav.py +++ b/tests/test_storage_webdav.py @@ -25,7 +25,7 @@ import secrets from collections.abc import Iterator from dataclasses import dataclass -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from email.utils import format_datetime from pathlib import Path from typing import Any @@ -134,7 +134,7 @@ class FakeDavServer: def __init__(self) -> None: self.files: dict[str, _Resource] = {} - self.collections: dict[str, datetime] = {DAV_ROOT: datetime.now(timezone.utc)} + self.collections: dict[str, datetime] = {DAV_ROOT: datetime.now(UTC)} self.requests: list[tuple[str, str]] = [] self.headers: list[dict[str, str]] = [] self.auth: list[Any] = [] @@ -212,7 +212,7 @@ def _put(self, path: str, options: dict[str, Any]) -> _Response: body = options["data"] data = body if isinstance(body, bytes) else body.read() created = path not in self.files - self.files[path] = _Resource(data, datetime.now(timezone.utc).replace(microsecond=0)) + self.files[path] = _Resource(data, datetime.now(UTC).replace(microsecond=0)) return _Response(201 if created else 204) def _get(self, path: str, options: dict[str, Any]) -> _Response: @@ -232,7 +232,7 @@ def _mkcol(self, path: str, _options: dict[str, Any]) -> _Response: return _Response(405) if not self._parent_exists(path): return _Response(409) - self.collections[path] = datetime.now(timezone.utc).replace(microsecond=0) + self.collections[path] = datetime.now(UTC).replace(microsecond=0) return _Response(201) def _relocate(self, path: str, options: dict[str, Any], *, move: bool) -> _Response: @@ -287,7 +287,7 @@ def backend(self, client: WebDAVClient) -> StorageBackend: class TestRootedWebDAVStorageContract(StorageContract): @pytest.fixture def backend(self, client: WebDAVClient, server: FakeDavServer) -> StorageBackend: - moment = datetime.now(timezone.utc) + moment = datetime.now(UTC) for collection in ("team", "team/a b", "other-team"): server.collections[f"{DAV_ROOT}/{collection}"] = moment server.files[f"{DAV_ROOT}/other-team/keep.txt"] = _Resource(b"keep", moment) @@ -442,9 +442,9 @@ def test_a_delete_the_server_could_not_finish_is_an_error( def test_mkdir_accepts_a_collection_that_appeared_meanwhile( storage: WebDAVStorage, server: FakeDavServer ) -> None: - server.collections[f"{DAV_ROOT}/dir"] = datetime.now(timezone.utc) + server.collections[f"{DAV_ROOT}/dir"] = datetime.now(UTC) storage._mkdir("dir") - server.files[f"{DAV_ROOT}/file"] = _Resource(b"x", datetime.now(timezone.utc)) + server.files[f"{DAV_ROOT}/file"] = _Resource(b"x", datetime.now(UTC)) with pytest.raises(StorageException) as caught: storage._mkdir("file") assert caught.value.__cause__.status_code == 405 @@ -532,7 +532,7 @@ def test_a_path_ending_in_white_space_is_refused( def test_a_directory_whose_name_ends_in_white_space_can_still_be_listed_and_deleted( storage: WebDAVStorage, server: FakeDavServer ) -> None: - moment = datetime.now(timezone.utc) + moment = datetime.now(UTC) server.collections[f"{DAV_ROOT}/dir"] = moment server.collections[f"{DAV_ROOT}/dir/odd "] = moment server.files[f"{DAV_ROOT}/dir/odd /a.txt"] = _Resource(b"x", moment) diff --git a/tests/test_workflow_actions.py b/tests/test_workflow_actions.py index da04b89..612a1a6 100644 --- a/tests/test_workflow_actions.py +++ b/tests/test_workflow_actions.py @@ -10,7 +10,7 @@ from __future__ import annotations import re -import sys +import tomllib from pathlib import Path import pytest @@ -18,11 +18,6 @@ from packaging.requirements import Requirement from packaging.utils import canonicalize_name -if sys.version_info >= (3, 11): - import tomllib -else: - import tomli as tomllib # declared in *.toml for Python<3.11 - _ROOT = next(p for p in Path(__file__).resolve().parents if (p / ".github" / "workflows").is_dir()) _WORKFLOWS = sorted((_ROOT / ".github" / "workflows").glob("*.yml")) _USES = re.compile(r"^\s*(?:-\s*)?uses:\s*(\S+)(.*)$")