wexample-wex-addon-dev-python 13.1.0__py3-none-any.whl → 13.2.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 (22) hide show
  1. wexample_wex_addon_dev_python/config_value/python_package_readme_config_value.py +13 -11
  2. wexample_wex_addon_dev_python/formatter/__init__.py +0 -0
  3. wexample_wex_addon_dev_python/formatter/python_code_formatter.py +46 -0
  4. wexample_wex_addon_dev_python/operation/__init__.py +0 -0
  5. wexample_wex_addon_dev_python/operation/generated_description_operation.py +127 -0
  6. wexample_wex_addon_dev_python/option/__init__.py +0 -0
  7. wexample_wex_addon_dev_python/option/generated_description_option.py +77 -0
  8. wexample_wex_addon_dev_python/options_provider/__init__.py +0 -0
  9. wexample_wex_addon_dev_python/options_provider/generative_options_provider.py +26 -0
  10. wexample_wex_addon_dev_python/python_addon_manager.py +31 -1
  11. wexample_wex_addon_dev_python/resources/ai/agents/describer/agent.yml +8 -0
  12. wexample_wex_addon_dev_python/resources/ai/agents/describer/context.j2 +36 -0
  13. wexample_wex_addon_dev_python/resources/design_rules/python_code.md +13 -0
  14. wexample_wex_addon_dev_python/resources/readme_templates/install.md.j2 +7 -0
  15. wexample_wex_addon_dev_python/resources/writing_rules/python_code.md +41 -0
  16. wexample_wex_addon_dev_python/workdir/python_package_workdir.py +10 -0
  17. wexample_wex_addon_dev_python/workdir/python_workdir.py +32 -3
  18. wexample_wex_addon_dev_python-13.2.0.dist-info/METADATA +310 -0
  19. {wexample_wex_addon_dev_python-13.1.0.dist-info → wexample_wex_addon_dev_python-13.2.0.dist-info}/RECORD +21 -8
  20. wexample_wex_addon_dev_python-13.1.0.dist-info/METADATA +0 -491
  21. {wexample_wex_addon_dev_python-13.1.0.dist-info → wexample_wex_addon_dev_python-13.2.0.dist-info}/WHEEL +0 -0
  22. {wexample_wex_addon_dev_python-13.1.0.dist-info → wexample_wex_addon_dev_python-13.2.0.dist-info}/entry_points.txt +0 -0
@@ -10,25 +10,28 @@ from wexample_wex_addon_app.config_value.app_readme_config_value import (
10
10
  class PythonPackageReadmeContentConfigValue(AppReadmeConfigValue):
11
11
  """README generation for Python packages."""
12
12
 
13
- def _get_app_description(self) -> str:
14
- """Extract description from pyproject.toml."""
15
- return self.workdir.get_app_config().get("project", {}).get("description")
13
+ def _get_app_description(self) -> str | None:
14
+ """Extract description from pyproject.toml, else from the wex config."""
15
+ description = self._get_project_table().get("description")
16
+ return description or super()._get_app_description()
16
17
 
17
18
  def _get_app_homepage(self) -> str:
18
19
  """Extract homepage URL from pyproject.toml."""
19
- project = self.workdir.get_app_config()
20
- raw_urls = project.get("urls", {})
20
+ raw_urls = self._get_project_table().get("urls", {})
21
21
  urls = raw_urls if isinstance(raw_urls, dict) else {}
22
22
  return urls.get("homepage") or urls.get("Homepage") or ""
23
23
 
24
24
  def _get_project_license(self) -> str | None:
25
25
  """Extract license information from pyproject.toml."""
26
- project = self.workdir.get_app_config()
27
- license_field = project.get("license", {})
26
+ license_field = self._get_project_table().get("license", {})
28
27
  if isinstance(license_field, dict):
29
28
  return license_field.get("text", "") or license_field.get("file", "")
30
29
  return str(license_field) if license_field else ""
31
30
 
31
+ def _get_project_table(self) -> dict:
32
+ """The `[project]` table, which is where PEP 621 puts every field read here."""
33
+ return self.workdir.get_app_config().get("project", {})
34
+
32
35
  def _get_template_context(self) -> dict:
33
36
  """Build template context with Python-specific variables.
34
37
 
@@ -36,9 +39,8 @@ class PythonPackageReadmeContentConfigValue(AppReadmeConfigValue):
36
39
  """
37
40
  context = super()._get_template_context()
38
41
 
39
- # Add Python-specific variable
40
- context["python_version"] = (
41
- self.workdir.get_app_config().get("project", {}).get("requires-python", "")
42
- )
42
+ project = self._get_project_table()
43
+ context["distribution_name"] = project.get("name", "")
44
+ context["python_version"] = project.get("requires-python", "")
43
45
 
44
46
  return context
File without changes
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, ClassVar
4
+
5
+ from wexample_helpers.decorator.base_class import base_class
6
+ from wexample_wex_addon_ai.formatter.abstract_formatter import AbstractFormatter
7
+
8
+ if TYPE_CHECKING:
9
+ from pathlib import Path
10
+
11
+
12
+ @base_class
13
+ class PythonCodeFormatter(AbstractFormatter):
14
+ """Python modules under a package's `src/`, the code the package actually ships.
15
+
16
+ Deliberately narrow: tests, examples and tooling scripts are Python too, but they
17
+ answer to different conventions and get their own formatter rather than a widened
18
+ match here.
19
+
20
+ The rules below say nothing about import placement, member ordering, blank lines,
21
+ f-strings or typing syntax — `wexample-filestate-python` rewrites all of those at
22
+ rectify time, so spending prompt on them would only ask an agent to imitate what a
23
+ deterministic pass fixes anyway. What is left is what no pass can decide.
24
+ """
25
+
26
+ FORMATTER_NAME: ClassVar[str] = "python-code"
27
+
28
+ def get_design_rules(self) -> str:
29
+ return self.read_resource("design_rules", "python_code.md")
30
+
31
+ def get_writing_rules(self) -> str:
32
+ return self.read_resource("writing_rules", "python_code.md")
33
+
34
+ def matches_path(self, path: Path) -> bool:
35
+ """A `.py` under the `src/` of a directory holding a `pyproject.toml`.
36
+
37
+ Anchored on the manifest rather than on the mere presence of a `src` segment,
38
+ so a vendored tree or a virtualenv that happens to contain `src/` is out.
39
+ """
40
+ if path.suffix != ".py":
41
+ return False
42
+
43
+ for parent in path.parents:
44
+ if parent.name == "src" and (parent.parent / "pyproject.toml").exists():
45
+ return True
46
+ return False
File without changes
@@ -0,0 +1,127 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from wexample_filestate.enum.scopes import Scope
6
+ from wexample_filestate.operation.abstract_operation import AbstractOperation
7
+ from wexample_helpers.classes.mixin.has_package_resources import HasPackageResources
8
+ from wexample_helpers.classes.private_field import private_field
9
+ from wexample_helpers.decorator.base_class import base_class
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+ CONFIG_KEY_SECTION = "global"
15
+ CONFIG_KEY_NAME = "description"
16
+ DESCRIPTION_MAX_LENGTH = 200
17
+ DESCRIPTION_PLACEHOLDER = "<one sentence, written by the describer agent on apply>"
18
+ DESCRIPTION_UNKNOWN = "UNKNOWN"
19
+
20
+
21
+ @base_class
22
+ class GeneratedDescriptionOperation(HasPackageResources, AbstractOperation):
23
+ """Write `global.description` into the app config, asking an agent for the sentence.
24
+
25
+ The model call lives here rather than in the option that builds this operation:
26
+ `FileStateDryRunResult` never calls `apply_operation`, so a preview stays offline and
27
+ free, and shows the placeholder instead of a sentence it would then have to produce a
28
+ second time on apply.
29
+ """
30
+
31
+ _previous: str | None = private_field(
32
+ default=None,
33
+ description="Value this operation replaced, for `undo`. None when the key was absent.",
34
+ )
35
+
36
+ @classmethod
37
+ def get_scopes(cls) -> list[Scope]:
38
+ return [Scope.CONTENT]
39
+
40
+ def apply_operation(self) -> None:
41
+ description = self._generate()
42
+ if description is None:
43
+ return
44
+
45
+ config_file = self.target.get_config_file()
46
+ content = config_file.read_parsed() or {}
47
+ section = content.setdefault(CONFIG_KEY_SECTION, {})
48
+
49
+ self._previous = section.get(CONFIG_KEY_NAME)
50
+ section[CONFIG_KEY_NAME] = description
51
+ config_file.write_parsed(content)
52
+
53
+ self.target.success(f"Description: {description}")
54
+
55
+ def undo(self) -> None:
56
+ config_file = self.target.get_config_file()
57
+ content = config_file.read_parsed() or {}
58
+ section = content.get(CONFIG_KEY_SECTION)
59
+ if not isinstance(section, dict):
60
+ return
61
+
62
+ if self._previous is None:
63
+ section.pop(CONFIG_KEY_NAME, None)
64
+ else:
65
+ section[CONFIG_KEY_NAME] = self._previous
66
+ config_file.write_parsed(content)
67
+
68
+ def _agent_config_path(self) -> Path:
69
+ return self.get_resource_path("ai", "agents", "describer", "agent.yml")
70
+
71
+ def _generate(self) -> str | None:
72
+ """The sentence, or None when nothing should be written.
73
+
74
+ Every failure mode lands on None: the hole stays, the pass reports it and moves on.
75
+ A rectification must not fail because a model was unreachable.
76
+ """
77
+ from wexample_wex_addon_ai.agent.claude_agent import ClaudeAgent
78
+ from wexample_wex_addon_ai.file.agent_yaml_file import AgentYamlFile
79
+
80
+ try:
81
+ agent = ClaudeAgent(
82
+ io=self.target.io,
83
+ app_workdir=self.target,
84
+ config_file=AgentYamlFile.create_from_path(
85
+ path=self._agent_config_path()
86
+ ),
87
+ )
88
+ sentence = agent.run_value_draft(self._prompt())
89
+ except Exception as exception:
90
+ self.target.warning(
91
+ f"Could not generate a description: {exception}. "
92
+ f"Leaving {CONFIG_KEY_SECTION}.{CONFIG_KEY_NAME} unset."
93
+ )
94
+ return None
95
+
96
+ return self._validate(sentence)
97
+
98
+ def _prompt(self) -> str:
99
+ return (
100
+ f"Package directory: {self.target.get_path()}\n"
101
+ f"Package name: {self.target.get_package_name()}\n"
102
+ f"\n"
103
+ f"Read the package and report its one-sentence description."
104
+ )
105
+
106
+ def _validate(self, sentence: str | None) -> str | None:
107
+ """Guard the one shape that would still poison a config file: a runaway sentence.
108
+
109
+ `report_value` already delimits the answer, so nothing has to be extracted from the
110
+ agent's prose — a missing report is simply `None`.
111
+ """
112
+ if sentence is None:
113
+ self.target.warning(
114
+ f"The describer reported nothing — "
115
+ f"leaving {CONFIG_KEY_SECTION}.{CONFIG_KEY_NAME} unset."
116
+ )
117
+ return None
118
+
119
+ if len(sentence) > DESCRIPTION_MAX_LENGTH:
120
+ self.target.warning(
121
+ f"The describer answered {len(sentence)} characters, over the "
122
+ f"{DESCRIPTION_MAX_LENGTH} allowed — leaving "
123
+ f"{CONFIG_KEY_SECTION}.{CONFIG_KEY_NAME} unset."
124
+ )
125
+ return None
126
+
127
+ return sentence
File without changes
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import TYPE_CHECKING, Any, Union
5
+
6
+ from wexample_config.config_option.abstract_config_option import AbstractConfigOption
7
+ from wexample_filestate.enum.scopes import Scope
8
+ from wexample_filestate.option.mixin.option_mixin import OptionMixin
9
+ from wexample_helpers.decorator.base_class import base_class
10
+
11
+ if TYPE_CHECKING:
12
+ from wexample_filestate.const.types_state_items import TargetFileOrDirectoryType
13
+ from wexample_filestate.operation.abstract_operation import AbstractOperation
14
+
15
+
16
+ @base_class
17
+ class GeneratedDescriptionOption(OptionMixin, AbstractConfigOption):
18
+ """Ask an agent for `global.description` when the app config has none.
19
+
20
+ Only ever fills a hole: a description already written — by a human or by an earlier
21
+ run — is left alone, which is what makes the option convergent. It fires once per
22
+ package, then never again. To have one rewritten, clear the key.
23
+
24
+ Declared on the workdir, not on `pyproject.toml`: the toml's `description` is already
25
+ derived from this key by `PythonPyprojectTomlFile._enforce_project_metadata`, so
26
+ filling the config is enough for the value to reach the manifest through the ordinary
27
+ deterministic path.
28
+ """
29
+
30
+ @classmethod
31
+ def get_scopes(cls) -> list[Scope]:
32
+ return [Scope.CONTENT]
33
+
34
+ @staticmethod
35
+ def get_raw_value_allowed_type() -> Any:
36
+ return Union[bool, Callable]
37
+
38
+ def applicable_on_file(self) -> bool:
39
+ return False
40
+
41
+ def applicable_on_missing(self) -> bool:
42
+ return False
43
+
44
+ def create_required_operation(
45
+ self, target: TargetFileOrDirectoryType, scopes: set[Scope]
46
+ ) -> AbstractOperation | None:
47
+ from wexample_wex_addon_dev_python.operation.generated_description_operation import (
48
+ CONFIG_KEY_NAME,
49
+ CONFIG_KEY_SECTION,
50
+ DESCRIPTION_PLACEHOLDER,
51
+ GeneratedDescriptionOperation,
52
+ )
53
+
54
+ value = self.get_value()
55
+ raw = value.raw if value is not None else None
56
+ if callable(raw):
57
+ raw = raw(target)
58
+ if not bool(raw):
59
+ return None
60
+
61
+ existing = target.get_config().search(
62
+ f"{CONFIG_KEY_SECTION}.{CONFIG_KEY_NAME}"
63
+ )
64
+ if not existing.is_none() and existing.get_str().strip():
65
+ return None
66
+
67
+ return GeneratedDescriptionOperation(
68
+ option=self,
69
+ target=target,
70
+ description=(
71
+ f"Write {CONFIG_KEY_SECTION}.{CONFIG_KEY_NAME}: "
72
+ f"{DESCRIPTION_PLACEHOLDER}"
73
+ ),
74
+ )
75
+
76
+ def get_description(self) -> str:
77
+ return "Have an agent write the app description when none is set"
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from wexample_config.options_provider.abstract_options_provider import (
6
+ AbstractOptionsProvider,
7
+ )
8
+
9
+ if TYPE_CHECKING:
10
+ from wexample_config.config_option.abstract_config_option import (
11
+ AbstractConfigOption,
12
+ )
13
+
14
+
15
+ class GenerativeOptionsProvider(AbstractOptionsProvider):
16
+ @classmethod
17
+ def get_options(cls) -> list[type[AbstractConfigOption]]:
18
+ if "_options_cache" not in cls.__dict__:
19
+ from wexample_wex_addon_dev_python.option.generated_description_option import (
20
+ GeneratedDescriptionOption,
21
+ )
22
+
23
+ cls._options_cache: list[type[AbstractConfigOption]] = [
24
+ GeneratedDescriptionOption
25
+ ]
26
+ return cls._options_cache
@@ -2,6 +2,9 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING
4
4
 
5
+ from wexample_wex_addon_ai.formatter.formatter_contributing_addon_mixin import (
6
+ FormatterContributingAddonMixin,
7
+ )
5
8
  from wexample_wex_addon_ai.selection.selection_contributing_addon_mixin import (
6
9
  SelectionContributingAddonMixin,
7
10
  )
@@ -9,10 +12,22 @@ from wexample_wex_core.common.abstract_addon_manager import AbstractAddonManager
9
12
 
10
13
  if TYPE_CHECKING:
11
14
  from wexample_cli.middleware.abstract_middleware import AbstractMiddleware
15
+ from wexample_wex_addon_ai.formatter.abstract_formatter import AbstractFormatter
12
16
  from wexample_wex_addon_ai.selection.abstract_selection import AbstractSelection
13
17
 
14
18
 
15
- class PythonAddonManager(SelectionContributingAddonMixin, AbstractAddonManager):
19
+ class PythonAddonManager(
20
+ FormatterContributingAddonMixin,
21
+ SelectionContributingAddonMixin,
22
+ AbstractAddonManager,
23
+ ):
24
+ def get_formatter_classes(self) -> list[type[AbstractFormatter]]:
25
+ from wexample_wex_addon_dev_python.formatter.python_code_formatter import (
26
+ PythonCodeFormatter,
27
+ )
28
+
29
+ return [PythonCodeFormatter]
30
+
16
31
  def get_local_configurable_keys(self) -> list[dict]:
17
32
  from wexample_wex_addon_dev_python.helper.pdm import (
18
33
  apply_pdm_bin_dir,
@@ -43,3 +58,18 @@ class PythonAddonManager(SelectionContributingAddonMixin, AbstractAddonManager):
43
58
  )
44
59
 
45
60
  return [PythonCodePerformanceSelection]
61
+
62
+ def get_workdir_types(self) -> dict[str, type]:
63
+ from wexample_wex_addon_dev_python.workdir.python_package_workdir import (
64
+ PythonPackageWorkdir,
65
+ )
66
+ from wexample_wex_addon_dev_python.workdir.python_packages_suite_workdir import (
67
+ PythonPackagesSuiteWorkdir,
68
+ )
69
+ from wexample_wex_addon_dev_python.workdir.python_workdir import PythonWorkdir
70
+
71
+ return {
72
+ "python": PythonWorkdir,
73
+ "python-package": PythonPackageWorkdir,
74
+ "python-packages-suite": PythonPackagesSuiteWorkdir,
75
+ }
@@ -0,0 +1,8 @@
1
+ description: Writes the one-sentence description of a Python package
2
+ model: claude:sonnet-5
3
+ modes: structured
4
+ todos: false
5
+ allowed_tools:
6
+ - Read
7
+ - Glob
8
+ - Grep
@@ -0,0 +1,36 @@
1
+ ## Domain: describing one Python package in one sentence
2
+
3
+ You are given a package directory. You read it and answer with a single sentence saying
4
+ what the package is for. That sentence lands in `global.description`, from which it is
5
+ copied verbatim into `pyproject.toml` and shown on the package registry.
6
+
7
+ ### Read before you write
8
+
9
+ The package name alone is not enough — `wexample-filestate-python` tells a reader nothing.
10
+ Look at what the code actually does: the public modules under `src/`, the README if there
11
+ is one, the classes a caller would import. A description written from the name is worse
12
+ than no description, because it reads as informative while saying nothing.
13
+
14
+ ### What the sentence has to do
15
+
16
+ Name the thing and say what problem it solves, in terms a developer who has never seen the
17
+ package would understand. Prefer the concrete over the categorical: "declares a desired
18
+ file tree and rectifies the disk to match it" beats "provides file management utilities".
19
+
20
+ ### Constraints
21
+
22
+ - One sentence. No trailing period is required, but do not write two.
23
+ - No more than about 120 characters. Registries truncate.
24
+ - Do not open with the package name, an article, or "A Python package that…" — the name is
25
+ already displayed next to the description. Start with the verb.
26
+ - Plain prose: no Markdown, no quotes around the sentence, no backticks.
27
+ - English.
28
+
29
+ ### Answering
30
+
31
+ The sentence goes into `report_value`, and only what you pass there is written to the
32
+ config file. Text you type outside that call is read by nobody.
33
+
34
+ If the package is too empty to describe honestly — no source, no README, nothing but
35
+ scaffolding — end your turn without calling `report_value`. The host writes nothing and
36
+ leaves the field for a human. Guessing is the one failure that matters here.
@@ -0,0 +1,13 @@
1
+ This file is design rules for Python shipped by a package, under its `src/`. Decide the
2
+ layout below before opening an editor — by the time a file is being written, its path is
3
+ already a decision made.
4
+
5
+ - One class per file, one file per class, and the file name is the class name in
6
+ snake_case — that last part is enforced, so a mismatch is an error, not a preference. A second class belongs in its own file, not appended to this one.
7
+ - Kinds live apart, each in its own directory: classes in `classes/`, helper functions in
8
+ `helper/`, constants in `const/`, enums in `enums/`. A module holding a class plus the
9
+ two functions that serve it is the case to split, not the exception that justifies
10
+ keeping them together.
11
+ - Content that is text rather than code — a prompt, a template, a long block of prose —
12
+ goes in a file under the package's `resources/`, read through `HasPackageResources`,
13
+ rather than a triple-quoted string in the class that uses it.
@@ -0,0 +1,7 @@
1
+ ## Installation
2
+
3
+ ```bash
4
+ pip install {{ distribution_name }}
5
+ ```
6
+
7
+ Requires Python {{ python_version }}.
@@ -0,0 +1,41 @@
1
+ This file is Python shipped by a package, under its `src/`. The file's path is already
2
+ decided by the time these rules apply — see `ai::design/rules --formatter python-code` for
3
+ the layout decisions that come before it.
4
+
5
+ - Code, names, comments and docstrings are in English, whatever language the conversation
6
+ is held in.
7
+ - Classes are attrs-based: `@base_class` (which applies `attrs.define(kw_only=True)`), and
8
+ attributes declared with `public_field(description=...)`, `protected_field(...)` or
9
+ `private_field(...)` from `wexample_helpers.classes`. No Pydantic — the description is
10
+ mandatory and is the field's documentation.
11
+ - Within a field, `default=` carries a scalar and `factory=` a mutable container. A `list`
12
+ or `dict` passed as `default` is shared by every instance ever built, which surfaces
13
+ much later as one object mutating another's state.
14
+ - Never write `__init__`: attrs generates it, and `private_field` is kept out of it by
15
+ design. Initialization logic goes in `__attrs_post_init__`, which must call
16
+ `super().__attrs_post_init__()` — parent classes rely on theirs running.
17
+ - Annotate parameters and any return type that is not obvious. Only the trivially
18
+ inferable returns (`None`, `bool`, `str`, `int`, `float`) get filled in later; everything
19
+ else is yours to write.
20
+ - A list literal written one item per line only stays sorted if you flag it: add
21
+ `# filestate: python-iterable-sort` above it. Without the marker nothing reorders it,
22
+ and it drifts.
23
+ - Import placement, member ordering, blank lines, f-strings, `from __future__ import
24
+ annotations`, the `TYPE_CHECKING` block and typing modernisation are settled later by a
25
+ formatting pass the operator runs, not by you. Write what reads naturally and leave the
26
+ form alone: do not reshuffle a file to imitate the pass, and do not run it yourself to
27
+ finish a task — a file whose only fault is cosmetic is a file that is done.
28
+ - A `try` is occasional and justified: it catches a named exception somewhere that knows
29
+ what to do about it. Wrapping a call defensively, catching `Exception`, or writing a
30
+ `finally` that lets the failure pass turns a bug into a wrong result reported nowhere.
31
+ Let it raise — a traceback at the point of failure is worth more than a silent fallback
32
+ discovered three layers later.
33
+ - Defensive code goes the same way: a guard against a state that cannot happen, a fallback
34
+ for a value that is always there, an `isinstance` check on something the signature
35
+ already types. Validate at the boundary — operator input, an external API, a file on
36
+ disk — and trust what our own code hands you.
37
+ - Comment only what the code cannot say: a constraint, an invariant, the reason a
38
+ surprising line is the way it is. A comment restating the line it sits above is noise,
39
+ and a docstring explaining what a well-named method obviously does is the same noise
40
+ indented. A comment that no longer matches the code it describes is deleted, not
41
+ updated around.
@@ -48,6 +48,16 @@ class PythonPackageWorkdir(PythonWorkdir):
48
48
  "Install: pipx install pdm — then run: wex core::env/configure"
49
49
  )
50
50
 
51
+ def get_required_knowledge_pages(self) -> dict[str, str]:
52
+ return {
53
+ **super().get_required_knowledge_pages(),
54
+ "usage/quickstart": (
55
+ "The shortest example that does something real: the import, the call, "
56
+ "the result. It must run as written against the published package. "
57
+ "Open with `## Quickstart`."
58
+ ),
59
+ }
60
+
51
61
  def prepare_value(self, raw_value: DictConfig | None = None) -> DictConfig:
52
62
  from wexample_helpers.helper.file import file_read
53
63
  from wexample_helpers.helper.module import module_get_path
@@ -101,8 +101,10 @@ class PythonWorkdir(
101
101
  )
102
102
 
103
103
  config_file = self.find_by_type(PythonPyprojectTomlFile)
104
- # Read once to populate content with file source.
105
- config_file.read_text(reload=reload)
104
+ # Read once to populate content with file source. A package being
105
+ # bootstrapped has no pyproject.toml yet, and reads as an empty config.
106
+ if config_file.get_path().exists():
107
+ config_file.read_text(reload=reload)
106
108
  return config_file
107
109
 
108
110
  def get_dependencies_versions(self) -> dict[str, str]:
@@ -116,15 +118,24 @@ class PythonWorkdir(
116
118
  PythonOptionsProvider,
117
119
  )
118
120
 
121
+ from wexample_wex_addon_dev_python.options_provider.generative_options_provider import (
122
+ GenerativeOptionsProvider,
123
+ )
124
+
119
125
  options = super().get_options_providers()
120
126
 
121
127
  options.append(PythonOptionsProvider)
128
+ options.append(GenerativeOptionsProvider)
122
129
 
123
130
  return options
124
131
 
125
132
  def get_package_import_name(self) -> str:
126
133
  """Get the full package import name with vendor prefix."""
127
- return f"{self.get_vendor_name()}_{self.get_project_name()}"
134
+ from wexample_helpers.helper.string import string_to_snake_case
135
+
136
+ # global.name may be written in any case: an import name has to be a
137
+ # valid Python identifier, matching the generated src/ directory.
138
+ return f"{self.get_vendor_name()}_{string_to_snake_case(self.get_project_name())}"
128
139
 
129
140
  def get_package_name(self) -> str:
130
141
  from wexample_helpers.helper.string import string_to_kebab_case
@@ -193,6 +204,7 @@ class PythonWorkdir(
193
204
  from wexample_filestate.option.sidecar_of_option import (
194
205
  SidecarOfOption,
195
206
  )
207
+ from wexample_filestate_python.file.python_file import PythonFile
196
208
  from wexample_filestate_python.file.python_test_stub_file import (
197
209
  PythonTestStubFile,
198
210
  )
@@ -200,11 +212,16 @@ class PythonWorkdir(
200
212
  from wexample_wex_addon_dev_python.file.python_pyproject_toml_file import (
201
213
  PythonPyprojectTomlFile,
202
214
  )
215
+ from wexample_wex_addon_dev_python.option.generated_description_option import (
216
+ GeneratedDescriptionOption,
217
+ )
203
218
 
204
219
  raw_value = super().prepare_value(raw_value=raw_value)
205
220
 
206
221
  self.append_agents(config=raw_value)
207
222
 
223
+ raw_value[GeneratedDescriptionOption.get_name()] = True
224
+
208
225
  children = raw_value["children"]
209
226
 
210
227
  self.add_gitignore_rules(
@@ -242,6 +259,11 @@ class PythonWorkdir(
242
259
  self._create_python_file_children_filter(
243
260
  exclude_dirs=("unit",),
244
261
  ),
262
+ {
263
+ "name": ".gitkeep",
264
+ "type": DiskItemType.FILE,
265
+ "should_exist": True,
266
+ },
245
267
  # NOTE: tests/unit/ and tests/unit/helpers/ omit
246
268
  # `should_exist` — we don't want filestate to delete
247
269
  # them when sidecars haven't been generated yet (no
@@ -322,6 +344,13 @@ class PythonWorkdir(
322
344
  self._create_python_file_children_filter(
323
345
  exclude_dirs=("helpers",),
324
346
  ),
347
+ {
348
+ # The factory above only reaches subdirectories.
349
+ "name": "__init__.py",
350
+ "class": PythonFile,
351
+ "type": DiskItemType.FILE,
352
+ "should_exist": True,
353
+ },
325
354
  {
326
355
  "name": "py.typed",
327
356
  "type": DiskItemType.FILE,