Release v0.1.2

This commit is contained in:
2026-06-25 19:58:20 +02:00
parent 39ad3500e2
commit 23318c709a
98 changed files with 3432 additions and 2339 deletions

View File

@@ -2,6 +2,8 @@ from __future__ import annotations
import fnmatch
import re
import time
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
from typing import Any, Iterable
@@ -69,6 +71,10 @@ class ResolvedAttachment(BaseModel):
zip_mode: ZipRuleMode = ZipRuleMode.INHERIT
zip_archive_id: str | None = None
zip_filename: str | None = None
message_filename_template: str | None = None
zip_entry_name_template: str | None = None
message_filenames: list[str] = Field(default_factory=list)
zip_entry_names: list[str] = Field(default_factory=list)
status: AttachmentMatchStatus
behavior: Behavior | None = None
matches: list[str] = Field(default_factory=list)
@@ -99,6 +105,7 @@ class AttachmentResolutionReport(BaseModel):
attachments_base_path: str
entries_count: int
entries: list[EntryAttachmentResolution] = Field(default_factory=list)
profile: dict[str, Any] = Field(default_factory=dict)
@property
def ready_count(self) -> int:
@@ -304,7 +311,55 @@ def _resolve_attachment_directory(
return (legacy_root / rendered_base_dir).resolve(), None
def _match_files(directory: Path, file_filter: str, include_subdirs: bool) -> list[Path]:
@dataclass(slots=True)
class AttachmentMatchIndex:
_files_by_directory: dict[tuple[str, bool], list[Path]] = field(default_factory=dict)
filesystem_scans: int = 0
indexed_files: int = 0
fallback_globs: int = 0
def _directory_key(self, directory: Path, *, recursive: bool) -> tuple[str, bool]:
return str(directory.resolve()), recursive
def _indexed_files(self, directory: Path, *, recursive: bool) -> list[Path]:
key = self._directory_key(directory, recursive=recursive)
if key not in self._files_by_directory:
iterator = directory.rglob("*") if recursive else directory.iterdir()
files = sorted(path for path in iterator if path.is_file())
self._files_by_directory[key] = files
self.filesystem_scans += 1
self.indexed_files += len(files)
return self._files_by_directory[key]
def iter_files(self, directory: Path, *, recursive: bool = True) -> list[Path]:
if not directory.exists() or not directory.is_dir():
return []
return list(self._indexed_files(directory, recursive=recursive))
def match_files(self, directory: Path, file_filter: str, include_subdirs: bool) -> list[Path]:
if not directory.exists() or not directory.is_dir():
return []
if not include_subdirs and ("/" in file_filter or "\\" in file_filter):
# Preserve pathlib.glob semantics for legacy filters that include a
# relative path segment instead of only a filename pattern.
self.fallback_globs += 1
return sorted(path for path in directory.glob(file_filter) if path.is_file())
return sorted(path for path in self._indexed_files(directory, recursive=include_subdirs) if fnmatch.fnmatch(path.name, file_filter))
def stats(self, *, duration_ms: float, rules_resolved: int) -> dict[str, Any]:
return {
"duration_ms": round(duration_ms, 2),
"rules_resolved": rules_resolved,
"indexed_directories": len(self._files_by_directory),
"filesystem_scans": self.filesystem_scans,
"indexed_files": self.indexed_files,
"fallback_globs": self.fallback_globs,
}
def _match_files(directory: Path, file_filter: str, include_subdirs: bool, match_index: AttachmentMatchIndex | None = None) -> list[Path]:
if match_index is not None:
return match_index.match_files(directory, file_filter, include_subdirs)
if not directory.exists() or not directory.is_dir():
return []
if include_subdirs:
@@ -343,6 +398,7 @@ def _resolve_one_config(
scope: AttachmentScope,
index: int,
config: AttachmentConfig,
match_index: AttachmentMatchIndex | None = None,
) -> ResolvedAttachment:
rendered_base_dir = _rendered_base_dir(config, values)
rendered_file_filter = _render_template(config.file_filter, values)
@@ -352,7 +408,7 @@ def _resolve_one_config(
attachment_config=config,
rendered_base_dir=rendered_base_dir,
)
matches = _match_files(directory, rendered_file_filter, config.include_subdirs)
matches = _match_files(directory, rendered_file_filter, config.include_subdirs, match_index)
allow_multiple = _rule_allows_multiple(config, rendered_file_filter)
issues: list[AttachmentIssue] = []
@@ -389,6 +445,8 @@ def _resolve_one_config(
zip_mode=config.zip.mode,
zip_archive_id=archive.id if archive else None,
zip_filename=_render_zip_filename(archive, values),
message_filename_template=config.message_filename_template,
zip_entry_name_template=config.zip_entry_name_template,
status=status,
behavior=behavior,
matches=[str(path) for path in matches],
@@ -417,6 +475,7 @@ def resolve_entry_attachments(
campaign_file: str | Path,
entry: EntryConfig,
entry_index: int,
match_index: AttachmentMatchIndex | None = None,
) -> EntryAttachmentResolution:
values = build_template_values(config, entry)
resolved: list[ResolvedAttachment] = []
@@ -431,6 +490,7 @@ def resolve_entry_attachments(
scope=scope,
index=index,
config=attachment_config,
match_index=match_index,
)
)
@@ -448,10 +508,13 @@ def resolve_entry_attachments(
def resolve_campaign_attachments(config: CampaignConfig, *, campaign_file: str | Path) -> AttachmentResolutionReport:
entries = load_campaign_entries(config, campaign_file=campaign_file)
base_path = _resolve_path(campaign_file, config.attachments.base_paths[0].path if config.attachments.base_paths else config.attachments.base_path)
started = time.perf_counter()
match_index = AttachmentMatchIndex()
resolved_entries = [
resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=index)
resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=index, match_index=match_index)
for index, entry in enumerate(entries, start=1)
]
rules_resolved = sum(len(entry.attachments) for entry in resolved_entries)
return AttachmentResolutionReport(
campaign_id=config.campaign.id,
campaign_name=config.campaign.name,
@@ -459,4 +522,5 @@ def resolve_campaign_attachments(config: CampaignConfig, *, campaign_file: str |
attachments_base_path=str(base_path),
entries_count=len(entries),
entries=resolved_entries,
profile=match_index.stats(duration_ms=(time.perf_counter() - started) * 1000, rules_resolved=rules_resolved),
)