apexcodexpy 0.2.3__tar.gz → 0.3.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apexcodexpy
3
- Version: 0.2.3
3
+ Version: 0.3.2
4
4
  Summary: The Non-Destructive Configuration Steward for Python Projects.
5
5
  License-File: LICENSE
6
6
  Author: Apex Dev
File without changes
@@ -1,13 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
- from dataclasses import dataclass
4
+ from dataclasses import dataclass, field
5
5
  from pathlib import Path
6
6
  from typing import Any
7
7
 
8
8
  import yaml
9
9
 
10
- from codex.tasks.result import ProgramResult, TaskResult
10
+ from codex.infra.program import Executable
11
+ from codex.infra.result import ProgramResult
11
12
 
12
13
 
13
14
  def _pip_config() -> dict[str, Any]:
@@ -55,19 +56,20 @@ STANDARDS = {
55
56
  class DependabotYaml:
56
57
  dry_run: bool = False
57
58
 
59
+ _result: ProgramResult = field(init=False, default=ProgramResult(name="DependaBot"))
60
+
58
61
  NAME = "dependabot.yml"
59
62
 
60
- def sync(self, target: Path) -> TaskResult:
63
+ def sync(self, target: Path) -> Executable:
61
64
  dependabot = self.yaml_on(target)
62
65
  dependabot.parent.mkdir(parents=True, exist_ok=True)
63
66
 
64
67
  return self._sync(dependabot)
65
68
 
66
- def _sync(self, dependabot: Path) -> ProgramResult:
69
+ def _sync(self, dependabot: Path) -> Executable:
67
70
  if self.dry_run:
68
- return ProgramResult.success(
69
- stdout=f"Changes would be applied:\n{json.dumps(STANDARDS, indent=2)}"
70
- )
71
+ dumped = json.dumps(STANDARDS, indent=2)
72
+ return self._result.success(message=f"Changes to apply:\n{dumped}")
71
73
 
72
74
  with dependabot.open(mode="w", encoding="utf-8", newline="\n") as stream:
73
75
  yaml.dump(
@@ -78,7 +80,7 @@ class DependabotYaml:
78
80
  default_flow_style=False,
79
81
  )
80
82
 
81
- return ProgramResult.success(stdout="Configuration synchronized successfully.")
83
+ return self._result.success(message=f"{self.NAME} synchronized successfully.\n")
82
84
 
83
85
  def yaml_on(self, path: Path) -> Path:
84
86
  return path / ".github" / self.NAME
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from collections.abc import Iterable, Iterator, MutableSequence
5
+ from dataclasses import dataclass, field, replace
6
+ from typing import Any, Protocol
7
+
8
+ from codex.infra.result import ComboResult, ProgramResult, TaskResult
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Program:
13
+ command: Command
14
+
15
+ name: str = "unknown"
16
+
17
+ def execute(self) -> ProgramResult:
18
+ return replace(
19
+ ProgramResult(name=self.name.capitalize()).parse(
20
+ subprocess.run(
21
+ list(self.command),
22
+ text=True,
23
+ encoding="utf-8",
24
+ capture_output=True,
25
+ )
26
+ ),
27
+ )
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Command:
32
+ name: str
33
+
34
+ _arguments: list[str] = field(init=False, default_factory=list)
35
+
36
+ def with_a(self, **kwargs: Any) -> Command:
37
+ return self.and_a(**kwargs)
38
+
39
+ def and_a(self, **kwargs: Any) -> Command:
40
+ assert len(kwargs) == 1, "with_a/and_a supports one argument at a time."
41
+
42
+ name, value = kwargs.popitem()
43
+
44
+ return self.append(f"--{name}={value}")
45
+
46
+ def flag(self, **kwargs: bool | None) -> Command:
47
+ for name, value in kwargs.items():
48
+ flag = self.dashify(name)
49
+
50
+ if value is not None:
51
+ self.append(f"--{flag}" if value else f"--no-{flag}")
52
+
53
+ return self
54
+
55
+ def dashify(self, name: str) -> str:
56
+ return name.replace("_", "-")
57
+
58
+ def extend(self, arguments: Iterable[Any]) -> Command:
59
+ for argument in arguments:
60
+ self.append(argument)
61
+
62
+ return self
63
+
64
+ def append(self, argument: Any) -> Command:
65
+ if argument is not None:
66
+ self._arguments.extend(str(argument).split())
67
+
68
+ return self
69
+
70
+ def __str__(self) -> str:
71
+ return " ".join(self)
72
+
73
+ def __iter__(self) -> Iterator[str]:
74
+ yield from self.name.split()
75
+
76
+ for argument in self._arguments:
77
+ yield from str(argument).split()
78
+
79
+
80
+ class Executable(Protocol):
81
+ def execute(self) -> TaskResult:
82
+ pass
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class MultiProgram:
87
+ sub_programs: MutableSequence[Executable] = field(default_factory=list)
88
+
89
+ def attach(self, program: Executable) -> MultiProgram:
90
+ self.sub_programs.append(program)
91
+
92
+ return self
93
+
94
+ def execute(self) -> TaskResult:
95
+ return ComboResult([program.execute() for program in self.sub_programs])
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class BoundedProgram:
100
+ before: MultiProgram = field(init=False, default_factory=MultiProgram)
101
+ action: MultiProgram = field(init=False, default_factory=MultiProgram)
102
+ after: MultiProgram = field(init=False, default_factory=MultiProgram)
103
+
104
+ def setup(self, program: Executable) -> BoundedProgram:
105
+ self.before.attach(program)
106
+
107
+ return self
108
+
109
+ def teardown(self, program: Executable) -> BoundedProgram:
110
+ self.after.attach(program)
111
+
112
+ return self
113
+
114
+ def attach(self, program: Executable) -> BoundedProgram:
115
+ self.action.attach(program)
116
+
117
+ return self
118
+
119
+ def execute(self) -> TaskResult:
120
+ return (
121
+ MultiProgram()
122
+ .attach(self.before)
123
+ .attach(self.action)
124
+ .attach(self.after)
125
+ .execute()
126
+ )
@@ -1,12 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
- from dataclasses import dataclass
3
+ from dataclasses import dataclass, field
4
4
  from pathlib import Path
5
5
  from typing import Any
6
6
 
7
7
  import tomlkit
8
8
 
9
- from codex.tasks import ProgramResult, TaskResult
9
+ from codex.infra.program import Executable
10
+ from codex.infra.result import ProgramResult
10
11
 
11
12
 
12
13
  def _mypy_config() -> dict[str, Any]:
@@ -69,14 +70,16 @@ STANDARDS = {
69
70
  class PyProjectToml:
70
71
  dry_run: bool = False
71
72
 
73
+ _result: ProgramResult = field(init=False, default=ProgramResult(name="PyProject"))
74
+
72
75
  NAME = "pyproject.toml"
73
76
 
74
- def sync(self, target: Path) -> TaskResult:
77
+ def sync(self, target: Path) -> Executable:
75
78
  return self._sync(self.toml_on(target))
76
79
 
77
- def _sync(self, pyproject: Path) -> TaskResult:
80
+ def _sync(self, pyproject: Path) -> Executable:
78
81
  if not pyproject.exists():
79
- return ProgramResult.fail(stderr=f"{self.NAME} not found.")
82
+ return self._result.fail(message=f"{self.NAME} not found.")
80
83
 
81
84
  document = tomlkit.parse(pyproject.read_text(encoding="utf-8"))
82
85
 
@@ -86,16 +89,14 @@ class PyProjectToml:
86
89
  modified = tomlkit.dumps(document)
87
90
 
88
91
  if original == modified:
89
- return ProgramResult.success(stdout="Configuration is already up to date.")
92
+ return self._result.success(message=f"{self.NAME} is already up to date.\n")
90
93
 
91
94
  if self.dry_run:
92
- return ProgramResult.success(
93
- stdout=f"Changes would be applied:\n{modified}"
94
- )
95
+ return self._result.success(message=f"Changes to apply:\n{modified}")
95
96
 
96
97
  pyproject.write_text(modified, encoding="utf-8", newline="\n")
97
98
 
98
- return ProgramResult.success(stdout="Configuration synchronized successfully.")
99
+ return self._result.success(message=f"{self.NAME} synchronized successfully.\n")
99
100
 
100
101
  def toml_on(self, path: Path) -> Path:
101
102
  return path / self.NAME
@@ -3,12 +3,19 @@ from __future__ import annotations
3
3
  from dataclasses import dataclass, field
4
4
  from typing import Protocol
5
5
 
6
- import typer
6
+ from codex.infra.result import Reporter
7
+ from codex.infra.typer.device import TyperDevice
7
8
 
8
9
 
9
10
  @dataclass(frozen=True)
10
11
  class DefaultReporter:
11
- device: _Device = field(default_factory=lambda: TyperDevice())
12
+ device: _Device = field(default_factory=TyperDevice)
13
+
14
+ def silenced(self, *, when: bool) -> Reporter:
15
+ if when:
16
+ return DefaultReporter(SilentDevice())
17
+
18
+ return self
12
19
 
13
20
  def success_of(self, name: str) -> None:
14
21
  self.device.with_green().echo(f"✅ {name} passed!")
@@ -40,24 +47,20 @@ class _Device(Protocol):
40
47
  pass
41
48
 
42
49
 
43
- @dataclass(frozen=True)
44
- class TyperDevice:
45
- color: str = typer.colors.RESET
46
-
47
- def with_green(self) -> TyperDevice:
48
- return TyperDevice(typer.colors.GREEN)
50
+ class SilentDevice: # pragma: no cover
51
+ def with_green(self) -> _Device:
52
+ return self
49
53
 
50
- def with_red(self) -> TyperDevice:
51
- return TyperDevice(typer.colors.RED)
54
+ def with_red(self) -> _Device:
55
+ return self
52
56
 
53
- def with_white(self) -> TyperDevice:
54
- return TyperDevice(typer.colors.WHITE)
57
+ def with_white(self) -> _Device:
58
+ return self
55
59
 
56
- def with_yellow(self) -> TyperDevice:
57
- return TyperDevice(typer.colors.YELLOW)
60
+ def with_yellow(self) -> _Device:
61
+ return self
58
62
 
59
- def echo(self, message: str) -> TyperDevice:
60
- if message.strip():
61
- typer.secho(message, fg=self.color)
63
+ def echo(self, message: str) -> _Device:
64
+ _ = message
62
65
 
63
66
  return self
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from subprocess import CompletedProcess
5
+ from typing import Any, Protocol
6
+
7
+
8
+ class TaskResult(Protocol):
9
+ def report(self, using: Reporter) -> TaskResult:
10
+ pass
11
+
12
+ def exit(self, using: type[Exception] | None = None) -> int:
13
+ pass
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ComboResult:
18
+ results: list[TaskResult] = field(default_factory=list)
19
+
20
+ @property
21
+ def _exit_code(self) -> int:
22
+ return sum(result.exit() for result in self.results)
23
+
24
+ def report(self, using: Reporter) -> ComboResult:
25
+ for result in self.results:
26
+ result.report(using)
27
+
28
+ return self
29
+
30
+ def exit(self, using: type[Exception] | None = None) -> int:
31
+ if using is None:
32
+ return self._exit_code
33
+
34
+ raise using(self._exit_code)
35
+
36
+
37
+ @dataclass(frozen=True, kw_only=True)
38
+ class ProgramResult:
39
+ name: str
40
+
41
+ stdout: str = ""
42
+ stderr: str = ""
43
+ exit_code: int = 0
44
+
45
+ def __int__(self) -> int:
46
+ return self.exit_code
47
+
48
+ def __bool__(self) -> bool:
49
+ return self.exit_code == 0
50
+
51
+ def parse(self, result: CompletedProcess[Any]) -> ProgramResult:
52
+ return ProgramResult(
53
+ name=self.name,
54
+ stdout=result.stdout or "",
55
+ stderr=result.stderr or "",
56
+ exit_code=result.returncode,
57
+ )
58
+
59
+ def success(self, *, message: str = "") -> ProgramResult:
60
+ return ProgramResult(
61
+ name=self.name,
62
+ stdout=message or self.stdout,
63
+ stderr=self.stderr,
64
+ exit_code=0,
65
+ )
66
+
67
+ def fail(self, *, message: str = "") -> ProgramResult:
68
+ return ProgramResult(
69
+ name=self.name,
70
+ stdout=self.stdout,
71
+ stderr=message or self.stderr,
72
+ exit_code=1,
73
+ )
74
+
75
+ def execute(self) -> ProgramResult:
76
+ return self
77
+
78
+ def report(self, using: Reporter) -> ProgramResult:
79
+ if self.exit_code == 0:
80
+ using.success_of(self.name)
81
+ else:
82
+ using.failure_of(self.name)
83
+
84
+ using.out(self.stdout)
85
+ using.error(self.stderr)
86
+
87
+ return self
88
+
89
+ def exit(self, using: type[Exception] | None = None) -> int:
90
+ if using is None:
91
+ return self.exit_code
92
+
93
+ raise using(self.exit_code)
94
+
95
+
96
+ class Reporter(Protocol):
97
+ def success_of(self, name: str) -> None:
98
+ pass
99
+
100
+ def failure_of(self, name: str) -> None:
101
+ pass
102
+
103
+ def out(self, message: str) -> None:
104
+ pass
105
+
106
+ def error(self, message: str) -> None:
107
+ pass
@@ -0,0 +1,262 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from time import time
6
+
7
+ from codex.infra.dependabot import DependabotYaml
8
+ from codex.infra.program import (
9
+ Command,
10
+ Executable,
11
+ MultiProgram,
12
+ Program,
13
+ )
14
+ from codex.infra.pyproject import PyProjectToml
15
+ from codex.infra.result import TaskResult
16
+ from codex.infra.tools import MyPy, Pip, Poetry, PoetryRun, Pytest, Ruff
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Codex:
21
+ tools: CodexTools
22
+
23
+ @classmethod
24
+ def target(cls, target: Path) -> Codex:
25
+ return cls(CodexTools(target))
26
+
27
+ def ci(self, on: bool) -> Codex | CiCodex:
28
+ if on:
29
+ return CiCodex(tools=self.tools)
30
+
31
+ return self
32
+
33
+ def sync(self, dry_run: bool = False) -> TaskResult:
34
+ return self.tools.sync(dry_run=dry_run).execute()
35
+
36
+ def lock(self) -> TaskResult:
37
+ return self.tools.lock().execute()
38
+
39
+ def update(self) -> TaskResult:
40
+ return self.tools.update().execute()
41
+
42
+ def install(self) -> TaskResult:
43
+ return self.tools.install().execute()
44
+
45
+ def lint(self) -> TaskResult:
46
+ return self.tools.lint().execute()
47
+
48
+ def fix(self, unsafe: bool = False) -> TaskResult:
49
+ return self.tools.fix(unsafe=unsafe).execute()
50
+
51
+ def unit(self) -> TaskResult:
52
+ return self.tools.unit().execute()
53
+
54
+ def integration(self) -> TaskResult:
55
+ return self.tools.integration().execute()
56
+
57
+ def behaviour(self) -> TaskResult:
58
+ return self.tools.behaviour().execute()
59
+
60
+ def build(self, tag: str) -> TaskResult:
61
+ return self.tools.build(tag).execute()
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class CiCodex:
66
+ tools: CodexTools
67
+
68
+ def lint(self) -> TaskResult:
69
+ return (
70
+ MultiProgram()
71
+ .attach(self.tools.install())
72
+ .attach(self.tools.lint())
73
+ .execute()
74
+ )
75
+
76
+ def unit(self) -> TaskResult:
77
+ return (
78
+ MultiProgram()
79
+ .attach(self.tools.install())
80
+ .attach(self.tools.unit(in_ci=True))
81
+ .execute()
82
+ )
83
+
84
+ def integration(self) -> TaskResult:
85
+ return (
86
+ MultiProgram()
87
+ .attach(self.tools.install())
88
+ .attach(self.tools.integration())
89
+ .execute()
90
+ )
91
+
92
+ def behaviour(self) -> TaskResult:
93
+ return (
94
+ MultiProgram()
95
+ .attach(self.tools.install())
96
+ .attach(self.tools.behaviour(in_ci=True))
97
+ .execute()
98
+ )
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class CodexTools:
103
+ target: Path
104
+
105
+ def sync(self, dry_run: bool = False) -> Executable:
106
+ return (
107
+ MultiProgram()
108
+ .attach(PyProjectToml(dry_run=dry_run).sync(self.target))
109
+ .attach(DependabotYaml(dry_run=dry_run).sync(self.target))
110
+ )
111
+
112
+ def lock(self) -> Executable:
113
+ return Program(name="lock", command=Poetry().lock())
114
+
115
+ def update(self) -> Executable:
116
+ return Program(name="update", command=Poetry().update())
117
+
118
+ def install(self) -> Executable:
119
+ return (
120
+ MultiProgram()
121
+ .attach(
122
+ Program(
123
+ name="tools",
124
+ command=Pip().install("pip", "poetry"),
125
+ )
126
+ )
127
+ .attach(
128
+ Program(
129
+ name="dependencies",
130
+ command=Poetry().install(),
131
+ )
132
+ )
133
+ )
134
+
135
+ def lint(self) -> Executable:
136
+ return (
137
+ PoetryRun()
138
+ .attach(
139
+ Program(
140
+ name="poetry",
141
+ command=Poetry().lint(self.target),
142
+ )
143
+ )
144
+ .attach(
145
+ Program(
146
+ name="black",
147
+ command=Ruff(on=self.target).format().flag(check=True),
148
+ )
149
+ )
150
+ .attach(
151
+ Program(
152
+ name="ruff",
153
+ command=Ruff(on=self.target).lint(),
154
+ )
155
+ )
156
+ .attach(
157
+ Program(
158
+ name="mypy",
159
+ command=MyPy(on=self.target).check(),
160
+ )
161
+ )
162
+ )
163
+
164
+ def fix(self, unsafe: bool = False) -> Executable:
165
+ return (
166
+ PoetryRun()
167
+ .attach(
168
+ Program(
169
+ name="black",
170
+ command=Ruff(on=self.target).format(),
171
+ )
172
+ )
173
+ .attach(
174
+ Program(
175
+ name="ruff",
176
+ command=(
177
+ Ruff(on=self.target)
178
+ .lint()
179
+ .flag(fix=True)
180
+ .flag(unsafe_fixes=unsafe)
181
+ ),
182
+ ),
183
+ )
184
+ )
185
+
186
+ def unit(self, in_ci: bool = False) -> Executable:
187
+ return PoetryRun().attach(
188
+ Program(
189
+ name="test",
190
+ command=(
191
+ Pytest.on(self.target)
192
+ .run()
193
+ .flag(cov=not in_ci or None)
194
+ .flag(last_failed=not in_ci or None)
195
+ ),
196
+ )
197
+ )
198
+
199
+ def behaviour(self, in_ci: bool = False) -> Executable:
200
+ return PoetryRun().attach(
201
+ Program(
202
+ name="behave",
203
+ command=(
204
+ Command("behave")
205
+ .append(self.target / "tests" / "features")
206
+ .with_a(format="progress" if in_ci else "pretty")
207
+ .flag(summary=not in_ci)
208
+ ),
209
+ )
210
+ )
211
+
212
+ def integration(self) -> Executable:
213
+ return (
214
+ Poetry()
215
+ .export(self.target)
216
+ .attach(
217
+ PoetryRun().attach(
218
+ Program(
219
+ name="test",
220
+ command=Pytest.on(self.target / "tests" / "integration").run(),
221
+ )
222
+ )
223
+ )
224
+ )
225
+
226
+ def build(self, tag: str) -> Executable:
227
+ return (
228
+ Poetry()
229
+ .export(self.target)
230
+ .attach(
231
+ Program(
232
+ name="build",
233
+ command=(
234
+ Command("docker build")
235
+ .flag(tag=True)
236
+ .append(tag)
237
+ .flag(build_arg=True)
238
+ .with_a(RELEASE=Git().revision() or str(int(time())))
239
+ .append(self.target)
240
+ ),
241
+ )
242
+ )
243
+ )
244
+
245
+
246
+ @dataclass(frozen=True)
247
+ class Git:
248
+ def revision(self, of: str = "HEAD", short: bool = True) -> str:
249
+ return (
250
+ Program(Command("git rev-parse").flag(short=short).append(of))
251
+ .execute()
252
+ .stdout
253
+ )
254
+
255
+ def amend(self) -> TaskResult:
256
+ return Program(
257
+ name="amend",
258
+ command=Command("git commit")
259
+ .flag(all=True)
260
+ .flag(amend=True)
261
+ .flag(edit=False),
262
+ ).execute()