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.
Files changed (58) hide show
  1. quickpub/__init__.py +10 -0
  2. quickpub/__main__.py +276 -0
  3. quickpub/classifiers.py +138 -0
  4. quickpub/dist_files.py +33 -0
  5. quickpub/enforcers.py +26 -0
  6. quickpub/files.py +246 -0
  7. quickpub/logging_.py +74 -0
  8. quickpub/package_manager_detection.py +43 -0
  9. quickpub/proxy.py +35 -0
  10. quickpub/py.typed +0 -0
  11. quickpub/qa.py +409 -0
  12. quickpub/strategies/__init__.py +8 -0
  13. quickpub/strategies/build_schema.py +21 -0
  14. quickpub/strategies/constraint_enforcer.py +17 -0
  15. quickpub/strategies/implementations/__init__.py +6 -0
  16. quickpub/strategies/implementations/build_schemas/__init__.py +2 -0
  17. quickpub/strategies/implementations/build_schemas/setuptools_build_schema.py +48 -0
  18. quickpub/strategies/implementations/build_schemas/uv_build_schema.py +50 -0
  19. quickpub/strategies/implementations/constraint_enforcers/__init__.py +5 -0
  20. quickpub/strategies/implementations/constraint_enforcers/license_enforcer.py +29 -0
  21. quickpub/strategies/implementations/constraint_enforcers/local_version_enforcer.py +80 -0
  22. quickpub/strategies/implementations/constraint_enforcers/pypi_remote_version_enforcer.py +88 -0
  23. quickpub/strategies/implementations/constraint_enforcers/pypirc_enforcer.py +45 -0
  24. quickpub/strategies/implementations/constraint_enforcers/readme_enforcer.py +29 -0
  25. quickpub/strategies/implementations/package_managers/__init__.py +2 -0
  26. quickpub/strategies/implementations/package_managers/installed_package_parser.py +29 -0
  27. quickpub/strategies/implementations/package_managers/pip_package_manager.py +31 -0
  28. quickpub/strategies/implementations/package_managers/uv_package_manager.py +45 -0
  29. quickpub/strategies/implementations/python_providers/__init__.py +4 -0
  30. quickpub/strategies/implementations/python_providers/conda_python_provider.py +54 -0
  31. quickpub/strategies/implementations/python_providers/default_python_provider.py +47 -0
  32. quickpub/strategies/implementations/python_providers/union_provider.py +52 -0
  33. quickpub/strategies/implementations/python_providers/uv_python_provider.py +39 -0
  34. quickpub/strategies/implementations/quality_assurance_runners/__init__.py +4 -0
  35. quickpub/strategies/implementations/quality_assurance_runners/mypy_qa_runner.py +98 -0
  36. quickpub/strategies/implementations/quality_assurance_runners/pylint_qa_runner.py +106 -0
  37. quickpub/strategies/implementations/quality_assurance_runners/pytest_qa_runner.py +188 -0
  38. quickpub/strategies/implementations/quality_assurance_runners/unittest_qa_runner.py +138 -0
  39. quickpub/strategies/implementations/upload_targets/__init__.py +2 -0
  40. quickpub/strategies/implementations/upload_targets/github_upload_target.py +61 -0
  41. quickpub/strategies/implementations/upload_targets/pypirc_upload_target.py +90 -0
  42. quickpub/strategies/package_manager.py +54 -0
  43. quickpub/strategies/python_provider.py +60 -0
  44. quickpub/strategies/quality_assurance_runner.py +208 -0
  45. quickpub/strategies/quickpub_strategy.py +14 -0
  46. quickpub/strategies/upload_target.py +19 -0
  47. quickpub/structures/__init__.py +3 -0
  48. quickpub/structures/bound.py +52 -0
  49. quickpub/structures/dependency.py +82 -0
  50. quickpub/structures/version.py +41 -0
  51. quickpub/validators.py +87 -0
  52. quickpub/worker_pool.py +16 -0
  53. quickpub-4.1.4.dist-info/METADATA +246 -0
  54. quickpub-4.1.4.dist-info/RECORD +58 -0
  55. quickpub-4.1.4.dist-info/WHEEL +5 -0
  56. quickpub-4.1.4.dist-info/entry_points.txt +2 -0
  57. quickpub-4.1.4.dist-info/licenses/LICENSE +21 -0
  58. 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,6 @@
1
+ from .constraint_enforcers import *
2
+ from .build_schemas import *
3
+ from .quality_assurance_runners import *
4
+ from .upload_targets import *
5
+ from .python_providers import *
6
+ from .package_managers import *
@@ -0,0 +1,2 @@
1
+ from .setuptools_build_schema import *
2
+ from .uv_build_schema import *
@@ -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,5 @@
1
+ from .local_version_enforcer import *
2
+ from .license_enforcer import *
3
+ from .pypi_remote_version_enforcer import *
4
+ from .pypirc_enforcer import *
5
+ from .readme_enforcer import *
@@ -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"]