mirror of
https://github.com/napnap75/multiarch-docker-images.git
synced 2026-11-11 00:22:12 +01:00
* 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>
289 lines
8.5 KiB
Python
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
|