4 changed files with 195 additions and 8 deletions
@ -0,0 +1,71 @@ |
|||||||
|
"""Safe discovery of local source documents for the guided import workflow.""" |
||||||
|
|
||||||
|
from dataclasses import dataclass |
||||||
|
from hashlib import sha256 |
||||||
|
from pathlib import Path |
||||||
|
|
||||||
|
from django.conf import settings |
||||||
|
|
||||||
|
from .parsers import MAX_BYTES, ParseError, parse_document |
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True) |
||||||
|
class DiscoveredFile: |
||||||
|
name: str |
||||||
|
document_hash: str |
||||||
|
profile: str | None |
||||||
|
sections: tuple[str, ...] |
||||||
|
code: str | None |
||||||
|
|
||||||
|
|
||||||
|
_PROFILES_BY_SUFFIX = { |
||||||
|
".csv": ("BANK_CSV_V1",), |
||||||
|
".txt": ("OUROCARD_FULL_V1", "OUROCARD_PARTIAL_V1"), |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
def _profile_and_sections(path: Path, data: bytes) -> tuple[str | None, tuple[str, ...]]: |
||||||
|
for profile in _PROFILES_BY_SUFFIX.get(path.suffix.casefold(), ()): |
||||||
|
try: |
||||||
|
result = parse_document(data, profile) |
||||||
|
except ParseError: |
||||||
|
continue |
||||||
|
items = tuple(item for item in result.items if not item.errors) |
||||||
|
if items: |
||||||
|
return profile, tuple(dict.fromkeys(item.section for item in items)) |
||||||
|
return None, () |
||||||
|
|
||||||
|
|
||||||
|
def _read_discovered_file(path: Path) -> DiscoveredFile: |
||||||
|
try: |
||||||
|
with path.open("rb") as stream: |
||||||
|
data = stream.read(MAX_BYTES + 1) |
||||||
|
except OSError: |
||||||
|
return DiscoveredFile(path.name, "", None, (), "UNREADABLE_FILE") |
||||||
|
if len(data) > MAX_BYTES: |
||||||
|
return DiscoveredFile(path.name, "", None, (), "INPUT_TOO_LARGE") |
||||||
|
document_hash = sha256(data).hexdigest() |
||||||
|
if path.suffix.casefold() not in _PROFILES_BY_SUFFIX: |
||||||
|
return DiscoveredFile(path.name, document_hash, None, (), "UNSUPPORTED_FILE") |
||||||
|
profile, sections = _profile_and_sections(path, data) |
||||||
|
if profile is None: |
||||||
|
return DiscoveredFile(path.name, document_hash, None, (), "UNRECOGNIZED_LAYOUT") |
||||||
|
return DiscoveredFile(path.name, document_hash, profile, sections, None) |
||||||
|
|
||||||
|
|
||||||
|
def discover_imports() -> tuple[DiscoveredFile, ...]: |
||||||
|
"""Return safe metadata for direct regular files under ``IMPORT_ROOT``.""" |
||||||
|
root = Path(settings.IMPORT_ROOT).resolve() |
||||||
|
try: |
||||||
|
entries = root.iterdir() |
||||||
|
except OSError: |
||||||
|
return () |
||||||
|
discovered = [] |
||||||
|
for path in entries: |
||||||
|
try: |
||||||
|
if path.is_symlink() or not path.is_file(): |
||||||
|
continue |
||||||
|
except OSError: |
||||||
|
continue |
||||||
|
discovered.append(_read_discovered_file(path)) |
||||||
|
return tuple(sorted(discovered, key=lambda row: row.name)) |
||||||
@ -0,0 +1,61 @@ |
|||||||
|
"""Synthetic checks for source-safe local import discovery.""" |
||||||
|
|
||||||
|
from hashlib import sha256 |
||||||
|
|
||||||
|
from django.test import override_settings |
||||||
|
|
||||||
|
from ingestion.guided_imports import discover_imports |
||||||
|
|
||||||
|
|
||||||
|
HEADER = b"Data,Dependencia,Historico,Documento,Tipo,Valor\n" |
||||||
|
|
||||||
|
|
||||||
|
def test_discovery_returns_only_safe_metadata_for_direct_supported_files(tmp_path): |
||||||
|
bank = HEADER + b"01/02/2026,,SYNTHETIC BANK ROW,,,12.34\n" |
||||||
|
full = b"1 - SYNTHETIC OWNER\n" + b"15.02.2026" + b"SYNTHETIC CARD ROW".ljust(37) + b"BR 12,34 0,00\n" |
||||||
|
nested = tmp_path / "nested" |
||||||
|
nested.mkdir() |
||||||
|
(tmp_path / "bank.csv").write_bytes(bank) |
||||||
|
(tmp_path / "card.txt").write_bytes(full) |
||||||
|
(nested / "hidden.csv").write_bytes(bank) |
||||||
|
|
||||||
|
with override_settings(IMPORT_ROOT=tmp_path): |
||||||
|
rows = discover_imports() |
||||||
|
|
||||||
|
assert [(row.name, row.profile, row.sections, row.code) for row in rows] == [ |
||||||
|
("bank.csv", "BANK_CSV_V1", ("1",), None), |
||||||
|
("card.txt", "OUROCARD_FULL_V1", ("1",), None), |
||||||
|
] |
||||||
|
assert [row.document_hash for row in rows] == [sha256(bank).hexdigest(), sha256(full).hexdigest()] |
||||||
|
assert all(not hasattr(row, "source_text") for row in rows) |
||||||
|
|
||||||
|
|
||||||
|
def test_discovery_reports_safe_codes_for_direct_unsupported_or_invalid_files(tmp_path): |
||||||
|
(tmp_path / "unknown.ofx").write_bytes(b"SYNTHETIC OFX") |
||||||
|
(tmp_path / "statement.pdf").write_bytes(b"%PDF-SYNTHETIC") |
||||||
|
(tmp_path / "invalid.txt").write_bytes(b"SYNTHETIC UNRECOGNIZED TEXT") |
||||||
|
(tmp_path / "oversize.csv").write_bytes(b"x" * (10 * 1024 * 1024 + 1)) |
||||||
|
|
||||||
|
with override_settings(IMPORT_ROOT=tmp_path): |
||||||
|
rows = discover_imports() |
||||||
|
|
||||||
|
assert [(row.name, row.profile, row.sections, row.code) for row in rows] == [ |
||||||
|
("invalid.txt", None, (), "UNRECOGNIZED_LAYOUT"), |
||||||
|
("oversize.csv", None, (), "INPUT_TOO_LARGE"), |
||||||
|
("statement.pdf", None, (), "UNSUPPORTED_FILE"), |
||||||
|
("unknown.ofx", None, (), "UNSUPPORTED_FILE"), |
||||||
|
] |
||||||
|
assert all(not hasattr(row, "source_text") for row in rows) |
||||||
|
|
||||||
|
|
||||||
|
def test_discovery_ignores_symlinks_and_non_files(tmp_path): |
||||||
|
bank = HEADER + b"01/02/2026,,SYNTHETIC BANK ROW,,,12.34\n" |
||||||
|
target = tmp_path / "target.csv" |
||||||
|
target.write_bytes(bank) |
||||||
|
(tmp_path / "linked.csv").symlink_to(target) |
||||||
|
(tmp_path / "directory.csv").mkdir() |
||||||
|
|
||||||
|
with override_settings(IMPORT_ROOT=tmp_path): |
||||||
|
rows = discover_imports() |
||||||
|
|
||||||
|
assert [row.name for row in rows] == ["target.csv"] |
||||||
Loading…
Reference in new issue