quickpub 4.1.4__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.
- quickpub/__init__.py +10 -0
- quickpub/__main__.py +276 -0
- quickpub/classifiers.py +138 -0
- quickpub/dist_files.py +33 -0
- quickpub/enforcers.py +26 -0
- quickpub/files.py +246 -0
- quickpub/logging_.py +74 -0
- quickpub/package_manager_detection.py +43 -0
- quickpub/proxy.py +35 -0
- quickpub/py.typed +0 -0
- quickpub/qa.py +409 -0
- quickpub/strategies/__init__.py +8 -0
- quickpub/strategies/build_schema.py +21 -0
- quickpub/strategies/constraint_enforcer.py +17 -0
- quickpub/strategies/implementations/__init__.py +6 -0
- quickpub/strategies/implementations/build_schemas/__init__.py +2 -0
- quickpub/strategies/implementations/build_schemas/setuptools_build_schema.py +48 -0
- quickpub/strategies/implementations/build_schemas/uv_build_schema.py +50 -0
- quickpub/strategies/implementations/constraint_enforcers/__init__.py +5 -0
- quickpub/strategies/implementations/constraint_enforcers/license_enforcer.py +29 -0
- quickpub/strategies/implementations/constraint_enforcers/local_version_enforcer.py +80 -0
- quickpub/strategies/implementations/constraint_enforcers/pypi_remote_version_enforcer.py +88 -0
- quickpub/strategies/implementations/constraint_enforcers/pypirc_enforcer.py +45 -0
- quickpub/strategies/implementations/constraint_enforcers/readme_enforcer.py +29 -0
- quickpub/strategies/implementations/package_managers/__init__.py +2 -0
- quickpub/strategies/implementations/package_managers/installed_package_parser.py +29 -0
- quickpub/strategies/implementations/package_managers/pip_package_manager.py +31 -0
- quickpub/strategies/implementations/package_managers/uv_package_manager.py +45 -0
- quickpub/strategies/implementations/python_providers/__init__.py +4 -0
- quickpub/strategies/implementations/python_providers/conda_python_provider.py +54 -0
- quickpub/strategies/implementations/python_providers/default_python_provider.py +47 -0
- quickpub/strategies/implementations/python_providers/union_provider.py +52 -0
- quickpub/strategies/implementations/python_providers/uv_python_provider.py +39 -0
- quickpub/strategies/implementations/quality_assurance_runners/__init__.py +4 -0
- quickpub/strategies/implementations/quality_assurance_runners/mypy_qa_runner.py +98 -0
- quickpub/strategies/implementations/quality_assurance_runners/pylint_qa_runner.py +106 -0
- quickpub/strategies/implementations/quality_assurance_runners/pytest_qa_runner.py +188 -0
- quickpub/strategies/implementations/quality_assurance_runners/unittest_qa_runner.py +138 -0
- quickpub/strategies/implementations/upload_targets/__init__.py +2 -0
- quickpub/strategies/implementations/upload_targets/github_upload_target.py +61 -0
- quickpub/strategies/implementations/upload_targets/pypirc_upload_target.py +90 -0
- quickpub/strategies/package_manager.py +54 -0
- quickpub/strategies/python_provider.py +60 -0
- quickpub/strategies/quality_assurance_runner.py +208 -0
- quickpub/strategies/quickpub_strategy.py +14 -0
- quickpub/strategies/upload_target.py +19 -0
- quickpub/structures/__init__.py +3 -0
- quickpub/structures/bound.py +52 -0
- quickpub/structures/dependency.py +82 -0
- quickpub/structures/version.py +41 -0
- quickpub/validators.py +87 -0
- quickpub/worker_pool.py +16 -0
- quickpub-4.1.4.dist-info/METADATA +246 -0
- quickpub-4.1.4.dist-info/RECORD +58 -0
- quickpub-4.1.4.dist-info/WHEEL +5 -0
- quickpub-4.1.4.dist-info/entry_points.txt +2 -0
- quickpub-4.1.4.dist-info/licenses/LICENSE +21 -0
- quickpub-4.1.4.dist-info/top_level.txt +1 -0
quickpub/qa.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
from abc import abstractmethod
|
|
5
|
+
from typing import (
|
|
6
|
+
ContextManager,
|
|
7
|
+
List,
|
|
8
|
+
Callable,
|
|
9
|
+
Tuple,
|
|
10
|
+
Dict,
|
|
11
|
+
Union,
|
|
12
|
+
Any,
|
|
13
|
+
Literal,
|
|
14
|
+
Optional,
|
|
15
|
+
Protocol,
|
|
16
|
+
runtime_checkable,
|
|
17
|
+
)
|
|
18
|
+
from danielutils import TemporaryFile, AsyncWorkerPool, RandomDataGenerator
|
|
19
|
+
from danielutils.async_.async_layered_command import AsyncLayeredCommand
|
|
20
|
+
|
|
21
|
+
from .enforcers import ExitEarlyError
|
|
22
|
+
from .strategies import (
|
|
23
|
+
PythonProvider,
|
|
24
|
+
QualityAssuranceRunner,
|
|
25
|
+
PackageManager,
|
|
26
|
+
PipPackageManager,
|
|
27
|
+
) # pylint: disable=relative-beyond-top-level
|
|
28
|
+
from .structures import Dependency, Version # pylint: disable=relative-beyond-top-level
|
|
29
|
+
from .enforcers import exit_if # pylint: disable=relative-beyond-top-level
|
|
30
|
+
from .worker_pool import WorkerPool
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
from danielutils import MultiContext # type:ignore
|
|
36
|
+
except ImportError:
|
|
37
|
+
|
|
38
|
+
class MultiContext(ContextManager[Any]): # type: ignore[misc,no-redef]
|
|
39
|
+
def __init__(self, *contexts: ContextManager[Any]) -> None:
|
|
40
|
+
self.contexts = contexts
|
|
41
|
+
|
|
42
|
+
def __enter__(self) -> Any: # type: ignore[return-value]
|
|
43
|
+
for context in self.contexts:
|
|
44
|
+
context.__enter__()
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def __exit__(
|
|
48
|
+
self,
|
|
49
|
+
exc_type: Optional[type[BaseException]],
|
|
50
|
+
exc_val: Optional[BaseException],
|
|
51
|
+
exc_tb: Optional[Any],
|
|
52
|
+
) -> Optional[bool]:
|
|
53
|
+
for context in self.contexts:
|
|
54
|
+
context.__exit__(exc_type, exc_val, exc_tb)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def __getitem__(self, index: int) -> ContextManager[Any]:
|
|
58
|
+
return self.contexts[index]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
ASYNC_POOL_NAME: str = "Quickpub QA"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@runtime_checkable
|
|
65
|
+
class SupportsProgress(Protocol):
|
|
66
|
+
"""Protocol for progress bar objects. Compatible with tqdm and similar progress tracking libraries."""
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def update(self, amount: int) -> None:
|
|
70
|
+
"""Update the progress bar by the specified amount."""
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
@abstractmethod
|
|
74
|
+
def total(self) -> int:
|
|
75
|
+
"""Get the total number of items to process."""
|
|
76
|
+
|
|
77
|
+
@total.setter
|
|
78
|
+
@abstractmethod
|
|
79
|
+
def total(self, amount: int) -> None:
|
|
80
|
+
"""Set the total number of items to process."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def global_import_sanity_check(
|
|
84
|
+
package_name: str,
|
|
85
|
+
executor: AsyncLayeredCommand,
|
|
86
|
+
is_system_interpreter: bool,
|
|
87
|
+
env_name: str,
|
|
88
|
+
task_id: int,
|
|
89
|
+
pbar: Optional[SupportsProgress] = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
logger.info(
|
|
92
|
+
"Running global import sanity check for package '%s' on environment '%s'",
|
|
93
|
+
package_name,
|
|
94
|
+
env_name,
|
|
95
|
+
)
|
|
96
|
+
try:
|
|
97
|
+
p = sys.executable if is_system_interpreter else "python"
|
|
98
|
+
file_name = f"./{RandomDataGenerator().name(15)}__sanity_check_main.py"
|
|
99
|
+
with TemporaryFile(file_name) as f:
|
|
100
|
+
f.writelines([f"from {package_name} import *"])
|
|
101
|
+
cmd = f"{p} {file_name}"
|
|
102
|
+
logger.debug("Executing sanity check command: %s", cmd)
|
|
103
|
+
code, stdout, stderr = await executor(cmd)
|
|
104
|
+
|
|
105
|
+
if code != 0:
|
|
106
|
+
logger.error(
|
|
107
|
+
"Sanity check failed for package '%s' on environment '%s' with return code %d",
|
|
108
|
+
package_name,
|
|
109
|
+
env_name,
|
|
110
|
+
code,
|
|
111
|
+
)
|
|
112
|
+
is_task_run_success[task_id] = False
|
|
113
|
+
else:
|
|
114
|
+
logger.debug(
|
|
115
|
+
"Sanity check passed for package '%s' on environment '%s'",
|
|
116
|
+
package_name,
|
|
117
|
+
env_name,
|
|
118
|
+
)
|
|
119
|
+
is_task_run_success[task_id] = True
|
|
120
|
+
|
|
121
|
+
msg = f"Env '{env_name}' failed sanity check."
|
|
122
|
+
if stderr:
|
|
123
|
+
if stderr[0] == "Traceback (most recent call last):":
|
|
124
|
+
msg += f" Got error '{stderr[-1]}' when tried 'from {package_name} import *'"
|
|
125
|
+
else:
|
|
126
|
+
msg += f" Try manually running the following script 'from {package_name} import *'"
|
|
127
|
+
exit_if(code != 0, msg)
|
|
128
|
+
except Exception as e:
|
|
129
|
+
logger.error(
|
|
130
|
+
"Sanity check encountered unexpected error for package '%s' on environment '%s': %s",
|
|
131
|
+
package_name,
|
|
132
|
+
env_name,
|
|
133
|
+
e,
|
|
134
|
+
)
|
|
135
|
+
is_task_run_success[task_id] = False
|
|
136
|
+
raise
|
|
137
|
+
finally:
|
|
138
|
+
if pbar is not None:
|
|
139
|
+
pbar.update(1)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def _get_installed_packages(
|
|
143
|
+
executor: AsyncLayeredCommand, env_name: str
|
|
144
|
+
) -> Dict[str, Union[str, Dependency]]:
|
|
145
|
+
return await PipPackageManager().list_installed(executor, env_name)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def validate_dependencies(
|
|
149
|
+
validation_exit_on_fail: bool,
|
|
150
|
+
required_dependencies: List[Dependency],
|
|
151
|
+
executor: AsyncLayeredCommand,
|
|
152
|
+
env_name: str,
|
|
153
|
+
task_id: int,
|
|
154
|
+
pbar: Optional[SupportsProgress] = None,
|
|
155
|
+
package_manager: Optional[PackageManager] = None,
|
|
156
|
+
) -> None:
|
|
157
|
+
if package_manager is None:
|
|
158
|
+
package_manager = PipPackageManager()
|
|
159
|
+
logger.info("Validating dependencies on environment '%s'", env_name)
|
|
160
|
+
try:
|
|
161
|
+
if validation_exit_on_fail:
|
|
162
|
+
currently_installed = await package_manager.list_installed(
|
|
163
|
+
executor, env_name
|
|
164
|
+
)
|
|
165
|
+
not_installed_properly = _check_dependency_satisfaction(
|
|
166
|
+
required_dependencies, currently_installed
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if not_installed_properly:
|
|
170
|
+
logger.error(
|
|
171
|
+
"Dependency validation failed on environment '%s': %s",
|
|
172
|
+
env_name,
|
|
173
|
+
not_installed_properly,
|
|
174
|
+
)
|
|
175
|
+
is_task_run_success[task_id] = False
|
|
176
|
+
else:
|
|
177
|
+
logger.debug(
|
|
178
|
+
"Dependency validation passed on environment '%s'", env_name
|
|
179
|
+
)
|
|
180
|
+
is_task_run_success[task_id] = True
|
|
181
|
+
|
|
182
|
+
exit_if(
|
|
183
|
+
bool(not_installed_properly),
|
|
184
|
+
f"On env '{env_name}' the following dependencies have problems: {(not_installed_properly)}",
|
|
185
|
+
)
|
|
186
|
+
except Exception as e:
|
|
187
|
+
logger.error(
|
|
188
|
+
"Dependency validation encountered unexpected error on environment '%s': %s",
|
|
189
|
+
env_name,
|
|
190
|
+
e,
|
|
191
|
+
)
|
|
192
|
+
is_task_run_success[task_id] = False
|
|
193
|
+
raise
|
|
194
|
+
finally:
|
|
195
|
+
if pbar is not None:
|
|
196
|
+
pbar.update(1)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _check_dependency_satisfaction(
|
|
200
|
+
required_dependencies: List[Dependency],
|
|
201
|
+
currently_installed: Dict[str, Union[str, Dependency]],
|
|
202
|
+
) -> List[Tuple[Dependency, str]]:
|
|
203
|
+
not_installed_properly: List[Tuple[Dependency, str]] = []
|
|
204
|
+
for req in required_dependencies:
|
|
205
|
+
if req.name not in currently_installed:
|
|
206
|
+
not_installed_properly.append((req, "dependency not found"))
|
|
207
|
+
else:
|
|
208
|
+
v = currently_installed[req.name]
|
|
209
|
+
if isinstance(v, str):
|
|
210
|
+
not_installed_properly.append(
|
|
211
|
+
(
|
|
212
|
+
req,
|
|
213
|
+
"Version format of dependency is not currently supported by quickpub",
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
elif isinstance(v, Dependency):
|
|
217
|
+
if not req.is_satisfied_by(v.ver):
|
|
218
|
+
not_installed_properly.append((req, "Invalid version installed"))
|
|
219
|
+
return not_installed_properly
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# Track all QA tasks (dependencies, sanity checks, QA runners)
|
|
223
|
+
is_task_run_success: List[bool] = []
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def run_config(
|
|
227
|
+
env_name: str,
|
|
228
|
+
async_executor: AsyncLayeredCommand,
|
|
229
|
+
runner: QualityAssuranceRunner,
|
|
230
|
+
config_id: int,
|
|
231
|
+
task_id: int,
|
|
232
|
+
*,
|
|
233
|
+
is_system_interpreter: bool,
|
|
234
|
+
validation_exit_on_fail: bool,
|
|
235
|
+
src_folder_path: str,
|
|
236
|
+
pbar: Optional[SupportsProgress] = None,
|
|
237
|
+
) -> None:
|
|
238
|
+
logger.info(
|
|
239
|
+
"Running QA config %d on environment '%s' with runner '%s'",
|
|
240
|
+
config_id,
|
|
241
|
+
env_name,
|
|
242
|
+
runner.__class__.__name__,
|
|
243
|
+
)
|
|
244
|
+
try:
|
|
245
|
+
await runner.run(
|
|
246
|
+
src_folder_path,
|
|
247
|
+
async_executor,
|
|
248
|
+
use_system_interpreter=is_system_interpreter,
|
|
249
|
+
env_name=env_name,
|
|
250
|
+
)
|
|
251
|
+
logger.debug(
|
|
252
|
+
"QA config %d completed successfully on environment '%s'",
|
|
253
|
+
config_id,
|
|
254
|
+
env_name,
|
|
255
|
+
)
|
|
256
|
+
is_task_run_success[task_id] = True
|
|
257
|
+
except ExitEarlyError as e:
|
|
258
|
+
logger.error(
|
|
259
|
+
"QA config %d failed on environment '%s': %s", config_id, env_name, e
|
|
260
|
+
)
|
|
261
|
+
is_task_run_success[task_id] = False
|
|
262
|
+
raise e
|
|
263
|
+
except Exception as e:
|
|
264
|
+
logger.error(
|
|
265
|
+
"QA config %d encountered unexpected error on environment '%s': %s",
|
|
266
|
+
config_id,
|
|
267
|
+
env_name,
|
|
268
|
+
e,
|
|
269
|
+
)
|
|
270
|
+
is_task_run_success[task_id] = False
|
|
271
|
+
if validation_exit_on_fail:
|
|
272
|
+
raise RuntimeError(
|
|
273
|
+
f"QA config {config_id} failed on environment '{env_name}': {e}"
|
|
274
|
+
) from e
|
|
275
|
+
return
|
|
276
|
+
finally:
|
|
277
|
+
if pbar is not None:
|
|
278
|
+
pbar.update(1)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _setup_qa_environment(
|
|
282
|
+
python_provider: PythonProvider,
|
|
283
|
+
) -> bool:
|
|
284
|
+
from .strategies import DefaultPythonProvider
|
|
285
|
+
|
|
286
|
+
return isinstance(python_provider, DefaultPythonProvider)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
async def _submit_qa_tasks(
|
|
290
|
+
python_provider: PythonProvider,
|
|
291
|
+
quality_assurance_strategies: List[QualityAssuranceRunner],
|
|
292
|
+
package_name: str,
|
|
293
|
+
src_folder_path: str,
|
|
294
|
+
dependencies: List[Dependency],
|
|
295
|
+
is_system_interpreter: bool,
|
|
296
|
+
pool: WorkerPool,
|
|
297
|
+
pbar: Optional[SupportsProgress],
|
|
298
|
+
package_manager: Optional[PackageManager] = None,
|
|
299
|
+
) -> int:
|
|
300
|
+
if package_manager is None:
|
|
301
|
+
package_manager = PipPackageManager()
|
|
302
|
+
total = 0
|
|
303
|
+
task_id = 0
|
|
304
|
+
with AsyncLayeredCommand() as base:
|
|
305
|
+
async for env_name, async_executor in python_provider:
|
|
306
|
+
logger.debug("Setting up QA tasks for environment '%s'", env_name)
|
|
307
|
+
with async_executor:
|
|
308
|
+
async_executor.prev = base
|
|
309
|
+
await package_manager.prepare(async_executor)
|
|
310
|
+
await pool.submit(
|
|
311
|
+
validate_dependencies,
|
|
312
|
+
args=[
|
|
313
|
+
python_provider.exit_on_fail,
|
|
314
|
+
dependencies,
|
|
315
|
+
async_executor,
|
|
316
|
+
env_name,
|
|
317
|
+
task_id,
|
|
318
|
+
pbar,
|
|
319
|
+
],
|
|
320
|
+
kwargs={"package_manager": package_manager},
|
|
321
|
+
name=f"Validate dependencies for env '{env_name}'",
|
|
322
|
+
)
|
|
323
|
+
total += 1
|
|
324
|
+
task_id += 1
|
|
325
|
+
await pool.submit(
|
|
326
|
+
global_import_sanity_check,
|
|
327
|
+
args=[
|
|
328
|
+
package_name,
|
|
329
|
+
async_executor,
|
|
330
|
+
is_system_interpreter,
|
|
331
|
+
env_name,
|
|
332
|
+
task_id,
|
|
333
|
+
pbar,
|
|
334
|
+
],
|
|
335
|
+
name=f"Global Import Sanity Check for env '{env_name}'",
|
|
336
|
+
)
|
|
337
|
+
total += 1
|
|
338
|
+
task_id += 1
|
|
339
|
+
for runner in quality_assurance_strategies:
|
|
340
|
+
await pool.submit(
|
|
341
|
+
run_config,
|
|
342
|
+
args=[env_name, async_executor, runner, task_id, task_id],
|
|
343
|
+
kwargs=dict(
|
|
344
|
+
src_folder_path=src_folder_path,
|
|
345
|
+
is_system_interpreter=is_system_interpreter,
|
|
346
|
+
validation_exit_on_fail=python_provider.exit_on_fail,
|
|
347
|
+
pbar=pbar,
|
|
348
|
+
),
|
|
349
|
+
name=f"Run config for '{env_name}' + '{runner.__class__.__qualname__}'",
|
|
350
|
+
)
|
|
351
|
+
total += 1
|
|
352
|
+
task_id += 1
|
|
353
|
+
if pbar is not None:
|
|
354
|
+
pbar.total = total
|
|
355
|
+
for _ in range(task_id):
|
|
356
|
+
is_task_run_success.append(False)
|
|
357
|
+
return total
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
async def _execute_qa_tasks(
|
|
361
|
+
pool: WorkerPool,
|
|
362
|
+
total: int,
|
|
363
|
+
qa_start_time: float,
|
|
364
|
+
) -> bool:
|
|
365
|
+
logger.info("Starting QA worker pool with %d total tasks", total)
|
|
366
|
+
await pool.start()
|
|
367
|
+
await pool.join()
|
|
368
|
+
success = all(is_task_run_success)
|
|
369
|
+
elapsed = time.perf_counter() - qa_start_time
|
|
370
|
+
logger.info("QA process completed in %.3fs. Success: %s", elapsed, success)
|
|
371
|
+
logger.debug("Task success breakdown: %s", is_task_run_success)
|
|
372
|
+
return success
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
async def qa(
|
|
376
|
+
python_provider: PythonProvider,
|
|
377
|
+
quality_assurance_strategies: List[QualityAssuranceRunner],
|
|
378
|
+
package_name: str,
|
|
379
|
+
src_folder_path: str,
|
|
380
|
+
dependencies: List[Dependency],
|
|
381
|
+
pbar: Optional[SupportsProgress] = None,
|
|
382
|
+
package_manager: Optional[PackageManager] = None,
|
|
383
|
+
) -> bool:
|
|
384
|
+
if package_manager is None:
|
|
385
|
+
package_manager = PipPackageManager()
|
|
386
|
+
logger.info(
|
|
387
|
+
"Starting QA process for package '%s' with %d QA strategies",
|
|
388
|
+
package_name,
|
|
389
|
+
len(quality_assurance_strategies),
|
|
390
|
+
)
|
|
391
|
+
qa_start_time = time.perf_counter()
|
|
392
|
+
is_task_run_success.clear()
|
|
393
|
+
is_system_interpreter = _setup_qa_environment(python_provider)
|
|
394
|
+
pool = WorkerPool(ASYNC_POOL_NAME, num_workers=5)
|
|
395
|
+
total = await _submit_qa_tasks(
|
|
396
|
+
python_provider,
|
|
397
|
+
quality_assurance_strategies,
|
|
398
|
+
package_name,
|
|
399
|
+
src_folder_path,
|
|
400
|
+
dependencies,
|
|
401
|
+
is_system_interpreter,
|
|
402
|
+
pool,
|
|
403
|
+
pbar,
|
|
404
|
+
package_manager,
|
|
405
|
+
)
|
|
406
|
+
return await _execute_qa_tasks(pool, total, qa_start_time)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
__all__ = ["qa", "SupportsProgress"]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from .implementations import *
|
|
2
|
+
from .upload_target import *
|
|
3
|
+
from .quality_assurance_runner import *
|
|
4
|
+
from .quickpub_strategy import *
|
|
5
|
+
from .build_schema import *
|
|
6
|
+
from .python_provider import *
|
|
7
|
+
from .package_manager import *
|
|
8
|
+
from .constraint_enforcer import *
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from abc import abstractmethod
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .quickpub_strategy import QuickpubStrategy
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BuildSchema(QuickpubStrategy):
|
|
11
|
+
"""Base class for build schema implementations. Subclass this to define custom build strategies."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, verbose: bool = True) -> None:
|
|
14
|
+
self.verbose = verbose
|
|
15
|
+
logger.debug("BuildSchema initialized with verbose=%s", verbose)
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def build(self, *args: Any, **kwargs: Any) -> None: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__all__ = ["BuildSchema"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from abc import abstractmethod
|
|
2
|
+
from typing import Any, Type
|
|
3
|
+
|
|
4
|
+
from ..enforcers import ExitEarlyError
|
|
5
|
+
from .quickpub_strategy import QuickpubStrategy
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConstraintEnforcer(QuickpubStrategy):
|
|
9
|
+
"""Base class for constraint enforcer implementations. Subclass this to define custom constraint validation logic."""
|
|
10
|
+
|
|
11
|
+
EXCEPTION_TYPE: Type[Exception] = ExitEarlyError
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def enforce(self, **kwargs: Any) -> None: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = ["ConstraintEnforcer"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from danielutils import file_exists, LayeredCommand, delete_file
|
|
7
|
+
|
|
8
|
+
from ...build_schema import BuildSchema
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SetuptoolsBuildSchema(BuildSchema):
|
|
14
|
+
"""Build schema implementation using setuptools. Creates source distributions via setup.py."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, setup_file_path: str = "./setup.py") -> None:
|
|
17
|
+
self._setup_file_path = setup_file_path
|
|
18
|
+
|
|
19
|
+
def build(self, verbose: bool = False, *args, **kwargs) -> None:
|
|
20
|
+
if not file_exists(self._setup_file_path):
|
|
21
|
+
logger.error("Setup file not found: %s", self._setup_file_path)
|
|
22
|
+
raise self.EXCEPTION_TYPE(f"Could not find {self._setup_file_path} file")
|
|
23
|
+
|
|
24
|
+
if verbose:
|
|
25
|
+
logger.info("Creating new distribution...")
|
|
26
|
+
|
|
27
|
+
sources_file_path: str = str(
|
|
28
|
+
os.path.join(
|
|
29
|
+
str(Path(self._setup_file_path).parent.resolve()),
|
|
30
|
+
"quickpub.egg-info/SOURCES.txt",
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
delete_file(sources_file_path)
|
|
35
|
+
|
|
36
|
+
with LayeredCommand() as exc:
|
|
37
|
+
ret, stdout, stderr = exc(
|
|
38
|
+
sys.executable + " " + self._setup_file_path + " sdist"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if ret != 0:
|
|
42
|
+
logger.error("Build command failed with return code %d: %s", ret, stderr)
|
|
43
|
+
raise self.EXCEPTION_TYPE(stderr)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"SetuptoolsBuildSchema",
|
|
48
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from danielutils import file_exists, LayeredCommand
|
|
5
|
+
|
|
6
|
+
from ...build_schema import BuildSchema
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class UvBuildSchema(BuildSchema):
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
pyproject_path: str = "./pyproject.toml",
|
|
15
|
+
sdist: bool = True,
|
|
16
|
+
wheel: bool = True,
|
|
17
|
+
) -> None:
|
|
18
|
+
if not sdist and not wheel:
|
|
19
|
+
raise self.EXCEPTION_TYPE(
|
|
20
|
+
"UvBuildSchema requires at least one of sdist or wheel"
|
|
21
|
+
)
|
|
22
|
+
self._pyproject_path = pyproject_path
|
|
23
|
+
self._sdist = sdist
|
|
24
|
+
self._wheel = wheel
|
|
25
|
+
|
|
26
|
+
def build_command(self) -> str:
|
|
27
|
+
if self._sdist and self._wheel:
|
|
28
|
+
return "uv build"
|
|
29
|
+
if self._sdist:
|
|
30
|
+
return "uv build --sdist"
|
|
31
|
+
return "uv build --wheel"
|
|
32
|
+
|
|
33
|
+
def build(self, verbose: bool = False, *args: Any, **kwargs: Any) -> None:
|
|
34
|
+
if not file_exists(self._pyproject_path):
|
|
35
|
+
logger.error("pyproject.toml not found: %s", self._pyproject_path)
|
|
36
|
+
raise self.EXCEPTION_TYPE(f"Could not find {self._pyproject_path} file")
|
|
37
|
+
|
|
38
|
+
command = self.build_command()
|
|
39
|
+
if verbose:
|
|
40
|
+
logger.info("Creating new distribution with '%s'...", command)
|
|
41
|
+
|
|
42
|
+
with LayeredCommand() as executor:
|
|
43
|
+
ret, stdout, stderr = executor(command)
|
|
44
|
+
|
|
45
|
+
if ret != 0:
|
|
46
|
+
logger.error("Build command failed with return code %d: %s", ret, stderr)
|
|
47
|
+
raise self.EXCEPTION_TYPE(stderr)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__ = ["UvBuildSchema"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from danielutils import file_exists
|
|
5
|
+
|
|
6
|
+
from ...constraint_enforcer import ConstraintEnforcer
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LicenseEnforcer(ConstraintEnforcer):
|
|
12
|
+
"""Enforces the presence of a license file at the specified path."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, path: str = "./LICENSE") -> None:
|
|
15
|
+
self.path = path
|
|
16
|
+
|
|
17
|
+
def enforce(self, **kwargs: Any) -> None:
|
|
18
|
+
logger.info("Checking for license file at '%s'", self.path)
|
|
19
|
+
|
|
20
|
+
if not file_exists(self.path):
|
|
21
|
+
logger.error("License file not found at '%s'", self.path)
|
|
22
|
+
raise self.EXCEPTION_TYPE(f"Could not find license file at '{self.path}'")
|
|
23
|
+
|
|
24
|
+
logger.info("License file found at '%s'", self.path)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"LicenseEnforcer",
|
|
29
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
from danielutils import directory_exists, get_files
|
|
5
|
+
|
|
6
|
+
from quickpub import Version
|
|
7
|
+
from ...constraint_enforcer import ConstraintEnforcer
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _version_from_dist_filename(name: str, filename: str) -> Optional[Version]:
|
|
13
|
+
prefix = f"{name}-"
|
|
14
|
+
if not filename.startswith(prefix):
|
|
15
|
+
return None
|
|
16
|
+
if filename.endswith(".tar.gz"):
|
|
17
|
+
version_str = filename[len(prefix) : -len(".tar.gz")]
|
|
18
|
+
elif filename.endswith(".whl"):
|
|
19
|
+
version_str = filename[len(prefix) : -len(".whl")].split("-")[0]
|
|
20
|
+
else:
|
|
21
|
+
return None
|
|
22
|
+
try:
|
|
23
|
+
return Version.from_str(version_str)
|
|
24
|
+
except ValueError:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LocalVersionEnforcer(ConstraintEnforcer):
|
|
29
|
+
"""Enforces that the new version is greater than the highest version found in the local dist directory."""
|
|
30
|
+
|
|
31
|
+
def enforce(
|
|
32
|
+
self, name: str, version: Version, demo: bool = False, **kwargs: Any
|
|
33
|
+
) -> None: # type: ignore[override]
|
|
34
|
+
if demo:
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
logger.info(
|
|
38
|
+
"Checking local version for package '%s' against version '%s'",
|
|
39
|
+
name,
|
|
40
|
+
version,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if not directory_exists("./dist"):
|
|
44
|
+
logger.info("No dist directory found, skipping local version check")
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
prev_builds = get_files("./dist")
|
|
48
|
+
if len(prev_builds) == 0:
|
|
49
|
+
logger.info("No previous builds found in dist directory")
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
max_local_version = Version(0, 0, 0)
|
|
53
|
+
found_artifact = False
|
|
54
|
+
for filename in prev_builds:
|
|
55
|
+
parsed = _version_from_dist_filename(name, filename)
|
|
56
|
+
if parsed is None:
|
|
57
|
+
continue
|
|
58
|
+
found_artifact = True
|
|
59
|
+
max_local_version = max(max_local_version, parsed)
|
|
60
|
+
|
|
61
|
+
if not found_artifact:
|
|
62
|
+
logger.info("No sdist or wheel artifacts found in dist directory")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
if version <= max_local_version:
|
|
66
|
+
logger.error(
|
|
67
|
+
"Version conflict: specified '%s' is not greater than local '%s'",
|
|
68
|
+
version,
|
|
69
|
+
max_local_version,
|
|
70
|
+
)
|
|
71
|
+
raise self.EXCEPTION_TYPE(
|
|
72
|
+
f"Specified version is '{version}' but (locally available) latest existing is '{max_local_version}'"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
logger.info(
|
|
76
|
+
"Local version check passed: '%s' > '%s'", version, max_local_version
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
__all__ = ["LocalVersionEnforcer"]
|