exdrf-util 0.1.17__tar.gz
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.
- exdrf_util-0.1.17/PKG-INFO +57 -0
- exdrf_util-0.1.17/README.md +27 -0
- exdrf_util-0.1.17/exdrf_util/__init__.py +0 -0
- exdrf_util-0.1.17/exdrf_util/__version__.py +24 -0
- exdrf_util-0.1.17/exdrf_util/check.py +499 -0
- exdrf_util-0.1.17/exdrf_util/check_verbose.py +89 -0
- exdrf_util-0.1.17/exdrf_util/merge_pdfs.py +138 -0
- exdrf_util-0.1.17/exdrf_util/pdf_common.py +324 -0
- exdrf_util-0.1.17/exdrf_util/py.typed +0 -0
- exdrf_util-0.1.17/exdrf_util/rotate_backups.py +53 -0
- exdrf_util-0.1.17/exdrf_util/table2base.py +82 -0
- exdrf_util-0.1.17/exdrf_util/table2doc.py +171 -0
- exdrf_util-0.1.17/exdrf_util/table2pdf.py +383 -0
- exdrf_util-0.1.17/exdrf_util/table_writer.py +733 -0
- exdrf_util-0.1.17/exdrf_util/task.py +328 -0
- exdrf_util-0.1.17/exdrf_util/typedefs.py +60 -0
- exdrf_util-0.1.17/exdrf_util.egg-info/PKG-INFO +57 -0
- exdrf_util-0.1.17/exdrf_util.egg-info/SOURCES.txt +23 -0
- exdrf_util-0.1.17/exdrf_util.egg-info/dependency_links.txt +1 -0
- exdrf_util-0.1.17/exdrf_util.egg-info/requires.txt +18 -0
- exdrf_util-0.1.17/exdrf_util.egg-info/top_level.txt +3 -0
- exdrf_util-0.1.17/pyproject.toml +77 -0
- exdrf_util-0.1.17/setup.cfg +4 -0
- exdrf_util-0.1.17/setup.py +6 -0
- exdrf_util-0.1.17/tests/__init__.py +1 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: exdrf-util
|
|
3
|
+
Version: 0.1.17
|
|
4
|
+
Summary: Utilities for Ex-DRF.
|
|
5
|
+
Author-email: Nicu Tofan <nicu.tofan@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Operating System :: OS Independent
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Typing :: Typed
|
|
11
|
+
Requires-Python: >=3.12.2
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: exdrf>=0.1.17
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: autoflake; extra == "dev"
|
|
16
|
+
Requires-Dist: black; extra == "dev"
|
|
17
|
+
Requires-Dist: build; extra == "dev"
|
|
18
|
+
Requires-Dist: flake8; extra == "dev"
|
|
19
|
+
Requires-Dist: isort; extra == "dev"
|
|
20
|
+
Requires-Dist: mypy; extra == "dev"
|
|
21
|
+
Requires-Dist: pre-commit; extra == "dev"
|
|
22
|
+
Requires-Dist: pyproject-flake8; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-mock; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest; extra == "dev"
|
|
26
|
+
Requires-Dist: twine; extra == "dev"
|
|
27
|
+
Requires-Dist: wheel; extra == "dev"
|
|
28
|
+
Requires-Dist: openpyxl-stubs; extra == "dev"
|
|
29
|
+
Requires-Dist: reportlab-stubs; extra == "dev"
|
|
30
|
+
|
|
31
|
+
# Utilities for Ex-DRF
|
|
32
|
+
|
|
33
|
+
**exdrf-util** is a grab bag of **small, optional helpers** built on **exdrf**
|
|
34
|
+
types and attrs. It is useful for desktop or back-office scripts that already
|
|
35
|
+
depend on **exdrf** and need shared table/export or task scaffolding. It is not
|
|
36
|
+
a required dependency for minimal API or ORM-only stacks.
|
|
37
|
+
|
|
38
|
+
## Contents (high level)
|
|
39
|
+
|
|
40
|
+
Modules under **`exdrf_util`** include:
|
|
41
|
+
|
|
42
|
+
- **Tabular export**: turn **openpyxl** cell ranges into **PDF** or **Word**
|
|
43
|
+
documents (`table2pdf`, `table2doc`, `table_writer`, `table2base`) using
|
|
44
|
+
**reportlab** / **python-docx** when those libraries are available in the
|
|
45
|
+
environment.
|
|
46
|
+
- **PDF utilities**: merge and manipulate PDFs (`merge_pdfs`), backup rotation
|
|
47
|
+
(`rotate_backups`).
|
|
48
|
+
- **Tasks**: attrs-based **`Task`** state machine tied to **`ExFieldBase`** for
|
|
49
|
+
form-like workflows (`task`).
|
|
50
|
+
- **Misc**: verbose checking helpers, shared typedefs for translation/context
|
|
51
|
+
protocols.
|
|
52
|
+
|
|
53
|
+
Runtime **`dependencies`** in `pyproject.toml` list only **exdrf**; features
|
|
54
|
+
that import **openpyxl**, **reportlab**, or **docx** expect you to install those
|
|
55
|
+
packages next to **exdrf-util** when you use the corresponding modules.
|
|
56
|
+
|
|
57
|
+
Python **3.12.2+** is required.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Utilities for Ex-DRF
|
|
2
|
+
|
|
3
|
+
**exdrf-util** is a grab bag of **small, optional helpers** built on **exdrf**
|
|
4
|
+
types and attrs. It is useful for desktop or back-office scripts that already
|
|
5
|
+
depend on **exdrf** and need shared table/export or task scaffolding. It is not
|
|
6
|
+
a required dependency for minimal API or ORM-only stacks.
|
|
7
|
+
|
|
8
|
+
## Contents (high level)
|
|
9
|
+
|
|
10
|
+
Modules under **`exdrf_util`** include:
|
|
11
|
+
|
|
12
|
+
- **Tabular export**: turn **openpyxl** cell ranges into **PDF** or **Word**
|
|
13
|
+
documents (`table2pdf`, `table2doc`, `table_writer`, `table2base`) using
|
|
14
|
+
**reportlab** / **python-docx** when those libraries are available in the
|
|
15
|
+
environment.
|
|
16
|
+
- **PDF utilities**: merge and manipulate PDFs (`merge_pdfs`), backup rotation
|
|
17
|
+
(`rotate_backups`).
|
|
18
|
+
- **Tasks**: attrs-based **`Task`** state machine tied to **`ExFieldBase`** for
|
|
19
|
+
form-like workflows (`task`).
|
|
20
|
+
- **Misc**: verbose checking helpers, shared typedefs for translation/context
|
|
21
|
+
protocols.
|
|
22
|
+
|
|
23
|
+
Runtime **`dependencies`** in `pyproject.toml` list only **exdrf**; features
|
|
24
|
+
that import **openpyxl**, **reportlab**, or **docx** expect you to install those
|
|
25
|
+
packages next to **exdrf-util** when you use the corresponding modules.
|
|
26
|
+
|
|
27
|
+
Python **3.12.2+** is required.
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Package version from PEP 621 or installed metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from exdrf.pep621_version import distribution_version, version_tuple_from_string
|
|
8
|
+
|
|
9
|
+
_PYPROJECT = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
|
10
|
+
_DIST_NAME = "exdrf-util"
|
|
11
|
+
|
|
12
|
+
__version__ = version = distribution_version(_DIST_NAME, _PYPROJECT)
|
|
13
|
+
__version_tuple__ = version_tuple = version_tuple_from_string(__version__)
|
|
14
|
+
|
|
15
|
+
__commit_id__ = commit_id = None
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"__version__",
|
|
19
|
+
"__version_tuple__",
|
|
20
|
+
"version",
|
|
21
|
+
"version_tuple",
|
|
22
|
+
"__commit_id__",
|
|
23
|
+
"commit_id",
|
|
24
|
+
]
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections import OrderedDict, defaultdict
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from typing import (
|
|
5
|
+
TYPE_CHECKING,
|
|
6
|
+
Any,
|
|
7
|
+
Dict,
|
|
8
|
+
Generic,
|
|
9
|
+
List,
|
|
10
|
+
Literal,
|
|
11
|
+
Optional,
|
|
12
|
+
Tuple,
|
|
13
|
+
TypeVar,
|
|
14
|
+
Union,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
import pluggy
|
|
18
|
+
from attrs import define, field
|
|
19
|
+
|
|
20
|
+
from .task import Task, TaskParameter
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from exdrf_util.typedefs import HasBasicContext, HasTranslate
|
|
24
|
+
|
|
25
|
+
# T represents the type of the item that the check is being performed on
|
|
26
|
+
# on each step.
|
|
27
|
+
T = TypeVar("T")
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ResultState(StrEnum):
|
|
33
|
+
"""The state of a check result.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
INITIAL: The check result was not yet computed.
|
|
37
|
+
PASSED: The check passed.
|
|
38
|
+
SKIPPED: The check was skipped.
|
|
39
|
+
FIXED: The check found a problem which was fixed.
|
|
40
|
+
PARTIALLY_FIXED: The check found a problem which was partially fixed.
|
|
41
|
+
NOT_FIXED: The check found a problem which was not fixed.
|
|
42
|
+
FAILED: The check failed to execute (raised an exception).
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
INITIAL = "initial"
|
|
46
|
+
PASSED = "passed"
|
|
47
|
+
SKIPPED = "skipped"
|
|
48
|
+
FIXED = "fixed"
|
|
49
|
+
PARTIALLY_FIXED = "partially_fixed"
|
|
50
|
+
NOT_FIXED = "not_fixed"
|
|
51
|
+
FAILED = "failed"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@define(slots=True, kw_only=True)
|
|
55
|
+
class CheckResult:
|
|
56
|
+
"""The result of a check over a particular record or a record set.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
check_id: The ID of the check that produced the result.
|
|
60
|
+
state: Whether the check passed, skipped (for cases when the filtration
|
|
61
|
+
could not be fully applied through the query and the runtime check
|
|
62
|
+
if required to determine if a particular record should be checked),
|
|
63
|
+
was fully or partially fixed or was detected but not fixed.
|
|
64
|
+
issue_hash: A pseudo-unique identifier for the issue. When possible we
|
|
65
|
+
should compute the hash so that the behavior of the issue
|
|
66
|
+
can be tracked across time. This may include the record ID,
|
|
67
|
+
the field ID, the check that was performed.
|
|
68
|
+
t_key: The translation code that can be used, along with the parameters,
|
|
69
|
+
to recreate the description.
|
|
70
|
+
params: Additional information about the result in dictionary format.
|
|
71
|
+
When recreating the description the code and params are used,
|
|
72
|
+
but additional information can be added here. Note that these
|
|
73
|
+
are NOT the parameters of the check.
|
|
74
|
+
description: A description of the result.
|
|
75
|
+
links: A list of links related to this result. Each link is a tuple of
|
|
76
|
+
(resource_name, record_id, label, address) where resource_name is
|
|
77
|
+
the name of the resource, record_id is either an int or a tuple of
|
|
78
|
+
ints, label is the text to display for the link, and address is
|
|
79
|
+
the URL/path to navigate to when the link is clicked.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
check_id: str
|
|
83
|
+
state: ResultState = field(default=ResultState.INITIAL)
|
|
84
|
+
issue_hash: Optional[str] = field(default=None)
|
|
85
|
+
t_key: str = field(default="", repr=False)
|
|
86
|
+
params: Dict[str, Any] = field(factory=dict)
|
|
87
|
+
description: str = field(default="", repr=False)
|
|
88
|
+
links: List[Tuple[str, Union[int, Tuple[int, ...]], str, str]] = field(
|
|
89
|
+
factory=list, repr=False
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@define(slots=True, kw_only=True)
|
|
94
|
+
class CheckTask(Task, Generic[T]):
|
|
95
|
+
"""A task that executes a check."""
|
|
96
|
+
|
|
97
|
+
check: "Check"
|
|
98
|
+
results: List[CheckResult] = field(factory=list)
|
|
99
|
+
|
|
100
|
+
def __attrs_post_init__(self):
|
|
101
|
+
if self.title == "":
|
|
102
|
+
self.title = self.check.title
|
|
103
|
+
if self.description == "":
|
|
104
|
+
self.description = self.check.description
|
|
105
|
+
if not self.parameters:
|
|
106
|
+
self.parameters = self.check.parameters
|
|
107
|
+
|
|
108
|
+
def results_by_type(self) -> Dict[ResultState, List[CheckResult]]:
|
|
109
|
+
"""Split the list of results by their state."""
|
|
110
|
+
results_by_type = defaultdict(list)
|
|
111
|
+
for result in self.results:
|
|
112
|
+
results_by_type[result.state].append(result)
|
|
113
|
+
return results_by_type
|
|
114
|
+
|
|
115
|
+
def result_count_by_type(self) -> Dict[ResultState, int]:
|
|
116
|
+
"""Count the number of results by their state."""
|
|
117
|
+
results_by_type = self.results_by_type()
|
|
118
|
+
return {
|
|
119
|
+
state: len(results) for state, results in results_by_type.items()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
def get_success_message(self, ctx: "HasTranslate") -> str:
|
|
123
|
+
if len(self.results) == 0:
|
|
124
|
+
return ctx.t(
|
|
125
|
+
"check.no-results", "The check did not find any results."
|
|
126
|
+
)
|
|
127
|
+
elif self.check.is_global:
|
|
128
|
+
assert len(self.results) == 1, (
|
|
129
|
+
"Global check should have exactly one result."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
result = self.results[0]
|
|
133
|
+
state = result.state
|
|
134
|
+
if state == ResultState.PASSED:
|
|
135
|
+
return ctx.t("check.global.success", "The check passed.")
|
|
136
|
+
elif state == ResultState.SKIPPED:
|
|
137
|
+
return ctx.t("check.global.skipped", "The check was skipped.")
|
|
138
|
+
elif state == ResultState.FIXED:
|
|
139
|
+
return ctx.t(
|
|
140
|
+
"check.global.fixed",
|
|
141
|
+
"The check detected an issue and fixed it.",
|
|
142
|
+
)
|
|
143
|
+
elif state == ResultState.NOT_FIXED:
|
|
144
|
+
return ctx.t(
|
|
145
|
+
result.t_key,
|
|
146
|
+
"The check detected an issue but did not fix it.",
|
|
147
|
+
**result.params,
|
|
148
|
+
)
|
|
149
|
+
elif state == ResultState.PARTIALLY_FIXED:
|
|
150
|
+
return ctx.t(
|
|
151
|
+
"check.global.partially-fixed",
|
|
152
|
+
"The check detected an issue and partially fixed it but "
|
|
153
|
+
"it requires further manual action.",
|
|
154
|
+
)
|
|
155
|
+
elif state == ResultState.FAILED:
|
|
156
|
+
return ctx.t(
|
|
157
|
+
result.t_key,
|
|
158
|
+
"The check failed to execute.",
|
|
159
|
+
**result.params,
|
|
160
|
+
)
|
|
161
|
+
else:
|
|
162
|
+
raise ValueError(f"Invalid result state: {state}")
|
|
163
|
+
else:
|
|
164
|
+
final = []
|
|
165
|
+
results_by_type = self.result_count_by_type()
|
|
166
|
+
passed = results_by_type.get(ResultState.PASSED, 0)
|
|
167
|
+
skipped = results_by_type.get(ResultState.SKIPPED, 0)
|
|
168
|
+
fixed = results_by_type.get(ResultState.FIXED, 0)
|
|
169
|
+
not_fixed = results_by_type.get(ResultState.NOT_FIXED, 0)
|
|
170
|
+
partially_fixed = results_by_type.get(
|
|
171
|
+
ResultState.PARTIALLY_FIXED, 0
|
|
172
|
+
)
|
|
173
|
+
failed = results_by_type.get(ResultState.FAILED, 0)
|
|
174
|
+
if passed > 0:
|
|
175
|
+
final.append(
|
|
176
|
+
ctx.t(
|
|
177
|
+
"check.success.count",
|
|
178
|
+
"{count} checks passed.",
|
|
179
|
+
count=passed,
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
if skipped > 0:
|
|
183
|
+
final.append(
|
|
184
|
+
ctx.t(
|
|
185
|
+
"check.skipped.count",
|
|
186
|
+
"{count} checks skipped.",
|
|
187
|
+
count=skipped,
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
if fixed > 0:
|
|
191
|
+
final.append(
|
|
192
|
+
ctx.t(
|
|
193
|
+
"check.fixed.count",
|
|
194
|
+
"{count} issues were fixed.",
|
|
195
|
+
count=fixed,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
if not_fixed > 0:
|
|
199
|
+
final.append(
|
|
200
|
+
ctx.t(
|
|
201
|
+
"check.not-fixed.count",
|
|
202
|
+
"{count} issues were not fixed.",
|
|
203
|
+
count=not_fixed,
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
if partially_fixed > 0:
|
|
207
|
+
final.append(
|
|
208
|
+
ctx.t(
|
|
209
|
+
"check.partially-fixed.count",
|
|
210
|
+
"{count} issues were partially fixed and require "
|
|
211
|
+
"further manual action.",
|
|
212
|
+
count=partially_fixed,
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
if failed > 0:
|
|
216
|
+
final.append(
|
|
217
|
+
ctx.t(
|
|
218
|
+
"check.failed.count",
|
|
219
|
+
"{count} checks failed to execute.",
|
|
220
|
+
count=failed,
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
return "\n".join(final)
|
|
224
|
+
|
|
225
|
+
def prepare_task(self, ctx: "HasBasicContext") -> bool: # type: ignore
|
|
226
|
+
"""Prepare the task."""
|
|
227
|
+
result = self.check.prepare_check(ctx)
|
|
228
|
+
if result is not None:
|
|
229
|
+
# Add any private data to the data.
|
|
230
|
+
assert isinstance(result, dict)
|
|
231
|
+
for k, v in result.items():
|
|
232
|
+
if k in self.data:
|
|
233
|
+
logger.warning(
|
|
234
|
+
"Private data key %s overrides preset with same name. "
|
|
235
|
+
"Avoid naming the preset data keys the same as the "
|
|
236
|
+
"private data keys to avoid unexpected behavior.",
|
|
237
|
+
k,
|
|
238
|
+
)
|
|
239
|
+
self.data[k] = v
|
|
240
|
+
|
|
241
|
+
# Move the parameters to the data.
|
|
242
|
+
for k, v in self.check.parameters.items():
|
|
243
|
+
if k in self.data:
|
|
244
|
+
logger.warning(
|
|
245
|
+
"Parameter %s overrides private data with same name. "
|
|
246
|
+
"Avoid naming the private data keys the same as the "
|
|
247
|
+
"parameters to avoid unexpected behavior.",
|
|
248
|
+
k,
|
|
249
|
+
)
|
|
250
|
+
self.data[k] = v.value
|
|
251
|
+
|
|
252
|
+
# If the result has a records member, we assume its length to be
|
|
253
|
+
# the number of records to check.
|
|
254
|
+
if "records" in result:
|
|
255
|
+
self.max_steps = len(result["records"])
|
|
256
|
+
return True
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
def cleanup_task(self, ctx: "HasBasicContext") -> bool: # type: ignore
|
|
260
|
+
"""Cleanup the task."""
|
|
261
|
+
return self.check.cleanup_check(ctx)
|
|
262
|
+
|
|
263
|
+
def get_failed_message(self, ctx: "HasTranslate") -> str:
|
|
264
|
+
return self.get_success_message(ctx)
|
|
265
|
+
|
|
266
|
+
def prepare_step(self, ctx: "HasTranslate") -> None:
|
|
267
|
+
"""Prepare the task."""
|
|
268
|
+
self.results.append(CheckResult(check_id=self.check.check_id))
|
|
269
|
+
|
|
270
|
+
def handle_exception(
|
|
271
|
+
self,
|
|
272
|
+
ctx: "HasTranslate",
|
|
273
|
+
e: Exception,
|
|
274
|
+
stage: Literal["prepare", "execute", "cleanup"],
|
|
275
|
+
) -> bool:
|
|
276
|
+
"""Handle an exception that occurred during the task execution."""
|
|
277
|
+
if stage in ("prepare", "cleanup"):
|
|
278
|
+
return super().handle_exception(ctx, e, stage)
|
|
279
|
+
|
|
280
|
+
t_key = "check.failed.exception"
|
|
281
|
+
result = self.results[-1]
|
|
282
|
+
result.check_id = self.check.check_id
|
|
283
|
+
result.state = ResultState.FAILED
|
|
284
|
+
result.params["exception"] = str(e)
|
|
285
|
+
result.params["e_class"] = e.__class__.__name__
|
|
286
|
+
result.description = ctx.t(
|
|
287
|
+
t_key,
|
|
288
|
+
"The check failed to execute: {exception} ({e_class}).",
|
|
289
|
+
**result.params,
|
|
290
|
+
)
|
|
291
|
+
return True
|
|
292
|
+
|
|
293
|
+
def execute_step(self, ctx: "HasBasicContext") -> None: # type: ignore
|
|
294
|
+
"""Execute one step in the task."""
|
|
295
|
+
item = self.check.get_record_to_check(self.step, self.data)
|
|
296
|
+
result = self.results[-1]
|
|
297
|
+
record_id = self.check.get_record_id(self.step, self.data, item)
|
|
298
|
+
result.params["id"] = record_id
|
|
299
|
+
result.issue_hash = self.check.compute_hash(self.step, self.data, item)
|
|
300
|
+
result = self.check.execute(ctx, item, result)
|
|
301
|
+
self.results[-1] = result
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@define(slots=True, frozen=True, kw_only=True)
|
|
305
|
+
class Check(Generic[T]):
|
|
306
|
+
"""A check that can be performed on a record or a record set.
|
|
307
|
+
|
|
308
|
+
Attributes:
|
|
309
|
+
check_id: A unique identifier for the check.
|
|
310
|
+
description: A description of the check.
|
|
311
|
+
detailed_description: A longer, beginner-friendly description of the
|
|
312
|
+
check, suitable for tooltips or help text.
|
|
313
|
+
category: The category of the check.
|
|
314
|
+
tags: The tags of the check.
|
|
315
|
+
is_global: Whether the check is global. A global check is one that
|
|
316
|
+
cannot iterate over the members so reporting the progress is
|
|
317
|
+
not possible.
|
|
318
|
+
has_fix: Whether this check provides an automatic fix for the problem
|
|
319
|
+
it detects. This does not mean that all issues will be fixed
|
|
320
|
+
or that the fix will be complete. It just means that the
|
|
321
|
+
class implements the fix method< wether a particular issue was
|
|
322
|
+
fixed or not will be reflected in the result.
|
|
323
|
+
parameters: Definition of the parameters that the check accepts
|
|
324
|
+
and their values.
|
|
325
|
+
"""
|
|
326
|
+
|
|
327
|
+
check_id: str
|
|
328
|
+
title: str = field(default="", repr=False)
|
|
329
|
+
description: str = field(default="", repr=False)
|
|
330
|
+
detailed_description: str = field(default="", repr=False)
|
|
331
|
+
category: str = field(default="", repr=False)
|
|
332
|
+
tags: List[str] = field(factory=list, repr=False)
|
|
333
|
+
is_global: bool = field(default=False, repr=False)
|
|
334
|
+
has_fix: bool = field(default=False, repr=False)
|
|
335
|
+
parameters: Dict[str, "TaskParameter"] = field(
|
|
336
|
+
factory=OrderedDict, repr=False
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
def prepare_check(self, ctx: "HasBasicContext") -> Optional[Dict[str, Any]]:
|
|
340
|
+
"""Prepare the check.
|
|
341
|
+
|
|
342
|
+
Returns a (possibly empty) dictionary of private data that
|
|
343
|
+
the check needs to store between the prepare and cleanup steps.
|
|
344
|
+
|
|
345
|
+
This is the best place to obtain and store the list of records that
|
|
346
|
+
will be checked.
|
|
347
|
+
|
|
348
|
+
If the result is None this indicates that the preparation step failed.
|
|
349
|
+
The check will be aborted and the user will be notified.
|
|
350
|
+
"""
|
|
351
|
+
del ctx
|
|
352
|
+
return {}
|
|
353
|
+
|
|
354
|
+
def cleanup_check(self, ctx: "HasBasicContext") -> bool:
|
|
355
|
+
"""Cleanup the check.
|
|
356
|
+
|
|
357
|
+
The result indicates whether the cleanup step was successful.
|
|
358
|
+
"""
|
|
359
|
+
with ctx.same_session() as s:
|
|
360
|
+
s.commit()
|
|
361
|
+
return True
|
|
362
|
+
|
|
363
|
+
def get_record_to_check(self, step: int, data: Dict[str, Any]) -> T:
|
|
364
|
+
"""Get the record to check out of the prepared data."""
|
|
365
|
+
raise NotImplementedError("Subclasses must implement this method.")
|
|
366
|
+
|
|
367
|
+
def get_record_id(self, step: int, data: Dict[str, Any], item: T) -> str:
|
|
368
|
+
"""Get the record ID of the item."""
|
|
369
|
+
return str(item.id) # type: ignore
|
|
370
|
+
|
|
371
|
+
def compute_hash(self, step: int, data: Dict[str, Any], item: T) -> str:
|
|
372
|
+
"""Compute the hash of the item.
|
|
373
|
+
|
|
374
|
+
The hash is used to track the changes of the item with respect
|
|
375
|
+
to this check through time.
|
|
376
|
+
"""
|
|
377
|
+
del step, data, item
|
|
378
|
+
return ""
|
|
379
|
+
|
|
380
|
+
def execute(
|
|
381
|
+
self,
|
|
382
|
+
ctx: "HasBasicContext",
|
|
383
|
+
item: Union[T, List[T]],
|
|
384
|
+
result: Optional["CheckResult"] = None,
|
|
385
|
+
) -> "CheckResult":
|
|
386
|
+
"""Execute the check on the given item.
|
|
387
|
+
|
|
388
|
+
For global checks the `item` is a list of all the records that were
|
|
389
|
+
found for the query. For non-global checks the `item` is a single
|
|
390
|
+
record.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
item: The item to check.
|
|
394
|
+
result: The result to update. If not provided a new result will be
|
|
395
|
+
created.
|
|
396
|
+
|
|
397
|
+
Returns:
|
|
398
|
+
The result of the check.
|
|
399
|
+
"""
|
|
400
|
+
raise NotImplementedError
|
|
401
|
+
|
|
402
|
+
def create_task(self) -> CheckTask[T]:
|
|
403
|
+
return CheckTask[T](
|
|
404
|
+
check=self,
|
|
405
|
+
title=self.title,
|
|
406
|
+
description=self.description,
|
|
407
|
+
parameters=self.parameters,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
PROJECT_NAME = "exdrf-checks"
|
|
412
|
+
exdrf_check_spec = pluggy.HookspecMarker(PROJECT_NAME)
|
|
413
|
+
exdrf_check_impl = pluggy.HookimplMarker(PROJECT_NAME)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class HookSpecs:
|
|
417
|
+
"""Hook specifications for the check registry."""
|
|
418
|
+
|
|
419
|
+
@exdrf_check_spec
|
|
420
|
+
def exdrf_checks(ctx: "HasTranslate", for_gui: bool) -> List["Check"]:
|
|
421
|
+
"""Get the checks that should be made available to the user.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
ctx: The context of the application.
|
|
425
|
+
for_gui: Whether the checks are being requested for the GUI.
|
|
426
|
+
Some parameters will use this information to import
|
|
427
|
+
different data for the GUI and the CLI.
|
|
428
|
+
"""
|
|
429
|
+
raise NotImplementedError
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
exdrf_checks_pm = pluggy.PluginManager(PROJECT_NAME)
|
|
433
|
+
exdrf_checks_pm.add_hookspecs(HookSpecs)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def get_all_checks(ctx: "HasTranslate", for_gui: bool) -> List["Check"]:
|
|
437
|
+
"""Get all the checks that should be made available to the user.
|
|
438
|
+
|
|
439
|
+
For your check to be noticed by the registry you need to:
|
|
440
|
+
|
|
441
|
+
1. Create a Check class that implements the check logic.
|
|
442
|
+
2. Implement the hook using the @exdrf_check_impl decorator.
|
|
443
|
+
3. Register your plugin with the plugin manager.
|
|
444
|
+
|
|
445
|
+
Example::
|
|
446
|
+
|
|
447
|
+
from exdrf_util.check import (
|
|
448
|
+
Check, CheckResult, ResultState, exdrf_check_impl, exdrf_checks_pm
|
|
449
|
+
)
|
|
450
|
+
from exdrf_util.typedefs import HasBasicContext
|
|
451
|
+
|
|
452
|
+
class MyCustomCheck(Check[str]):
|
|
453
|
+
\"\"\"A custom check that validates string length.\"\"\"
|
|
454
|
+
|
|
455
|
+
def __init__(self):
|
|
456
|
+
super().__init__(
|
|
457
|
+
check_id="my_custom_check",
|
|
458
|
+
title="Custom String Check",
|
|
459
|
+
description="Checks if string length is valid",
|
|
460
|
+
category="validation"
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
def execute(
|
|
464
|
+
self,
|
|
465
|
+
ctx: HasBasicContext,
|
|
466
|
+
item: str,
|
|
467
|
+
result: Optional[CheckResult] = None,
|
|
468
|
+
) -> CheckResult:
|
|
469
|
+
if result is None:
|
|
470
|
+
result = CheckResult()
|
|
471
|
+
|
|
472
|
+
if len(item) < 5:
|
|
473
|
+
result.state = ResultState.NOT_FIXED
|
|
474
|
+
result.t_key = "check.string.too_short"
|
|
475
|
+
result.params = {"length": len(item), "min_length": 5}
|
|
476
|
+
result.description = (
|
|
477
|
+
f"String is too short: {len(item)} < 5"
|
|
478
|
+
)
|
|
479
|
+
else:
|
|
480
|
+
result.state = ResultState.PASSED
|
|
481
|
+
|
|
482
|
+
return result
|
|
483
|
+
|
|
484
|
+
class MyChecksPlugin:
|
|
485
|
+
\"\"\"Plugin that provides custom checks.\"\"\"
|
|
486
|
+
|
|
487
|
+
@exdrf_check_impl
|
|
488
|
+
def exdrf_checks(self) -> List[Check]:
|
|
489
|
+
\"\"\"Return the list of checks provided by this plugin.\"\"\"
|
|
490
|
+
return [MyCustomCheck()]
|
|
491
|
+
|
|
492
|
+
# Register the plugin
|
|
493
|
+
exdrf_checks_pm.register(MyChecksPlugin())
|
|
494
|
+
"""
|
|
495
|
+
result = []
|
|
496
|
+
for hookimpl in exdrf_checks_pm.hook.exdrf_checks(ctx=ctx, for_gui=for_gui):
|
|
497
|
+
result.extend(hookimpl)
|
|
498
|
+
logger.debug(f"Found {len(result)} checks")
|
|
499
|
+
return result
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Pre-commit helper: ensure any .py file defining VERBOSE uses VERBOSE = 1.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python -m exdrf_util.check_verbose [--fix] <file.py> ...
|
|
5
|
+
With --fix, rewrites VERBOSE = n to VERBOSE = 1 in place (autofix).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
# Match VERBOSE = <integer> at line start (optional leading/trailing
|
|
12
|
+
# space/comments).
|
|
13
|
+
VERBOSE_PATTERN = re.compile(
|
|
14
|
+
r"^\s*VERBOSE\s*=\s*(\d+)(?:\s*#.*)?\s*$", re.MULTILINE
|
|
15
|
+
)
|
|
16
|
+
# Capture up to the number for substitution (fix mode).
|
|
17
|
+
VERBOSE_FIX_PATTERN = re.compile(r"^(\s*VERBOSE\s*=\s*)\d+", re.MULTILINE)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def check_file(path: str) -> list[tuple[int, int]]:
|
|
21
|
+
"""Find lines where VERBOSE is set to a value other than 1.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
path: Path to a Python source file.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
List of (line_number, value) for each VERBOSE = x with x != 1.
|
|
28
|
+
Empty if file is ok or not readable.
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
with open(path, encoding="utf-8-sig") as f:
|
|
32
|
+
text = f.read()
|
|
33
|
+
except OSError as e:
|
|
34
|
+
print("%s: could not read file: %s" % (path, e), file=sys.stderr)
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
violations = []
|
|
38
|
+
for match in VERBOSE_PATTERN.finditer(text):
|
|
39
|
+
value = int(match.group(1))
|
|
40
|
+
if value != 1:
|
|
41
|
+
line_no = text[: match.start()].count("\n") + 1
|
|
42
|
+
violations.append((line_no, value))
|
|
43
|
+
return violations
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def fix_file(path: str) -> bool:
|
|
47
|
+
"""Rewrite VERBOSE = n to VERBOSE = 1 in place. Return True if changed."""
|
|
48
|
+
try:
|
|
49
|
+
with open(path, encoding="utf-8-sig") as f:
|
|
50
|
+
text = f.read()
|
|
51
|
+
except OSError as e:
|
|
52
|
+
print("%s: could not read file: %s" % (path, e), file=sys.stderr)
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
new_text = VERBOSE_FIX_PATTERN.sub(r"\g<1>1", text)
|
|
56
|
+
if new_text == text:
|
|
57
|
+
return False
|
|
58
|
+
with open(path, "w", encoding="utf-8", newline="") as f:
|
|
59
|
+
f.write(new_text)
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main() -> int:
|
|
64
|
+
"""Run the check on files passed as arguments. Exit 1 if any violation."""
|
|
65
|
+
argv = sys.argv[1:]
|
|
66
|
+
fix_mode = bool(argv and argv[0] == "--fix")
|
|
67
|
+
if fix_mode:
|
|
68
|
+
argv = argv[1:]
|
|
69
|
+
paths = [p for p in argv if p.endswith(".py")]
|
|
70
|
+
|
|
71
|
+
if fix_mode:
|
|
72
|
+
for path in paths:
|
|
73
|
+
if fix_file(path):
|
|
74
|
+
print("%s: VERBOSE set to 1" % path)
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
failed = False
|
|
78
|
+
for path in paths:
|
|
79
|
+
for line_no, value in check_file(path):
|
|
80
|
+
print(
|
|
81
|
+
"%s:%d: VERBOSE = %d (must be VERBOSE = 1)"
|
|
82
|
+
% (path, line_no, value)
|
|
83
|
+
)
|
|
84
|
+
failed = True
|
|
85
|
+
return 1 if failed else 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
sys.exit(main())
|