From af9982eb66c5322d028c7561b97c2861ae6a5f0b Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 2 Nov 2026 21:56:41 +0800 Subject: [PATCH 1/2] Require Python 3.11 and stop supporting 3.10 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. requires-python is >=3.11, the 3.10 classifier is gone, the CI matrices start at 3.11, the docs give 3.11 as the floor, and the locks are regenerated with their recorded commands for 3.11 and lose their 3.10-only entries; no version on 3.11 or newer changes. --- .github/requirements/integration.in | 1 - .github/workflows/ci-dev.yml | 2 +- .github/workflows/ci-stable.yml | 2 +- CLAUDE.md | 4 ++-- README.md | 2 +- README.zh-CN.md | 2 +- README.zh-TW.md | 2 +- architecture.md | 2 +- automation_file/core/plugins.py | 2 +- dev.toml | 3 +-- docs/updates/2026-11.md | 7 +++++++ docs/updates/README.md | 3 ++- mypy.ini | 2 +- requirements.txt | 1 - ruff.toml | 2 +- stable.toml | 3 +-- tests/test_stable_release.py | 4 ++-- 17 files changed, 24 insertions(+), 20 deletions(-) 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/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/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..b4fefb2 100644 --- a/docs/updates/2026-11.md +++ b/docs/updates/2026-11.md @@ -14,3 +14,10 @@ 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. +- **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..6b59b41 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] 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/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): From 99c62d283c6dc216c601eea638fecc3d7f24a045 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Mon, 2 Nov 2026 22:05:14 +0800 Subject: [PATCH 2/2] Apply ruff's py311 rules The py311 target enables pyupgrade rules that 3.10 kept off: datetime.UTC replaces timezone.utc, and the version checks that only did anything on 3.10 are gone. UP042 is ignored: StrEnum would change what str() returns, which is a separate decision from the 3.11 floor. --- automation_file/app/dashboard_service.py | 4 ++-- automation_file/audit/record.py | 8 ++++---- automation_file/audit/store.py | 4 ++-- automation_file/core/config.py | 10 ++-------- automation_file/core/manifest.py | 2 +- automation_file/events/model.py | 4 ++-- automation_file/integrity/manifest.py | 4 ++-- automation_file/integrity/monitor.py | 4 ++-- automation_file/integrity/remediation.py | 4 ++-- automation_file/integrity/report.py | 4 ++-- automation_file/integrity/snapshot.py | 8 ++++---- automation_file/pipeline/model.py | 4 ++-- automation_file/scheduler/cron.py | 2 +- automation_file/scheduler/manager.py | 2 +- automation_file/scheduler/runs.py | 4 ++-- automation_file/storage/dropbox_storage.py | 6 +++--- automation_file/storage/fsspec_storage.py | 8 ++++---- automation_file/storage/ftp_storage.py | 4 ++-- automation_file/storage/local_storage.py | 4 ++-- automation_file/storage/memory_storage.py | 4 ++-- automation_file/storage/sftp_storage.py | 4 ++-- automation_file/storage/smb_storage.py | 4 ++-- automation_file/storage/timestamps.py | 4 ++-- automation_file/storage/webdav_storage.py | 6 +++--- automation_file/utils/rotate.py | 10 +++++----- docs/updates/2026-11.md | 1 + ruff.toml | 3 +++ tests/drive_stand_in.py | 6 +++--- tests/ftp_stand_in.py | 8 ++++---- tests/graph_stand_in.py | 6 +++--- tests/scheduler_kit.py | 4 ++-- tests/test_audit_v2.py | 6 +++--- tests/test_dev_toml_parity.py | 7 +------ tests/test_integrity_detector.py | 4 ++-- tests/test_integrity_object_store.py | 4 ++-- tests/test_integrity_snapshot.py | 8 ++++---- tests/test_optional_dependencies.py | 6 +----- tests/test_pipeline_store.py | 4 ++-- tests/test_rotate.py | 4 ++-- tests/test_scheduler_pipeline.py | 3 +-- tests/test_scheduler_runs.py | 4 ++-- tests/test_scheduler_triggers.py | 4 +--- tests/test_storage_azure.py | 12 +++++------- tests/test_storage_dropbox.py | 8 ++++---- tests/test_storage_fsspec.py | 12 ++++++------ tests/test_storage_gdrive.py | 4 ++-- tests/test_storage_s3.py | 20 +++++++------------- tests/test_storage_sftp.py | 4 ++-- tests/test_storage_smb.py | 6 +++--- tests/test_storage_timestamps.py | 20 ++++++++++---------- tests/test_storage_types.py | 4 ++-- tests/test_storage_webdav.py | 16 ++++++++-------- tests/test_workflow_actions.py | 7 +------ 53 files changed, 145 insertions(+), 172 deletions(-) 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/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/docs/updates/2026-11.md b/docs/updates/2026-11.md index b4fefb2..28c163e 100644 --- a/docs/updates/2026-11.md +++ b/docs/updates/2026-11.md @@ -20,4 +20,5 @@ Index and query commands: [README.md](README.md). New entries go at the end. - **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/ruff.toml b/ruff.toml index 6b59b41..ad60503 100644 --- a/ruff.toml +++ b/ruff.toml @@ -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/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_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+)(.*)$")