potent 0.1.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.
potent/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ import typer
2
+
3
+ from potent.commands.dump_schema import app as dump_schema
4
+ from potent.commands.reset import app as reset
5
+ from potent.commands.run import app as run
6
+ from potent.commands.validate import app as validate
7
+
8
+ # COMMAND IMPORTS ^
9
+
10
+ app = typer.Typer(
11
+ no_args_is_help=True,
12
+ help="Idempotently run commands across folders.",
13
+ # hide the completion args
14
+ add_completion=False,
15
+ )
16
+
17
+ app.add_typer(validate)
18
+ app.add_typer(dump_schema)
19
+ app.add_typer(run)
20
+ app.add_typer(reset)
21
+ # COMMANDS ^
22
+
23
+
24
+ def main():
25
+ app()
@@ -0,0 +1,19 @@
1
+ from pathlib import Path
2
+ from typing import Annotated
3
+
4
+ import typer
5
+
6
+
7
+ def is_plan_json(_ctx: typer.Context, _param: typer.CallbackParam, value: Path):
8
+ if value.suffixes != [".plan", ".json"]:
9
+ raise typer.BadParameter("File must have .plan.json extension")
10
+ return value
11
+
12
+
13
+ # important that this _doesn't_ have a `type PlainJson =` declaration
14
+ PlanJson = Annotated[
15
+ Path,
16
+ typer.Argument(
17
+ exists=True, dir_okay=False, resolve_path=True, callback=is_plan_json
18
+ ),
19
+ ]
@@ -0,0 +1,15 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import typer
5
+
6
+ from potent.shellprint import Shellprint
7
+
8
+ app = typer.Typer()
9
+
10
+
11
+ @app.command()
12
+ def dump_schema():
13
+ Path(__file__, "..", "..", "..", "..", "schema.json").resolve().write_text(
14
+ json.dumps(Shellprint.model_json_schema(), indent=2)
15
+ )
@@ -0,0 +1,17 @@
1
+ import typer
2
+
3
+ from potent.commands._types import PlanJson
4
+ from potent.shellprint import Shellprint
5
+
6
+ app = typer.Typer()
7
+
8
+
9
+ @app.command()
10
+ def reset(path: PlanJson):
11
+ """
12
+ Resets a plan file so it can be run again from scratch.
13
+ """
14
+ with path.open("r+") as plan_file:
15
+ plan = Shellprint.from_file(plan_file)
16
+ plan.reset()
17
+ plan.save(plan_file)
potent/commands/run.py ADDED
@@ -0,0 +1,80 @@
1
+ from pathlib import Path
2
+ from subprocess import CalledProcessError
3
+ from time import sleep
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.panel import Panel
8
+
9
+ # from rich.live import Live
10
+ from potent.commands._types import PlanJson
11
+ from potent.shellprint import Shellprint
12
+
13
+ app = typer.Typer()
14
+
15
+
16
+ def directory_header(console: Console, directory: Path) -> None:
17
+ return console.rule(
18
+ f"📂 [bold underline]{directory.name}[/] 📂", style="bright_cyan"
19
+ )
20
+
21
+
22
+ @app.command()
23
+ def run(path: PlanJson):
24
+ # TODO: probably make this internal to the class??
25
+ # can maybe use a generator so the presentation is controlled in the CLI
26
+ plan = Shellprint.from_path(path)
27
+ console = Console()
28
+
29
+ console.print(f"Running [bold yellow]{str(path)}")
30
+
31
+ with path.open("r+") as plan_file:
32
+ for directory in plan.directories:
33
+ console.print()
34
+ if plan.directory_complete(directory):
35
+ directory_header(console, directory)
36
+
37
+ # directory_spinner.ok(f"☑️ {directory.name}")
38
+ console.print("☑️ [green]already finished")
39
+ continue
40
+
41
+ try:
42
+ directory_header(console, directory)
43
+
44
+ for step in plan.steps:
45
+ success = None
46
+ output = ""
47
+ style = ""
48
+ if step.completed(directory):
49
+ output = "Already completed"
50
+ subtitle = "skipped"
51
+ else:
52
+ result = step.run(directory)
53
+ plan.save(plan_file)
54
+ if success := result.success:
55
+ style = "green"
56
+ subtitle = "Succeeded"
57
+ else:
58
+ style = "red"
59
+ subtitle = "Failed"
60
+
61
+ output = result.output or "[dim]no output"
62
+
63
+ console.print(
64
+ Panel(
65
+ f"\n{output.strip()}\n",
66
+ title=f"[dim white]step[not dim]: {step.slug}",
67
+ title_align="left",
68
+ border_style=style,
69
+ subtitle=f"[dim white]result:[/] {subtitle}",
70
+ subtitle_align="left",
71
+ )
72
+ )
73
+ console.print()
74
+ if success is False:
75
+ break
76
+
77
+ except NotImplementedError:
78
+ # directory_spinner.fail("ERR")
79
+ print(" err!")
80
+ continue
@@ -0,0 +1,24 @@
1
+ import rich
2
+ import typer
3
+
4
+ from potent.commands._types import PlanJson
5
+ from potent.shellprint import Shellprint
6
+
7
+ app = typer.Typer()
8
+
9
+
10
+ @app.command()
11
+ def validate(path: PlanJson):
12
+ # can handle this pretty cleanly by pegging the step location and the error type (missing, etc)
13
+ # probably want to hide the traceback though
14
+ # and maybe return
15
+ # can raise typer.Exit(code=int)
16
+ # print(f"Shellprint @ {str(path)}")
17
+ # print(Shellprint.from_path(path))
18
+ rich.print(Shellprint.from_path(path).summarize(path))
19
+
20
+
21
+ # could try to wrap, but the basic error is pretty good:
22
+ # from pydantic import ValidationError
23
+ # except ValidationError as e:
24
+ # print(e.errors()[0])
@@ -0,0 +1,81 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+ from typing import Annotated, Literal, Optional, final
4
+
5
+ from pydantic import AfterValidator, BaseModel, DirectoryPath
6
+
7
+ from potent.util import truthy_list
8
+
9
+ Status = Literal["not-started", "failed", "completed"]
10
+
11
+
12
+ # useful for writing back absolute paths, which is good for portability
13
+ def make_abs_path(value: Path) -> Path:
14
+ if not value.is_absolute():
15
+ raise ValueError("path is not absolute")
16
+ return value
17
+
18
+
19
+ AbsPath = Annotated[
20
+ DirectoryPath,
21
+ AfterValidator(make_abs_path),
22
+ ]
23
+
24
+
25
+ class DirectiveResult(BaseModel):
26
+ success: bool
27
+ output: str
28
+
29
+ @staticmethod
30
+ def from_process(result: subprocess.CompletedProcess[str]):
31
+ return DirectiveResult(success=result.returncode == 0, output=result.stdout)
32
+
33
+
34
+ class EmptyConfig(BaseModel):
35
+ pass
36
+
37
+
38
+ class BaseDirective(BaseModel):
39
+ comment: Optional[str] = None
40
+ directory_statuses: dict[AbsPath, Status] = {}
41
+ config: EmptyConfig = EmptyConfig()
42
+
43
+ @final
44
+ def run(self, directory: Path) -> DirectiveResult:
45
+ # try:
46
+ result = self._run(directory)
47
+ # except Exception: # noqa: BLE001
48
+ # success = False
49
+
50
+ self.directory_statuses[directory] = "completed" if result.success else "failed"
51
+ return result
52
+
53
+ def _run(self, directory: Path) -> DirectiveResult:
54
+ raise NotImplementedError
55
+
56
+ def reset(self) -> None:
57
+ self.directory_statuses = {}
58
+
59
+ def completed(self, directory: Path) -> bool:
60
+ return self.directory_statuses.get(directory) == "completed"
61
+
62
+ def failed(self, directory: Path) -> bool:
63
+ return self.directory_statuses.get(directory) == "failed"
64
+
65
+ def initialize_dirs(self, directories: list[Path]) -> None:
66
+ self.directory_statuses |= dict.fromkeys(directories, "not-started")
67
+
68
+ def _run_cmd(
69
+ self, directory: Path, cmd: list[str]
70
+ ) -> subprocess.CompletedProcess[str]:
71
+ """
72
+ helper for shelling out
73
+ """
74
+ return subprocess.run(
75
+ truthy_list(cmd),
76
+ cwd=directory,
77
+ check=False,
78
+ stdout=subprocess.PIPE,
79
+ stderr=subprocess.STDOUT,
80
+ text=True,
81
+ )
@@ -0,0 +1,23 @@
1
+ from pathlib import Path
2
+ from time import sleep
3
+ from typing import Literal, override
4
+
5
+ from potent.directives._base import BaseDirective, DirectiveResult
6
+
7
+
8
+ class CleanWorkdir(BaseDirective):
9
+ slug: Literal["clean-status"]
10
+
11
+ @override
12
+ def _run(self, directory: Path) -> DirectiveResult:
13
+ result = self._run_cmd(directory, ["git", "status", "--porcelain"])
14
+ sleep(2)
15
+
16
+ success = not result.stdout
17
+
18
+ return DirectiveResult(
19
+ success=success,
20
+ output="Working directory clean!"
21
+ if success
22
+ else "Uncommitted changes; clear before proceeding.",
23
+ )
@@ -0,0 +1,21 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+ from typing import Literal
4
+
5
+ from potent.directives._base import BaseDirective, DirectiveResult
6
+
7
+
8
+ class GitPull(BaseDirective):
9
+ slug: Literal["git-pull"]
10
+
11
+ def _run(self, directory: Path) -> DirectiveResult:
12
+ result = self._run_cmd(directory, ["git", "pull"])
13
+
14
+ # print stdout? not sure if/when it's useful, can be long
15
+ return DirectiveResult.from_process(result)
16
+
17
+ if result.returncode == 0:
18
+ return True
19
+
20
+ # print(result.stderr)
21
+ return False
@@ -0,0 +1,49 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+ from typing import Literal, override
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from potent.directives._base import BaseDirective, DirectiveResult
8
+ from potent.util import truthy_list
9
+
10
+
11
+ class Config(BaseModel):
12
+ branch: str
13
+ create: bool = False
14
+
15
+
16
+ class SwitchBranch(BaseDirective):
17
+ """
18
+ Creates a branch if missing. Re-verifies that you're on that branch during every run.
19
+ """
20
+
21
+ slug: Literal["switch-branch"]
22
+ config: Config
23
+ """
24
+ branch name
25
+ """
26
+ # base: Optional[str]
27
+ # """
28
+ # Branch to base the new branch off of. Will switch to this first to create the branch.
29
+ # """
30
+
31
+ @override
32
+ def _run(self, directory: Path) -> DirectiveResult:
33
+ result = self._run_cmd(
34
+ directory,
35
+ ["git", "switch", "-c" if self.config.create else "", self.config.branch],
36
+ )
37
+
38
+ return DirectiveResult.from_process(result)
39
+
40
+ if result.returncode == 0:
41
+ return True
42
+
43
+ print(result.stderr)
44
+ return False
45
+
46
+ # def verify(self) -> None:
47
+ # """
48
+ # verify branch on every run
49
+ # """
potent/shellprint.py ADDED
@@ -0,0 +1,132 @@
1
+ from pathlib import Path
2
+ from typing import Annotated, Any, Literal, Optional, TextIO, Union
3
+
4
+ from annotated_types import Len
5
+ from pydantic import AfterValidator, BaseModel, Field
6
+ from rich.console import Group
7
+ from rich.tree import Tree
8
+
9
+ from potent.directives._base import AbsPath
10
+ from potent.directives.clean_workdir import CleanWorkdir
11
+ from potent.directives.git_pull import GitPull
12
+ from potent.directives.switch_branch import SwitchBranch
13
+
14
+
15
+ def unique_items(v):
16
+ if len(v) != len(set(v)):
17
+ raise ValueError("list is not unique")
18
+ return v
19
+
20
+
21
+ Version = Literal["v1"]
22
+
23
+
24
+ class Shellprint(BaseModel):
25
+ version: Version = "v1"
26
+ comment: Optional[str] = None
27
+ steps: list[
28
+ Annotated[
29
+ Union[
30
+ GitPull,
31
+ SwitchBranch,
32
+ CleanWorkdir,
33
+ ],
34
+ Field(discriminator="slug"),
35
+ ]
36
+ ]
37
+ directories: Annotated[
38
+ list[AbsPath],
39
+ Len(min_length=1),
40
+ AfterValidator(unique_items),
41
+ ]
42
+ _path: Optional[Path] = None
43
+
44
+ @staticmethod
45
+ def from_file(f: TextIO) -> "Shellprint":
46
+ return Shellprint.model_validate_json(f.read())
47
+
48
+ @staticmethod
49
+ def from_path(f: Path) -> "Shellprint":
50
+ plan = Shellprint.model_validate_json(f.read_text())
51
+ plan._path = f
52
+ return plan
53
+
54
+ # def run(self):
55
+ # if not self._path:
56
+ # raise ValueError("Can't run plan without path")
57
+
58
+ # with self._path.open("r+") as fp:
59
+ # pass
60
+
61
+ def save(self, f: TextIO):
62
+ """
63
+ Operates on an open file for performance reasons
64
+ """
65
+ f.seek(0)
66
+ f.truncate()
67
+ f.write(self.model_dump_json(indent=2))
68
+ f.flush()
69
+
70
+ def reset(self):
71
+ for p in self.steps:
72
+ p.reset()
73
+
74
+ def render(self) -> Group:
75
+ """
76
+ Outputs a task tree suitable for Rich to show progress (w/ spinners)
77
+ """
78
+ raise NotImplementedError
79
+
80
+ def directory_complete(self, directory: Path) -> bool:
81
+ return all(s.completed(directory) for s in self.steps)
82
+
83
+ def directory_failed(self, directory: Path) -> bool:
84
+ return any(s.failed(directory) for s in self.steps)
85
+
86
+ # def directory_started(self, directory: Path) -> bool:
87
+ # return any(s.completed(directory) for s in self.steps)
88
+
89
+ def summarize(self, path: Path) -> Tree:
90
+ """
91
+ Show this plan as plaintext
92
+ """
93
+
94
+ root = Tree(f"[yellow] {path.absolute()}")
95
+ # only print all steps if nothing has printed them yet
96
+ should_print_all = True
97
+
98
+ for d in self.directories:
99
+ # res.append("")
100
+ if self.directory_complete(d):
101
+ root.add(f"✅ {d.name}", style="green", guide_style="green")
102
+
103
+ # res.append(f"✅ {d.name}")
104
+ elif self.directory_failed(d):
105
+ should_print_all = False
106
+ failed = root.add(f"❌ {d.name}", style="red", guide_style="red")
107
+ # res.append(f"❌ {d.name}:\n")
108
+ for s in self.steps:
109
+ if s.completed(d):
110
+ failed.add(f"✅ {s.slug}", style="green")
111
+ # res.append(f" ✅ {s.slug}")
112
+ elif s.failed(d):
113
+ failed.add(f"❌ {s.slug}", style="red")
114
+ # res.append(f" ❌ {s.slug}")
115
+ else:
116
+ failed.add(f"⌛ {s.slug}", style="dim white")
117
+ # res.append(f" ⌛ {s.slug}")
118
+ else:
119
+ pending = root.add(f"⌛ {d.name}", style="yellow")
120
+ if should_print_all:
121
+ should_print_all = False
122
+ for s in self.steps:
123
+ pending.add(f"{s.slug}", style="white")
124
+ else:
125
+ pending.add("same as above", style="dim white")
126
+
127
+ # res.append(f"⌛ {d.name}")
128
+
129
+ # res.append("")
130
+
131
+ # return "\n".join(res)
132
+ return root
potent/util.py ADDED
@@ -0,0 +1,5 @@
1
+ def truthy_list[T](l: list[T]) -> list[T]:
2
+ """
3
+ Return a list with the falsy elements removed
4
+ """
5
+ return list(filter(None, l))
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.3
2
+ Name: potent
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Author: David Brownman
6
+ Author-email: David Brownman <oss@xavd.id>
7
+ Requires-Dist: pydantic>=2.11.9
8
+ Requires-Dist: rich>=14.1.0
9
+ Requires-Dist: typer>=0.19.2
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+
13
+ # potent
14
+
15
+ A CLI for running idem**potent** shell scripts.
@@ -0,0 +1,16 @@
1
+ potent/__init__.py,sha256=ce918eb97d15f4a3960ef5f2b5dd0764eb851483a1ca7d80309bd9841cebf7b4,525
2
+ potent/commands/_types.py,sha256=47779c61185599d4d8aa3028c40ebc278c0b6439e3e9434d6839704cee2bfaf4,496
3
+ potent/commands/dump_schema.py,sha256=4bd3e87987e7d938718d8762c8a53d48e524ddb5803d22d1535fadb54eb71c84,297
4
+ potent/commands/reset.py,sha256=667887b00ee9439fe514344ff5cc019c4db5c24dec26e06a64c5de4dc470722e,376
5
+ potent/commands/run.py,sha256=930eee66eb58e821bba1b4c9f6fca36b65e36ffd5d0e6447366e14173e89085d,2681
6
+ potent/commands/validate.py,sha256=558d26917e2a52dc2b1bce5d18cdbe1ac85be69098255f1460f1bae13e5475cb,680
7
+ potent/directives/_base.py,sha256=27e57e0e150eae39f34b9a7f25c147bea1c88ae1855156d609a6bd9bb44adc09,2207
8
+ potent/directives/clean_workdir.py,sha256=13267d8c6fdb366c671006d29201603af58e41898b200282a7462f091b528349,632
9
+ potent/directives/git_pull.py,sha256=81206ffcfd80fc34a7bf8f09151b8c6ff74de7827a0e14bddca5f22816ce8857,548
10
+ potent/directives/switch_branch.py,sha256=13214177ccad7fa7fe7cc3222cea693e0a94c835e1b823530d43eea09eee2ce2,1137
11
+ potent/shellprint.py,sha256=d5398996012996e9c983c086be233334b0a0f26f82d4ad6311d903dbea654202,4035
12
+ potent/util.py,sha256=3296ad7a17b5de3d650b87cd6f88ec7513b3830e24e628b88a44c918b459d249,142
13
+ potent-0.1.0.dist-info/WHEEL,sha256=b6dc288e80aa2d1b1518ddb3502fd5b53e8fd6cb507ed2a4f932e9e6088b264a,78
14
+ potent-0.1.0.dist-info/entry_points.txt,sha256=1bebb82783e5027775b9f2a63e7d53332c7db78888b88ceb13513e786b98bcc9,40
15
+ potent-0.1.0.dist-info/METADATA,sha256=510f1974b0b45981d5ce14084d261e24e1c066d13bdf2943c37dacbb61cad12c,363
16
+ potent-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.8.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ potent = potent:main
3
+