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/__init__.py +92 -0
- ipaapi/_payload.py +150 -0
- ipaapi/auth.py +575 -0
- ipaapi/cli.py +971 -0
- ipaapi/client.py +658 -0
- ipaapi/dataset.py +285 -0
- ipaapi/errors.py +101 -0
- ipaapi/history.py +145 -0
- ipaapi/mapping.py +485 -0
- ipaapi/models.py +193 -0
- ipaapi/triage.py +136 -0
- ipaapi-1.0.0.dist-info/METADATA +833 -0
- ipaapi-1.0.0.dist-info/RECORD +16 -0
- ipaapi-1.0.0.dist-info/WHEEL +4 -0
- ipaapi-1.0.0.dist-info/entry_points.txt +2 -0
- ipaapi-1.0.0.dist-info/licenses/LICENSE +21 -0
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}")
|