wexample-wex-addon-dev-python 10.2.0__py3-none-any.whl → 11.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. wexample_wex_addon_dev_python/commands/code/check/mypy.py +4 -3
  2. wexample_wex_addon_dev_python/commands/code/check/pylint.py +25 -19
  3. wexample_wex_addon_dev_python/commands/code/check/pyright.py +18 -14
  4. wexample_wex_addon_dev_python/commands/code/check.py +4 -7
  5. wexample_wex_addon_dev_python/commands/code/format/black.py +14 -15
  6. wexample_wex_addon_dev_python/commands/code/format/isort.py +14 -15
  7. wexample_wex_addon_dev_python/commands/code/format.py +8 -5
  8. wexample_wex_addon_dev_python/commands/code/rename.py +134 -0
  9. wexample_wex_addon_dev_python/commands/examples/validate.py +1 -2
  10. wexample_wex_addon_dev_python/common/pypi_registry_gateway.py +5 -3
  11. wexample_wex_addon_dev_python/config_value/python_package_readme_config_value.py +2 -8
  12. wexample_wex_addon_dev_python/file/python_app_iml_file.py +3 -2
  13. wexample_wex_addon_dev_python/file/python_pyproject_toml_file.py +7 -7
  14. wexample_wex_addon_dev_python/helpers/pdm.py +3 -2
  15. wexample_wex_addon_dev_python/middleware/each_python_file_middleware.py +12 -14
  16. wexample_wex_addon_dev_python/refactor/__init__.py +7 -0
  17. wexample_wex_addon_dev_python/refactor/python_package_rename_handler.py +215 -0
  18. wexample_wex_addon_dev_python/refactor/symbol_kind.py +19 -0
  19. wexample_wex_addon_dev_python/selection/abstract_python_code_selection.py +22 -9
  20. wexample_wex_addon_dev_python/selection/python_code_performance_selection.py +8 -9
  21. wexample_wex_addon_dev_python/services/python/commands/service/install.py +7 -10
  22. wexample_wex_addon_dev_python/services/python/commands/service/setup.py +5 -3
  23. wexample_wex_addon_dev_python/workdir/mixin/with_profiling_python_workdir_mixin.py +15 -16
  24. wexample_wex_addon_dev_python/workdir/python_package_workdir.py +9 -7
  25. wexample_wex_addon_dev_python/workdir/python_packages_suite_workdir.py +2 -3
  26. wexample_wex_addon_dev_python/workdir/python_workdir.py +86 -20
  27. {wexample_wex_addon_dev_python-10.2.0.dist-info → wexample_wex_addon_dev_python-11.0.0.dist-info}/METADATA +10 -10
  28. {wexample_wex_addon_dev_python-10.2.0.dist-info → wexample_wex_addon_dev_python-11.0.0.dist-info}/RECORD +30 -26
  29. {wexample_wex_addon_dev_python-10.2.0.dist-info → wexample_wex_addon_dev_python-11.0.0.dist-info}/WHEEL +0 -0
  30. {wexample_wex_addon_dev_python-10.2.0.dist-info → wexample_wex_addon_dev_python-11.0.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,215 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ import libcst as cst
7
+
8
+ # Directories never scanned for source files (build/venv artefacts, VCS).
9
+ _SKIP_DIR_NAMES: frozenset[str] = frozenset({
10
+ "__pycache__",
11
+ ".venv",
12
+ "venv",
13
+ "env",
14
+ ".git",
15
+ "node_modules",
16
+ ".pytest_cache",
17
+ ".mypy_cache",
18
+ ".ruff_cache",
19
+ ".pdm-build",
20
+ "dist",
21
+ "build",
22
+ })
23
+
24
+
25
+ def _dotted_to_parts(node) -> tuple[str, ...] | None:
26
+ """Flatten a chained `cst.Attribute` / `cst.Name` into ('a', 'b', 'c')."""
27
+ parts: list[str] = []
28
+ cur = node
29
+ while isinstance(cur, cst.Attribute):
30
+ if not isinstance(cur.attr, cst.Name):
31
+ return None
32
+ parts.append(cur.attr.value)
33
+ cur = cur.value
34
+ if not isinstance(cur, cst.Name):
35
+ return None
36
+ parts.append(cur.value)
37
+ parts.reverse()
38
+ return tuple(parts)
39
+
40
+
41
+ def _parts_to_dotted(parts: tuple[str, ...]):
42
+ node: cst.BaseExpression = cst.Name(parts[0])
43
+ for part in parts[1:]:
44
+ node = cst.Attribute(value=node, attr=cst.Name(part))
45
+ return node
46
+
47
+
48
+ @dataclass
49
+ class RenameReport:
50
+ """Outcome of a package rename run.
51
+
52
+ `package_dir_old`/`new` describe the move (or proposed move in dry-run).
53
+ `files_changed` lists every .py whose imports were rewritten.
54
+ """
55
+ files_changed: list[Path] = field(default_factory=list)
56
+ files_scanned: int = 0
57
+ package_dir_new: Path | None = None
58
+ package_dir_old: Path | None = None
59
+
60
+
61
+ class PythonPackageRenameHandler:
62
+ """Rename a Python package (directory + its dotted name) within a workdir.
63
+
64
+ Locates the directory whose path tail matches `source` (split on `.`),
65
+ rewrites every `import …` / `from … import …` that starts with `source`
66
+ to use `target` instead, and finally renames the directory on disk.
67
+
68
+ Scope = a single workdir. Cross-package propagation (suite-wide) is
69
+ delegated to a later step via the event bus.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ workdir_path: Path,
75
+ source: str,
76
+ target: str,
77
+ *,
78
+ dry_run: bool = True,
79
+ ) -> None:
80
+ self.workdir_path = workdir_path
81
+ self.source = source
82
+ self.target = target
83
+ self.dry_run = dry_run
84
+ self._source_parts: tuple[str, ...] = tuple(source.split("."))
85
+ self._target_parts: tuple[str, ...] = tuple(target.split("."))
86
+ if not all(self._source_parts) or not all(self._target_parts):
87
+ raise ValueError("source and target must be dotted names without empty parts")
88
+
89
+ def find_package_dir(self) -> Path:
90
+ """Return the unique directory matching `source` as a Python package."""
91
+ n = len(self._source_parts) # hoisted: avoid recomputing per candidate
92
+ matches: list[Path] = []
93
+ for init_file in self.workdir_path.rglob("__init__.py"):
94
+ if any(part in _SKIP_DIR_NAMES for part in init_file.parts):
95
+ continue
96
+ pkg_dir = init_file.parent
97
+ if len(pkg_dir.parts) < n:
98
+ continue
99
+ if pkg_dir.parts[-n:] == self._source_parts: # tuple cmp, no Path alloc
100
+ matches.append(pkg_dir)
101
+
102
+ if not matches:
103
+ raise FileNotFoundError(
104
+ f"No package directory matching '{self.source}' under {self.workdir_path}"
105
+ )
106
+ if len(matches) > 1:
107
+ joined = "\n ".join(str(m) for m in matches)
108
+ raise ValueError(
109
+ f"Multiple packages match '{self.source}', refine the dotted name:\n {joined}"
110
+ )
111
+ return matches[0]
112
+
113
+ def run(self) -> RenameReport:
114
+ report = RenameReport()
115
+ package_dir = self.find_package_dir()
116
+ report.package_dir_old = package_dir
117
+ report.package_dir_new = package_dir.parent / self._target_parts[-1]
118
+
119
+ for py_file in self._iter_python_files():
120
+ report.files_scanned += 1
121
+ new_source = self._rewrite_imports(py_file)
122
+ if new_source is None:
123
+ continue
124
+ report.files_changed.append(py_file)
125
+ if not self.dry_run:
126
+ py_file.write_text(new_source, encoding="utf-8")
127
+
128
+ if not self.dry_run:
129
+ # Move happens AFTER rewrites: a file under the package itself
130
+ # would otherwise be looked up at the new path that doesn't exist
131
+ # yet during the rewrite pass.
132
+ package_dir.rename(report.package_dir_new)
133
+
134
+ return report
135
+
136
+ def _iter_python_files(self):
137
+ stack: list[Path] = [self.workdir_path]
138
+ while stack:
139
+ current = stack.pop()
140
+ try:
141
+ entries = list(current.iterdir())
142
+ except (PermissionError, FileNotFoundError):
143
+ continue
144
+ for entry in entries:
145
+ if entry.is_dir():
146
+ if entry.name not in _SKIP_DIR_NAMES:
147
+ stack.append(entry)
148
+ elif entry.suffix == ".py":
149
+ yield entry
150
+
151
+ def _rewrite_imports(self, py_file: Path) -> str | None:
152
+ """Return rewritten source, or None when no import touched this file."""
153
+ source = py_file.read_text(encoding="utf-8")
154
+ try:
155
+ module = cst.parse_module(source)
156
+ except cst.ParserSyntaxError:
157
+ return None
158
+
159
+ transformer = _ImportRenameTransformer(
160
+ old_parts=self._source_parts,
161
+ new_parts=self._target_parts,
162
+ )
163
+ new_module = module.visit(transformer)
164
+ if not transformer.changed:
165
+ return None
166
+ return new_module.code
167
+
168
+
169
+ class _ImportRenameTransformer(cst.CSTTransformer):
170
+ """Rewrites import statements whose dotted name starts with `old_parts`."""
171
+
172
+ def __init__(self, old_parts: tuple[str, ...], new_parts: tuple[str, ...]) -> None:
173
+ super().__init__()
174
+ self.old_parts = old_parts
175
+ self.new_parts = new_parts
176
+ self._n = len(old_parts) # cached: avoid repeated len() + attr-lookup
177
+ self.changed = False
178
+
179
+ def leave_Import(
180
+ self,
181
+ original_node: cst.Import,
182
+ updated_node: cst.Import,
183
+ ) -> cst.Import:
184
+ new_names = []
185
+ touched = False
186
+ for alias in updated_node.names:
187
+ parts = _dotted_to_parts(alias.name)
188
+ if parts is None or not self._starts_with(parts):
189
+ new_names.append(alias)
190
+ continue
191
+ new_parts = self.new_parts + parts[self._n:]
192
+ new_names.append(alias.with_changes(name=_parts_to_dotted(new_parts)))
193
+ touched = True
194
+ if not touched:
195
+ return updated_node
196
+ self.changed = True
197
+ return updated_node.with_changes(names=new_names)
198
+
199
+ def leave_ImportFrom(
200
+ self,
201
+ original_node: cst.ImportFrom,
202
+ updated_node: cst.ImportFrom,
203
+ ) -> cst.ImportFrom:
204
+ # `from . import X` has module=None; relative imports are out of scope.
205
+ if updated_node.module is None:
206
+ return updated_node
207
+ parts = _dotted_to_parts(updated_node.module)
208
+ if parts is None or not self._starts_with(parts):
209
+ return updated_node
210
+ new_parts = self.new_parts + parts[self._n:]
211
+ self.changed = True
212
+ return updated_node.with_changes(module=_parts_to_dotted(new_parts))
213
+
214
+ def _starts_with(self, parts: tuple[str, ...]) -> bool:
215
+ return len(parts) >= self._n and parts[: self._n] == self.old_parts
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class SymbolKind(str, Enum):
7
+ """The kind of symbol targeted by a rename.
8
+
9
+ Step 1 only implements PACKAGE; the other members are listed so callers can
10
+ already pass `--kind class` and get a clear `NotImplementedError` instead
11
+ of a parsing error.
12
+ """
13
+
14
+ PACKAGE = "package"
15
+ MODULE = "module"
16
+ CLASS = "class"
17
+ FUNCTION = "function"
18
+ METHOD = "method"
19
+ CONSTANT = "constant"
@@ -32,14 +32,27 @@ class AbstractPythonCodeSelection(AbstractCodeFromDirectorySelection):
32
32
  MIN_SIZE_BYTES: ClassVar[int] = 50
33
33
 
34
34
  def get_excluded_file_names(self) -> tuple[str, ...]:
35
- return super().get_excluded_file_names() + ("__init__.py",)
35
+ try:
36
+ return self._excl_file_names # type: ignore[attr-defined]
37
+ except AttributeError:
38
+ self._excl_file_names: tuple[str, ...] = (
39
+ super().get_excluded_file_names() + ("__init__.py",)
40
+ )
41
+ return self._excl_file_names
36
42
 
37
43
  def get_excluded_path_fragments(self) -> tuple[str, ...]:
38
- return super().get_excluded_path_fragments() + (
39
- "__pycache__",
40
- "/tests/",
41
- "/build/",
42
- "/dist/",
43
- "/.venv/",
44
- "/.pdm-build/",
45
- )
44
+ try:
45
+ return self._excl_path_frags # type: ignore[attr-defined]
46
+ except AttributeError:
47
+ self._excl_path_frags: tuple[str, ...] = (
48
+ super().get_excluded_path_fragments()
49
+ + (
50
+ "__pycache__",
51
+ "/tests/",
52
+ "/build/",
53
+ "/dist/",
54
+ "/.venv/",
55
+ "/.pdm-build/",
56
+ )
57
+ )
58
+ return self._excl_path_frags
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import ast
3
4
  from typing import TYPE_CHECKING, ClassVar
4
5
 
5
6
  from wexample_helpers.decorator.base_class import base_class
@@ -67,8 +68,6 @@ class PythonCodePerformanceSelection(AbstractPythonCodeSelection):
67
68
  files isn't what this sweep is for, the operator fixes those first.
68
69
  Other I/O errors (FileNotFoundError, PermissionError) propagate.
69
70
  """
70
- import ast
71
-
72
71
  if not super().is_candidate(item):
73
72
  return False
74
73
  try:
@@ -104,8 +103,6 @@ class PythonCodePerformanceSelection(AbstractPythonCodeSelection):
104
103
  already visits every descendant function independently. Keeps each
105
104
  check decoupled from its container.
106
105
  """
107
- import ast
108
-
109
106
  for deco in node.decorator_list:
110
107
  deco_name = (
111
108
  deco.id
@@ -117,21 +114,23 @@ class PythonCodePerformanceSelection(AbstractPythonCodeSelection):
117
114
  if deco_name == "abstractmethod":
118
115
  return True
119
116
 
120
- body = list(node.body)
117
+ body = node.body
118
+ start = 0
121
119
  if (
122
120
  body
123
121
  and isinstance(body[0], ast.Expr)
124
122
  and isinstance(body[0].value, ast.Constant)
125
123
  and isinstance(body[0].value.value, str)
126
124
  ):
127
- body = body[1:]
125
+ start = 1
128
126
 
129
- if not body:
127
+ effective_len = len(body) - start
128
+ if effective_len == 0:
130
129
  return True
131
- if len(body) != 1:
130
+ if effective_len != 1:
132
131
  return False
133
132
 
134
- stmt = body[0]
133
+ stmt = body[start]
135
134
 
136
135
  if isinstance(stmt, ast.Pass):
137
136
  return True
@@ -29,27 +29,24 @@ def python__service__install(
29
29
  context: ExecutionContext,
30
30
  service: AppService,
31
31
  ) -> None:
32
- project_name = service.app_workdir.get_project_name()
33
-
34
32
  config_file = service.app_workdir.get_config_file()
35
33
  config = config_file.read_config()
36
34
 
37
- from wexample_app.const.globals import WORKDIR_SETUP_DIR
38
-
39
35
  if config.search("docker.images").is_none():
36
+ from wexample_app.const.globals import WORKDIR_SETUP_DIR
37
+
38
+ project_name = service.app_workdir.get_project_name()
39
+ docker_images_dir = WORKDIR_SETUP_DIR / "docker" / "images"
40
+
40
41
  config.set_by_path(
41
42
  "docker.images",
42
43
  {
43
44
  "base": {
44
- "dockerfile": str(
45
- WORKDIR_SETUP_DIR / "docker" / "images" / "Dockerfile.base"
46
- ),
45
+ "dockerfile": str(docker_images_dir / "Dockerfile.base"),
47
46
  "tag": f"{project_name}:local",
48
47
  },
49
48
  "develop": {
50
- "dockerfile": str(
51
- WORKDIR_SETUP_DIR / "docker" / "images" / "Dockerfile.develop"
52
- ),
49
+ "dockerfile": str(docker_images_dir / "Dockerfile.develop"),
53
50
  "tag": f"{project_name}-dev:local",
54
51
  "depends_on": "base",
55
52
  },
@@ -53,16 +53,18 @@ def python__service__setup(
53
53
  return
54
54
 
55
55
  ordered = resolve_build_order(builds)
56
+ app_path_str = str(app_path)
56
57
 
57
58
  for build_name in ordered:
58
59
  build = builds[build_name]
59
60
  dockerfile = str(app_path / build["dockerfile"])
60
61
  tag = build["tag"]
61
62
  cmd = ["docker", "build", "-f", dockerfile, "-t", tag]
62
- if "depends_on" in build:
63
- parent_tag = builds[build["depends_on"]]["tag"]
63
+ depends_on = build.get("depends_on")
64
+ if depends_on is not None:
65
+ parent_tag = builds[depends_on]["tag"]
64
66
  cmd += ["--build-arg", f"BASE_IMAGE={parent_tag}"]
65
- cmd.append(str(app_path))
67
+ cmd.append(app_path_str)
66
68
  context.io.log(f"Building image '{build_name}' → {tag}")
67
69
  subprocess.run(cmd, check=True)
68
70
 
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from pathlib import Path
4
+
3
5
  from wexample_helpers.decorator.base_class import base_class
4
6
  from wexample_wex_addon_app.workdir.mixin.abstract_profiling_workdir_mixin import (
5
7
  AbstractProfilingWorkdirMixin,
@@ -24,7 +26,8 @@ class WithProfilingPythonWorkdirMixin(AbstractProfilingWorkdirMixin):
24
26
  import json
25
27
  import subprocess
26
28
 
27
- bench_output_path = self.get_path() / self._BENCH_OUTPUT_FILENAME
29
+ workdir_path = self.get_path()
30
+ bench_output_path = workdir_path / self._BENCH_OUTPUT_FILENAME
28
31
  python = self._get_profiling_python()
29
32
 
30
33
  bench_result = subprocess.run(
@@ -39,7 +42,7 @@ class WithProfilingPythonWorkdirMixin(AbstractProfilingWorkdirMixin):
39
42
  ],
40
43
  capture_output=True,
41
44
  text=True,
42
- cwd=str(self.get_path()),
45
+ cwd=str(workdir_path),
43
46
  )
44
47
 
45
48
  if not bench_output_path.exists():
@@ -69,27 +72,23 @@ class WithProfilingPythonWorkdirMixin(AbstractProfilingWorkdirMixin):
69
72
 
70
73
  def _get_profiling_python(self) -> Path:
71
74
  import sys
72
- from pathlib import Path
73
75
 
74
76
  return Path(sys.executable)
75
77
 
76
78
  def _parse_profiling_output(self, raw: dict) -> dict:
77
- entries = []
78
- for bench in raw.get("benchmarks", []):
79
+ def _bench_entry(bench: dict) -> dict:
79
80
  stats = bench.get("stats", {})
80
- entries.append(
81
- {
82
- "name": bench.get("name"),
83
- "min_ms": round(stats.get("min", 0) * 1000, 3),
84
- "mean_ms": round(stats.get("mean", 0) * 1000, 3),
85
- "median_ms": round(stats.get("median", 0) * 1000, 3),
86
- "max_ms": round(stats.get("max", 0) * 1000, 3),
87
- "rounds": stats.get("rounds", 0),
88
- }
89
- )
81
+ return {
82
+ "name": bench.get("name"),
83
+ "min_ms": round(stats.get("min", 0) * 1000, 3),
84
+ "mean_ms": round(stats.get("mean", 0) * 1000, 3),
85
+ "median_ms": round(stats.get("median", 0) * 1000, 3),
86
+ "max_ms": round(stats.get("max", 0) * 1000, 3),
87
+ "rounds": stats.get("rounds", 0),
88
+ }
90
89
 
91
90
  return {
92
91
  "language": "python",
93
92
  "tool": "pytest-benchmark",
94
- "entries": entries,
93
+ "entries": [_bench_entry(b) for b in raw.get("benchmarks", [])],
95
94
  }
@@ -156,10 +156,11 @@ class PythonPackageWorkdir(PythonWorkdir):
156
156
  import re
157
157
 
158
158
  pkg = searched_package.get_package_import_name()
159
+ escaped = re.escape(pkg)
159
160
  pattern = (
160
161
  rf"(?m)^\s*(?:"
161
- rf"from\s+{re.escape(pkg)}(?:\.[\w\.]+)?\s+import\s+"
162
- rf"|import\s+{re.escape(pkg)}(?:\.[\w\.]+)?(?:\s+as\s+\w+)?\b"
162
+ rf"from\s+{escaped}(?:\.[\w\.]+)?\s+import\s+"
163
+ rf"|import\s+{escaped}(?:\.[\w\.]+)?(?:\s+as\s+\w+)?\b"
163
164
  rf")"
164
165
  )
165
166
  return self.search_in_codebase(pattern, regex=True, flags=re.MULTILINE)
@@ -198,7 +199,8 @@ class PythonPackageWorkdir(PythonWorkdir):
198
199
  import griffe
199
200
 
200
201
  module_name = self.get_package_name().replace("-", "_")
201
- repo_path = str(self.get_path())
202
+ path = self.get_path()
203
+ repo_path = str(path)
202
204
 
203
205
  previous = griffe.load_git(
204
206
  module_name,
@@ -208,10 +210,10 @@ class PythonPackageWorkdir(PythonWorkdir):
208
210
  )
209
211
  current = griffe.load(
210
212
  module_name,
211
- search_paths=[str(self.get_path() / "src")],
213
+ search_paths=[str(path / "src")],
212
214
  )
213
215
 
214
- if list(griffe.find_breaking_changes(previous, current)):
216
+ if any(griffe.find_breaking_changes(previous, current)):
215
217
  return UPGRADE_TYPE_MAJOR
216
218
 
217
219
  return UPGRADE_TYPE_INTERMEDIATE
@@ -353,7 +355,7 @@ class PythonPackageWorkdir(PythonWorkdir):
353
355
  )
354
356
  python_install_dependencies_in_venv(
355
357
  venv_path=venv_path,
356
- names=self.get_app_config_file().optional_group_array(group="dev"),
358
+ names=toml_file.optional_group_array(group="dev"),
357
359
  )
358
360
 
359
361
  self.subtitle(
@@ -489,7 +491,7 @@ class PythonPackageWorkdir(PythonWorkdir):
489
491
  )
490
492
 
491
493
  PollingCallbackManager(
492
- callback=lambda: True if gateway.has_version(package, version) else None,
494
+ callback=lambda: gateway.has_version(package, version) or None,
493
495
  max_attempts=max_attempts,
494
496
  delay_seconds_callback=lambda _attempt: delay_seconds,
495
497
  on_retry_callback=on_retry,
@@ -52,9 +52,8 @@ class PythonPackagesSuiteWorkdir(FrameworkPackageSuiteWorkdir):
52
52
  for n in sorted(nodes):
53
53
  G.add_node(n)
54
54
  for src in sorted(dependencies_map.keys()):
55
- for dst in sorted(dependencies_map.get(src, [])):
56
- if dst in nodes:
57
- G.add_edge(src, dst)
55
+ for dst in sorted(dependencies_map[src]):
56
+ G.add_edge(src, dst)
58
57
 
59
58
  if not (G.has_node(start) and G.has_node(target)):
60
59
  return []