ipaapi 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ipaapi/triage.py ADDED
@@ -0,0 +1,136 @@
1
+ """Sort input files into ``submitted/`` and ``failed/`` as a batch is processed.
2
+
3
+ Submitting dozens of files against a finite analysis allowance means a run can
4
+ stop partway through for a reason that is nobody's fault. Re-running then risks
5
+ resubmitting work that already succeeded, burning allowance twice.
6
+
7
+ So each file is moved as its fate becomes known:
8
+
9
+ ``submitted/``
10
+ IPA accepted it and returned analysis IDs. Done; do not resubmit.
11
+
12
+ ``failed/``
13
+ The file itself is the problem -- it failed validation, or IPA rejected it
14
+ for a reason that will recur. A ``.error.txt`` note is written beside it
15
+ explaining what went wrong.
16
+
17
+ left in place
18
+ The allowance was exhausted. Nothing is wrong with the file, so it stays
19
+ where it is and is picked up by the next run.
20
+
21
+ Files are moved, never copied, so the source directory shrinks to exactly the
22
+ work still outstanding.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import pathlib
28
+ import shutil
29
+ from typing import List, Optional
30
+
31
+ __all__ = ["Triage", "SUBMITTED_DIRNAME", "FAILED_DIRNAME", "TRIAGE_DIRNAMES"]
32
+
33
+ SUBMITTED_DIRNAME = "submitted"
34
+ FAILED_DIRNAME = "failed"
35
+
36
+ #: Never treated as input, so a second run does not pick up its own output.
37
+ TRIAGE_DIRNAMES = frozenset({SUBMITTED_DIRNAME, FAILED_DIRNAME})
38
+
39
+
40
+ class Triage:
41
+ """Moves files into ``submitted/`` and ``failed/`` under *root*.
42
+
43
+ Args:
44
+ root: Directory the files were found in. The destination folders are
45
+ created inside it, on first use only -- a run that triages nothing
46
+ leaves no empty directories behind.
47
+ dry_run: Report the moves that would happen without performing any.
48
+ """
49
+
50
+ def __init__(self, root: pathlib.Path, dry_run: bool = False) -> None:
51
+ self.root = pathlib.Path(root)
52
+ self.dry_run = dry_run
53
+ self.submitted_dir = self.root / SUBMITTED_DIRNAME
54
+ self.failed_dir = self.root / FAILED_DIRNAME
55
+ self.submitted: List[pathlib.Path] = []
56
+ self.failed: List[pathlib.Path] = []
57
+ self.left: List[pathlib.Path] = []
58
+
59
+ # -- outcomes ----------------------------------------------------------
60
+
61
+ def mark_submitted(self, path: pathlib.Path) -> Optional[pathlib.Path]:
62
+ """Move *path* into ``submitted/``."""
63
+ self.submitted.append(pathlib.Path(path))
64
+ return self._move(path, self.submitted_dir)
65
+
66
+ def mark_failed(
67
+ self, path: pathlib.Path, reason: str
68
+ ) -> Optional[pathlib.Path]:
69
+ """Move *path* into ``failed/`` and write a note explaining *reason*."""
70
+ self.failed.append(pathlib.Path(path))
71
+ destination = self._move(path, self.failed_dir)
72
+ if destination is not None and not self.dry_run:
73
+ self._write_note(destination, reason)
74
+ return destination
75
+
76
+ def mark_left(self, path: pathlib.Path) -> None:
77
+ """Record that *path* stays put, for the next run to pick up."""
78
+ self.left.append(pathlib.Path(path))
79
+
80
+ # -- mechanics ---------------------------------------------------------
81
+
82
+ def _move(
83
+ self, path: pathlib.Path, destination_dir: pathlib.Path
84
+ ) -> Optional[pathlib.Path]:
85
+ path = pathlib.Path(path)
86
+ target = destination_dir / path.name
87
+ if self.dry_run:
88
+ return target
89
+ try:
90
+ destination_dir.mkdir(parents=True, exist_ok=True)
91
+ target = _unique(target)
92
+ shutil.move(str(path), str(target))
93
+ return target
94
+ except OSError as exc:
95
+ # Never let a filing problem lose an analysis IPA has accepted.
96
+ print(
97
+ f"Warning: could not move {path.name!r} to "
98
+ f"{destination_dir.name}/ ({exc}). The file has been left in place."
99
+ )
100
+ return None
101
+
102
+ @staticmethod
103
+ def _write_note(moved_to: pathlib.Path, reason: str) -> None:
104
+ note = moved_to.with_suffix(moved_to.suffix + ".error.txt")
105
+ try:
106
+ note.write_text(reason.rstrip() + "\n", encoding="utf-8")
107
+ except OSError:
108
+ pass # The message was printed too; the note is a convenience.
109
+
110
+ # -- reporting ---------------------------------------------------------
111
+
112
+ def summary(self) -> str:
113
+ """One-line-per-category summary, suitable for the end of a run."""
114
+ verb = "would be moved" if self.dry_run else "moved"
115
+ lines = []
116
+ if self.submitted:
117
+ lines.append(f"{len(self.submitted)} file(s) {verb} to {SUBMITTED_DIRNAME}/")
118
+ if self.failed:
119
+ lines.append(f"{len(self.failed)} file(s) {verb} to {FAILED_DIRNAME}/")
120
+ if self.left:
121
+ lines.append(
122
+ f"{len(self.left)} file(s) left in place for the next run"
123
+ )
124
+ return "\n".join(lines)
125
+
126
+
127
+ def _unique(target: pathlib.Path) -> pathlib.Path:
128
+ """Return a path that does not exist, suffixing ``-1``, ``-2`` as needed."""
129
+ if not target.exists():
130
+ return target
131
+ stem, suffix = target.stem, target.suffix
132
+ for n in range(1, 1000):
133
+ candidate = target.with_name(f"{stem}-{n}{suffix}")
134
+ if not candidate.exists():
135
+ return candidate
136
+ return target.with_name(f"{stem}-{id(target)}{suffix}")