wexample-filestate-python 0.0.40__tar.gz → 0.0.43__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 (65) hide show
  1. {wexample_filestate_python-0.0.40 → wexample_filestate_python-0.0.43}/PKG-INFO +10 -24
  2. {wexample_filestate_python-0.0.40 → wexample_filestate_python-0.0.43}/README.md +5 -12
  3. {wexample_filestate_python-0.0.40 → wexample_filestate_python-0.0.43}/pyproject.toml +5 -15
  4. wexample_filestate_python-0.0.43/src/wexample_filestate_python/common/pipy_gateway.py +20 -0
  5. wexample_filestate_python-0.0.43/src/wexample_filestate_python/config_option/python_config_option.py +49 -0
  6. wexample_filestate_python-0.0.43/src/wexample_filestate_python/const/__init__.py +0 -0
  7. wexample_filestate_python-0.0.43/src/wexample_filestate_python/const/name_pattern.py +4 -0
  8. wexample_filestate_python-0.0.43/src/wexample_filestate_python/file/__init__.py +0 -0
  9. wexample_filestate_python-0.0.43/src/wexample_filestate_python/file/python_file.py +12 -0
  10. wexample_filestate_python-0.0.43/src/wexample_filestate_python/helpers/__init__.py +0 -0
  11. wexample_filestate_python-0.0.43/src/wexample_filestate_python/helpers/package.py +141 -0
  12. wexample_filestate_python-0.0.43/src/wexample_filestate_python/helpers/toml.py +116 -0
  13. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/__init__.py +0 -0
  14. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/abstract_python_file_operation.py +84 -0
  15. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/python_add_future_annotations_operation.py +105 -0
  16. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/python_add_return_types_operation.py +285 -0
  17. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/python_fix_attrs_operation.py +53 -0
  18. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/python_fix_blank_lines_operation.py +62 -0
  19. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/python_format_operation.py +50 -0
  20. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/python_fstringify_operation.py +49 -0
  21. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/python_modernize_typing_operation.py +44 -0
  22. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/python_order_class_attributes_operation.py +50 -0
  23. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/python_order_class_docstring_operation.py +54 -0
  24. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/python_order_class_methods_operation.py +53 -0
  25. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/python_order_constants_operation.py +51 -0
  26. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/python_order_iterable_items_operation.py +48 -0
  27. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/python_order_main_guard_operation.py +58 -0
  28. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/python_order_module_docstring_operation.py +87 -0
  29. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/python_order_module_functions_operation.py +58 -0
  30. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/python_order_module_metadata_operation.py +76 -0
  31. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/python_order_type_checking_block_operation.py +65 -0
  32. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/python_relocate_imports_operation.py +203 -0
  33. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/python_remove_unused_imports_operation.py +60 -0
  34. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/python_sort_imports_operation.py +42 -0
  35. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/python_unquote_annotations_operation.py +101 -0
  36. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/utils/__init__.py +0 -0
  37. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/utils/python_attrs_utils.py +112 -0
  38. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/utils/python_blank_lines_utils.py +597 -0
  39. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/utils/python_class_attributes_utils.py +261 -0
  40. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/utils/python_class_docstring_utils.py +85 -0
  41. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/utils/python_class_methods_utils.py +230 -0
  42. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/utils/python_constants_utils.py +299 -0
  43. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/utils/python_docstring_utils.py +117 -0
  44. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/utils/python_functions_utils.py +212 -0
  45. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/utils/python_iterable_utils.py +131 -0
  46. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/utils/python_main_guard_utils.py +80 -0
  47. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/utils/python_module_metadata_utils.py +146 -0
  48. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/utils/python_type_checking_utils.py +113 -0
  49. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/utils/relocate_imports/__init__.py +7 -0
  50. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/utils/relocate_imports/python_import_rewriter.py +400 -0
  51. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/utils/relocate_imports/python_localize_runtime_imports.py +249 -0
  52. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/utils/relocate_imports/python_parser_import_index.py +54 -0
  53. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/utils/relocate_imports/python_runtime_symbol_collector.py +33 -0
  54. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operation/utils/relocate_imports/python_usage_collector.py +360 -0
  55. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operations_provider/__init__.py +0 -0
  56. wexample_filestate_python-0.0.43/src/wexample_filestate_python/operations_provider/python_operations_provider.py +103 -0
  57. wexample_filestate_python-0.0.43/src/wexample_filestate_python/options_provider/__init__.py +0 -0
  58. wexample_filestate_python-0.0.43/src/wexample_filestate_python/options_provider/python_options_provider.py +24 -0
  59. wexample_filestate_python-0.0.43/src/wexample_filestate_python/py.typed +0 -0
  60. wexample_filestate_python-0.0.43/src/wexample_filestate_python/workdir/__init__.py +0 -0
  61. wexample_filestate_python-0.0.43/tests/tests/__init__.py +0 -0
  62. wexample_filestate_python-0.0.43/tests/wexample_tests/__init__.py +0 -0
  63. {wexample_filestate_python-0.0.40/tests/tests → wexample_filestate_python-0.0.43/src/wexample_filestate_python}/__init__.py +0 -0
  64. {wexample_filestate_python-0.0.40/tests/wexample_tests → wexample_filestate_python-0.0.43/src/wexample_filestate_python/common}/__init__.py +0 -0
  65. /wexample_filestate_python-0.0.40/src/wexample_filestate_python/py.typed → /wexample_filestate_python-0.0.43/src/wexample_filestate_python/config_option/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: wexample-filestate-python
3
- Version: 0.0.40
3
+ Version: 0.0.43
4
4
  Summary: Helpers for Python.
5
5
  Author-Email: weeger <contact@wexample.com>
6
6
  License: MIT
@@ -9,17 +9,10 @@ Classifier: License :: OSI Approved :: MIT License
9
9
  Classifier: Operating System :: OS Independent
10
10
  Project-URL: homepage, https://github.com/wexample/python-filestate-python
11
11
  Requires-Python: >=3.10
12
- Requires-Dist: autoflake
13
- Requires-Dist: black
14
- Requires-Dist: flynt
15
- Requires-Dist: isort
16
- Requires-Dist: networkx
17
- Requires-Dist: packaging
18
- Requires-Dist: pydantic<3,>=2
19
- Requires-Dist: python-dotenv
20
- Requires-Dist: pyupgrade
21
- Requires-Dist: tomlkit
22
- Requires-Dist: wexample-filestate-git==0.0.38
12
+ Requires-Dist: attrs>=23.1.0
13
+ Requires-Dist: cattrs>=23.1.0
14
+ Requires-Dist: wexample-filestate==0.0.53
15
+ Requires-Dist: wexample-helpers-api==0.0.34
23
16
  Provides-Extra: dev
24
17
  Requires-Dist: pytest; extra == "dev"
25
18
  Description-Content-Type: text/markdown
@@ -28,7 +21,7 @@ Description-Content-Type: text/markdown
28
21
 
29
22
  Helpers for Python.
30
23
 
31
- Version: 0.0.40
24
+ Version: 0.0.42
32
25
 
33
26
  ## Requirements
34
27
 
@@ -36,17 +29,10 @@ Version: 0.0.40
36
29
 
37
30
  ## Dependencies
38
31
 
39
- - autoflake
40
- - black
41
- - flynt
42
- - isort
43
- - networkx
44
- - packaging
45
- - pydantic>=2,<3
46
- - python-dotenv
47
- - pyupgrade
48
- - tomlkit
49
- - wexample-filestate-git==0.0.38
32
+ - attrs>=23.1.0
33
+ - cattrs>=23.1.0
34
+ - wexample-filestate==0.0.52
35
+ - wexample-helpers-api==0.0.33
50
36
 
51
37
  ## Installation
52
38
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  Helpers for Python.
4
4
 
5
- Version: 0.0.40
5
+ Version: 0.0.42
6
6
 
7
7
  ## Requirements
8
8
 
@@ -10,17 +10,10 @@ Version: 0.0.40
10
10
 
11
11
  ## Dependencies
12
12
 
13
- - autoflake
14
- - black
15
- - flynt
16
- - isort
17
- - networkx
18
- - packaging
19
- - pydantic>=2,<3
20
- - python-dotenv
21
- - pyupgrade
22
- - tomlkit
23
- - wexample-filestate-git==0.0.38
13
+ - attrs>=23.1.0
14
+ - cattrs>=23.1.0
15
+ - wexample-filestate==0.0.52
16
+ - wexample-helpers-api==0.0.33
24
17
 
25
18
  ## Installation
26
19
 
@@ -6,7 +6,7 @@ build-backend = "pdm.backend"
6
6
 
7
7
  [project]
8
8
  name = "wexample-filestate-python"
9
- version = "0.0.40"
9
+ version = "0.0.43"
10
10
  description = "Helpers for Python."
11
11
  authors = [
12
12
  { name = "weeger", email = "contact@wexample.com" },
@@ -18,17 +18,10 @@ classifiers = [
18
18
  "Operating System :: OS Independent",
19
19
  ]
20
20
  dependencies = [
21
- "autoflake",
22
- "black",
23
- "flynt",
24
- "isort",
25
- "networkx",
26
- "packaging",
27
- "pydantic>=2,<3",
28
- "python-dotenv",
29
- "pyupgrade",
30
- "tomlkit",
31
- "wexample-filestate-git==0.0.38",
21
+ "attrs>=23.1.0",
22
+ "cattrs>=23.1.0",
23
+ "wexample-filestate==0.0.53",
24
+ "wexample-helpers-api==0.0.34",
32
25
  ]
33
26
 
34
27
  [project.readme]
@@ -50,9 +43,6 @@ dev = [
50
43
  distribution = true
51
44
 
52
45
  [tool.pdm.build]
53
- includes = [
54
- "src/wexample_filestate_python/py.typed",
55
- ]
56
46
  package-dir = "src"
57
47
  packages = [
58
48
  { include = "wexample_filestate_python", from = "src" },
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from wexample_helpers.classes.field import public_field
4
+ from wexample_helpers_api.common.abstract_gateway import AbstractGateway
5
+ from wexample_helpers.decorator.base_class import base_class
6
+
7
+
8
+ @base_class
9
+ class PipyGateway(AbstractGateway):
10
+ base_url: str | None = public_field(
11
+ default="https://pypi.org/", description="Base Pipy API URL"
12
+ )
13
+
14
+ def package_release_exists(self, package_name: str, version: str) -> bool:
15
+ response = self.make_request(f"pypi/{package_name}/json")
16
+ # Package exists
17
+ if response.status_code == 200:
18
+ return bool(response.json().get("releases", {}).get(version))
19
+
20
+ return False
@@ -0,0 +1,49 @@
1
+ from typing import Any, ClassVar
2
+
3
+ from wexample_config.config_option.abstract_config_option import AbstractConfigOption
4
+
5
+
6
+ class PythonConfigOption(AbstractConfigOption):
7
+ # filestate: python-constant-sort
8
+ # New preferred option name to add `from __future__ import annotations`
9
+ OPTION_NAME_ADD_FUTURE_ANNOTATIONS: ClassVar[str] = "add_future_annotations"
10
+ OPTION_NAME_ADD_RETURN_TYPES: ClassVar[str] = "add_return_types"
11
+ # Fix attrs usage (ensure kw_only=True, etc.)
12
+ OPTION_NAME_FIX_ATTRS: ClassVar[str] = "fix_attrs"
13
+ # Fix blank lines in Python files (after signatures, docstrings, etc.)
14
+ OPTION_NAME_FIX_BLANK_LINES: ClassVar[str] = "fix_blank_lines"
15
+ OPTION_NAME_FORMAT: ClassVar[str] = "format"
16
+ OPTION_NAME_FSTRINGIFY: ClassVar[str] = "fstringify"
17
+ OPTION_NAME_MODERNIZE_TYPING: ClassVar[str] = "modernize_typing"
18
+ # Sort class attributes: special first, then public A–Z, then private/protected A–Z
19
+ OPTION_NAME_ORDER_CLASS_ATTRIBUTES: ClassVar[str] = "order_class_attributes"
20
+ # Ensure class docstring is first statement after header/decorators
21
+ OPTION_NAME_ORDER_CLASS_DOCSTRING: ClassVar[str] = "order_class_docstring"
22
+ # Order class methods (dunders sequence, class/staticmethods, properties, instances)
23
+ OPTION_NAME_ORDER_CLASS_METHODS: ClassVar[str] = "order_class_methods"
24
+ # Sort flagged UPPER_CASE constant blocks at module level
25
+ OPTION_NAME_ORDER_CONSTANTS: ClassVar[str] = "order_constants"
26
+ # Sort items inside flagged iterable literals (lists/dicts)
27
+ OPTION_NAME_ORDER_ITERABLE_ITEMS: ClassVar[str] = "order_iterable_items"
28
+ # Ensure if __name__ == "__main__" block is at the very end
29
+ OPTION_NAME_ORDER_MAIN_GUARD: ClassVar[str] = "order_main_guard"
30
+ # Order module docstring to be at the top of the file
31
+ OPTION_NAME_ORDER_MODULE_DOCSTRING: ClassVar[str] = "order_module_docstring"
32
+ # Order module-level functions (public A–Z, then private)
33
+ OPTION_NAME_ORDER_MODULE_FUNCTIONS: ClassVar[str] = "order_module_functions"
34
+ # Group and sort module metadata at module level
35
+ OPTION_NAME_ORDER_MODULE_METADATA: ClassVar[str] = "order_module_metadata"
36
+ # Normalize blank lines between program structures (spacing rules)
37
+ OPTION_NAME_ORDER_SPACING: ClassVar[str] = "order_spacing"
38
+ # Move TYPE_CHECKING blocks to after regular imports
39
+ OPTION_NAME_ORDER_TYPE_CHECKING_BLOCK: ClassVar[str] = "order_type_checking_block"
40
+ # Relocate imports by usage (runtime-in-method, class property types, type-only)
41
+ OPTION_NAME_RELOCATE_IMPORTS: ClassVar[str] = "relocate_imports"
42
+ OPTION_NAME_REMOVE_UNUSED: ClassVar[str] = "remove_unused"
43
+ OPTION_NAME_SORT_IMPORTS: ClassVar[str] = "sort_imports"
44
+ # New policy: unquote annotations (remove string annotations)
45
+ OPTION_NAME_UNQUOTE_ANNOTATIONS: ClassVar[str] = "unquote_annotations"
46
+
47
+ @staticmethod
48
+ def get_raw_value_allowed_type() -> Any:
49
+ return list[str]
@@ -0,0 +1,4 @@
1
+ from __future__ import annotations
2
+
3
+ # filestate: python-constant-sort
4
+ NAME_PATTERN_PYTHON_NOT_PYCACHE = "^(?!__pycache__$).+$"
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import ClassVar
4
+
5
+ from wexample_filestate.item.item_target_file import ItemTargetFile
6
+
7
+
8
+ class PythonFile(ItemTargetFile):
9
+ EXTENSION_ENV: ClassVar[str] = "py"
10
+
11
+ def _expected_file_name_extension(self) -> str | None:
12
+ return PythonFile.EXTENSION_ENV
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from typing import TYPE_CHECKING
5
+
6
+ import tomli
7
+
8
+ if TYPE_CHECKING:
9
+ from pathlib import Path
10
+
11
+
12
+ def package_get_dependencies(root_dir: str | Path) -> dict[str, set[str]]:
13
+ """
14
+ Get dependencies between packages in a directory.
15
+ """
16
+ from pathlib import Path
17
+
18
+ packages_root = Path(root_dir)
19
+ if not packages_root.exists() or not packages_root.is_dir():
20
+ raise ValueError(f"Error: {packages_root} does not exist or is not a directory")
21
+
22
+ dependencies = {}
23
+
24
+ # First pass: collect all local packages
25
+ for package_dir in packages_root.iterdir():
26
+ if not package_dir.is_dir():
27
+ continue
28
+
29
+ package_info = package_get_info(package_dir)
30
+ if package_info:
31
+ name, _ = package_info
32
+ dependencies[name] = set()
33
+
34
+ # Second pass: analyze dependencies
35
+ for package_dir in packages_root.iterdir():
36
+ if not package_dir.is_dir():
37
+ continue
38
+
39
+ package_info = package_get_info(package_dir)
40
+ if package_info:
41
+ name, deps = package_info
42
+ if name in dependencies:
43
+ # Only keep dependencies that are local packages
44
+ dependencies[name] = {dep for dep in deps if dep in dependencies}
45
+
46
+ return dependencies
47
+
48
+
49
+ def package_get_info(package_dir: Path) -> tuple[str, set[str]] | None:
50
+ """
51
+ Get package name and its dependencies from setup.py or pyproject.toml.
52
+ """
53
+ # Try pyproject.toml first
54
+ toml_path = package_dir / "pyproject.toml"
55
+ if toml_path.exists():
56
+ metadata = package_parse_toml(toml_path)
57
+ else:
58
+ # Fallback to setup.py
59
+ setup_py_path = package_dir / "setup.py"
60
+ if setup_py_path.exists():
61
+ metadata = package_parse_setup(setup_py_path)
62
+ else:
63
+ return None
64
+
65
+ name = metadata.get("name")
66
+ if not name:
67
+ return None
68
+
69
+ deps = metadata.get("install_requires", [])
70
+ return name, set(deps)
71
+
72
+
73
+ def package_list_sorted(root_dir: str | Path) -> list[str]:
74
+ """
75
+ Get a list of package names sorted by dependency order.
76
+ """
77
+ from wexample_filestate.helpers.dependencies import dependencies_sort
78
+
79
+ dependencies = package_get_dependencies(root_dir)
80
+ if not dependencies:
81
+ return []
82
+
83
+ # Convert dependencies dict to list for sorting
84
+ packages = list(dependencies.keys())
85
+
86
+ def get_deps(pkg: str) -> set[str]:
87
+ return dependencies[pkg]
88
+
89
+ return dependencies_sort(packages, get_deps)
90
+
91
+
92
+ def package_normalize_name(val: str) -> str:
93
+ import re as _re
94
+
95
+ # strip extras, versions, markers
96
+ base = _re.split(r"[\s<>=!~;\[]", val, maxsplit=1)[0]
97
+ return base.strip().lower()
98
+
99
+
100
+ def package_parse_setup(path: Path) -> dict:
101
+ """
102
+ Parse a setup.py file to extract metadata.
103
+ """
104
+ with open(path) as f:
105
+ content = f.read()
106
+
107
+ tree = ast.parse(content)
108
+ for node in ast.walk(tree):
109
+ if (
110
+ isinstance(node, ast.Call)
111
+ and isinstance(node.func, ast.Name)
112
+ and node.func.id == "setup"
113
+ ):
114
+ result = {}
115
+ for kw in node.keywords:
116
+ if isinstance(kw.value, ast.Str):
117
+ result[kw.arg] = kw.value.s
118
+ elif isinstance(kw.value, ast.List):
119
+ result[kw.arg] = [
120
+ elt.s for elt in kw.value.elts if isinstance(elt, ast.Str)
121
+ ]
122
+ return result
123
+ return {}
124
+
125
+
126
+ def package_parse_toml(path: Path) -> dict:
127
+ """
128
+ Parse a pyproject.toml file to extract metadata.
129
+ """
130
+ try:
131
+ with open(path, "rb") as f:
132
+ data = tomli.load(f)
133
+ if "project" in data:
134
+ project_data = data["project"]
135
+ return {
136
+ "name": project_data.get("name"),
137
+ "install_requires": project_data.get("dependencies", []),
138
+ }
139
+ except Exception as e:
140
+ print(f"Error parsing {path}: {e}")
141
+ return {}
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ if TYPE_CHECKING:
6
+ from tomlkit.items import Array
7
+
8
+
9
+ def toml_ensure_array(tbl: Any, key: str) -> tuple[Any, bool]:
10
+ """
11
+ Ensure an array exists at tbl[key] and return (array, changed).
12
+ Uses tomlkit.array() for creation.
13
+ """
14
+ from tomlkit import array
15
+
16
+ arr = tbl.get(key) if isinstance(tbl, dict) else None
17
+ if arr is None:
18
+ arr = array()
19
+ tbl[key] = arr
20
+ return arr, True
21
+ return arr, False
22
+
23
+
24
+ def toml_ensure_array_multiline(tbl: Any, key: str) -> tuple[Array, bool]:
25
+ """
26
+ Ensure an array exists at tbl[key] and force multiline formatting.
27
+ Returns (array, changed_created).
28
+ """
29
+ from tomlkit.items import Array
30
+
31
+ arr, changed = toml_ensure_array(tbl, key)
32
+ # Force multiline for readability when dumping
33
+ if isinstance(arr, Array):
34
+ arr.multiline(True)
35
+ return arr, changed
36
+
37
+
38
+ def toml_ensure_table(doc: Any, path: list[str]) -> tuple[Any, bool]:
39
+ """
40
+ Ensure a nested TOML table exists and return (table, changed).
41
+ Path example: ["tool", "pdm", "build"]. Uses tomlkit.table() for missing parts.
42
+ """
43
+ from tomlkit import table
44
+
45
+ if not isinstance(path, list) or not path:
46
+ raise ValueError("path must be a non-empty list of keys")
47
+
48
+ changed = False
49
+ current = doc
50
+ for key in path:
51
+ tbl = current.get(key) if isinstance(current, dict) else None
52
+ if not tbl or not isinstance(tbl, dict):
53
+ tbl = table()
54
+ current[key] = tbl
55
+ changed = True
56
+ current = tbl
57
+ return current, changed
58
+
59
+
60
+ def toml_get_string_value(item: Any) -> str:
61
+ """Return the string content of a tomlkit String or generic item as str."""
62
+ from tomlkit.items import String
63
+
64
+ if isinstance(item, String):
65
+ return item.value
66
+ return str(item)
67
+
68
+
69
+ def toml_set_array_multiline(tbl: Any, key: str, values: list[Any]) -> Array:
70
+ """
71
+ Replace tbl[key] with a tomlkit array built from values and set multiline(True).
72
+ Returns the created Array instance.
73
+ """
74
+ from tomlkit import array
75
+
76
+ arr = array(values)
77
+ arr.multiline(True)
78
+ tbl[key] = arr
79
+ return arr
80
+
81
+
82
+ def toml_sort_string_array(arr: Any) -> bool:
83
+ """
84
+ Sort a tomlkit Array of String items in-place (case-insensitive) while
85
+ preserving the existing multiline/style flags.
86
+
87
+ Returns True if the array was changed.
88
+ """
89
+ from tomlkit.items import Array, String
90
+
91
+ # Validate array type
92
+ if not isinstance(arr, Array):
93
+ return False
94
+
95
+ items = list(arr)
96
+ if not items or not all(isinstance(i, String) for i in items):
97
+ return False
98
+
99
+ values = [i.value for i in items]
100
+ sorted_items = [
101
+ x
102
+ for _, x in sorted(zip([v.lower() for v in values], items), key=lambda t: t[0])
103
+ ]
104
+
105
+ if items == sorted_items:
106
+ return False
107
+
108
+ multiline_flag = getattr(arr, "multiline", None)
109
+ # Clear and re-append to preserve tomlkit item identity
110
+ while len(arr):
111
+ arr.pop()
112
+ for item in sorted_items:
113
+ arr.append(item)
114
+ if multiline_flag is not None:
115
+ arr.multiline(multiline_flag)
116
+ return True
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from wexample_filestate.operation.abstract_existing_file_operation import (
6
+ AbstractExistingFileOperation,
7
+ )
8
+
9
+ if TYPE_CHECKING:
10
+ from wexample_config.config_option.abstract_config_option import (
11
+ AbstractConfigOption,
12
+ )
13
+ from wexample_filestate.enum.scopes import Scope
14
+
15
+
16
+ class AbstractPythonFileOperation(AbstractExistingFileOperation):
17
+ @classmethod
18
+ def get_option_name(cls) -> str:
19
+ raise NotImplementedError
20
+
21
+ @classmethod
22
+ def get_scope(cls) -> Scope:
23
+ from wexample_filestate.enum.scopes import Scope
24
+
25
+ return Scope.CONTENT
26
+
27
+ @classmethod
28
+ def _execute_and_wrap_stdout(cls, callback):
29
+ """Execute a callback and wrap any stdout/stderr output with additional newlines.
30
+
31
+ This ensures that output from external tools doesn't interfere with progress indicators
32
+ by adding a newline after any captured output.
33
+
34
+ Args:
35
+ callback: Function to execute that may produce stdout/stderr output
36
+
37
+ Returns:
38
+ The return value of the callback function
39
+ """
40
+ import io
41
+ import sys
42
+
43
+ old_stdout = sys.stdout
44
+ old_stderr = sys.stderr
45
+ captured_stdout = io.StringIO()
46
+ captured_stderr = io.StringIO()
47
+ sys.stdout = captured_stdout
48
+ sys.stderr = captured_stderr
49
+
50
+ try:
51
+ result = callback()
52
+ finally:
53
+ sys.stdout = old_stdout
54
+ sys.stderr = old_stderr
55
+
56
+ stdout_content = captured_stdout.getvalue()
57
+ stderr_content = captured_stderr.getvalue()
58
+
59
+ if stdout_content.strip():
60
+ print(stdout_content.rstrip())
61
+ print()
62
+ if stderr_content.strip():
63
+ print(stderr_content.rstrip(), file=sys.stderr)
64
+ print(file=sys.stderr)
65
+
66
+ return result
67
+
68
+ def applicable_for_option(self, option: AbstractConfigOption) -> bool:
69
+ """Generic applicability for Python file transforms controlled by a single option name."""
70
+ from wexample_filestate_python.config_option.python_config_option import (
71
+ PythonConfigOption,
72
+ )
73
+
74
+ # Option type
75
+ if not isinstance(option, PythonConfigOption):
76
+ return False
77
+
78
+ # Option value must contain our specific option name
79
+ value = option.get_value()
80
+ if value is None or not value.has_item_in_list(self.get_option_name()):
81
+ return False
82
+
83
+ # Delegate change detection to the base helper
84
+ return self.source_need_change(self.target)
@@ -0,0 +1,105 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from wexample_filestate.const.types_state_items import TargetFileOrDirectoryType
4
+
5
+ from .abstract_python_file_operation import AbstractPythonFileOperation
6
+
7
+ if TYPE_CHECKING:
8
+ from wexample_filestate.const.types_state_items import TargetFileOrDirectoryType
9
+
10
+
11
+ class PythonAddFutureAnnotationsOperation(AbstractPythonFileOperation):
12
+ """Ensure `from __future__ import annotations` is present at module top.
13
+
14
+ Triggered by: {"python": ["add_future_annotations"]}
15
+
16
+ Note: This operation historically removed __future__ imports; we repurpose it
17
+ to add the annotations future consistently across the codebase, per new policy.
18
+ """
19
+
20
+ @classmethod
21
+ def get_option_name(cls) -> str:
22
+ from wexample_filestate_python.config_option.python_config_option import (
23
+ PythonConfigOption,
24
+ )
25
+
26
+ return PythonConfigOption.OPTION_NAME_ADD_FUTURE_ANNOTATIONS
27
+
28
+ @classmethod
29
+ def preview_source_change(cls, target: TargetFileOrDirectoryType) -> str | None:
30
+ """Return source with a `from __future__ import annotations` inserted
31
+ in the correct place if it is not already present.
32
+
33
+ Rules:
34
+ - Keep shebang on first line intact.
35
+ - Keep encoding cookie (PEP 263) within first two lines intact.
36
+ - If module docstring exists, insert after the docstring statement.
37
+ - Otherwise, insert after shebang/encoding header block.
38
+ - If the future import already exists anywhere, return the original source.
39
+ """
40
+ src = cls._read_current_str_or_fail(target)
41
+
42
+ # Fast path: already present
43
+ if "from __future__ import annotations" in src:
44
+ return src
45
+
46
+ import ast
47
+ import re
48
+
49
+ lines = src.splitlines(keepends=True)
50
+
51
+ # Detect shebang and encoding cookie positions
52
+ idx = 0
53
+ if lines and lines[0].startswith("#!"):
54
+ idx = 1
55
+ # Encoding cookie can be on first or second line (after shebang)
56
+ enc_re = re.compile(r"^#.*coding[:=]\\s*([-_.a-zA-Z0-9]+)")
57
+ for i in range(idx, min(idx + 2, len(lines))):
58
+ if enc_re.match(lines[i]):
59
+ idx = i + 1
60
+
61
+ # Parse to find module docstring span
62
+ try:
63
+ tree = ast.parse(src)
64
+ except SyntaxError:
65
+ # If parsing fails, be conservative: insert after header block
66
+ insert_at = idx
67
+ else:
68
+ body = getattr(tree, "body", [])
69
+ if (
70
+ body
71
+ and isinstance(body[0], ast.Expr)
72
+ and isinstance(getattr(body[0], "value", None), ast.Constant)
73
+ and isinstance(body[0].value.value, str)
74
+ ):
75
+ # Module docstring present; insert after its end_lineno
76
+ doc_end = getattr(body[0], "end_lineno", body[0].lineno) # 1-based
77
+ insert_at = max(idx, doc_end) # line number where next stmt starts
78
+ else:
79
+ insert_at = idx
80
+
81
+ # lines is 0-based; insert_at is 1-based line count from AST
82
+ insert_index = max(0, min(len(lines), insert_at))
83
+
84
+ # Ensure there is a newline after the inserted import if needed
85
+ future_line = "from __future__ import annotations\n"
86
+
87
+ # Avoid inserting duplicate blank lines: if previous line is not blank and not newline, keep
88
+ # If there are existing future imports right after docstring, we can insert alongside them; no special handling needed
89
+ lines.insert(insert_index, future_line)
90
+ # If there isn't a blank line after the future import and next line is not blank, add one for readability
91
+ j = insert_index + 1
92
+ if j < len(lines):
93
+ if lines[j].strip() != "":
94
+ lines.insert(j, "\n")
95
+
96
+ return "".join(lines)
97
+
98
+ def describe_after(self) -> str:
99
+ return "`from __future__ import annotations` has been added in the correct position."
100
+
101
+ def describe_before(self) -> str:
102
+ return "The file may be missing `from __future__ import annotations` at the module top."
103
+
104
+ def description(self) -> str:
105
+ return "Add `from __future__ import annotations` at the proper location (after shebang/encoding and module docstring)."