wexample-wex-addon-dev-python 10.2.0__py3-none-any.whl → 11.1.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 +26 -13
  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 +100 -20
  27. {wexample_wex_addon_dev_python-10.2.0.dist-info → wexample_wex_addon_dev_python-11.1.0.dist-info}/METADATA +10 -10
  28. {wexample_wex_addon_dev_python-10.2.0.dist-info → wexample_wex_addon_dev_python-11.1.0.dist-info}/RECORD +30 -26
  29. {wexample_wex_addon_dev_python-10.2.0.dist-info → wexample_wex_addon_dev_python-11.1.0.dist-info}/WHEEL +0 -0
  30. {wexample_wex_addon_dev_python-10.2.0.dist-info → wexample_wex_addon_dev_python-11.1.0.dist-info}/entry_points.txt +0 -0
@@ -35,12 +35,13 @@ def _code_check_mypy(kernel: Kernel, file_path: str) -> bool:
35
35
  # Build and check the file
36
36
  source = BuildSource(path=file_path, module=None, text=None)
37
37
  result = build.build(sources=[source], options=options, alt_lib_path=None)
38
- if result.errors:
38
+ errors = result.errors
39
+ if errors:
39
40
  kernel.io.log_indent_up()
40
- kernel.io.error(f"Mypy errors:")
41
+ kernel.io.error("Mypy errors:")
41
42
  kernel.io.log_indent_up()
42
43
 
43
- for error in result.errors:
44
+ for error in errors:
44
45
  kernel.io.error(message=error, symbol=False)
45
46
 
46
47
  kernel.io.log_indent_down(number=2)
@@ -5,6 +5,16 @@ from typing import TYPE_CHECKING
5
5
  if TYPE_CHECKING:
6
6
  from wexample_cli.context.execution_context import ExecutionContext
7
7
 
8
+ # Pre-computed once at import time; avoids list allocation + join on every call.
9
+ _PYLINT_DISABLE_FLAG = "--disable=" + ",".join([
10
+ "missing-module-docstring",
11
+ "import-outside-toplevel",
12
+ "no-name-in-module",
13
+ "broad-exception-caught",
14
+ "c-extension-no-member",
15
+ "line-too-long",
16
+ ])
17
+
8
18
 
9
19
  def _code_check_pylint(context: ExecutionContext, file_path: str) -> bool:
10
20
  """Check a Python file using pylint for code quality.
@@ -22,23 +32,13 @@ def _code_check_pylint(context: ExecutionContext, file_path: str) -> bool:
22
32
 
23
33
  # Use subprocess to capture pylint output
24
34
  # This avoids issues with pylint's direct printing to stdout
25
- # List of warnings to disable
26
- disabled_warnings = [
27
- "missing-module-docstring",
28
- "import-outside-toplevel",
29
- "no-name-in-module",
30
- "broad-exception-caught",
31
- "c-extension-no-member",
32
- "line-too-long",
33
- ]
34
-
35
35
  cmd = [
36
36
  sys.executable,
37
37
  "-m",
38
38
  "pylint",
39
39
  file_path,
40
40
  "--output-format=json",
41
- f"--disable={','.join(disabled_warnings)}",
41
+ _PYLINT_DISABLE_FLAG,
42
42
  ]
43
43
  process = subprocess.run(cmd, capture_output=True, text=True, check=False)
44
44
 
@@ -53,19 +53,25 @@ def _code_check_pylint(context: ExecutionContext, file_path: str) -> bool:
53
53
  # Parse the JSON output
54
54
  results = json.loads(json_output)
55
55
 
56
- # Filter messages by type
57
- errors = [msg for msg in results if msg.get("type") in ("error", "fatal")]
58
- warnings = [msg for msg in results if msg.get("type") == "warning"]
59
- conventions = [
60
- msg for msg in results if msg.get("type") in ("convention", "refactor", "info")
61
- ]
56
+ # Categorise messages in a single pass instead of three separate comprehensions.
57
+ errors: list = []
58
+ warnings: list = []
59
+ conventions: list = []
60
+ for msg in results:
61
+ t = msg.get("type")
62
+ if t in ("error", "fatal"):
63
+ errors.append(msg)
64
+ elif t == "warning":
65
+ warnings.append(msg)
66
+ elif t in ("convention", "refactor", "info"):
67
+ conventions.append(msg)
62
68
 
63
69
  # Display results if any issues found
64
70
  if errors or warnings or conventions:
65
71
  # Display errors
66
72
  if errors:
67
73
  context.io.log_indent_up()
68
- context.io.error(f"Pylint errors:")
74
+ context.io.error("Pylint errors:")
69
75
  context.io.log_indent_up()
70
76
 
71
77
  for error in errors:
@@ -80,7 +86,7 @@ def _code_check_pylint(context: ExecutionContext, file_path: str) -> bool:
80
86
  # Display warnings with detailed logging
81
87
  if warnings:
82
88
  context.io.log_indent_up()
83
- context.io.warning(f"Pylint warnings:")
89
+ context.io.warning("Pylint warnings:")
84
90
  context.io.log_indent_up()
85
91
 
86
92
  for warning in warnings:
@@ -1,5 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
4
+ import subprocess
5
+ import sys
3
6
  from typing import TYPE_CHECKING
4
7
 
5
8
  if TYPE_CHECKING:
@@ -16,10 +19,6 @@ def _code_check_pyright(kernel: Kernel, file_path: str) -> bool:
16
19
  Returns:
17
20
  bool: True if check passes, False otherwise
18
21
  """
19
- import json
20
- import subprocess
21
- import sys
22
-
23
22
  # Use subprocess to run pyright
24
23
  cmd = [sys.executable, "-m", "pyright", file_path, "--outputjson"]
25
24
  process = subprocess.run(cmd, capture_output=True, text=True, check=False)
@@ -42,20 +41,25 @@ def _code_check_pyright(kernel: Kernel, file_path: str) -> bool:
42
41
  # Parse the JSON output
43
42
  results = json.loads(json_output)
44
43
 
45
- # Extract diagnostics
46
- diagnostics = results.get("diagnostics", [])
47
-
48
- # Filter by severity
49
- errors = [diag for diag in diagnostics if diag.get("severity") == "error"]
50
- warnings = [diag for diag in diagnostics if diag.get("severity") == "warning"]
51
- info = [diag for diag in diagnostics if diag.get("severity") == "information"]
44
+ # Classify diagnostics in a single pass (avoids 3× iteration and repeated .get("severity"))
45
+ errors: list = []
46
+ warnings: list = []
47
+ info: list = []
48
+ for diag in results.get("diagnostics", []):
49
+ sev = diag.get("severity")
50
+ if sev == "error":
51
+ errors.append(diag)
52
+ elif sev == "warning":
53
+ warnings.append(diag)
54
+ elif sev == "information":
55
+ info.append(diag)
52
56
 
53
57
  # Display results if any issues found
54
58
  if errors or warnings or info:
55
59
  # Display errors
56
60
  if errors:
57
61
  kernel.io.log_indent_up()
58
- kernel.io.error(f"Pyright errors:")
62
+ kernel.io.error("Pyright errors:")
59
63
 
60
64
  for error in errors:
61
65
  line = error.get("range", {}).get("start", {}).get("line", 0) + 1
@@ -70,7 +74,7 @@ def _code_check_pyright(kernel: Kernel, file_path: str) -> bool:
70
74
  # Display warnings
71
75
  if warnings:
72
76
  kernel.io.log_indent_up()
73
- kernel.io.warning(f"Pyright warnings:")
77
+ kernel.io.warning("Pyright warnings:")
74
78
 
75
79
  for warning in warnings:
76
80
  line = warning.get("range", {}).get("start", {}).get("line", 0) + 1
@@ -85,7 +89,7 @@ def _code_check_pyright(kernel: Kernel, file_path: str) -> bool:
85
89
  # Display information
86
90
  if info:
87
91
  kernel.io.log_indent_up()
88
- kernel.io.info(f"Pyright information:")
92
+ kernel.io.info("Pyright information:")
89
93
  kernel.io.log_indent_up()
90
94
 
91
95
  for item in info:
@@ -74,16 +74,13 @@ def python__code__check(
74
74
  }
75
75
 
76
76
  # Determine which tools to run
77
- if tool and tool.lower() in tool_map:
77
+ tool_lower = tool.lower() if tool else None
78
+ if tool_lower and tool_lower in tool_map:
78
79
  # Run only the specified tool
79
- check_functions = [tool_map[tool.lower()]]
80
+ check_functions = [tool_map[tool_lower]]
80
81
  else:
81
82
  # Run all tools if no specific tool is specified or if the specified tool is invalid
82
- check_functions = [
83
- _code_check_mypy,
84
- _code_check_pylint,
85
- _code_check_pyright,
86
- ]
83
+ check_functions = list(tool_map.values())
87
84
 
88
85
  # Track overall success
89
86
  all_checks_passed = True
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import subprocess
4
+ import sys
3
5
  from typing import TYPE_CHECKING
4
6
 
5
7
  if TYPE_CHECKING:
@@ -16,11 +18,8 @@ def _code_format_black(kernel: Kernel, file_path: str) -> bool:
16
18
  Returns:
17
19
  bool: True if formatting succeeds, False otherwise
18
20
  """
19
- import subprocess
20
- import sys
21
-
22
21
  # Use subprocess to run black
23
- cmd = [sys.executable, "-m", "black", file_path]
22
+ cmd = (sys.executable, "-m", "black", file_path)
24
23
  process = subprocess.run(cmd, capture_output=True, text=True, check=False)
25
24
 
26
25
  # Check if the command was successful
@@ -30,17 +29,17 @@ def _code_format_black(kernel: Kernel, file_path: str) -> bool:
30
29
  else:
31
30
  kernel.io.success(f"Black: {file_path} already well formatted")
32
31
  return True
33
- else:
34
- kernel.io.error(f"Black failed to format {file_path}")
35
- kernel.io.log_indent_up()
36
32
 
37
- if process.stderr:
38
- kernel.io.error(f"Error: {process.stderr}", symbol=False)
39
- if process.stdout:
40
- kernel.io.error(f"Output: {process.stdout}", symbol=False)
33
+ kernel.io.error(f"Black failed to format {file_path}")
34
+ kernel.io.log_indent_up()
35
+
36
+ if process.stderr:
37
+ kernel.io.error(f"Error: {process.stderr}", symbol=False)
38
+ if process.stdout:
39
+ kernel.io.error(f"Output: {process.stdout}", symbol=False)
41
40
 
42
- # Add detailed error properties
43
- kernel.io.properties({"returncode": process.returncode, "command": cmd})
41
+ # Add detailed error properties
42
+ kernel.io.properties({"returncode": process.returncode, "command": cmd})
44
43
 
45
- kernel.io.log_indent_down()
46
- return False
44
+ kernel.io.log_indent_down()
45
+ return False
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import subprocess
4
+ import sys
3
5
  from typing import TYPE_CHECKING
4
6
 
5
7
  if TYPE_CHECKING:
@@ -16,13 +18,10 @@ def _code_format_isort(kernel: Kernel, file_path: str) -> bool:
16
18
  Returns:
17
19
  bool: True if formatting succeeds, False otherwise
18
20
  """
19
- import subprocess
20
- import sys
21
-
22
21
  # Use subprocess to run isort
23
22
  # --profile=black ensures compatibility with Black formatter
24
23
  cmd = [sys.executable, "-m", "isort", "--profile=black", file_path]
25
- process = subprocess.run(cmd, capture_output=True, text=True, check=False)
24
+ process = subprocess.run(cmd, capture_output=True, text=True)
26
25
 
27
26
  # Check if the command was successful
28
27
  if process.returncode == 0:
@@ -31,17 +30,17 @@ def _code_format_isort(kernel: Kernel, file_path: str) -> bool:
31
30
  else:
32
31
  kernel.io.success(f"isort successfully reformatted imports in {file_path}")
33
32
  return True
34
- else:
35
- kernel.io.error(f"isort failed to format imports in {file_path}")
36
- kernel.io.log_indent_up()
37
33
 
38
- if process.stderr:
39
- kernel.io.error(f"Error: {process.stderr}", symbol=False)
40
- if process.stdout:
41
- kernel.io.error(f"Output: {process.stdout}", symbol=False)
34
+ kernel.io.error(f"isort failed to format imports in {file_path}")
35
+ kernel.io.log_indent_up()
36
+
37
+ if process.stderr:
38
+ kernel.io.error(f"Error: {process.stderr}", symbol=False)
39
+ if process.stdout:
40
+ kernel.io.error(f"Output: {process.stdout}", symbol=False)
42
41
 
43
- # Add detailed error properties
44
- kernel.io.properties({"returncode": process.returncode, "command": cmd})
42
+ # Add detailed error properties
43
+ kernel.io.properties({"returncode": process.returncode, "command": cmd})
45
44
 
46
- kernel.io.log_indent_down()
47
- return False
45
+ kernel.io.log_indent_down()
46
+ return False
@@ -61,13 +61,16 @@ def python__code__format(
61
61
  "isort": _code_format_isort,
62
62
  }
63
63
 
64
+ # Normalise once to avoid repeated .lower() calls
65
+ tool_lower = tool.lower() if tool else None
66
+
64
67
  # Determine which tools to run
65
- if tool and tool.lower() in tool_map:
68
+ if tool_lower in tool_map:
66
69
  # Run only the specified tool
67
- format_functions = [tool_map[tool.lower()]]
70
+ format_functions = [tool_map[tool_lower]]
68
71
  else:
69
- # Run all tools if no specific tool is specified or if the specified tool is invalid
70
- if tool and tool.lower() not in tool_map:
72
+ # In the else branch, a truthy tool_lower is already known to be absent from tool_map
73
+ if tool_lower:
71
74
  kernel.io.warning(f"Unknown tool '{tool}', running all available tools")
72
75
 
73
76
  # Run isort first, then black (recommended order)
@@ -85,7 +88,7 @@ def python__code__format(
85
88
  format_result = format_function(kernel, file)
86
89
 
87
90
  # Update overall success status
88
- all_formats_passed = all_formats_passed and format_result
91
+ all_formats_passed &= format_result
89
92
 
90
93
  # Stop if a format fails and stop_on_failure is True
91
94
  if not format_result and stop_on_failure:
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from wexample_cli.const.tags import AudienceTag, EffectTag, ScopeTag
6
+ from wexample_cli.decorator.command import command
7
+ from wexample_cli.decorator.middleware import middleware
8
+ from wexample_cli.decorator.option import option
9
+ from wexample_wex_addon_app.middleware.app_middleware import AppMiddleware
10
+ from wexample_wex_core.const.globals import COMMAND_TYPE_ADDON
11
+
12
+ from wexample_wex_addon_dev_python.const.tags import DomainTag
13
+
14
+ if TYPE_CHECKING:
15
+ from wexample_app.response.abstract_response import AbstractResponse
16
+ from wexample_cli.context.execution_context import ExecutionContext
17
+ from wexample_wex_addon_app.workdir.managed_workdir import ManagedWorkdir
18
+
19
+
20
+ @option(
21
+ name="source",
22
+ type=str,
23
+ required=True,
24
+ description="Dotted source name to rename (e.g. 'mylib.helpers').",
25
+ )
26
+ @option(
27
+ name="target",
28
+ type=str,
29
+ required=True,
30
+ description="Dotted target name (e.g. 'mylib.helper').",
31
+ )
32
+ @option(
33
+ name="kind",
34
+ type=str,
35
+ required=False,
36
+ default="package",
37
+ description="Symbol kind to rename. Step 1 supports only 'package'.",
38
+ )
39
+ @option(
40
+ name="dry_run",
41
+ type=bool,
42
+ is_flag=True,
43
+ default=False,
44
+ description="Preview impacted files without writing. Default: changes are applied.",
45
+ )
46
+ @middleware(middleware=AppMiddleware)
47
+ @command(
48
+ type=COMMAND_TYPE_ADDON,
49
+ description=(
50
+ "Rename a Python package directory and propagate the change to every "
51
+ "import statement in the workdir. Dry-run by default."
52
+ ),
53
+ tags=[
54
+ DomainTag.LANGUAGE_PYTHON,
55
+ EffectTag.WRITE,
56
+ AudienceTag.AGENT_SAFE,
57
+ ScopeTag.LOCAL,
58
+ ScopeTag.PACKAGE,
59
+ ],
60
+ )
61
+ def python__code__rename(
62
+ context: ExecutionContext,
63
+ app_workdir: ManagedWorkdir,
64
+ source: str,
65
+ target: str,
66
+ kind: str = "package",
67
+ dry_run: bool = False,
68
+ ) -> AbstractResponse:
69
+ from wexample_app.response.failure_response import FailureResponse
70
+ from wexample_app.response.table_response import TableResponse
71
+
72
+ from wexample_wex_addon_dev_python.refactor.python_package_rename_handler import (
73
+ PythonPackageRenameHandler,
74
+ )
75
+ from wexample_wex_addon_dev_python.refactor.symbol_kind import SymbolKind
76
+
77
+ try:
78
+ kind_enum = SymbolKind(kind)
79
+ except ValueError:
80
+ return FailureResponse(
81
+ kernel=context.kernel,
82
+ message=(
83
+ f"Unknown --kind {kind!r}. Valid: "
84
+ f"{', '.join([k.value for k in SymbolKind])}"
85
+ ),
86
+ )
87
+
88
+ if kind_enum is not SymbolKind.PACKAGE:
89
+ return FailureResponse(
90
+ kernel=context.kernel,
91
+ message=(
92
+ f"--kind {kind_enum.value} not implemented yet "
93
+ "(step 1 covers 'package' only)."
94
+ ),
95
+ )
96
+
97
+ workdir_path = app_workdir.get_path()
98
+ handler = PythonPackageRenameHandler(
99
+ workdir_path=workdir_path,
100
+ source=source,
101
+ target=target,
102
+ dry_run=dry_run,
103
+ )
104
+ try:
105
+ 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
+ )
115
+ except ValueError as e:
116
+ # Raised on ambiguous match (multiple packages) or invalid dotted name.
117
+ return FailureResponse(kernel=context.kernel, message=str(e))
118
+
119
+ mode = "APPLIED" if not dry_run else "DRY-RUN"
120
+ context.io.log(
121
+ f"[{mode}] {report.package_dir_old} → {report.package_dir_new}"
122
+ )
123
+ context.io.log(
124
+ f"[{mode}] scanned={report.files_scanned} changed={len(report.files_changed)}"
125
+ )
126
+
127
+ rows = [
128
+ [str(p.relative_to(workdir_path))] for p in report.files_changed
129
+ ]
130
+ return TableResponse(
131
+ kernel=context.kernel,
132
+ content=rows,
133
+ headers=["File"],
134
+ )
@@ -27,5 +27,4 @@ def python__examples__validate(
27
27
  ExamplePydanticClassWithPublicVarInternallyDefined,
28
28
  )
29
29
 
30
- example_class = ExamplePydanticClassWithPublicVarInternallyDefined()
31
- context.kernel.log(example_class)
30
+ context.kernel.log(ExamplePydanticClassWithPublicVarInternallyDefined())
@@ -1,9 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
4
+
3
5
  from wexample_api.common.abstract_gateway import AbstractGateway
4
6
  from wexample_helpers.classes.field import public_field
5
7
  from wexample_helpers.decorator.base_class import base_class
6
8
 
9
+ _HAS_VERSION_STATUS_CODES: tuple[int, ...] = (200, 404)
10
+
7
11
 
8
12
  @base_class
9
13
  class PypiRegistryGateway(AbstractGateway):
@@ -27,8 +31,6 @@ class PypiRegistryGateway(AbstractGateway):
27
31
  def __attrs_post_init__(self) -> None:
28
32
  super().__attrs_post_init__()
29
33
  if self.token:
30
- import base64
31
-
32
34
  credentials = base64.b64encode(
33
35
  f"{self.username or '__token__'}:{self.token}".encode()
34
36
  ).decode()
@@ -43,7 +45,7 @@ class PypiRegistryGateway(AbstractGateway):
43
45
  """
44
46
  response = self.make_request(
45
47
  endpoint=f"simple/{package}/",
46
- expected_status_codes=[200, 404],
48
+ expected_status_codes=_HAS_VERSION_STATUS_CODES,
47
49
  fatal_if_unexpected=False,
48
50
  quiet=True,
49
51
  )
@@ -1,15 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import TYPE_CHECKING
4
-
5
3
  from wexample_helpers.decorator.base_class import base_class
6
4
  from wexample_wex_addon_app.config_value.app_readme_config_value import (
7
5
  AppReadmeConfigValue,
8
6
  )
9
7
 
10
- if TYPE_CHECKING:
11
- pass
12
-
13
8
 
14
9
  @base_class
15
10
  class PythonPackageReadmeContentConfigValue(AppReadmeConfigValue):
@@ -22,9 +17,8 @@ class PythonPackageReadmeContentConfigValue(AppReadmeConfigValue):
22
17
  def _get_app_homepage(self) -> str:
23
18
  """Extract homepage URL from pyproject.toml."""
24
19
  project = self.workdir.get_app_config()
25
- urls = (
26
- project.get("urls", {}) if isinstance(project.get("urls", {}), dict) else {}
27
- )
20
+ raw_urls = project.get("urls", {})
21
+ urls = raw_urls if isinstance(raw_urls, dict) else {}
28
22
  return urls.get("homepage") or urls.get("Homepage") or ""
29
23
 
30
24
  def _get_project_license(self) -> str | None:
@@ -29,13 +29,14 @@ class PythonAppImlFile(ImlFile):
29
29
  return ({"@type": "sourceFolder", "@forTests": "false"},)
30
30
 
31
31
  def _default_source_folders(self) -> Iterable[dict[str, Any]]:
32
+ base = self.MODULE_DIR_URL
32
33
  return (
33
34
  {
34
- "@url": f"{self.MODULE_DIR_URL}/src",
35
+ "@url": f"{base}/src",
35
36
  "@isTestSource": "false",
36
37
  },
37
38
  {
38
- "@url": f"{self.MODULE_DIR_URL}/tests",
39
+ "@url": f"{base}/tests",
39
40
  "@isTestSource": "true",
40
41
  },
41
42
  )
@@ -79,16 +79,23 @@ class PythonPyprojectTomlFile(AppDependenciesConfigFileMixin, TomlFile):
79
79
  content = content or self.read_parsed()
80
80
 
81
81
  workdir = self.get_parent_item()
82
- import_name = workdir.get_package_import_name()
82
+ # `src_import_name` is None for non-distributable projects (e.g. a
83
+ # CLI app whose `src/` contains arbitrary subdirs rather than a
84
+ # single `{vendor}_{name}/` module). The packaging-distribution
85
+ # enforces below consult it and become no-ops when None — they
86
+ # would otherwise inject `packages=[{include: '<name>', from: 'src'}]`
87
+ # and `coverage.source = ['<name>']` referencing a non-existent
88
+ # module. Source of truth lives on the workdir, not here.
89
+ src_import_name = workdir.get_src_import_name()
83
90
  project_name = workdir.get_package_name()
84
91
  project_version = workdir.get_setup_version()
85
92
 
86
93
  self._enforce_build_system(content)
87
- self._enforce_pdm_build(content, import_name)
94
+ self._enforce_pdm_build(content, src_import_name)
88
95
  self._enforce_project_metadata(content, project_name, project_version)
89
96
  self._normalize_dependencies(content)
90
97
  self._ensure_dev_dependencies(content)
91
- self._enforce_pytest_coverage_config(content, import_name)
98
+ self._enforce_pytest_coverage_config(content, src_import_name)
92
99
  self._reorder_toml_sections(content)
93
100
 
94
101
  result = dumps(content)
@@ -105,7 +112,7 @@ class PythonPyprojectTomlFile(AppDependenciesConfigFileMixin, TomlFile):
105
112
  deps = self._get_deps_array(optional=optional, group=group)
106
113
 
107
114
  map = {}
108
- for spec in list(deps):
115
+ for spec in deps:
109
116
  req = Requirement(spec)
110
117
  # name: version
111
118
  map[canonicalize_name(req.name)] = str(req.specifier)
@@ -140,7 +147,7 @@ class PythonPyprojectTomlFile(AppDependenciesConfigFileMixin, TomlFile):
140
147
 
141
148
  target = canonicalize_name(package_name)
142
149
  filtered: list[str] = []
143
- for existing in list(deps):
150
+ for existing in deps:
144
151
  try:
145
152
  existing_name = canonicalize_name(Requirement(str(existing)).name)
146
153
  except Exception:
@@ -176,6 +183,13 @@ class PythonPyprojectTomlFile(AppDependenciesConfigFileMixin, TomlFile):
176
183
  build_tbl["build-backend"] = "pdm.backend"
177
184
 
178
185
  def _enforce_pdm_build(self, content: dict, import_name: str | None) -> None:
186
+ # Pure packaging-distribution config — when the workdir reports no
187
+ # `src/` module (None), this whole section has nothing meaningful
188
+ # to write: there is no package to declare, no setuptools find
189
+ # scope to define. Skip wholesale.
190
+ if not import_name:
191
+ return
192
+
179
193
  from wexample_filestate_python.helpers.toml import toml_ensure_table
180
194
 
181
195
  tool_tbl, _ = toml_ensure_table(content, ["tool"])
@@ -184,9 +198,8 @@ class PythonPyprojectTomlFile(AppDependenciesConfigFileMixin, TomlFile):
184
198
 
185
199
  pdm_tbl["distribution"] = True
186
200
  build_pdm_tbl["package-dir"] = "src"
187
- if import_name:
188
- build_pdm_tbl["packages"] = [{"include": import_name, "from": "src"}]
189
- build_pdm_tbl.pop("includes", None)
201
+ build_pdm_tbl["packages"] = [{"include": import_name, "from": "src"}]
202
+ build_pdm_tbl.pop("includes", None)
190
203
 
191
204
  # Add setuptools exclusion of testing package
192
205
  setuptools_tbl, _ = toml_ensure_table(tool_tbl, ["setuptools"])
@@ -298,12 +311,12 @@ class PythonPyprojectTomlFile(AppDependenciesConfigFileMixin, TomlFile):
298
311
  deps_arr = self._dependencies_array()
299
312
 
300
313
  runtime_pkgs = {
301
- package_normalize_name(toml_get_string_value(it)) for it in list(deps_arr)
314
+ package_normalize_name(toml_get_string_value(it)) for it in deps_arr
302
315
  }
303
- dev_values = [toml_get_string_value(it).strip() for it in list(dev_arr)]
316
+ dev_set = {toml_get_string_value(it).strip() for it in dev_arr}
304
317
 
305
318
  for pkg in ["pytest", "pytest-cov"]:
306
- if pkg not in runtime_pkgs and pkg not in dev_values:
319
+ if pkg not in runtime_pkgs and pkg not in dev_set:
307
320
  dev_arr.append(pkg)
308
321
 
309
322
  toml_sort_string_array(dev_arr)
@@ -326,7 +339,6 @@ class PythonPyprojectTomlFile(AppDependenciesConfigFileMixin, TomlFile):
326
339
  )
327
340
 
328
341
  deps_arr = self._dependencies_array()
329
- toml_sort_string_array(deps_arr)
330
342
 
331
343
  # Read the keep list from [tool.filestate].keep
332
344
  keep_packages: set[str] = set()
@@ -390,8 +402,9 @@ class PythonPyprojectTomlFile(AppDependenciesConfigFileMixin, TomlFile):
390
402
  existing_keys = list(d.keys())
391
403
 
392
404
  # Build the new order: ordered keys first, then remaining keys
405
+ key_order_set = frozenset(key_order)
393
406
  ordered_keys = [k for k in key_order if k in existing_keys]
394
- remaining_keys = [k for k in existing_keys if k not in key_order]
407
+ remaining_keys = [k for k in existing_keys if k not in key_order_set]
395
408
  new_order = ordered_keys + remaining_keys
396
409
 
397
410
  # Reorder by removing and re-adding in the desired order
@@ -4,8 +4,9 @@ from __future__ import annotations
4
4
  def apply_pdm_bin_dir(value: str) -> None:
5
5
  import os
6
6
 
7
- if value not in os.environ.get("PATH", ""):
8
- os.environ["PATH"] = f"{value}:{os.environ.get('PATH', '')}"
7
+ path = os.environ.get("PATH", "")
8
+ if value not in path:
9
+ os.environ["PATH"] = f"{value}:{path}"
9
10
 
10
11
 
11
12
  def detect_pdm_bin_dir() -> str | None: