Skip to content

Commit ee4514f

Browse files
authored
Merge pull request #75 from smkent/manage-cookie
Add manage-cookie utility for keeping templated projects up to date
2 parents 2662650 + 093053a commit ee4514f

File tree

11 files changed

+395
-12
lines changed

11 files changed

+395
-12
lines changed

.github/workflows/cd.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ jobs:
2424
- name: 💾 Check out repository
2525
uses: actions/checkout@v3
2626

27+
- name: 📜 Fetch full repository history for unit tests
28+
run: git fetch --unshallow
29+
2730
- name: 🐍 Set up Python project with Poetry
2831
uses: ./.github/workflows/actions/python-poetry
2932
with:

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ jobs:
3232
- name: 💾 Check out repository
3333
uses: actions/checkout@v3
3434

35+
- name: 📜 Fetch full repository history for unit tests
36+
run: git fetch --unshallow
37+
3538
- name: 🐍 Set up Python project with Poetry
3639
uses: ./.github/workflows/actions/python-poetry
3740
with:

cookie_python/manage/__init__.py

Whitespace-only changes.

cookie_python/manage/main.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
from enum import Enum
5+
from typing import Callable, Optional
6+
7+
from .update import update_action
8+
9+
10+
class Action(str, Enum):
11+
UPDATE = (
12+
"update",
13+
update_action,
14+
"Update repository cruft and dependencies",
15+
)
16+
17+
def __new__(
18+
cls,
19+
value: str,
20+
func: Optional[Callable[[argparse.Namespace], None]] = None,
21+
description: str = "",
22+
) -> Action:
23+
obj = str.__new__(cls, value)
24+
obj._value_ = value
25+
obj.func = func # type: ignore
26+
obj.description = description # type: ignore
27+
return obj
28+
29+
30+
def main() -> None:
31+
ap = argparse.ArgumentParser()
32+
ap.add_argument(
33+
"action",
34+
type=lambda value: Action(str(value)),
35+
choices=list(Action),
36+
)
37+
ap.add_argument("repo", nargs="+", help="Repository URL")
38+
ap.add_argument(
39+
"-p",
40+
"--pretend",
41+
"--dry-run",
42+
dest="dry_run",
43+
action="store_true",
44+
help="Dry run",
45+
)
46+
args = ap.parse_args()
47+
args.action.func(args)

cookie_python/manage/repo.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
from __future__ import annotations
2+
3+
import contextlib
4+
import json
5+
import os
6+
import subprocess
7+
import sys
8+
import tempfile
9+
from functools import cached_property, partial
10+
from pathlib import Path
11+
from types import TracebackType
12+
from typing import Any, Optional
13+
14+
15+
class RepoSandbox:
16+
def __init__(self, repo: str, dry_run: bool = False) -> None:
17+
self._stack = contextlib.ExitStack()
18+
self.repo = repo
19+
self.branch = "update-cookie"
20+
self.dry_run = dry_run
21+
22+
def __enter__(self) -> RepoSandbox:
23+
return self
24+
25+
def __exit__(
26+
self,
27+
exc_type: Optional[type[BaseException]] = None,
28+
exc_val: Optional[BaseException] = None,
29+
exc_tb: Optional[TracebackType] = None,
30+
) -> None:
31+
self._stack.close()
32+
33+
@cached_property
34+
def tempdir(self) -> Path:
35+
return Path(
36+
self._stack.enter_context(
37+
tempfile.TemporaryDirectory(suffix=".manage_cookie")
38+
)
39+
)
40+
41+
@cached_property
42+
def clone_path(self) -> Path:
43+
subprocess.run(
44+
["git", "clone", self.repo, "repo"], cwd=self.tempdir, check=True
45+
)
46+
clone_path = self.tempdir / "repo"
47+
run = partial(subprocess.run, cwd=clone_path, check=True)
48+
if (
49+
run(
50+
["git", "ls-remote", "origin", self.branch],
51+
capture_output=True,
52+
)
53+
.stdout.decode() # type: ignore
54+
.strip()
55+
):
56+
raise Exception(f'Branch "{self.branch}" already exists on remote')
57+
run(["git", "checkout", "-b", self.branch])
58+
run(["git", "reset", "--hard", "origin/main"])
59+
return clone_path
60+
61+
def cruft_attr(self, attr: str) -> str:
62+
with open(self.clone_path / ".cruft.json") as f:
63+
cruft = json.load(f)
64+
value = cruft[attr]
65+
assert isinstance(value, str)
66+
return value
67+
68+
def run(
69+
self, *popenargs: Any, check: bool = True, **kwargs: Any
70+
) -> subprocess.CompletedProcess:
71+
kwargs.setdefault("cwd", self.clone_path)
72+
return subprocess.run(*popenargs, check=check, **kwargs)
73+
74+
def shell(self) -> None:
75+
if sys.__stdin__.isatty():
76+
print('Starting shell. Run "exit 1" to abort.')
77+
self.run([os.environ.get("SHELL", "/bin/bash")])
78+
79+
def commit_changes(self, message: str) -> None:
80+
self.run(["git", "add", "--", "."])
81+
self.run(
82+
["git", "commit", "--no-verify", "-F", "-"],
83+
input=message.replace("```\n", "").encode(),
84+
)
85+
if self.dry_run:
86+
self.run(
87+
[
88+
"git",
89+
"--no-pager",
90+
"show",
91+
"--",
92+
".",
93+
":!poetry.lock",
94+
":!.cruft.json",
95+
]
96+
)
97+
self.lint_test()
98+
99+
def lint_test(self) -> None:
100+
self.run(["poetry", "run", "poe", "lint"], check=False)
101+
with contextlib.suppress(subprocess.CalledProcessError):
102+
self.run(["git", "add", "--", "."])
103+
self.run(["git", "commit", "-m", "Apply automatic linting fixes"])
104+
try:
105+
self.run(["poetry", "run", "poe", "test"])
106+
except subprocess.CalledProcessError as e:
107+
print(e)
108+
print("Resolve errors and exit shell to continue")
109+
self.shell()
110+
111+
def open_pr(self, message: str) -> None:
112+
if self.dry_run:
113+
return
114+
self.run(["git", "push", "origin", self.branch])
115+
commit_title, _, *commit_body = message.splitlines()
116+
self.run(
117+
[
118+
"gh",
119+
"pr",
120+
"create",
121+
"--title",
122+
commit_title.strip(),
123+
"--body-file",
124+
"-",
125+
"--base",
126+
"main",
127+
"--head",
128+
self.branch,
129+
],
130+
input=os.linesep.join(commit_body).encode("utf-8"),
131+
)

cookie_python/manage/update.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import os
2+
from argparse import Namespace
3+
from pathlib import Path
4+
from typing import Optional
5+
6+
from .repo import RepoSandbox
7+
8+
9+
def update_cruft(repo: RepoSandbox) -> Optional[str]:
10+
before_ref = repo.cruft_attr("commit")
11+
repo.run(["poetry", "env", "remove", "--all"], check=False)
12+
repo.run(["poetry", "env", "use", "/usr/bin/python3"])
13+
repo.run(["poetry", "install"])
14+
repo.run(["poetry", "run", "cruft", "update", "-y"])
15+
after_ref = repo.cruft_attr("commit")
16+
if before_ref == after_ref:
17+
return None
18+
for try_count in range(1):
19+
rej_files = [
20+
fn.strip()
21+
for fn in repo.run(
22+
["find", ".", "-iname", "*.rej"],
23+
capture_output=True,
24+
check=True,
25+
)
26+
.stdout.decode()
27+
.splitlines()
28+
]
29+
conflicts = any(
30+
line.startswith("U")
31+
for line in repo.run(
32+
["git", "status", "--porcelain"],
33+
capture_output=True,
34+
check=True,
35+
)
36+
.stdout.decode()
37+
.splitlines()
38+
)
39+
if rej_files or conflicts:
40+
if try_count == 0:
41+
print(f">>> Conflicts found: {rej_files}")
42+
print("Resolve conflicts and exit shell to continue")
43+
repo.shell()
44+
continue
45+
raise Exception(f"Unresolved conflicts: {rej_files}")
46+
47+
cruft_repo = repo.cruft_attr("template")
48+
range_prefix = None
49+
if cruft_repo.startswith("https://github.com"):
50+
range_prefix = f"{cruft_repo}/compare/"
51+
if Path(cruft_repo).is_dir():
52+
template_repo_path = Path(cruft_repo)
53+
else:
54+
template_repo_path = repo.tempdir / "cookie_repo"
55+
repo.run(
56+
["git", "clone", cruft_repo, template_repo_path], cwd=repo.tempdir
57+
)
58+
compare_cmd = [
59+
"git",
60+
"log",
61+
"--oneline",
62+
"--graph",
63+
f"{before_ref}...{after_ref}",
64+
]
65+
graph_output = repo.run(
66+
compare_cmd, cwd=template_repo_path, capture_output=True, check=True
67+
).stdout.decode()
68+
return (
69+
"Applied updates from upstream project template commits:\n\n"
70+
f"{range_prefix or ''}{before_ref}...{after_ref}\n\n"
71+
f"```\n{graph_output.strip()}\n```\n\n"
72+
)
73+
74+
75+
def update_dependencies(repo: RepoSandbox) -> Optional[str]:
76+
repo.run(["poetry", "run", "pre-commit", "autoupdate"])
77+
updates = repo.run(
78+
["poetry", "update", "--no-cache"], capture_output=True
79+
).stdout.decode("utf-8")
80+
print(updates)
81+
try:
82+
while (
83+
not updates.splitlines()[0]
84+
.lower()
85+
.startswith("package operations")
86+
):
87+
updates = os.linesep.join(updates.splitlines()[1:])
88+
except IndexError:
89+
print("No updates in output detected")
90+
return None
91+
update_lines = [u.strip().replace("•", "-") for u in updates.splitlines()]
92+
if len(update_lines) < 3:
93+
print("No updates in output detected")
94+
return None
95+
return (
96+
"Updated project dependencies via `poetry update`:"
97+
+ os.linesep * 2
98+
+ os.linesep.join(update_lines)
99+
)
100+
101+
102+
def update_action(args: Namespace) -> None:
103+
for repo_url in args.repo:
104+
with RepoSandbox(repo_url, args.dry_run) as repo:
105+
actions = []
106+
msg_body = ""
107+
cruft_msg = update_cruft(repo)
108+
if cruft_msg:
109+
msg_body += cruft_msg
110+
actions.append("project template cruft")
111+
deps_msg = update_dependencies(repo)
112+
if deps_msg:
113+
msg_body += deps_msg
114+
actions.append("dependencies")
115+
if not msg_body:
116+
return None
117+
message = f"Update {', '.join(actions)}\n\n{msg_body}"
118+
repo.commit_changes(message)
119+
repo.open_pr(message)
File renamed without changes.

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ pytest-sugar = "*"
4848
types-PyYAML = "*"
4949

5050
[tool.poetry.scripts]
51-
new-cookie = "cookie_python.main:main"
51+
new-cookie = "cookie_python.new:main"
52+
manage-cookie = "cookie_python.manage.main:main"
5253

5354
[tool.poetry-dynamic-versioning]
5455
enable = false

tests/conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import os
2+
from tempfile import TemporaryDirectory
3+
from typing import Iterator
4+
15
import pytest
26

37

@@ -16,3 +20,9 @@ def opt_update_expected_outputs(request: pytest.FixtureRequest) -> bool:
1620
value = request.config.getoption("--update-expected-outputs")
1721
assert isinstance(value, bool)
1822
return value
23+
24+
25+
@pytest.fixture
26+
def temp_dir() -> Iterator[str]:
27+
with TemporaryDirectory(prefix="cookie-python.unittest.") as td:
28+
yield os.path.join(td)

0 commit comments

Comments
 (0)