simple-module-cli 0.0.32__tar.gz → 0.0.33__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.
Files changed (136) hide show
  1. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/PKG-INFO +1 -1
  2. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/pyproject.toml +1 -1
  3. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/package_update.py +40 -65
  4. simple_module_cli-0.0.33/simple_module_cli/pypi.py +55 -0
  5. simple_module_cli-0.0.33/simple_module_cli/requirements.py +232 -0
  6. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/main.py +5 -2
  7. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/conftest.py +38 -1
  8. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_cli_package_update.py +38 -41
  9. simple_module_cli-0.0.33/tests/test_cli_package_update_pins.py +279 -0
  10. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_module_css_build.py +12 -3
  11. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/.gitignore +0 -0
  12. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/LICENSE +0 -0
  13. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/README.md +0 -0
  14. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/__init__.py +0 -0
  15. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/_env.py +0 -0
  16. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/_module_build.py +0 -0
  17. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/_module_host.py +0 -0
  18. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/_templating.py +0 -0
  19. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/add_cmd.py +0 -0
  20. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/app_project.py +0 -0
  21. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/case.py +0 -0
  22. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/catalog.py +0 -0
  23. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/cli.py +0 -0
  24. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/docker_assets.py +0 -0
  25. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/git_source.py +0 -0
  26. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/module_cmd.py +0 -0
  27. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/new.py +0 -0
  28. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/pins.py +0 -0
  29. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/plugins.py +0 -0
  30. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/pyproject_edit.py +0 -0
  31. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/recipes.py +0 -0
  32. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/scaffolding.py +0 -0
  33. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/skills/README.md +0 -0
  34. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/skills/simple-module-cli/SKILL.md +0 -0
  35. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/skills/simple-module-conventions/SKILL.md +0 -0
  36. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/skills/simple-module-creating/SKILL.md +0 -0
  37. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/skills/simple-module-database/SKILL.md +0 -0
  38. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/skills/simple-module-doctor/SKILL.md +0 -0
  39. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/skills/simple-module-inertia-pages/SKILL.md +0 -0
  40. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/skills/simple-module-locales/SKILL.md +0 -0
  41. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/skills/simple-module-migrations/SKILL.md +0 -0
  42. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/skills/simple-module-registries/SKILL.md +0 -0
  43. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/skills/simple-module-testing/SKILL.md +0 -0
  44. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/skills_cmd.py +0 -0
  45. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/docker/Makefile.snippet +0 -0
  46. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/docker/compose-base-postgres.yml.tpl +0 -0
  47. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/docker/compose-base-sqlite.yml.tpl +0 -0
  48. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/docker/compose-tasks-postgres.yml.tpl +0 -0
  49. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/docker/compose-tasks-sqlite.yml.tpl +0 -0
  50. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/docker/dockerignore +0 -0
  51. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/docker/host-flat.Dockerfile +0 -0
  52. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/docker/host.Dockerfile +0 -0
  53. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/.env.example +0 -0
  54. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/.gitignore +0 -0
  55. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/Makefile +0 -0
  56. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/README.md.tpl +0 -0
  57. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/_optional/background_tasks/Makefile.snippet +0 -0
  58. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/_optional/background_tasks/run_worker.py +0 -0
  59. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/alembic.ini +0 -0
  60. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/client_app/app.tsx +0 -0
  61. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/client_app/main.tsx +0 -0
  62. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/client_app/module-assets.ts +0 -0
  63. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/client_app/package.json.tpl +0 -0
  64. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/client_app/pages/Error.tsx +0 -0
  65. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/client_app/pages/Landing.tsx +0 -0
  66. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/client_app/pages.ts +0 -0
  67. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/client_app/styles.css +0 -0
  68. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/client_app/tsconfig.json +0 -0
  69. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/client_app/vite.config.ts +0 -0
  70. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/client_app/vite.dev-url.ts +0 -0
  71. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/migrations/env.py +0 -0
  72. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/migrations/script.py.mako +0 -0
  73. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/migrations/versions/.gitkeep +0 -0
  74. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/pyproject.toml.tpl +0 -0
  75. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/routes.py +0 -0
  76. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/host/templates/index.html +0 -0
  77. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/module/.github/workflows/ci.yml +0 -0
  78. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/module/.github/workflows/publish.yml.tpl +0 -0
  79. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/module/.gitignore +0 -0
  80. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/module/README.md.tpl +0 -0
  81. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/module/__PACKAGE__/__init__.py +0 -0
  82. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/module/__PACKAGE__/endpoints/__init__.py +0 -0
  83. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/module/__PACKAGE__/endpoints/api.py.tpl +0 -0
  84. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/module/__PACKAGE__/endpoints/views.py.tpl +0 -0
  85. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/module/__PACKAGE__/module.py.tpl +0 -0
  86. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/module/__PACKAGE__/pages/Index.tsx.tpl +0 -0
  87. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/module/__PACKAGE__/services.py.tpl +0 -0
  88. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/module/__PACKAGE__/settings.py.tpl +0 -0
  89. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/module/_optional/standalone/package.json.tpl +0 -0
  90. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/module/_optional/standalone/tsconfig.json.tpl +0 -0
  91. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/module/package.json.tpl +0 -0
  92. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/module/pyproject.toml.tpl +0 -0
  93. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/module/tests/test___PACKAGE__.py.tpl +0 -0
  94. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/module/tsconfig.json.tpl +0 -0
  95. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/workspace/.env.example +0 -0
  96. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/workspace/.gitignore +0 -0
  97. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/workspace/Makefile +0 -0
  98. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/workspace/README.md.tpl +0 -0
  99. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/workspace/package.json.tpl +0 -0
  100. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/templates/workspace/pyproject.toml.tpl +0 -0
  101. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/update_cmd.py +0 -0
  102. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/simple_module_cli/wizard.py +0 -0
  103. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_build_packaging.py +0 -0
  104. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_case.py +0 -0
  105. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_cli_add_cmd.py +0 -0
  106. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_cli_add_spec_parse.py +0 -0
  107. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_cli_catalog.py +0 -0
  108. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_cli_docker_assets.py +0 -0
  109. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_cli_git_scan.py +0 -0
  110. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_cli_new.py +0 -0
  111. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_cli_new_dest_tolerance.py +0 -0
  112. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_cli_new_git_module.py +0 -0
  113. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_cli_new_migration_bootstrap.py +0 -0
  114. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_cli_new_regressions.py +0 -0
  115. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_cli_new_scaffold_layout.py +0 -0
  116. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_cli_pyproject_edit.py +0 -0
  117. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_cli_recipes.py +0 -0
  118. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_cli_update_sources.py +0 -0
  119. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_cli_vite_port_env.py +0 -0
  120. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_cli_wizard.py +0 -0
  121. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_cli_wizard_git.py +0 -0
  122. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_create_module_ci_skip.py +0 -0
  123. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_env_helper.py +0 -0
  124. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_module_cmd_build.py +0 -0
  125. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_module_cmd_e2e.py +0 -0
  126. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_module_cmd_verify.py +0 -0
  127. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_module_css_assets.py +0 -0
  128. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_module_npm_aliases.py +0 -0
  129. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_module_pages_manifest.py +0 -0
  130. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_no_framework_deps.py +0 -0
  131. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_plugin_discovery.py +0 -0
  132. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_scaffold_rollback.py +0 -0
  133. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_scaffolding_host.py +0 -0
  134. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_scaffolding_module.py +0 -0
  135. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_scaffolding_module_js.py +0 -0
  136. {simple_module_cli-0.0.32 → simple_module_cli-0.0.33}/tests/test_skills_cmd.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_cli
3
- Version: 0.0.32
3
+ Version: 0.0.33
4
4
  Summary: Standalone scaffolder for the SimpleModule framework — `smpy new`, `smpy create-module`, plugin host.
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_cli"
3
- version = "0.0.32"
3
+ version = "0.0.33"
4
4
  description = "Standalone scaffolder for the SimpleModule framework — `smpy new`, `smpy create-module`, plugin host."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -2,8 +2,14 @@
2
2
 
3
3
  Walks the project's ``pyproject.toml`` (and any ``[tool.uv.workspace]`` members),
4
4
  finds every dependency whose distribution name starts with ``simple_module_`` /
5
- ``simple-module-``, queries PyPI for the latest non-yanked release, and rewrites
6
- the constraint to ``name>=<latest>``.
5
+ ``simple-module-``, queries PyPI for the latest non-yanked release, and points
6
+ the constraint at it.
7
+
8
+ The rewrite preserves the pin style you wrote: ``==0.0.32`` becomes
9
+ ``==0.0.33``, ``>=0.0.32`` becomes ``>=0.0.33``, and an upper bound that
10
+ excludes the latest release skips the dependency instead of being dropped.
11
+ ``--loosen`` restores the older behaviour of rewriting every constraint to
12
+ ``name>=<latest>``.
7
13
 
8
14
  Dependencies whose ``[tool.uv.sources]`` entry points at a workspace member, a
9
15
  local path, a git ref, or a URL are left untouched — those aren't installed
@@ -12,11 +18,7 @@ from PyPI.
12
18
 
13
19
  from __future__ import annotations
14
20
 
15
- import json
16
21
  import re
17
- import urllib.error
18
- import urllib.request
19
- from collections.abc import Callable
20
22
  from concurrent.futures import ThreadPoolExecutor
21
23
  from dataclasses import dataclass
22
24
  from pathlib import Path
@@ -26,17 +28,12 @@ import tomlkit
26
28
  import typer
27
29
  from tomlkit.items import Array, Table
28
30
 
29
- __all__ = ["package_update", "run_update"]
31
+ from simple_module_cli.pypi import Fetcher, default_fetcher, fetch_latest
32
+ from simple_module_cli.requirements import parse_requirement, rewrite_requirement
30
33
 
31
- Fetcher = Callable[[str], dict[str, Any]]
34
+ __all__ = ["package_update", "run_update"]
32
35
 
33
- _PYPI_URL = "https://pypi.org/pypi/{name}/json"
34
36
  _SM_PREFIX_RE = re.compile(r"^simple[_-]module[_-]", re.IGNORECASE)
35
- # PEP 440 release segments contain only digits + dots; any letter signals
36
- # a pre/post/dev release (a, b, rc, post, dev). Coarser than packaging.version
37
- # but `packaging` isn't a CLI dep (see test_no_framework_deps.py).
38
- _PRE_RELEASE_RE = re.compile(r"[a-zA-Z]")
39
- _REQ_OPS = ("===", "==", ">=", "<=", "!=", "~=", ">", "<")
40
37
 
41
38
 
42
39
  @dataclass(frozen=True)
@@ -60,14 +57,8 @@ def _is_sm_package(name: str) -> bool:
60
57
 
61
58
  def _dep_name(spec: str) -> str | None:
62
59
  """Extract the distribution name from a PEP 508 requirement string."""
63
- base = spec.split(";", 1)[0].strip()
64
- base = base.split("[", 1)[0]
65
- for op in _REQ_OPS:
66
- if op in base:
67
- base = base.split(op, 1)[0]
68
- break
69
- name = base.strip()
70
- return name or None
60
+ parsed = parse_requirement(spec)
61
+ return parsed.name if parsed else None
71
62
 
72
63
 
73
64
  def _is_local_source(source: Any) -> bool:
@@ -89,40 +80,6 @@ def _get_uv_section(doc: tomlkit.TOMLDocument, key: str) -> dict[str, Any] | Non
89
80
  return section if isinstance(section, dict) else None
90
81
 
91
82
 
92
- def _fetch_latest(name: str, *, include_pre: bool, fetcher: Fetcher) -> str | None:
93
- try:
94
- data = fetcher(_PYPI_URL.format(name=name))
95
- except (urllib.error.HTTPError, urllib.error.URLError):
96
- return None
97
- releases = data.get("releases") or {}
98
- candidates: list[str] = []
99
- for version, files in releases.items():
100
- if not files:
101
- continue
102
- if any(f.get("yanked") for f in files):
103
- continue
104
- if not include_pre and _PRE_RELEASE_RE.search(version):
105
- continue
106
- candidates.append(version)
107
- if candidates:
108
- return max(candidates, key=_version_key)
109
- info = data.get("info") or {}
110
- return info.get("version")
111
-
112
-
113
- def _version_key(v: str) -> tuple[int, ...]:
114
- parts: list[int] = []
115
- for part in v.split("."):
116
- digits = re.match(r"\d+", part)
117
- parts.append(int(digits.group()) if digits else 0)
118
- return tuple(parts)
119
-
120
-
121
- def _default_fetcher(url: str) -> dict[str, Any]:
122
- with urllib.request.urlopen(url, timeout=10) as resp:
123
- return json.loads(resp.read().decode("utf-8"))
124
-
125
-
126
83
  def _workspace_member_dirs(root_pyproject: Path, doc: tomlkit.TOMLDocument) -> list[Path]:
127
84
  workspace = _get_uv_section(doc, "workspace")
128
85
  if workspace is None:
@@ -166,6 +123,7 @@ def _process_file(
166
123
  doc: tomlkit.TOMLDocument,
167
124
  *,
168
125
  cache: dict[str, str | None],
126
+ loosen: bool = False,
169
127
  ) -> tuple[list[Change], list[Skip], tomlkit.TOMLDocument | None]:
170
128
  project = doc.get("project")
171
129
  if not isinstance(project, (dict, Table)):
@@ -190,11 +148,13 @@ def _process_file(
190
148
  if latest is None:
191
149
  skips.append(Skip(path, name, "not found on PyPI"))
192
150
  continue
193
- new_dep = f"{name}>={latest}"
194
- if new_dep == dep_str.strip():
151
+ result = rewrite_requirement(dep_str, latest, loosen=loosen)
152
+ if result.spec is None:
153
+ if result.reason:
154
+ skips.append(Skip(path, name, result.reason))
195
155
  continue
196
- deps[idx] = new_dep
197
- changes.append(Change(path, name, dep_str.strip(), new_dep))
156
+ deps[idx] = result.spec
157
+ changes.append(Change(path, name, dep_str.strip(), result.spec))
198
158
 
199
159
  return changes, skips, doc if changes else None
200
160
 
@@ -226,6 +186,7 @@ def run_update(
226
186
  *,
227
187
  dry_run: bool = False,
228
188
  include_pre: bool = False,
189
+ loosen: bool = False,
229
190
  fetcher: Fetcher | None = None,
230
191
  ) -> None:
231
192
  """Programmatic entry point — separated from the Typer command for testing."""
@@ -234,7 +195,7 @@ def run_update(
234
195
  typer.echo(f"ERROR: {root} not found.", err=True)
235
196
  raise typer.Exit(code=1)
236
197
 
237
- fetch = fetcher or _default_fetcher
198
+ fetch = fetcher or default_fetcher
238
199
  root_doc = tomlkit.parse(root.read_text(encoding="utf-8"))
239
200
  files: list[tuple[Path, tomlkit.TOMLDocument]] = [(root, root_doc)]
240
201
  for member in _workspace_member_dirs(root, root_doc):
@@ -246,7 +207,7 @@ def run_update(
246
207
  if unique_names:
247
208
  with ThreadPoolExecutor(max_workers=min(8, len(unique_names))) as pool:
248
209
  results = pool.map(
249
- lambda n: (n, _fetch_latest(n, include_pre=include_pre, fetcher=fetch)),
210
+ lambda n: (n, fetch_latest(n, include_pre=include_pre, fetcher=fetch)),
250
211
  unique_names,
251
212
  )
252
213
  cache = dict(results)
@@ -256,7 +217,7 @@ def run_update(
256
217
  pending: list[tuple[Path, tomlkit.TOMLDocument]] = []
257
218
 
258
219
  for file, doc in files:
259
- changes, skips, new_doc = _process_file(file, doc, cache=cache)
220
+ changes, skips, new_doc = _process_file(file, doc, cache=cache, loosen=loosen)
260
221
  all_changes.extend(changes)
261
222
  all_skips.extend(skips)
262
223
  if new_doc is not None:
@@ -282,6 +243,20 @@ def package_update(
282
243
  bool,
283
244
  typer.Option("--include-pre", help="Include pre-release versions."),
284
245
  ] = False,
246
+ loosen: Annotated[
247
+ bool,
248
+ typer.Option(
249
+ "--loosen",
250
+ help="Rewrite every constraint to `>=<latest>` instead of keeping its operator.",
251
+ ),
252
+ ] = False,
285
253
  ) -> None:
286
- """Update all simple_module_* dependencies to the latest PyPI versions."""
287
- run_update(path, dry_run=dry_run, include_pre=include_pre)
254
+ """Update all simple_module_* dependencies to the latest PyPI versions.
255
+
256
+ Each constraint keeps the operator it already has — `==0.0.32` becomes
257
+ `==0.0.33`, `>=0.0.32` becomes `>=0.0.33` — so bumping versions never
258
+ silently changes a project's pinning policy. A dependency whose upper
259
+ bound excludes the latest release is reported and left alone. Pass
260
+ `--loosen` to rewrite everything to `>=<latest>` instead.
261
+ """
262
+ run_update(path, dry_run=dry_run, include_pre=include_pre, loosen=loosen)
@@ -0,0 +1,55 @@
1
+ """PyPI release lookup for ``smpy package-update``.
2
+
3
+ Split out of ``package_update`` so the network half is separately testable and
4
+ neither file approaches the 300-line cap. The ``Fetcher`` indirection is what
5
+ lets the CLI tests run without touching the network.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ import urllib.error
13
+ import urllib.request
14
+ from collections.abc import Callable
15
+ from typing import Any
16
+
17
+ from simple_module_cli.requirements import version_key
18
+
19
+ __all__ = ["PYPI_URL", "Fetcher", "default_fetcher", "fetch_latest"]
20
+
21
+ Fetcher = Callable[[str], dict[str, Any]]
22
+
23
+ PYPI_URL = "https://pypi.org/pypi/{name}/json"
24
+
25
+ # PEP 440 release segments contain only digits + dots; any letter signals
26
+ # a pre/post/dev release (a, b, rc, post, dev). Coarser than packaging.version
27
+ # but `packaging` isn't a CLI dep (see test_no_framework_deps.py).
28
+ _PRE_RELEASE_RE = re.compile(r"[a-zA-Z]")
29
+
30
+
31
+ def fetch_latest(name: str, *, include_pre: bool, fetcher: Fetcher) -> str | None:
32
+ """Latest non-yanked release of ``name``, or ``None`` if PyPI doesn't have it."""
33
+ try:
34
+ data = fetcher(PYPI_URL.format(name=name))
35
+ except (urllib.error.HTTPError, urllib.error.URLError):
36
+ return None
37
+ releases = data.get("releases") or {}
38
+ candidates: list[str] = []
39
+ for version, files in releases.items():
40
+ if not files:
41
+ continue
42
+ if any(f.get("yanked") for f in files):
43
+ continue
44
+ if not include_pre and _PRE_RELEASE_RE.search(version):
45
+ continue
46
+ candidates.append(version)
47
+ if candidates:
48
+ return max(candidates, key=version_key)
49
+ info = data.get("info") or {}
50
+ return info.get("version")
51
+
52
+
53
+ def default_fetcher(url: str) -> dict[str, Any]:
54
+ with urllib.request.urlopen(url, timeout=10) as resp:
55
+ return json.loads(resp.read().decode("utf-8"))
@@ -0,0 +1,232 @@
1
+ """PEP 508 requirement rewriting for ``smpy package-update``.
2
+
3
+ Split out of ``package_update`` so the version-bump rules live in one place
4
+ with their own tests, and so neither file approaches the 300-line cap.
5
+
6
+ The rule that matters: bumping a dependency changes its *version*, not its
7
+ *pin style*. ``simple_module_core==0.0.32`` becomes ``==0.0.33``, not
8
+ ``>=0.0.33`` — a host that pins exactly has made a deliberate choice, and the
9
+ published module wheels pin exactly too, so loosening the host leaves the
10
+ effective version decided by whichever wheel pins hardest. ``--loosen``
11
+ restores the old blanket ``>=`` rewrite for anyone who wants it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from dataclasses import dataclass
18
+
19
+ __all__ = ["ParsedRequirement", "RewriteResult", "parse_requirement", "rewrite_requirement"]
20
+
21
+ # Longest-first: ``===`` must win over ``==``, and ``>=`` over ``>``.
22
+ _OPS = ("===", "==", "~=", "!=", ">=", "<=", ">", "<")
23
+ _CLAUSE_RE = re.compile(rf"\s*({'|'.join(re.escape(op) for op in _OPS)})\s*([^,]+)")
24
+
25
+ #: Operators naming a plain floor the bump should raise. ``>`` is deliberately
26
+ #: absent: ``>0.0.31`` is already satisfied by anything newer, and rewriting it
27
+ #: to ``>0.0.33`` would exclude the very version being installed. ``~=`` is
28
+ #: absent too — it carries an implicit ceiling, handled separately below.
29
+ _FLOOR_OPS = frozenset({"==", "===", ">="})
30
+
31
+ #: Suffix marking a PEP 440 wildcard (``==1.4.*``, ``!=1.0.*``). These match on
32
+ #: a *prefix* of the release segments, so they must never go through the
33
+ #: numeric comparison — ``version_key`` maps ``*`` to 0, which would read
34
+ #: ``1.0.*`` as ``1.0.0`` and quietly answer the wrong question.
35
+ _WILDCARD = ".*"
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ParsedRequirement:
40
+ """A PEP 508 requirement split into the parts the rewrite cares about."""
41
+
42
+ name: str
43
+ #: Extras bracket including the brackets (``"[redis]"``), or ``""``.
44
+ extras: str
45
+ #: ``(operator, version)`` pairs in source order.
46
+ clauses: tuple[tuple[str, str], ...]
47
+ #: Environment marker including the leading ``;``, or ``""``.
48
+ marker: str
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class RewriteResult:
53
+ """Outcome of a rewrite: a new spec, or a reason there isn't one."""
54
+
55
+ #: The rewritten requirement, or ``None`` when nothing should change.
56
+ spec: str | None
57
+ #: Human-readable reason when ``spec`` is ``None`` and the user should be
58
+ #: told (an excluded latest); ``None`` when the no-op is unremarkable.
59
+ reason: str | None = None
60
+
61
+
62
+ def version_key(version: str) -> tuple[int, ...]:
63
+ """Coarse PEP 440 release-segment ordering.
64
+
65
+ ``packaging`` isn't a CLI dependency (see ``test_no_framework_deps.py``),
66
+ so this compares the numeric release segments only. Good enough to answer
67
+ "does the latest release trip this upper bound", which is all it's for.
68
+ """
69
+ parts: list[int] = []
70
+ for part in version.split("."):
71
+ digits = re.match(r"\d+", part)
72
+ parts.append(int(digits.group()) if digits else 0)
73
+ return tuple(parts)
74
+
75
+
76
+ def _compare(left: str, right: str) -> int:
77
+ """Three-way compare two versions on their release segments, zero-padded."""
78
+ a, b = version_key(left), version_key(right)
79
+ width = max(len(a), len(b))
80
+ a += (0,) * (width - len(a))
81
+ b += (0,) * (width - len(b))
82
+ return (a > b) - (a < b)
83
+
84
+
85
+ def parse_requirement(spec: str) -> ParsedRequirement | None:
86
+ """Split a PEP 508 requirement string, or ``None`` if the name is unusable."""
87
+ base, sep, marker = spec.partition(";")
88
+ base = base.strip()
89
+ marker = f";{marker}" if sep else ""
90
+
91
+ head, bracket, rest = base.partition("[")
92
+ if bracket:
93
+ extras_body, closed, tail = rest.partition("]")
94
+ if not closed:
95
+ return None
96
+ extras = f"[{extras_body}]"
97
+ remainder = tail
98
+ else:
99
+ extras = ""
100
+ # No extras: the name runs until the first operator character.
101
+ match = re.match(r"[^<>=!~]*", head)
102
+ name_end = match.end() if match else 0
103
+ head, remainder = head[:name_end], head[name_end:]
104
+
105
+ name = head.strip()
106
+ if not name:
107
+ return None
108
+
109
+ clauses = tuple((op, version.strip()) for op, version in _CLAUSE_RE.findall(remainder))
110
+ return ParsedRequirement(name=name, extras=extras, clauses=clauses, marker=marker)
111
+
112
+
113
+ def _render(parsed: ParsedRequirement, clauses: tuple[tuple[str, str], ...]) -> str:
114
+ body = ",".join(f"{op}{version}" for op, version in clauses)
115
+ return f"{parsed.name}{parsed.extras}{body}{parsed.marker}"
116
+
117
+
118
+ def _matches_wildcard(version: str, pattern: str) -> bool:
119
+ """Does ``version`` fall under a PEP 440 wildcard like ``1.4.*``?"""
120
+ prefix = version_key(pattern[: -len(_WILDCARD)])
121
+ release = version_key(version)
122
+ return len(release) >= len(prefix) and release[: len(prefix)] == prefix
123
+
124
+
125
+ def _compatible_band(version: str) -> str | None:
126
+ """The wildcard a ``~=`` clause implies, or ``None`` if it has none.
127
+
128
+ ``~=1.4`` means ``>=1.4, ==1.*``; ``~=1.4.2`` means ``>=1.4.2, ==1.4.*``.
129
+ A single-segment ``~=1`` is invalid PEP 440 and has no band to derive.
130
+ """
131
+ parts = version.split(".")
132
+ if len(parts) < 2:
133
+ return None
134
+ return ".".join(parts[:-1]) + _WILDCARD
135
+
136
+
137
+ def _allows(op: str, version: str, latest: str) -> bool:
138
+ """Would this clause still be satisfied by ``latest``?
139
+
140
+ Floors (``==``/``===``/``>=``) are always "allowed": the rewrite moves them
141
+ *to* ``latest``, so they can't exclude it. Everything else either carries a
142
+ ceiling of its own or is left verbatim, and has to be checked.
143
+ """
144
+ if op in _FLOOR_OPS and not version.endswith(_WILDCARD):
145
+ return True
146
+ if version.endswith(_WILDCARD):
147
+ # ``==1.0.*`` allows anything under the prefix; ``!=1.0.*`` allows
148
+ # anything outside it.
149
+ inside = _matches_wildcard(latest, version)
150
+ return inside if op in ("==", "===") else not inside
151
+ if op == "~=":
152
+ band = _compatible_band(version)
153
+ return band is not None and _matches_wildcard(latest, band)
154
+ cmp = _compare(latest, version)
155
+ if op == "<":
156
+ return cmp < 0
157
+ if op == "<=":
158
+ return cmp <= 0
159
+ if op == "!=":
160
+ return cmp != 0
161
+ # ``>`` — already satisfied by anything newer than the pin.
162
+ return True
163
+
164
+
165
+ def _excluded_by(parsed: ParsedRequirement, latest: str) -> str | None:
166
+ """Return the clause that rules ``latest`` out, or ``None`` if it's allowed."""
167
+ for op, version in parsed.clauses:
168
+ if not _allows(op, version, latest):
169
+ return f"{op}{version}"
170
+ return None
171
+
172
+
173
+ def _bump(op: str, version: str, latest: str) -> str:
174
+ """The version this clause should carry after the update.
175
+
176
+ Only a plain floor moves, and only *upwards*: PyPI's latest can legitimately
177
+ be older than what a project already asks for (a version bumped in the
178
+ workspace but not yet published), and rewriting ``==0.0.33`` to ``==0.0.32``
179
+ would force an actual downgrade on the next ``uv sync``.
180
+
181
+ A wildcard band (``==1.0.*``, ``!=1.0.*``) that already allows ``latest`` is
182
+ left exactly as written — narrowing it to a single release would be a policy
183
+ change, not a version bump. ``~=`` moves within its own width for the same
184
+ reason: ``~=1.4`` becomes ``~=1.5``, never ``~=1.5.0``, because the latter
185
+ narrows the implied band from ``==1.*`` to ``==1.5.*`` and would make the
186
+ next run report the following minor as excluded.
187
+ """
188
+ if version.endswith(_WILDCARD):
189
+ return version
190
+ if op == "~=":
191
+ segments = len(version.split("."))
192
+ parts = latest.split(".")
193
+ if len(parts) < segments:
194
+ return version
195
+ candidate = ".".join(parts[:segments])
196
+ return candidate if _compare(candidate, version) > 0 else version
197
+ if op in _FLOOR_OPS:
198
+ return latest if _compare(latest, version) > 0 else version
199
+ return version
200
+
201
+
202
+ def rewrite_requirement(spec: str, latest: str, *, loosen: bool = False) -> RewriteResult:
203
+ """Point ``spec`` at ``latest``, preserving its pin style unless ``loosen``.
204
+
205
+ Returns a ``RewriteResult`` whose ``spec`` is ``None`` when the requirement
206
+ already asks for ``latest`` (or for something newer), when it carries no
207
+ floor to raise, or when an upper bound excludes ``latest`` (the last
208
+ carries a ``reason``).
209
+ """
210
+ parsed = parse_requirement(spec)
211
+ if parsed is None:
212
+ return RewriteResult(None)
213
+
214
+ if loosen or not parsed.clauses:
215
+ # Nothing to preserve — an unconstrained dependency has no pin style,
216
+ # so it gets the tool's default floor. Extras and markers survive.
217
+ new = f"{parsed.name}{parsed.extras}>={latest}{parsed.marker}"
218
+ return RewriteResult(None) if new == spec.strip() else RewriteResult(new)
219
+
220
+ blocker = _excluded_by(parsed, latest)
221
+ if blocker is not None:
222
+ return RewriteResult(None, reason=f"{latest} excluded by {blocker}")
223
+
224
+ bumped = tuple((op, _bump(op, version, latest)) for op, version in parsed.clauses)
225
+ if bumped == parsed.clauses:
226
+ # Nothing here names a floor to raise — a bare ``>``/``<``/``!=``, or a
227
+ # wildcard band that already covers ``latest``. Rewriting either would
228
+ # invent a constraint the author didn't ask for.
229
+ return RewriteResult(None)
230
+
231
+ new = _render(parsed, bumped)
232
+ return RewriteResult(None) if new == spec.strip() else RewriteResult(new)
@@ -33,12 +33,15 @@ if str(_HOST_DIR) not in sys.path:
33
33
  os.chdir(_REPO_ROOT)
34
34
  load_dotenv_into_environ(_REPO_ROOT / ".env")
35
35
 
36
- from simple_module_hosting import Settings, create_app # noqa: E402
36
+ from simple_module_hosting import create_app, merge_host_settings # noqa: E402
37
37
  from simple_module_hosting.logging import setup_logging # noqa: E402
38
38
 
39
39
  from routes import router as host_router # noqa: E402
40
40
 
41
- settings = Settings()
41
+ # merge_host_settings, not Settings(): the host knobs (log level, trusted
42
+ # proxy, auth provider, DB pool) are DB-backed, and create_app's own fallback
43
+ # does not fire because settings is passed to it explicitly below.
44
+ settings = merge_host_settings()
42
45
 
43
46
  setup_logging(
44
47
  level=settings.log_level,
@@ -1,4 +1,4 @@
1
- """Fixtures shared by the module-asset tests in this directory.
1
+ """Fixtures shared by the tests in this directory.
2
2
 
3
3
  These test files have no ``__init__.py`` (test basenames are globally unique
4
4
  instead), so a plain helper import across files is not available — a fixture is
@@ -10,10 +10,47 @@ from __future__ import annotations
10
10
  import subprocess
11
11
  import sys
12
12
  from pathlib import Path
13
+ from urllib.error import HTTPError
13
14
 
14
15
  import pytest
15
16
 
16
17
 
18
+ @pytest.fixture
19
+ def fake_pypi():
20
+ """Return a factory building a `package-update` fetcher stub.
21
+
22
+ Takes `{dist_name: latest_version}` and answers only for those names;
23
+ anything else raises the 404 the real fetcher would see. Keeps the CLI
24
+ tests off the network.
25
+ """
26
+
27
+ def factory(versions: dict[str, str]):
28
+ def fetcher(url: str) -> dict:
29
+ name = url.rsplit("/json", 1)[0].rsplit("/", 1)[1]
30
+ if name not in versions:
31
+ raise HTTPError(url, 404, "not found", {}, None)
32
+ latest = versions[name]
33
+ return {"info": {"version": latest}, "releases": {latest: [{"yanked": False}]}}
34
+
35
+ return fetcher
36
+
37
+ return factory
38
+
39
+
40
+ @pytest.fixture
41
+ def write_pyproject():
42
+ """Return a factory writing a minimal pyproject with the given deps."""
43
+
44
+ def factory(path: Path, *deps: str) -> None:
45
+ body = "".join(f' "{d}",\n' for d in deps)
46
+ path.write_text(
47
+ f'[project]\nname = "x"\nversion = "0"\ndependencies = [\n{body}]\n',
48
+ encoding="utf-8",
49
+ )
50
+
51
+ return factory
52
+
53
+
17
54
  @pytest.fixture
18
55
  def make_importable_module(tmp_path: Path):
19
56
  """Return a factory creating a real importable package bound to a ModuleBase.