wexample-wex-addon-dev-python 11.1.1__tar.gz → 12.0.0__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 (84) hide show
  1. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/PKG-INFO +6 -6
  2. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/README.md +3 -3
  3. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/pyproject.toml +3 -3
  4. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/commands/code/rename.py +52 -16
  5. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/file/python_pyproject_toml_file.py +6 -12
  6. wexample_wex_addon_dev_python-12.0.0/src/wexample_wex_addon_dev_python/refactor/applier.py +46 -0
  7. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/refactor/python_package_rename_handler.py +102 -56
  8. wexample_wex_addon_dev_python-12.0.0/src/wexample_wex_addon_dev_python/refactor/rename_plan.py +43 -0
  9. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/selection/python_code_performance_selection.py +39 -21
  10. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/workdir/python_workdir.py +4 -6
  11. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/__init__.py +0 -0
  12. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/__pycache__/__init__.py +0 -0
  13. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/commands/__init__.py +0 -0
  14. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/commands/code/__init__.py +0 -0
  15. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/commands/code/check/__init__.py +0 -0
  16. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/commands/code/check/mypy.py +0 -0
  17. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/commands/code/check/pylint.py +0 -0
  18. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/commands/code/check/pyright.py +0 -0
  19. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/commands/code/check.py +0 -0
  20. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/commands/code/format/__init__.py +0 -0
  21. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/commands/code/format/black.py +0 -0
  22. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/commands/code/format/isort.py +0 -0
  23. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/commands/code/format.py +0 -0
  24. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/commands/examples/__init__.py +0 -0
  25. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/commands/examples/utils/__init__.py +0 -0
  26. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/commands/examples/utils/some_example_type.py +0 -0
  27. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/commands/examples/validate.py +0 -0
  28. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/commands/release/__init__.py +0 -0
  29. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/common/__init__.py +0 -0
  30. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/common/pypi_registry_gateway.py +0 -0
  31. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/config_value/__init__.py +0 -0
  32. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/config_value/__pycache__/__init__.py +0 -0
  33. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/config_value/python_package_readme_config_value.py +0 -0
  34. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/const/__init__.py +0 -0
  35. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/const/__pycache__/__init__.py +0 -0
  36. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/const/package.py +0 -0
  37. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/const/python.py +0 -0
  38. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/const/tags.py +0 -0
  39. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/file/__init__.py +0 -0
  40. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/file/__pycache__/__init__.py +0 -0
  41. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/file/python_app_iml_file.py +0 -0
  42. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/helpers/__init__.py +0 -0
  43. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/helpers/pdm.py +0 -0
  44. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/middleware/__init__.py +0 -0
  45. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/middleware/__pycache__/__init__.py +0 -0
  46. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/middleware/each_python_file_middleware.py +0 -0
  47. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/py.typed +0 -0
  48. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/python_addon_manager.py +0 -0
  49. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/refactor/__init__.py +0 -0
  50. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/refactor/symbol_kind.py +0 -0
  51. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/resources/.wex.yml +0 -0
  52. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/resources/docker/Dockerfile.python-profiling +0 -0
  53. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/resources/package_publish_gitlab.yml +0 -0
  54. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/resources/readme_templates/tests.md.j2 +0 -0
  55. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/selection/__init__.py +0 -0
  56. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/selection/abstract_python_code_selection.py +0 -0
  57. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/services/__init__.py +0 -0
  58. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/services/python/__init__.py +0 -0
  59. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/services/python/app_service.py +0 -0
  60. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/services/python/commands/__init__.py +0 -0
  61. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/services/python/commands/service/__init__.py +0 -0
  62. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/services/python/commands/service/install.py +0 -0
  63. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/services/python/commands/service/setup.py +0 -0
  64. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/services/python/docker/.wex.yml +0 -0
  65. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/services/python/docker/docker-compose.yml +0 -0
  66. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/services/python/samples/__init__.py +0 -0
  67. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/services/python/samples/docker/__init__.py +0 -0
  68. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/services/python/samples/docker/docker-compose.yml +0 -0
  69. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/services/python/samples/docker/entrypoint.sh +0 -0
  70. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/services/python/samples/docker/images/Dockerfile.base +0 -0
  71. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/services/python/samples/docker/images/Dockerfile.develop +0 -0
  72. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/services/python/samples/docker/images/__init__.py +0 -0
  73. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/services/python/service.yml +0 -0
  74. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/workdir/__init__.py +0 -0
  75. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/workdir/__pycache__/__init__.py +0 -0
  76. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/workdir/mixin/__init__.py +0 -0
  77. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/workdir/mixin/with_profiling_python_workdir_mixin.py +0 -0
  78. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/workdir/python_package_workdir.py +0 -0
  79. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/src/wexample_wex_addon_dev_python/workdir/python_packages_suite_workdir.py +0 -0
  80. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/tests/helpers/__init__.py +0 -0
  81. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/tests/helpers/test_pdm.py +0 -0
  82. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/tests/unit/__init__.py +0 -0
  83. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/tests/unit/helpers/__init__.py +0 -0
  84. {wexample_wex_addon_dev_python-11.1.1 → wexample_wex_addon_dev_python-12.0.0}/tests/unit/helpers/test_pdm.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: wexample-wex-addon-dev-python
3
- Version: 11.1.1
3
+ Version: 12.0.0
4
4
  Summary: Python dev addon for wex
5
5
  Author-Email: weeger <contact@wexample.com>
6
6
  License: MIT
@@ -17,8 +17,8 @@ Requires-Dist: pylint
17
17
  Requires-Dist: pyright
18
18
  Requires-Dist: wexample-api>=6.6.0
19
19
  Requires-Dist: wexample-filestate-python>=8.2.0
20
- Requires-Dist: wexample-wex-addon-ai>=11.1.0
21
- Requires-Dist: wexample-wex-addon-app>=25.4.0
20
+ Requires-Dist: wexample-wex-addon-ai>=11.3.0
21
+ Requires-Dist: wexample-wex-addon-app>=26.1.0
22
22
  Provides-Extra: dev
23
23
  Requires-Dist: pytest; extra == "dev"
24
24
  Requires-Dist: pytest-cov; extra == "dev"
@@ -26,7 +26,7 @@ Description-Content-Type: text/markdown
26
26
 
27
27
  # wex_addon_dev_python
28
28
 
29
- Version: 11.1.1
29
+ Version: 12.0.0
30
30
 
31
31
  Python dev addon for wex
32
32
 
@@ -114,8 +114,8 @@ Visit the [Wexample Suite documentation](https://docs.wexample.com) for the comp
114
114
  - pyright:
115
115
  - wexample-api: >=6.6.0
116
116
  - wexample-filestate-python: >=8.2.0
117
- - wexample-wex-addon-ai: >=11.1.0
118
- - wexample-wex-addon-app: >=25.4.0
117
+ - wexample-wex-addon-ai: >=11.3.0
118
+ - wexample-wex-addon-app: >=26.1.0
119
119
 
120
120
  ## Versioning & Compatibility Policy
121
121
 
@@ -1,6 +1,6 @@
1
1
  # wex_addon_dev_python
2
2
 
3
- Version: 11.1.1
3
+ Version: 12.0.0
4
4
 
5
5
  Python dev addon for wex
6
6
 
@@ -88,8 +88,8 @@ Visit the [Wexample Suite documentation](https://docs.wexample.com) for the comp
88
88
  - pyright:
89
89
  - wexample-api: >=6.6.0
90
90
  - wexample-filestate-python: >=8.2.0
91
- - wexample-wex-addon-ai: >=11.1.0
92
- - wexample-wex-addon-app: >=25.4.0
91
+ - wexample-wex-addon-ai: >=11.3.0
92
+ - wexample-wex-addon-app: >=26.1.0
93
93
 
94
94
  ## Versioning & Compatibility Policy
95
95
 
@@ -6,7 +6,7 @@ build-backend = "pdm.backend"
6
6
 
7
7
  [project]
8
8
  name = "wexample-wex-addon-dev-python"
9
- version = "11.1.1"
9
+ version = "12.0.0"
10
10
  description = "Python dev addon for wex"
11
11
  authors = [
12
12
  { name = "weeger", email = "contact@wexample.com" },
@@ -26,8 +26,8 @@ dependencies = [
26
26
  "pyright",
27
27
  "wexample-api>=6.6.0",
28
28
  "wexample-filestate-python>=8.2.0",
29
- "wexample-wex-addon-ai>=11.1.0",
30
- "wexample-wex-addon-app>=25.4.0",
29
+ "wexample-wex-addon-ai>=11.3.0",
30
+ "wexample-wex-addon-app>=26.1.0",
31
31
  ]
32
32
 
33
33
  [project.readme]
@@ -94,26 +94,24 @@ def python__code__rename(
94
94
  ),
95
95
  )
96
96
 
97
- workdir_path = app_workdir.get_path()
97
+ scope_roots, suite_info = _resolve_scope_roots(context.kernel, app_workdir)
98
+
98
99
  handler = PythonPackageRenameHandler(
99
- workdir_path=workdir_path,
100
+ scope_roots=scope_roots,
100
101
  source=source,
101
102
  target=target,
102
103
  dry_run=dry_run,
103
104
  )
104
105
  try:
105
106
  report = handler.run()
106
- except FileNotFoundError:
107
- return FailureResponse(
108
- kernel=context.kernel,
109
- message=(
110
- f"No Python package matching '{source}' under {workdir_path}. "
111
- "Check the dotted name (e.g. 'mylib.helpers') and that you're "
112
- "in the right workdir."
113
- ),
114
- )
107
+ except FileNotFoundError as e:
108
+ return FailureResponse(kernel=context.kernel, message=str(e))
115
109
  except ValueError as e:
116
- # Raised on ambiguous match (multiple packages) or invalid dotted name.
110
+ # Ambiguous match (multiple packages) or invalid dotted name.
111
+ return FailureResponse(kernel=context.kernel, message=str(e))
112
+ except RuntimeError as e:
113
+ # Preflight failure (permissions, target exists). Plan was thrown away,
114
+ # no file has been written — safe to surface as a clean failure.
117
115
  return FailureResponse(kernel=context.kernel, message=str(e))
118
116
 
119
117
  mode = "APPLIED" if not dry_run else "DRY-RUN"
@@ -121,14 +119,52 @@ def python__code__rename(
121
119
  f"[{mode}] {report.package_dir_old} → {report.package_dir_new}"
122
120
  )
123
121
  context.io.log(
124
- f"[{mode}] scanned={report.files_scanned} changed={len(report.files_changed)}"
122
+ f"[{mode}] scope={suite_info} scanned={report.files_scanned} "
123
+ f"changed={len(report.files_changed)}"
125
124
  )
126
125
 
127
- rows = [
128
- [str(p.relative_to(workdir_path))] for p in report.files_changed
129
- ]
126
+ # Display paths relative to the matched scope root they live under, so the
127
+ # output stays compact regardless of how many roots are involved.
128
+ rows = [[str(_shortest_rel(p, scope_roots))] for p in report.files_changed]
130
129
  return TableResponse(
131
130
  kernel=context.kernel,
132
131
  content=rows,
133
132
  headers=["File"],
134
133
  )
134
+
135
+
136
+ def _resolve_scope_roots(kernel, app_workdir):
137
+ """Return (scope_roots, info_string).
138
+
139
+ Looks up a containing suite via `find_suite_workdir_path()`; if found,
140
+ materialises the suite workdir and returns the path of every ordered
141
+ sibling package. Otherwise falls back to the current workdir alone.
142
+ """
143
+ from wexample_wex_addon_app.app_addon_manager import AppAddonManager
144
+
145
+ suite_path = app_workdir.find_suite_workdir_path()
146
+ if not suite_path:
147
+ return [app_workdir.get_path()], f"workdir={app_workdir.get_path()}"
148
+
149
+ suite_workdir = AppAddonManager.from_kernel(kernel).create_app_workdir(
150
+ path=suite_path
151
+ )
152
+ packages = suite_workdir.get_ordered_packages()
153
+ if not packages:
154
+ return [app_workdir.get_path()], f"workdir={app_workdir.get_path()}"
155
+
156
+ roots = [p.get_path() for p in packages]
157
+ return roots, f"suite={suite_path} (packages={len(roots)})"
158
+
159
+
160
+ def _shortest_rel(path, roots):
161
+ """Return `path` relative to the longest matching root, else absolute."""
162
+ best = None
163
+ for root in roots:
164
+ try:
165
+ rel = path.relative_to(root)
166
+ except ValueError:
167
+ continue
168
+ if best is None or len(rel.parts) < len(best.parts):
169
+ best = rel
170
+ return best if best is not None else path
@@ -111,13 +111,10 @@ class PythonPyprojectTomlFile(AppDependenciesConfigFileMixin, TomlFile):
111
111
 
112
112
  deps = self._get_deps_array(optional=optional, group=group)
113
113
 
114
- map = {}
115
- for spec in deps:
116
- req = Requirement(spec)
117
- # name: version
118
- map[canonicalize_name(req.name)] = str(req.specifier)
119
-
120
- return map
114
+ return {
115
+ canonicalize_name(req.name): str(req.specifier)
116
+ for req in (Requirement(spec) for spec in deps)
117
+ }
121
118
 
122
119
  def optional_group_array(self, group: str):
123
120
  """Ensure and return project.optional-dependencies[group] as multi-line array."""
@@ -398,13 +395,10 @@ class PythonPyprojectTomlFile(AppDependenciesConfigFileMixin, TomlFile):
398
395
  """Reorder dictionary keys according to the specified order.
399
396
  Keys not in key_order will appear after ordered keys in their original order.
400
397
  """
401
- # Get all existing keys
402
- existing_keys = list(d.keys())
403
-
404
398
  # Build the new order: ordered keys first, then remaining keys
405
399
  key_order_set = frozenset(key_order)
406
- ordered_keys = [k for k in key_order if k in existing_keys]
407
- remaining_keys = [k for k in existing_keys if k not in key_order_set]
400
+ ordered_keys = [k for k in key_order if k in d]
401
+ remaining_keys = [k for k in d if k not in key_order_set]
408
402
  new_order = ordered_keys + remaining_keys
409
403
 
410
404
  # Reorder by removing and re-adding in the desired order
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from pathlib import Path
5
+
6
+ from wexample_wex_addon_dev_python.refactor.rename_plan import RenamePlan
7
+
8
+
9
+ class RenameApplier(ABC):
10
+ """Strategy that actually mutates the filesystem from a `RenamePlan`.
11
+
12
+ Kept abstract so we can swap a naive write/rename today for a
13
+ filestate-backed applier later (observability, rollback, dry-run unified
14
+ with the rest of the framework) without touching the handler/commands.
15
+ """
16
+
17
+ def apply(self, plan: RenamePlan) -> None:
18
+ """Default sequencing: all file writes first, then directory renames.
19
+
20
+ Subclasses may override for a different ordering or to wrap the whole
21
+ batch in a transaction.
22
+ """
23
+ for path, content in plan.rewrites.items():
24
+ self.write_file(path, content)
25
+ for old_dir, new_dir in plan.dir_renames:
26
+ self.rename_dir(old_dir, new_dir)
27
+
28
+ @abstractmethod
29
+ def rename_dir(self, old: Path, new: Path) -> None: ...
30
+
31
+ @abstractmethod
32
+ def write_file(self, path: Path, content: str) -> None: ...
33
+
34
+
35
+ class NaiveRenameApplier(RenameApplier):
36
+ """Direct os-level applier. No rollback, no observability beyond the OS.
37
+
38
+ Sufficient for V2; deeper integration with filestate is deferred until we
39
+ measure that it pays off (volume, types of errors actually encountered).
40
+ """
41
+
42
+ def rename_dir(self, old: Path, new: Path) -> None:
43
+ old.rename(new)
44
+
45
+ def write_file(self, path: Path, content: str) -> None:
46
+ path.write_text(content, encoding="utf-8")
@@ -5,6 +5,12 @@ from pathlib import Path
5
5
 
6
6
  import libcst as cst
7
7
 
8
+ from wexample_wex_addon_dev_python.refactor.applier import (
9
+ NaiveRenameApplier,
10
+ RenameApplier,
11
+ )
12
+ from wexample_wex_addon_dev_python.refactor.rename_plan import RenamePlan
13
+
8
14
  # Directories never scanned for source files (build/venv artefacts, VCS).
9
15
  _SKIP_DIR_NAMES: frozenset[str] = frozenset({
10
16
  "__pycache__",
@@ -52,6 +58,7 @@ class RenameReport:
52
58
  `package_dir_old`/`new` describe the move (or proposed move in dry-run).
53
59
  `files_changed` lists every .py whose imports were rewritten.
54
60
  """
61
+
55
62
  files_changed: list[Path] = field(default_factory=list)
56
63
  files_scanned: int = 0
57
64
  package_dir_new: Path | None = None
@@ -59,25 +66,36 @@ class RenameReport:
59
66
 
60
67
 
61
68
  class PythonPackageRenameHandler:
62
- """Rename a Python package (directory + its dotted name) within a workdir.
69
+ """Rename a Python package and propagate the change to importers.
70
+
71
+ Three phases, fully separated:
72
+ 1. compute_plan() — read-only scan, builds a `RenamePlan` in memory.
73
+ 2. plan.preflight() — validation (permissions, target existence).
74
+ 3. applier.apply(plan) — single batch of writes + the directory rename.
63
75
 
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.
76
+ Splitting plan/apply is the durability strategy: a permission error mid-run
77
+ can't leave the repo half-rewritten. Splitting from the applier lets us
78
+ swap the write backend (naive filestate-backed) without touching this
79
+ handler.
67
80
 
68
- Scope = a single workdir. Cross-package propagation (suite-wide) is
69
- delegated to a later step via the event bus.
81
+ `scope_roots` = the set of roots to sweep for both the source package
82
+ directory AND import rewrites. Caller passes the sibling-package roots
83
+ when running suite-wide (typically obtained via the framework's
84
+ `FrameworkPackageSuiteWorkdir.get_ordered_packages()`), or a single
85
+ workdir when running standalone.
70
86
  """
71
87
 
72
88
  def __init__(
73
89
  self,
74
- workdir_path: Path,
90
+ scope_roots: list[Path],
75
91
  source: str,
76
92
  target: str,
77
93
  *,
78
94
  dry_run: bool = True,
79
95
  ) -> None:
80
- self.workdir_path = workdir_path
96
+ if not scope_roots:
97
+ raise ValueError("scope_roots must contain at least one path")
98
+ self.scope_roots = scope_roots
81
99
  self.source = source
82
100
  self.target = target
83
101
  self.dry_run = dry_run
@@ -86,67 +104,96 @@ class PythonPackageRenameHandler:
86
104
  if not all(self._source_parts) or not all(self._target_parts):
87
105
  raise ValueError("source and target must be dotted names without empty parts")
88
106
 
107
+ def compute_plan(self) -> RenamePlan:
108
+ """Phase 1: scan and build the plan. No write yet."""
109
+ plan = RenamePlan()
110
+ package_dir = self.find_package_dir()
111
+ plan.dir_renames.append(
112
+ (package_dir, package_dir.parent / self._target_parts[-1])
113
+ )
114
+
115
+ for py_file in self._iter_python_files():
116
+ plan.files_scanned += 1
117
+ new_source = self._rewrite_imports(py_file)
118
+ if new_source is not None:
119
+ plan.rewrites[py_file] = new_source
120
+
121
+ return plan
122
+
89
123
  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
124
+ """Return the unique directory matching `source` across all scope roots."""
125
+ n = len(self._source_parts)
92
126
  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)
127
+ for root in self.scope_roots:
128
+ for init_file in root.rglob("__init__.py"):
129
+ if any(part in _SKIP_DIR_NAMES for part in init_file.parent.parts):
130
+ continue
131
+ pkg_dir = init_file.parent
132
+ if len(pkg_dir.parts) < n:
133
+ continue
134
+ if pkg_dir.parts[-n:] == self._source_parts:
135
+ matches.append(pkg_dir)
136
+
137
+ # Same path discovered through two overlapping roots → dedupe.
138
+ unique = list({str(m): m for m in matches}.values())
101
139
 
102
- if not matches:
140
+ if not unique:
141
+ roots_str = ", ".join(str(r) for r in self.scope_roots)
103
142
  raise FileNotFoundError(
104
- f"No package directory matching '{self.source}' under {self.workdir_path}"
143
+ f"No package directory matching '{self.source}' under: {roots_str}"
105
144
  )
106
- if len(matches) > 1:
107
- joined = "\n ".join(str(m) for m in matches)
145
+ if len(unique) > 1:
146
+ joined = "\n ".join(str(m) for m in unique)
108
147
  raise ValueError(
109
148
  f"Multiple packages match '{self.source}', refine the dotted name:\n {joined}"
110
149
  )
111
- return matches[0]
150
+ return unique[0]
112
151
 
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]
152
+ def run(
153
+ self,
154
+ applier: RenameApplier | None = None,
155
+ ) -> RenameReport:
156
+ """Compute plan preflight → apply (or stop after compute in dry-run)."""
157
+ plan = self.compute_plan()
158
+ old_dir, new_dir = plan.dir_renames[0]
159
+ report = RenameReport(
160
+ files_changed=list(plan.rewrites.keys()),
161
+ files_scanned=plan.files_scanned,
162
+ package_dir_new=new_dir,
163
+ package_dir_old=old_dir,
164
+ )
118
165
 
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")
166
+ if self.dry_run:
167
+ return report
127
168
 
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)
169
+ problems = plan.preflight()
170
+ if problems:
171
+ joined = "\n - ".join(problems)
172
+ raise RuntimeError(f"Plan preflight failed:\n - {joined}")
133
173
 
174
+ (applier or NaiveRenameApplier()).apply(plan)
134
175
  return report
135
176
 
136
177
  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
178
+ """Iterate every .py file under any scope root, skipping build/VCS dirs."""
179
+ seen: set[str] = set()
180
+ for root in self.scope_roots:
181
+ stack: list[Path] = [root]
182
+ while stack:
183
+ current = stack.pop()
184
+ try:
185
+ for entry in current.iterdir():
186
+ if entry.is_dir():
187
+ if entry.name not in _SKIP_DIR_NAMES:
188
+ stack.append(entry)
189
+ elif entry.suffix == ".py":
190
+ key = str(entry.resolve())
191
+ if key in seen:
192
+ continue
193
+ seen.add(key)
194
+ yield entry
195
+ except (PermissionError, FileNotFoundError):
196
+ continue
150
197
 
151
198
  def _rewrite_imports(self, py_file: Path) -> str | None:
152
199
  """Return rewritten source, or None when no import touched this file."""
@@ -173,7 +220,7 @@ class _ImportRenameTransformer(cst.CSTTransformer):
173
220
  super().__init__()
174
221
  self.old_parts = old_parts
175
222
  self.new_parts = new_parts
176
- self._n = len(old_parts) # cached: avoid repeated len() + attr-lookup
223
+ self._n = len(old_parts)
177
224
  self.changed = False
178
225
 
179
226
  def leave_Import(
@@ -201,7 +248,6 @@ class _ImportRenameTransformer(cst.CSTTransformer):
201
248
  original_node: cst.ImportFrom,
202
249
  updated_node: cst.ImportFrom,
203
250
  ) -> cst.ImportFrom:
204
- # `from . import X` has module=None; relative imports are out of scope.
205
251
  if updated_node.module is None:
206
252
  return updated_node
207
253
  parts = _dotted_to_parts(updated_node.module)
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass
9
+ class RenamePlan:
10
+ """Everything a rename will touch, fully computed before any disk write.
11
+
12
+ The plan is collected, validated (`preflight`), then applied as one batch.
13
+ Splitting the phases means a permission error or a target-already-exists
14
+ error stops the run before any partial change reaches the filesystem.
15
+ """
16
+
17
+ rewrites: dict[Path, str] = field(default_factory=dict)
18
+ """Per-file new source code. Keys = absolute file paths."""
19
+
20
+ dir_renames: list[tuple[Path, Path]] = field(default_factory=list)
21
+ """(old_dir, new_dir) pairs. Ordered: applied last, after rewrites."""
22
+
23
+ files_scanned: int = 0
24
+ """Total .py files inspected (for reporting)."""
25
+
26
+ def preflight(self) -> list[str]:
27
+ """Return a list of human-readable problems, empty if plan can run.
28
+
29
+ Checks (cheap, no writes):
30
+ - each file in `rewrites` is writable
31
+ - each `dir_rename` target doesn't already exist
32
+ - each `dir_rename` source exists
33
+ """
34
+ problems: list[str] = []
35
+ for path in self.rewrites:
36
+ if not os.access(path, os.W_OK):
37
+ problems.append(f"Not writable: {path}")
38
+ for old_dir, new_dir in self.dir_renames:
39
+ if not old_dir.exists():
40
+ problems.append(f"Source directory missing: {old_dir}")
41
+ if new_dir.exists():
42
+ problems.append(f"Target directory already exists: {new_dir}")
43
+ return problems
@@ -12,6 +12,10 @@ from wexample_wex_addon_dev_python.selection.abstract_python_code_selection impo
12
12
  if TYPE_CHECKING:
13
13
  from pathlib import Path
14
14
 
15
+ # Hoisted to module scope so the generator inside `is_candidate` captures it via
16
+ # a single LOAD_DEREF (closure) instead of rebuilding the tuple on each call.
17
+ _FUNC_TYPES: tuple[type, ...] = (ast.FunctionDef, ast.AsyncFunctionDef)
18
+
15
19
 
16
20
  @base_class
17
21
  class PythonCodePerformanceSelection(AbstractPythonCodeSelection):
@@ -78,9 +82,14 @@ class PythonCodePerformanceSelection(AbstractPythonCodeSelection):
78
82
  tree = ast.parse(source)
79
83
  except SyntaxError:
80
84
  return False
85
+ # Local bindings keep hot-path overhead minimal:
86
+ # - `func_types`: one LOAD_DEREF instead of LOAD_GLOBAL per generator tick.
87
+ # - `is_trivial`: one LOAD_DEREF instead of LOAD_DEREF(self)+LOAD_ATTR
88
+ # for every function-def node that passes the isinstance guard.
89
+ func_types = _FUNC_TYPES
90
+ is_trivial = self._is_trivial_function
81
91
  return any(
82
- isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
83
- and not self._is_trivial_function(node)
92
+ isinstance(node, func_types) and not is_trivial(node)
84
93
  for node in ast.walk(tree)
85
94
  )
86
95
 
@@ -92,7 +101,11 @@ class PythonCodePerformanceSelection(AbstractPythonCodeSelection):
92
101
  - `pass`.
93
102
  - `...` (Ellipsis statement — modern stub idiom).
94
103
  - `raise NotImplementedError` or `raise NotImplementedError(...)`.
95
- - `super().__init__(...)` only, when the function IS `__init__`.
104
+ - `super().<same_name>(...)` (with or without `return`) pure
105
+ pass-through to the parent. Matches when the called super-method
106
+ name equals the wrapping function's name; a mismatched name is
107
+ almost certainly a bug or unusual API and we let the perf agent
108
+ look at it.
96
109
 
97
110
  Plus: any decorator named `abstractmethod` (bare or qualified like
98
111
  `abc.abstractmethod`) → trivial regardless of body. Abstract definitions
@@ -104,13 +117,9 @@ class PythonCodePerformanceSelection(AbstractPythonCodeSelection):
104
117
  check decoupled from its container.
105
118
  """
106
119
  for deco in node.decorator_list:
107
- deco_name = (
108
- deco.id
109
- if isinstance(deco, ast.Name)
110
- else deco.attr
111
- if isinstance(deco, ast.Attribute)
112
- else None
113
- )
120
+ # `ast.Name` has `id`; `ast.Attribute` has `attr`; other node types
121
+ # have neither — two C-level getattr probes beat two isinstance calls.
122
+ deco_name = getattr(deco, "id", None) or getattr(deco, "attr", None)
114
123
  if deco_name == "abstractmethod":
115
124
  return True
116
125
 
@@ -153,19 +162,28 @@ class PythonCodePerformanceSelection(AbstractPythonCodeSelection):
153
162
  ):
154
163
  return True
155
164
 
156
- if node.name == "__init__" and isinstance(stmt, ast.Expr):
165
+ # Same-name super pass-through: `super().<node.name>(...)` as either a
166
+ # bare expression or a return value. Matches `def foo(...): super().foo(...)`,
167
+ # `def foo(...): return super().foo(...)`, and the historical `__init__`
168
+ # case (which is subsumed). Different-name super calls (`def cleanup():
169
+ # super().close()`) intentionally fall through — that's not a textbook
170
+ # pass-through and might be hiding intent.
171
+ call = None
172
+ if isinstance(stmt, ast.Expr):
173
+ call = stmt.value
174
+ elif isinstance(stmt, ast.Return) and stmt.value is not None:
157
175
  call = stmt.value
176
+ if (
177
+ isinstance(call, ast.Call)
178
+ and isinstance(call.func, ast.Attribute)
179
+ and call.func.attr == node.name
180
+ ):
181
+ base = call.func.value
158
182
  if (
159
- isinstance(call, ast.Call)
160
- and isinstance(call.func, ast.Attribute)
161
- and call.func.attr == "__init__"
183
+ isinstance(base, ast.Call)
184
+ and isinstance(base.func, ast.Name)
185
+ and base.func.id == "super"
162
186
  ):
163
- base = call.func.value
164
- if (
165
- isinstance(base, ast.Call)
166
- and isinstance(base.func, ast.Name)
167
- and base.func.id == "super"
168
- ):
169
- return True
187
+ return True
170
188
 
171
189
  return False
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import re
3
4
  from pathlib import Path
4
5
  from typing import TYPE_CHECKING
5
6
 
@@ -29,6 +30,8 @@ from wexample_wex_addon_dev_python.const.python import (
29
30
  PYTHON_PYTEST_COV_REPORT_DIR,
30
31
  )
31
32
  from wexample_wex_addon_dev_python.file.python_app_iml_file import PythonAppImlFile
33
+
34
+ _RE_TEST_DEF = re.compile(r"^\s*(?:async )?def test_", re.MULTILINE)
32
35
  from wexample_wex_addon_dev_python.workdir.mixin.with_profiling_python_workdir_mixin import (
33
36
  WithProfilingPythonWorkdirMixin,
34
37
  )
@@ -83,16 +86,13 @@ class PythonWorkdir(
83
86
  return venv_path
84
87
 
85
88
  def count_tests(self) -> int:
86
- import re
87
-
88
89
  tests_path = self.get_path() / "tests"
89
90
  if not tests_path.is_dir():
90
91
  return 0
91
92
 
92
- pattern = re.compile(r"^\s*(?:async )?def test_", re.MULTILINE)
93
93
  count = 0
94
94
  for file in tests_path.rglob("test_*.py"):
95
- count += len(pattern.findall(file.read_text()))
95
+ count += len(_RE_TEST_DEF.findall(file.read_text()))
96
96
  return count
97
97
 
98
98
  def get_app_config_file(self, reload: bool = True) -> PythonPyprojectTomlFile:
@@ -580,8 +580,6 @@ class PythonWorkdir(
580
580
  # relationship — the static dict identity is what `SidecarOfOption`
581
581
  # resolves on).
582
582
  def _entry_filter(entry):
583
- if not exclude_dirs:
584
- return True
585
583
  return not any(
586
584
  segment in exclude_dirs for segment in entry.parent.parts
587
585
  )