Files
multiarch-docker-images/automatic-pdf-renamer/app/config.py
T
72f8254e10 US0: Paperless-ngx Initial Import - Reusable Components + Import Script (#9)
* Add sidecar.py module for User Story 0 (Paperless-ngx import)

Implements the sidecar data model, schema validation, atomic writes,
and I/O helpers per PRD section 7.

- Sidecar and SidecarEvent dataclasses with full PRD schema
- SidecarStatus and EventType enums
- build_sidecar(), serialize(), deserialize() functions
- validate_sidecar() with type and required field checking
- get_sidecar_key() for .meta.json suffix generation
- write_atomic() for crash-safe temp-key + copy + delete pattern
- read_sidecar(), sidecar_exists(), matching_sha256() for storage I/O

This is a reusable component for both the import script and main service.

Co-authored-by: napnap75 <napnap75@users.noreply.github.com>

* Add storage/ module with S3 and File backends

Implements the storage layer abstraction for User Story 0.

- StorageBackend ABC: defines interface for all storage implementations
- S3Storage: boto3-based backend for Garage S3 (production)
- FileStorage: filesystem-based backend for testing
- get_storage_backend() factory function
- Atomic operations: put, get, delete, copy, exists, list, head

Both backends implement the same interface, allowing seamless
switching between production S3 and local testing.

Co-authored-by: napnap75 <napnap75@users.noreply.github.com>

* Add period.py, extractor.py, and config.py reusable modules

- period.py: Period parsing from filenames and computation from dates
  - parse_period_from_filename(): Extracts month-year, quarter, year, date patterns
  - compute_period_from_date(): Computes period per template rules (granularity + offset)
  - get_period(): Main entry point with filename fallback to rule-based computation
  - validate_period_consistency(): Checks period vs emission_date + rule
  - Supports French month names (août, janvier, etc.)

- extractor.py: PDF text extraction using PyMuPDF
  - extract_first_page_text(): Extracts text from first PDF page
  - extract_and_validate(): Extracts and checks minimum text length
  - TextExtractor class for dependency injection and mocking

- config.py: Configuration management
  - S3Config, PaperlessConfig, PollerConfig, ClassifierConfig dataclasses
  - AppConfig: Main application configuration from env vars
  - TemplateRegistry: Loads and manages template YAML configurations
  - load_config(), load_templates() helper functions

All modules are reusable by both import script and main service.

Co-authored-by: napnap75 <napnap75@users.noreply.github.com>

* Add import_paperless.py script for User Story 0

Implements the standalone import script that:
- Connects to Paperless-ngx REST API (PAPERLESS_URL + token from env)
- Connects to Garage S3 bucket via boto3 (S3_ENDPOINT_URL, credentials, bucket)
- Lists all documents from Paperless via paginated /api/documents/
- Fetches document metadata and maps to sidecar fields:
  - template: first path segment of storage_path
  - emission_date: document created field
  - emitting_company: correspondent name
  - document_type: document type name
  - period: parsed from filename tail, fallback to month-year from emission_date
  - extracted_text: first page text via PyMuPDF
  - optional_fields: from Paperless labels
- Downloads PDF via /api/documents/{id}/download/
- Stores PDF at files/{template}/{original filename}
- Writes sidecar {key}.meta.json with atomic temp-key + copy pattern
- Supports resumability (skip if PDF + sidecar exist with matching SHA256)
- Dry-run flag for testing
- Limit flag for trial runs
- Summary reporting at end
- Exit code: 0 if all succeed, 1 if any failures

Dependencies: requests, boto3, pymupdf, python-dateutil

Usage: python scripts/import_paperless.py [--dry-run] [--limit N] [--verbose]
Environment: PAPERLESS_URL, PAPERLESS_TOKEN, S3_ENDPOINT_URL, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_BUCKET_NAME

Co-authored-by: napnap75 <napnap75@users.noreply.github.com>

* Add requirements.txt for import script dependencies

Lists all Python dependencies needed to run scripts/import_paperless.py:
- requests: Paperless-ngx API client
- boto3: Garage S3 client
- pymupdf: PDF text extraction
- python-dateutil: Date parsing
- PyYAML: YAML config parsing (optional)

Co-authored-by: napnap75 <napnap75@users.noreply.github.com>

* Update import script: add storage backend selection (s3/file)

- Added --storage-backend argument (choices: s3, file, default: s3)
- Added --file-base-dir argument for file storage
- Storage backend validation based on selection
- Updated help text and epilog with backend-specific env vars
- Importer class now accepts storage_backend and file_base_dir params
- _init_storage() method handles backend initialization
- Summary now shows which storage backend was used

Usage examples:
  # S3 storage (default)
  python scripts/import_paperless.py

  # File storage for testing
  python scripts/import_paperless.py --storage-backend file --file-base-dir ./test_storage

  # Explicit S3
  python scripts/import_paperless.py --storage-backend s3 --s3-endpoint ... --s3-bucket ...

Co-authored-by: napnap75 <napnap75@users.noreply.github.com>

* Add README.md for scripts directory

Comprehensive documentation for import_paperless.py script:
- Overview and features
- Requirements and installation
- Usage examples (env vars + CLI args)
- Storage backends (S3 vs file)
- Metadata mapping table
- Output structure
- Resumability notes
- Exit codes
- Example output
- Troubleshooting guide
- Development/testing notes
- Architecture overview

Helps users understand how to use the import script for User Story 0.

Co-authored-by: napnap75 <napnap75@users.noreply.github.com>

* Fix Paperless API field mapping

The Paperless-ngx API returns IDs for correspondent, document_type, and tags,
not the actual string values. This commit fixes the import script to:

- Use 'archived_file_name' instead of 'filename' for the actual filename
- Fetch correspondent name from /api/correspondents/{id}/ endpoint
- Fetch document type name from /api/document_types/{id}/ endpoint
- Fetch tag names from /api/tags/{id}/ endpoint
- Add caching for fetched entities to avoid duplicate API requests

This ensures the sidecar metadata contains actual human-readable strings
instead of numeric IDs.

Changes:
- PaperlessClient: Added get_correspondent_name(), get_document_type_name(), get_tag_name()
- PaperlessClient: Added caches for fetched entities
- get_all_documents(): Now resolves IDs to names for all fields
- Updated module docstring to document this behavior

Co-authored-by: napnap75 <napnap75@users.noreply.github.com>

* Refactor PaperlessClient: change labels to list, add storage path caching, and improve document fetching logic

---------

Co-authored-by: Vibe Nuage Agent <vibe@mistral.ai>
Co-authored-by: napnap75 <napnap75@users.noreply.github.com>
2026-10-03 10:05:49 +02:00

289 lines
8.5 KiB
Python

"""Configuration module for Automatic PDF Renamer.
Provides environment variable parsing and template YAML loading.
"""
import logging
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
logger = logging.getLogger(__name__)
@dataclass
class S3Config:
"""S3 storage configuration."""
endpoint_url: str
access_key: str
secret_key: str
bucket_name: str
region: str = "us-east-1"
@classmethod
def from_env(cls) -> "S3Config":
"""Create S3Config from environment variables."""
return cls(
endpoint_url=os.environ.get("S3_ENDPOINT_URL", ""),
access_key=os.environ.get("S3_ACCESS_KEY_ID", ""),
secret_key=os.environ.get("S3_SECRET_ACCESS_KEY", ""),
bucket_name=os.environ.get("S3_BUCKET_NAME", ""),
region=os.environ.get("S3_REGION", "us-east-1"),
)
def validate(self) -> None:
"""Validate that all required fields are set."""
if not self.endpoint_url:
raise ValueError("S3_ENDPOINT_URL environment variable is required")
if not self.access_key:
raise ValueError("S3_ACCESS_KEY_ID environment variable is required")
if not self.secret_key:
raise ValueError("S3_SECRET_ACCESS_KEY environment variable is required")
if not self.bucket_name:
raise ValueError("S3_BUCKET_NAME environment variable is required")
@dataclass
class PaperlessConfig:
"""Paperless-ngx API configuration."""
url: str
token: str
timeout: int = 30
@classmethod
def from_env(cls) -> "PaperlessConfig":
"""Create PaperlessConfig from environment variables."""
return cls(
url=os.environ.get("PAPERLESS_URL", ""),
token=os.environ.get("PAPERLESS_TOKEN", ""),
timeout=int(os.environ.get("PAPERLESS_TIMEOUT", "30")),
)
def validate(self) -> None:
"""Validate that all required fields are set."""
if not self.url:
raise ValueError("PAPERLESS_URL environment variable is required")
if not self.token:
raise ValueError("PAPERLESS_TOKEN environment variable is required")
@dataclass
class PollerConfig:
"""Inbox poller configuration."""
interval_seconds: int = 60
inbox_prefix: str = "inbox/"
pending_prefix: str = "pending/"
files_prefix: str = "files/"
@classmethod
def from_env(cls) -> "PollerConfig":
"""Create PollerConfig from environment variables."""
return cls(
interval_seconds=int(os.environ.get("POLLER_INTERVAL", "60")),
inbox_prefix=os.environ.get("INBOX_PREFIX", "inbox/"),
pending_prefix=os.environ.get("PENDING_PREFIX", "pending/"),
files_prefix=os.environ.get("FILES_PREFIX", "files/"),
)
@dataclass
class ClassifierConfig:
"""Classifier configuration."""
confidence_threshold: float = 0.8
model_dir: str = "./models"
@classmethod
def from_env(cls) -> "ClassifierConfig":
"""Create ClassifierConfig from environment variables."""
return cls(
confidence_threshold=float(os.environ.get("CONFIDENCE_THRESHOLD", "0.8")),
model_dir=os.environ.get("MODEL_DIR", "./models"),
)
@dataclass
class AppConfig:
"""Main application configuration."""
s3: S3Config = field(default_factory=S3Config)
paperless: PaperlessConfig | None = None
poller: PollerConfig = field(default_factory=PollerConfig)
classifier: ClassifierConfig = field(default_factory=ClassifierConfig)
debug: bool = False
sqlite_path: str = "./renamer.db"
@classmethod
def from_env(cls) -> "AppConfig":
"""Create AppConfig from environment variables."""
debug = os.environ.get("DEBUG", "").lower() in ("true", "1", "yes")
config = cls(
s3=S3Config.from_env(),
paperless=None, # Only needed for import script
poller=PollerConfig.from_env(),
classifier=ClassifierConfig.from_env(),
debug=debug,
sqlite_path=os.environ.get("SQLITE_PATH", "./renamer.db"),
)
# Only load paperless config if PAPERLESS_URL is set
if os.environ.get("PAPERLESS_URL"):
config.paperless = PaperlessConfig.from_env()
return config
def validate(self) -> None:
"""Validate the configuration."""
self.s3.validate()
if self.paperless:
self.paperless.validate()
@dataclass
class TemplateConfig:
"""Template configuration from YAML."""
name: str
document_family: str
document_types: list[str]
period: dict[str, Any]
optional_fields: dict[str, Any] = field(default_factory=dict)
key_pattern: str = ""
target_prefix: str = ""
@classmethod
def from_dict(cls, name: str, data: dict[str, Any]) -> "TemplateConfig":
"""Create a TemplateConfig from a YAML dict."""
return cls(
name=name,
document_family=data.get("document_family", name),
document_types=data.get("document_types", []),
period=data.get("period", {}),
optional_fields=data.get("optional_fields", {}),
key_pattern=data.get("key_pattern", ""),
target_prefix=data.get("target_prefix", ""),
)
class TemplateRegistry:
"""Registry for template configurations."""
def __init__(self):
self._templates: dict[str, TemplateConfig] = {}
def load_from_file(self, filepath: str) -> None:
"""Load templates from a YAML file.
Args:
filepath: Path to the YAML file.
"""
with open(filepath, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if not data or not isinstance(data, dict):
logger.warning(f"No templates found in {filepath}")
return
for name, config in data.items():
template = TemplateConfig.from_dict(name, config)
self._templates[name] = template
logger.info(f"Loaded template: {name}")
def load_from_directory(self, dirpath: str) -> None:
"""Load templates from all YAML files in a directory.
Args:
dirpath: Path to the directory containing YAML files.
"""
path = Path(dirpath)
if not path.exists():
logger.warning(f"Template directory {dirpath} does not exist")
return
for yaml_file in path.glob("*.yaml") + path.glob("*.yml"):
self.load_from_file(str(yaml_file))
def get(self, name: str) -> TemplateConfig | None:
"""Get a template by name.
Args:
name: The template name.
Returns:
The TemplateConfig, or None if not found.
"""
return self._templates.get(name)
def list_all(self) -> list[str]:
"""List all template names.
Returns:
List of template names.
"""
return list(self._templates.keys())
def get_period_rule(self, template_name: str) -> "PeriodRule" | None:
"""Get the period rule for a template.
Args:
template_name: The template name.
Returns:
PeriodRule, or None if not configured.
"""
from .period import PeriodRule, PeriodGranularity, PeriodOffset
template = self.get(template_name)
if not template or not template.period:
return None
return PeriodRule(
granularity=PeriodGranularity(template.period.get("granularity", "month")),
offset=PeriodOffset(template.period.get("offset", "current")),
)
def load_config() -> AppConfig:
"""Load application configuration from environment variables.
Returns:
AppConfig instance.
"""
return AppConfig.from_env()
def load_templates(template_path: str | None = None) -> TemplateRegistry:
"""Load template configurations.
Args:
template_path: Path to YAML file or directory. If None, uses TEMPLATE_PATH env var.
Returns:
TemplateRegistry with loaded templates.
"""
registry = TemplateRegistry()
if template_path is None:
template_path = os.environ.get("TEMPLATE_PATH", "./templates.yaml")
if not template_path:
logger.warning("No template path configured")
return registry
path = Path(template_path)
if path.is_file():
registry.load_from_file(str(path))
elif path.is_dir():
registry.load_from_directory(str(path))
else:
logger.warning(f"Template path {template_path} does not exist")
return registry