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.
- wexample_wex_addon_dev_python/config_value/python_package_readme_config_value.py +13 -11
- wexample_wex_addon_dev_python/formatter/__init__.py +0 -0
- wexample_wex_addon_dev_python/formatter/python_code_formatter.py +46 -0
- wexample_wex_addon_dev_python/operation/__init__.py +0 -0
- wexample_wex_addon_dev_python/operation/generated_description_operation.py +127 -0
- wexample_wex_addon_dev_python/option/__init__.py +0 -0
- wexample_wex_addon_dev_python/option/generated_description_option.py +77 -0
- wexample_wex_addon_dev_python/options_provider/__init__.py +0 -0
- wexample_wex_addon_dev_python/options_provider/generative_options_provider.py +26 -0
- wexample_wex_addon_dev_python/python_addon_manager.py +31 -1
- wexample_wex_addon_dev_python/resources/ai/agents/describer/agent.yml +8 -0
- wexample_wex_addon_dev_python/resources/ai/agents/describer/context.j2 +36 -0
- wexample_wex_addon_dev_python/resources/design_rules/python_code.md +13 -0
- wexample_wex_addon_dev_python/resources/readme_templates/install.md.j2 +7 -0
- wexample_wex_addon_dev_python/resources/writing_rules/python_code.md +41 -0
- wexample_wex_addon_dev_python/workdir/python_package_workdir.py +10 -0
- wexample_wex_addon_dev_python/workdir/python_workdir.py +32 -3
- wexample_wex_addon_dev_python-13.2.0.dist-info/METADATA +310 -0
- {wexample_wex_addon_dev_python-13.1.0.dist-info → wexample_wex_addon_dev_python-13.2.0.dist-info}/RECORD +21 -8
- wexample_wex_addon_dev_python-13.1.0.dist-info/METADATA +0 -491
- {wexample_wex_addon_dev_python-13.1.0.dist-info → wexample_wex_addon_dev_python-13.2.0.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
context["
|
|
41
|
-
|
|
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"
|
|
File without changes
|
|
@@ -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(
|
|
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,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,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
|
-
|
|
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
|
-
|
|
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,
|