apexcodexpy 0.7.3__tar.gz → 0.7.4__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 (27) hide show
  1. {apexcodexpy-0.7.3 → apexcodexpy-0.7.4}/PKG-INFO +1 -1
  2. apexcodexpy-0.7.4/codex/infra/pyproject.py +190 -0
  3. {apexcodexpy-0.7.3 → apexcodexpy-0.7.4}/codex/infra/result.py +3 -0
  4. {apexcodexpy-0.7.3 → apexcodexpy-0.7.4}/codex/infra/service.py +18 -17
  5. apexcodexpy-0.7.4/codex/infra/typer/device.py +52 -0
  6. {apexcodexpy-0.7.3 → apexcodexpy-0.7.4}/codex/infra/typer/root.py +0 -15
  7. {apexcodexpy-0.7.3 → apexcodexpy-0.7.4}/codex/infra/workflows/base.py +1 -6
  8. {apexcodexpy-0.7.3 → apexcodexpy-0.7.4}/pyproject.toml +1 -2
  9. apexcodexpy-0.7.3/codex/infra/pyproject.py +0 -157
  10. apexcodexpy-0.7.3/codex/infra/typer/device.py +0 -28
  11. apexcodexpy-0.7.3/codex/infra/workflows/lock.py +0 -34
  12. {apexcodexpy-0.7.3 → apexcodexpy-0.7.4}/LICENSE +0 -0
  13. {apexcodexpy-0.7.3 → apexcodexpy-0.7.4}/README.md +0 -0
  14. {apexcodexpy-0.7.3 → apexcodexpy-0.7.4}/codex/__init__.py +0 -0
  15. {apexcodexpy-0.7.3 → apexcodexpy-0.7.4}/codex/infra/__init__.py +0 -0
  16. {apexcodexpy-0.7.3 → apexcodexpy-0.7.4}/codex/infra/program.py +0 -0
  17. {apexcodexpy-0.7.3 → apexcodexpy-0.7.4}/codex/infra/reporters.py +0 -0
  18. {apexcodexpy-0.7.3 → apexcodexpy-0.7.4}/codex/infra/tools.py +0 -0
  19. {apexcodexpy-0.7.3 → apexcodexpy-0.7.4}/codex/infra/typer/__init__.py +0 -0
  20. {apexcodexpy-0.7.3 → apexcodexpy-0.7.4}/codex/infra/typer/testing.py +0 -0
  21. {apexcodexpy-0.7.3 → apexcodexpy-0.7.4}/codex/infra/workflows/__init__.py +0 -0
  22. {apexcodexpy-0.7.3 → apexcodexpy-0.7.4}/codex/infra/workflows/build.py +0 -0
  23. {apexcodexpy-0.7.3 → apexcodexpy-0.7.4}/codex/infra/workflows/ci.py +0 -0
  24. {apexcodexpy-0.7.3 → apexcodexpy-0.7.4}/codex/infra/workflows/dependabot.py +0 -0
  25. {apexcodexpy-0.7.3 → apexcodexpy-0.7.4}/codex/infra/workflows/validate.py +0 -0
  26. {apexcodexpy-0.7.3 → apexcodexpy-0.7.4}/codex/infra/yamler.py +0 -0
  27. {apexcodexpy-0.7.3 → apexcodexpy-0.7.4}/codex/runner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apexcodexpy
3
- Version: 0.7.3
3
+ Version: 0.7.4
4
4
  Summary: The Non-Destructive Configuration Steward for Python Projects.
5
5
  License-File: LICENSE
6
6
  Author: Apex Dev
@@ -0,0 +1,190 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import hashlib
5
+ from dataclasses import dataclass, field
6
+ from functools import cached_property
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ import tomlkit
11
+
12
+ from codex.infra.result import ProgramResult
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Mapping
16
+
17
+ from codex.infra.program import Executable
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class PyProjectToml:
22
+ name: str = "PyProject"
23
+ path: Path = Path("pyproject.toml")
24
+
25
+ def sync(self, target: Path) -> Executable:
26
+ pyproject = PyProjectFile(target / self.path, standards=self.standards)
27
+
28
+ if not pyproject.exists():
29
+ return ProgramResult(name=self.name).fail(message=f"{self.name} not found.")
30
+
31
+ if not pyproject.is_outdated():
32
+ return ProgramResult(name=self.name).no_changes()
33
+
34
+ pyproject.sync()
35
+
36
+ return ProgramResult(name=self.name).workflow_success()
37
+
38
+ @property
39
+ def standards(self) -> Mapping[str, Any]:
40
+ return {
41
+ "tool": {
42
+ "mypy": self._mypy,
43
+ "ruff": self._ruff,
44
+ "coverage": self._coveragepy,
45
+ }
46
+ }
47
+
48
+ @property
49
+ def _mypy(self) -> dict[str, Any]:
50
+ return {
51
+ "strict": True,
52
+ "ignore_missing_imports": True,
53
+ "show_error_codes": True,
54
+ "pretty": True,
55
+ }
56
+
57
+ @property
58
+ def _ruff(self) -> dict[str, Any]:
59
+ return {
60
+ "lint": {
61
+ "select": [
62
+ "A",
63
+ "ARG",
64
+ "B",
65
+ "C4",
66
+ "PT",
67
+ "RSE",
68
+ "RET",
69
+ "SIM",
70
+ "T20",
71
+ "E",
72
+ "W",
73
+ "F",
74
+ "I",
75
+ "N",
76
+ "UP",
77
+ "RUF",
78
+ "FURB",
79
+ "PERF",
80
+ "LOG",
81
+ "PIE",
82
+ "SLF",
83
+ "DTZ",
84
+ "PTH",
85
+ "TRY",
86
+ "PL",
87
+ "TCH",
88
+ ],
89
+ "mccabe": {
90
+ "max-complexity": 10,
91
+ },
92
+ }
93
+ }
94
+
95
+ @property
96
+ def _coveragepy(self) -> dict[str, Any]:
97
+ return {
98
+ "run": {
99
+ "branch": True,
100
+ },
101
+ "report": {
102
+ "skip_empty": True,
103
+ "skip_covered": True,
104
+ "show_missing": True,
105
+ },
106
+ }
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class PyProjectFile:
111
+ path: Path
112
+
113
+ standards: Mapping[str, Any] = field(default_factory=dict)
114
+
115
+ @cached_property
116
+ def current(self) -> Tomler:
117
+ return Tomler.read(self.path)
118
+
119
+ @cached_property
120
+ def latest(self) -> Tomler:
121
+ return self.current.update_with(self.standards)
122
+
123
+ def exists(self) -> bool:
124
+ return self.path.exists()
125
+
126
+ def is_outdated(self) -> bool:
127
+ return self.current != self.latest
128
+
129
+ def sync(self) -> None:
130
+ self.path.write_text(self.latest.dumps(), encoding="utf-8", newline="\n")
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class Tomler:
135
+ document: Mapping[str, Any]
136
+
137
+ @classmethod
138
+ def read(cls, path: Path) -> Tomler:
139
+ return cls(tomlkit.parse(path.read_text(encoding="utf-8")))
140
+
141
+ def dumps(self) -> str:
142
+ return tomlkit.dumps(self.document)
143
+
144
+ def update_with(self, source: Mapping[str, Any]) -> Tomler:
145
+ return Tomler(deep_merge(source, self.document))
146
+
147
+ def hashed(self) -> str:
148
+ return hashlib.sha256(self.dumps().encode()).hexdigest()
149
+
150
+
151
+ def deep_merge(
152
+ source: Mapping[str, Any],
153
+ destination: Mapping[str, Any],
154
+ ) -> Mapping[str, Any]:
155
+ """
156
+ Recursively merges two mappings, taking the source and merging it into
157
+ the destination. Performs a deep merge such that nested dictionaries or
158
+ arrays are combined intelligently.
159
+
160
+ The function identifies the type of each key-value pair in the `source`
161
+ and `destination` mappings and determines how to merge them based on
162
+ their data types, supporting nested dictionaries and TOML data
163
+ structures.
164
+
165
+ :param source: The source mapping to merge into the destination.
166
+ :param destination: The destination mapping that will be updated with the
167
+ data from `source`.
168
+ :return: A new mapping that represents the merged result of `source`
169
+ and `destination`.
170
+ """
171
+ result = {**destination}
172
+
173
+ for key, value in source.items():
174
+ match (result.get(key), value):
175
+ case (dict(destination_value), dict(source_value)):
176
+ # Both exist and are dicts -> recursively merge
177
+ result[key] = deep_merge(source_value, destination_value)
178
+ case (_, dict(source_value)):
179
+ # Only source is a dict -> convert to TOML table
180
+ result[key] = deep_merge(source_value, {})
181
+ case (_, list(src_list)):
182
+ # Formatted TOML array
183
+ new_array = tomlkit.array()
184
+ new_array.extend(copy.deepcopy(src_list))
185
+ new_array.multiline(True)
186
+ result[key] = new_array
187
+ case _:
188
+ result[key] = copy.deepcopy(value)
189
+
190
+ return result
@@ -61,6 +61,9 @@ class ProgramResult:
61
61
  def no_changes(self) -> ProgramResult:
62
62
  return self.success(message="Already up to date.\n")
63
63
 
64
+ def workflow_success(self) -> ProgramResult:
65
+ return self.success(message="Workflow succeeded!\n")
66
+
64
67
  def success(self, *, message: str = "") -> ProgramResult:
65
68
  return ProgramResult(
66
69
  name=self.name,
@@ -22,7 +22,6 @@ from codex.infra.workflows import (
22
22
  CheckCodeWorkflow,
23
23
  DependabotWorkflow,
24
24
  )
25
- from codex.infra.workflows.lock import CodexLockWorkflow
26
25
 
27
26
  if TYPE_CHECKING:
28
27
  from pathlib import Path
@@ -137,14 +136,12 @@ class CodexTools:
137
136
  def sync(self, force: bool) -> Executable:
138
137
  return (
139
138
  MultiProgram()
140
- .attach(PyProjectToml().sync(self.target))
141
139
  .attach(DependabotWorkflow().sync(self.target, force=force))
142
140
  .attach(CheckCodeWorkflow().sync(self.target, force=force))
143
141
  .attach(
144
142
  BuildCodeWorkflow(disclaimer=False).sync(self.target, force=force),
145
143
  when=self.target.joinpath("Dockerfile").exists(),
146
144
  )
147
- .attach(CodexLockWorkflow().sync(self.target, force=force))
148
145
  )
149
146
 
150
147
  def lock(self) -> Executable:
@@ -207,23 +204,27 @@ class CodexTools:
207
204
 
208
205
  def fix(self, unsafe: bool = False) -> Executable:
209
206
  return (
210
- PoetryRun()
207
+ MultiProgram()
208
+ .attach(PyProjectToml().sync(self.target))
211
209
  .attach(
212
- Program(
213
- name="black",
214
- command=Ruff(on=self.target).format(),
210
+ PoetryRun()
211
+ .attach(
212
+ Program(
213
+ name="black",
214
+ command=Ruff(on=self.target).format(),
215
+ )
215
216
  )
216
- )
217
- .attach(
218
- Program(
219
- name="ruff",
220
- command=(
221
- Ruff(on=self.target)
222
- .lint()
223
- .flag(fix=True)
224
- .flag(unsafe_fixes=unsafe)
217
+ .attach(
218
+ Program(
219
+ name="ruff",
220
+ command=(
221
+ Ruff(on=self.target)
222
+ .lint()
223
+ .flag(fix=True)
224
+ .flag(unsafe_fixes=unsafe)
225
+ ),
225
226
  ),
226
- ),
227
+ )
227
228
  )
228
229
  )
229
230
 
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from textwrap import dedent
5
+ from typing import TYPE_CHECKING
6
+
7
+ import typer
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Callable
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class TyperDevice:
15
+ color: str = typer.colors.RESET
16
+
17
+ def with_green(self) -> TyperDevice:
18
+ return TyperDevice(typer.colors.GREEN)
19
+
20
+ def with_red(self) -> TyperDevice:
21
+ return TyperDevice(typer.colors.RED)
22
+
23
+ def with_white(self) -> TyperDevice:
24
+ return TyperDevice(typer.colors.WHITE)
25
+
26
+ def with_yellow(self) -> TyperDevice:
27
+ return TyperDevice(typer.colors.YELLOW)
28
+
29
+ def echo(self, message: str) -> TyperDevice:
30
+ _Message.parse(message).display(using=lambda m: typer.secho(m, fg=self.color))
31
+
32
+ return self
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class _Message:
37
+ raw: str
38
+
39
+ @staticmethod
40
+ def parse(raw: str) -> _Message | _NoMessage:
41
+ message = dedent(raw)
42
+
43
+ return _Message(message) if message.strip() else _NoMessage()
44
+
45
+ def display(self, using: Callable[[str], None]) -> None:
46
+ using(self.raw)
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class _NoMessage:
51
+ def display(self, using: Callable[[str], None]) -> None:
52
+ _ = using
@@ -4,25 +4,10 @@ import typer
4
4
 
5
5
  from codex.infra.reporters import DefaultReporter, SimpleReporter
6
6
  from codex.infra.service import Codex, Git
7
- from codex.infra.workflows.lock import CodexLockWorkflow
8
7
 
9
8
  cli = typer.Typer(no_args_is_help=True)
10
9
 
11
10
 
12
- @cli.callback()
13
- def verify_codex_lock(ctx: typer.Context) -> None:
14
- if ctx.invoked_subcommand == "sync":
15
- return
16
-
17
- if CodexLockWorkflow().verify():
18
- return
19
-
20
- typer.echo("Codex is out of sync!")
21
- typer.echo("Run `codex sync` to bring configuration up to date")
22
-
23
- raise typer.Exit(1)
24
-
25
-
26
11
  @cli.command(
27
12
  name="version",
28
13
  help="Show the codex version and exit.",
@@ -46,14 +46,9 @@ class YamlWorkflow(ABC):
46
46
 
47
47
  standards.dump_to(path)
48
48
 
49
- return self.success()
49
+ return ProgramResult(name=self.name).workflow_success()
50
50
 
51
51
  return LazyExecutable(_)
52
52
 
53
53
  def _utcnow(self) -> str:
54
54
  return datetime.now(tz=UTC).replace(microsecond=0).isoformat(sep=" ")
55
-
56
- def success(self) -> ProgramResult:
57
- return ProgramResult(name=self.name).success(
58
- message=f"{self.name} workflow succeeded.\n"
59
- )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "apexcodexpy"
3
- version = "0.7.3"
3
+ version = "0.7.4"
4
4
  description = "The Non-Destructive Configuration Steward for Python Projects."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -66,7 +66,6 @@ select = [
66
66
  "PL",
67
67
  "TCH",
68
68
  ]
69
-
70
69
  ignore = [
71
70
  "TRY003",
72
71
  "PLR0913",
@@ -1,157 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import hashlib
4
- from dataclasses import dataclass, field
5
- from pathlib import Path
6
- from typing import TYPE_CHECKING, Any, Self
7
-
8
- import tomlkit
9
- from tomlkit import TOMLDocument
10
-
11
- from codex.infra.result import ProgramResult
12
-
13
- if TYPE_CHECKING:
14
- from collections.abc import Mapping, MutableMapping
15
-
16
- from codex.infra.program import Executable
17
-
18
-
19
- def _mypy_config() -> dict[str, Any]:
20
- return {
21
- "strict": True,
22
- "ignore_missing_imports": True,
23
- "show_error_codes": True,
24
- "pretty": True,
25
- }
26
-
27
-
28
- def _ruff_config() -> dict[str, Any]:
29
- return {
30
- "lint": {
31
- "select": [
32
- "A",
33
- "ARG",
34
- "B",
35
- "C4",
36
- "PT",
37
- "RSE",
38
- "RET",
39
- "SIM",
40
- "T20",
41
- "E",
42
- "W",
43
- "F",
44
- "I",
45
- "N",
46
- "UP",
47
- "RUF",
48
- "FURB",
49
- "PERF",
50
- "LOG",
51
- "PIE",
52
- "SLF",
53
- "DTZ",
54
- "PTH",
55
- "TRY",
56
- "PL",
57
- "TCH",
58
- ],
59
- "mccabe": {
60
- "max-complexity": 10,
61
- },
62
- }
63
- }
64
-
65
-
66
- def _coverage_config() -> dict[str, Any]:
67
- return {
68
- "run": {
69
- "branch": True,
70
- },
71
- "report": {
72
- "skip_empty": True,
73
- "skip_covered": True,
74
- "show_missing": True,
75
- },
76
- }
77
-
78
-
79
- STANDARDS = {
80
- "tool": {
81
- "mypy": _mypy_config(),
82
- "ruff": _ruff_config(),
83
- "coverage": _coverage_config(),
84
- }
85
- }
86
-
87
-
88
- @dataclass(frozen=True)
89
- class PyProjectToml:
90
- _result: ProgramResult = field(init=False, default=ProgramResult(name="PyProject"))
91
-
92
- NAME = "pyproject.toml"
93
-
94
- def sync(self, target: Path) -> Executable:
95
- return self._sync(self.toml_on(target))
96
-
97
- def _sync(self, pyproject: Path) -> Executable:
98
- if not pyproject.exists():
99
- return self._result.fail(message=f"{self.NAME} not found.")
100
-
101
- document = Tomler.read(pyproject)
102
- original = document.dumps()
103
- modified = document.update_with(STANDARDS).dumps()
104
-
105
- if original == modified:
106
- return self._result.no_changes()
107
-
108
- pyproject.write_text(modified, encoding="utf-8", newline="\n")
109
-
110
- return self._result.success(message=f"{self.NAME} synchronized successfully.\n")
111
-
112
- def toml_on(self, path: Path) -> Path:
113
- return path / self.NAME
114
-
115
- @staticmethod
116
- def latest() -> Tomler:
117
- return Tomler.read(Path("pyproject.toml")).update_with(STANDARDS)
118
-
119
-
120
- @dataclass(frozen=True)
121
- class Tomler:
122
- document: TOMLDocument
123
-
124
- @classmethod
125
- def read(cls, path: Path) -> Tomler:
126
- return cls(tomlkit.parse(path.read_text(encoding="utf-8")))
127
-
128
- def dumps(self) -> str:
129
- return tomlkit.dumps(self.document)
130
-
131
- def update_with(self, source: Mapping[str, Any]) -> Self:
132
- deep_merge(source, self.document)
133
-
134
- return self
135
-
136
- def hashed(self) -> str:
137
- return hashlib.sha256(self.dumps().encode()).hexdigest()
138
-
139
-
140
- def deep_merge(
141
- source: Mapping[str, Any],
142
- destination: MutableMapping[str, Any],
143
- ) -> None:
144
- """Recursively merges the source into destination with TOML formatting."""
145
- for key, value in source.items():
146
- match value:
147
- case dict():
148
- if key not in destination:
149
- destination[key] = tomlkit.table()
150
- deep_merge(value, destination[key])
151
- case list():
152
- new_array = tomlkit.array()
153
- new_array.extend(value)
154
- new_array.multiline(True)
155
- destination[key] = new_array
156
- case _:
157
- destination[key] = value
@@ -1,28 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from dataclasses import dataclass
4
-
5
- import typer
6
-
7
-
8
- @dataclass(frozen=True)
9
- class TyperDevice:
10
- color: str = typer.colors.RESET
11
-
12
- def with_green(self) -> TyperDevice:
13
- return TyperDevice(typer.colors.GREEN)
14
-
15
- def with_red(self) -> TyperDevice:
16
- return TyperDevice(typer.colors.RED)
17
-
18
- def with_white(self) -> TyperDevice:
19
- return TyperDevice(typer.colors.WHITE)
20
-
21
- def with_yellow(self) -> TyperDevice:
22
- return TyperDevice(typer.colors.YELLOW)
23
-
24
- def echo(self, message: str) -> TyperDevice:
25
- if message.strip():
26
- typer.secho(message, fg=self.color)
27
-
28
- return self
@@ -1,34 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import hashlib
4
- from dataclasses import dataclass
5
- from pathlib import Path
6
-
7
- from codex.infra.yamler import Yamler
8
-
9
- from ..pyproject import PyProjectToml
10
- from .base import YamlWorkflow
11
-
12
-
13
- def hash_of(path: Path) -> str:
14
- with path.open("rb") as f:
15
- return hashlib.file_digest(f, hashlib.sha256).hexdigest()
16
-
17
-
18
- @dataclass(frozen=True, kw_only=True)
19
- class CodexLockWorkflow(YamlWorkflow):
20
- name: str = "CodexLock"
21
- path: Path = Path("codex.lock")
22
-
23
- @property
24
- def standards(self) -> Yamler:
25
- return Yamler.parse(
26
- {
27
- "hashes": {
28
- "pyproject": PyProjectToml.latest().hashed(),
29
- },
30
- }
31
- )
32
-
33
- def verify(self) -> bool:
34
- return not self.path.exists() or self.standards == Yamler.read(self.path)
File without changes
File without changes
File without changes