Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ ignore-paths=docs/requirements\.txt
# C0114/C0115/C0116 — docstring requirements are enforced by review, not lint.
# C0415 — ``import-outside-toplevel`` is used deliberately for
# lazy imports of heavy / optional modules (see CLAUDE.md).
# R0401 — ``cyclic-import``; the cloud-backend packages depend on
# ``core.action_registry`` for the ``ActionRegistry`` type
# while the registry lazy-imports the backend packages
# inside ``_register_cloud_backends``. The runtime flow is
# sound; pylint's static analysis flags it as a cycle.
# R0801 — ``duplicate-code``; the remote backend client wrappers
# (S3 / Azure / Dropbox / FTP / SFTP) intentionally share
# the "lazy ``later_init`` + ``require_client``" skeleton.
# A shared base class would add indirection without value.
# R0903 — too-few-public-methods; dataclasses and frozen option
# objects are allowed to have no methods.
# W0511 — TODO/FIXME markers; tracked via issues, not lint.
Expand All @@ -19,6 +28,8 @@ disable=
C0115,
C0116,
C0415,
R0401,
R0801,
R0903,
W0511

Expand Down
17 changes: 12 additions & 5 deletions automation_file/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,6 @@
from pathlib import Path
from typing import Any

if sys.version_info >= (3, 11):
import tomllib
else: # pragma: no cover - exercised only on Python 3.10 runners
import tomli as tomllib

from automation_file.core.secrets import (
ChainedSecretProvider,
default_provider,
Expand All @@ -59,6 +54,17 @@
)


def _load_tomllib() -> Any:
if sys.version_info >= (3, 11):
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


class ConfigException(FileAutomationException):
"""Raised when the config file is missing, unparseable, or malformed."""

Expand All @@ -81,6 +87,7 @@ def load(
config_path = Path(path)
if not config_path.is_file():
raise ConfigException(f"config file not found: {config_path}")
tomllib = _load_tomllib()
try:
raw = tomllib.loads(config_path.read_text(encoding="utf-8"))
except (OSError, tomllib.TOMLDecodeError) as err:
Expand Down
1 change: 1 addition & 0 deletions automation_file/core/dag_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ def execute_action_dag(
def _run_action(action: list) -> Any:
# Use the single-action path so exceptions surface as real exceptions
# rather than being swallowed by execute_action's per-action try/except.
# pylint: disable=protected-access # intra-package call to the documented Template Method
return default_executor._execute_event(action)


Expand Down
2 changes: 1 addition & 1 deletion tests/test_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def test_purge_removes_old_rows(tmp_path: Path) -> None:
# Backdate the row so it's older than the cutoff.
import sqlite3

with sqlite3.connect(log._db_path) as conn:
with sqlite3.connect(log._db_path) as conn: # pylint: disable=protected-access
conn.execute("UPDATE audit SET ts = ? WHERE action = 'old'", (time.time() - 3600,))
conn.commit()
log.record("fresh", {})
Expand Down
1 change: 1 addition & 0 deletions tests/test_callback_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def test_callback_runs_after_trigger() -> None:

result = executor.callback_function(
trigger_function_name="trigger",
# pylint: disable-next=unnecessary-lambda # kwargs-style call; list.append rejects kwargs
callback_function=lambda tag: seen.append(tag),
callback_function_param={"tag": "done"},
value="hi",
Expand Down
2 changes: 1 addition & 1 deletion tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ def test_empty_config_yields_no_sinks(tmp_path: Path) -> None:
path = tmp_path / "c.toml"
_write_toml(path, "# nothing configured\n")
config = AutomationConfig.load(path)
assert config.notification_sinks() == []
assert not config.notification_sinks()


def test_file_secret_provider_resolved_from_config(tmp_path: Path) -> None:
Expand Down
2 changes: 2 additions & 0 deletions tests/test_cross_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
local-to-local round trips, and error paths.
"""

# pylint: disable=protected-access # tests deliberately probe _split / _split_bucket

from __future__ import annotations

from pathlib import Path
Expand Down
2 changes: 1 addition & 1 deletion tests/test_dir_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def test_rename_dir_missing_source(tmp_path: Path) -> None:
dir_ops.rename_dir(str(tmp_path / "missing"), str(tmp_path / "out"))


def test_remove_dir_tree(tmp_path: Path, sample_dir: Path) -> None:
def test_remove_dir_tree(sample_dir: Path) -> None:
assert dir_ops.remove_dir_tree(str(sample_dir)) is True
assert not sample_dir.exists()

Expand Down
4 changes: 2 additions & 2 deletions tests/test_executor_extras.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,15 @@ def test_validate_first_aborts_before_execution() -> None:
[["count"], ["count"], ["does_not_exist"]],
validate_first=True,
)
assert calls == [] # nothing ran because validation failed first
assert not calls # nothing ran because validation failed first


def test_dry_run_does_not_invoke_commands() -> None:
executor = _fresh_executor()
calls: list[int] = []
executor.registry.register("count", lambda: calls.append(1) or 1)
results = executor.execute_action([["count"], ["count"]], dry_run=True)
assert calls == []
assert not calls
assert all(value.startswith("dry_run:") for value in results.values())


Expand Down
5 changes: 3 additions & 2 deletions tests/test_fast_find.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def test_scandir_find_returns_absolute_paths(tmp_path: Path) -> None:


def test_scandir_find_handles_missing_root(tmp_path: Path) -> None:
assert list(scandir_find(tmp_path / "does-not-exist")) == []
assert not list(scandir_find(tmp_path / "does-not-exist"))


def test_fast_find_falls_back_to_scandir_when_index_disabled(tmp_path: Path) -> None:
Expand All @@ -79,7 +79,7 @@ def test_fast_find_falls_back_to_scandir_when_index_disabled(tmp_path: Path) ->


def test_fast_find_returns_empty_for_missing_root(tmp_path: Path) -> None:
assert fast_find(tmp_path / "missing") == []
assert not fast_find(tmp_path / "missing")


def test_fast_find_respects_limit(tmp_path: Path) -> None:
Expand Down Expand Up @@ -152,6 +152,7 @@ def fake_capture(argv: list[str]) -> list[str]:

(tmp_path / "a.log").write_text("a", encoding="utf-8")
monkeypatch.setattr(ff, "_capture", fake_capture)
# pylint: disable-next=protected-access # exercises the indexer fallback helper
result = ff._run_indexer(indexer, tmp_path, "*.log", True, None)
assert result == [str(tmp_path / "a.log")]
assert captured["argv"][0] == indexer
2 changes: 1 addition & 1 deletion tests/test_file_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def test_copy_all_file_to_dir_missing(tmp_path: Path) -> None:
file_ops.copy_all_file_to_dir(str(tmp_path / "missing"), str(tmp_path))


def test_rename_file_unique_names(tmp_path: Path, sample_dir: Path) -> None:
def test_rename_file_unique_names(sample_dir: Path) -> None:
"""Regression: original impl renamed every match to the same name, overwriting."""
assert file_ops.rename_file(str(sample_dir), "renamed", file_extension="txt") is True
root_names = sorted(p.name for p in sample_dir.iterdir() if p.is_file())
Expand Down
2 changes: 1 addition & 1 deletion tests/test_fim.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def test_extras_ignored_by_default(tmp_path: Path) -> None:
summary = monitor.check_once()
assert "new.txt" in summary["extra"]
assert summary["ok"] is True
assert recorder.messages == []
assert not recorder.messages


def test_alert_on_extra_flag(tmp_path: Path) -> None:
Expand Down
12 changes: 9 additions & 3 deletions tests/test_ftp_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ def nlst(self, path: str) -> list[str]:
return self.listings.get(path, [])


@pytest.fixture
def fake_ftp(monkeypatch: pytest.MonkeyPatch) -> _FakeFTP:
@pytest.fixture(name="fake_ftp")
def _fake_ftp(monkeypatch: pytest.MonkeyPatch) -> _FakeFTP:
fake = _FakeFTP()
monkeypatch.setattr(ftp_instance, "_ftp", fake, raising=False)
return fake
Expand Down Expand Up @@ -94,6 +94,7 @@ def test_upload_file_missing_source_raises(fake_ftp: _FakeFTP, tmp_path: Path) -
missing = tmp_path / "nope.txt"
with pytest.raises(FileNotExistsException):
upload_ops.ftp_upload_file(str(missing), "remote/nope.txt")
assert not fake_ftp.stored


def test_upload_file_stores_payload(fake_ftp: _FakeFTP, tmp_path: Path) -> None:
Expand All @@ -110,12 +111,17 @@ def test_upload_dir_uploads_all_files(fake_ftp: _FakeFTP, tmp_path: Path) -> Non
(tmp_path / "sub" / "b.txt").write_bytes(b"B")
uploaded = upload_ops.ftp_upload_dir(str(tmp_path), "root")
assert sorted(uploaded) == ["root/a.txt", "root/sub/b.txt"]
assert {cmd for cmd, _ in fake_ftp.stored} == {
"STOR root/a.txt",
"STOR root/sub/b.txt",
}


def test_download_file_writes_target(fake_ftp: _FakeFTP, tmp_path: Path) -> None:
target = tmp_path / "out" / "file.bin"
assert download_ops.ftp_download_file("remote/file.bin", str(target)) is True
assert target.read_bytes() == b"payload"
assert fake_ftp.retrieved == ["RETR remote/file.bin"]


def test_delete_path_calls_delete(fake_ftp: _FakeFTP) -> None:
Expand All @@ -124,7 +130,7 @@ def test_delete_path_calls_delete(fake_ftp: _FakeFTP) -> None:


def test_list_dir_returns_names(fake_ftp: _FakeFTP) -> None:
assert list_ops.ftp_list_dir(".") == ["a.txt", "b.txt"]
assert list_ops.ftp_list_dir(".") == fake_ftp.listings["."]


def test_close_on_fresh_client_is_noop() -> None:
Expand Down
6 changes: 3 additions & 3 deletions tests/test_grep.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
from automation_file import GrepException, build_default_registry, grep_files, iter_grep


@pytest.fixture
def sample_tree(tmp_path: Path) -> Path:
@pytest.fixture(name="sample_tree")
def _sample_tree(tmp_path: Path) -> Path:
(tmp_path / "a.txt").write_text("hello world\nfoo bar\nHELLO AGAIN\n", encoding="utf-8")
(tmp_path / "b.log").write_text("nothing to see here\n", encoding="utf-8")
sub = tmp_path / "sub"
Expand Down Expand Up @@ -46,7 +46,7 @@ def test_grep_invalid_regex_raises(sample_tree: Path) -> None:

def test_grep_glob_filter(sample_tree: Path) -> None:
hits = grep_files(str(sample_tree), "hello", glob="*.log")
assert hits == []
assert not hits


def test_grep_empty_pattern_raises(sample_tree: Path) -> None:
Expand Down
22 changes: 13 additions & 9 deletions tests/test_http_download_resume.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ def __init__(self, chunks: list[bytes]) -> None:
def raise_for_status(self) -> None: ...

def iter_content(self, chunk_size: int) -> list[bytes]:
del chunk_size # matches requests.Response API; value not needed in the fake
return list(self._chunks)


@pytest.fixture
def patch_validator(monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.fixture(name="patch_validator")
def _patch_validator(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(http_download, "validate_http_url", lambda _url: None, raising=True)


Expand All @@ -42,8 +43,9 @@ def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
monkeypatch.setattr(requests, "get", fake_get, raising=True)


@pytest.mark.usefixtures("patch_validator")
def test_download_resume_sends_range_header_and_appends(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, patch_validator: None
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
target = tmp_path / "big.bin"
part = tmp_path / "big.bin.part"
Expand All @@ -60,8 +62,9 @@ def test_download_resume_sends_range_header_and_appends(
assert captured["headers"] == {"Range": "bytes=8-"}


@pytest.mark.usefixtures("patch_validator")
def test_download_without_resume_skips_part_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, patch_validator: None
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
target = tmp_path / "big.bin"
captured: dict[str, Any] = {}
Expand All @@ -75,8 +78,9 @@ def test_download_without_resume_skips_part_file(
assert captured["headers"] is None


@pytest.mark.usefixtures("patch_validator")
def test_download_fresh_resume_without_existing_part(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, patch_validator: None
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
target = tmp_path / "big.bin"
captured: dict[str, Any] = {}
Expand All @@ -89,9 +93,8 @@ def test_download_fresh_resume_without_existing_part(
assert captured["headers"] is None # no Range since nothing pre-downloaded


def test_download_verifies_sha256_match(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, patch_validator: None
) -> None:
@pytest.mark.usefixtures("patch_validator")
def test_download_verifies_sha256_match(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
payload = b"verified"
target = tmp_path / "v.bin"
captured: dict[str, Any] = {}
Expand All @@ -106,8 +109,9 @@ def test_download_verifies_sha256_match(
assert target.exists()


@pytest.mark.usefixtures("patch_validator")
def test_download_checksum_mismatch_removes_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, patch_validator: None
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
target = tmp_path / "v.bin"
captured: dict[str, Any] = {}
Expand Down
3 changes: 2 additions & 1 deletion tests/test_http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ def _post(url: str, payload: object, headers: dict[str, str] | None = None) -> t
data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(url, data=data, headers=headers or {}, method="POST")
try:
with urllib.request.urlopen(request, timeout=3) as resp: # nosec B310 - URL built from a loopback test server address
# B310 suppressed: URL built from a loopback test server address.
with urllib.request.urlopen(request, timeout=3) as resp: # nosec B310
return resp.status, resp.read().decode("utf-8")
except urllib.error.HTTPError as error:
return error.code, error.read().decode("utf-8")
Expand Down
4 changes: 2 additions & 2 deletions tests/test_json_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
)


@pytest.fixture
def sample_json(tmp_path: Path) -> Path:
@pytest.fixture(name="sample_json")
def _sample_json(tmp_path: Path) -> Path:
path = tmp_path / "config.json"
path.write_text(
json.dumps(
Expand Down
4 changes: 2 additions & 2 deletions tests/test_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ def test_write_and_verify_round_trip(tmp_path: Path) -> None:
result = verify_manifest(root, manifest_path)
assert result["ok"] is True
assert set(result["matched"]) == {"a.txt", "nested/b.txt"}
assert result["missing"] == []
assert result["modified"] == []
assert not result["missing"]
assert not result["modified"]


def test_verify_detects_modified_file(tmp_path: Path) -> None:
Expand Down
10 changes: 8 additions & 2 deletions tests/test_metrics.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Tests for the Prometheus metrics exporter."""

# pylint: disable=protected-access # prometheus_client exposes counter state via ._value

from __future__ import annotations

import urllib.request
Expand Down Expand Up @@ -82,8 +84,12 @@
host, port = server.server_address
try:
url = insecure_url("http", f"{host}:{port}/other")
with pytest.raises(urllib.error.HTTPError) as info:
urllib.request.urlopen(url, timeout=3) # nosec B310 - loopback test server
with (
pytest.raises(urllib.error.HTTPError) as info,
# nosec B310 - loopback test server
urllib.request.urlopen(url, timeout=3), # nosec B310
):
pass

Check warning on line 92 in tests/test_metrics.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Either remove or fill this block of code.

See more on https://sonarcloud.io/project/issues?id=Integration-Automation_FileAutomation&issues=AZ2_hd4ebjk09M001ly5&open=AZ2_hd4ebjk09M001ly5&pullRequest=58
assert info.value.code == 404
finally:
server.shutdown()
Expand Down
4 changes: 2 additions & 2 deletions tests/test_rotate.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ def _make_file(path: Path, days_ago: int) -> None:
os.utime(path, (when, when))


@pytest.fixture
def backup_dir(tmp_path: Path) -> Path:
@pytest.fixture(name="backup_dir")
def _backup_dir(tmp_path: Path) -> Path:
# Create 30 daily backups
for day in range(30):
_make_file(tmp_path / f"backup-{day:02d}.zip", days_ago=day)
Expand Down
2 changes: 2 additions & 0 deletions tests/test_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ def test_scheduler_skips_overlap_by_default() -> None:
action_list=[["FA_schedule_list"]],
)
job.running = True # pretend previous run is still in flight
# pylint: disable-next=protected-access # exercises the overlap-guard path
engine._dispatch(job, dt.datetime(2026, 4, 21, 12, 0))
assert job.skipped == 1
assert job.runs == 0
Expand All @@ -189,6 +190,7 @@ def test_scheduler_allows_overlap_when_opted_in() -> None:
allow_overlap=True,
)
job.running = True
# pylint: disable-next=protected-access # exercises the overlap-allow path
engine._dispatch(job, dt.datetime(2026, 4, 21, 12, 0))
assert job.runs == 1
assert job.skipped == 0
Expand Down
Loading
Loading