potent 0.1.0__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.
potent-0.1.0/PKG-INFO ADDED
@@ -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.
potent-0.1.0/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # potent
2
+
3
+ A CLI for running idem**potent** shell scripts.
@@ -0,0 +1,56 @@
1
+ [project]
2
+ name = "potent"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [{ name = "David Brownman", email = "oss@xavd.id" }]
7
+ requires-python = ">=3.12"
8
+ dependencies = ["pydantic>=2.11.9", "rich>=14.1.0", "typer>=0.19.2"]
9
+
10
+ [project.scripts]
11
+ potent = "potent:main"
12
+
13
+ [build-system]
14
+ requires = ["uv_build>=0.8.4,<0.9.0"]
15
+ build-backend = "uv_build"
16
+
17
+ [tool.ruff.lint]
18
+ select = [
19
+ "E", # PEP8 recommendations
20
+ "F", # bugs
21
+ "I001", # import sorting
22
+ "BLE", # catching root Exception
23
+ "A", # built-in shadowing
24
+ "C4", # unnecessary comprehensions
25
+ "ISC", # implicit string concat
26
+ "PIE", # misc useful lints
27
+ "Q", # better quoting behavior
28
+ "RSE", # no parens on exceptions that lack args
29
+ "RET", # more correct return behavior
30
+ "SIM", # general style things
31
+ "TC", # type-only imports should be in a typecheck block
32
+ "ARG", # unused args
33
+ "PTH", # use pathlib
34
+ "FLY", # short "".join calls
35
+ "PERF", # performance hints
36
+ "PL", # pylint, general recommendations
37
+ # "RUF", # these are a little picky for me
38
+ ]
39
+
40
+ ignore = [
41
+ "E501", # skip enforcing line length
42
+ "E741", # ignore short variable names
43
+ "PLR2004", # magic values
44
+ "PLR0911", # too many returns
45
+ "PLR0912", # too many branches
46
+ "PLR0913", # too many arguments
47
+ 'Q000', # single quotes; handled by formatter
48
+ ]
49
+
50
+ unfixable = [
51
+ "F401", # don't remove unused imports
52
+ "F841", # don't remove unused variables
53
+ ]
54
+
55
+ [dependency-groups]
56
+ dev = ["pytest>=8.4.2", "ruff>=0.13.3"]
@@ -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)
@@ -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
+ # """
@@ -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
@@ -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))