Skip to content

Commit e5b6ec8

Browse files
Merge pull request #13 from RonnyPfannschmidt/fix-subst-and-paths
enhance substitutions and path handling
2 parents 27e3652 + a3e2a23 commit e5b6ec8

11 files changed

+466
-326
lines changed

.pre-commit-config.yaml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
repos:
22
- repo: https://github.com/ambv/black
3-
rev: 18.6b4
3+
rev: 21.7b0
44
hooks:
55
- id: black
66
args: [--safe, --quiet]
@@ -14,6 +14,7 @@ repos:
1414
- id: debug-statements
1515
- id: flake8
1616
- repo: https://github.com/asottile/pyupgrade
17-
rev: v1.4.0
17+
rev: v2.21.2
1818
hooks:
1919
- id: pyupgrade
20+
args: [--py36-plus]

pyproject.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[build-system]
2+
requires = ["setuptools>45", "setuptools_scm[toml]>6.3.1", "wheel"]
3+
build-backend = "setuptools.build_meta"
4+
5+
6+
[tool.setuptools_scm]
7+
8+
9+
[tool.mypy]
10+
python_version = "3.7"
11+
12+
13+
[[tool.mymy.overrides]]

regendoc/__init__.py

Lines changed: 102 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
1-
import sys
1+
from __future__ import annotations
22
import os
3-
import click
3+
from regendoc.actions import Action
4+
from typing import Callable, Generator, Sequence
5+
import contextlib
6+
import typer
47
import tempfile
5-
import re
6-
import io
7-
8+
from pathlib import Path
89
from .parse import parse_actions, correct_content
9-
from .actions import ACTIONS
10+
from .substitute import SubstituteAddress, SubstituteRegex, default_substituters
11+
import logging
12+
from rich.logging import RichHandler
13+
from rich.console import Console
14+
from rich.progress import BarColumn, Progress, TextColumn, TimeRemainingColumn
15+
16+
log = logging.getLogger("regendoc")
17+
log.propagate = False
1018

19+
NORMALIZERS = Sequence[Callable[[str], str]]
1120

12-
def normalize_content(content, operators):
21+
22+
def normalize_content(content: str, operators: NORMALIZERS) -> str:
1323
lines = content.splitlines(True)
1424
result = []
1525
for line in lines:
@@ -19,21 +29,23 @@ def normalize_content(content, operators):
1929
return "".join(result)
2030

2131

22-
def check_file(name, content, tmp_dir, normalize, verbose=True):
32+
def check_file(
33+
path: Path, content: list[str], tmp_dir: Path, normalize: NORMALIZERS
34+
) -> list[Action]:
2335
needed_updates = []
24-
for action in parse_actions(content, file=name):
25-
method = ACTIONS[action["action"]]
26-
new_content = method(
27-
name=name, target_dir=tmp_dir, action=action, verbose=verbose
28-
)
36+
for action in parse_actions(content, file=path):
37+
38+
new_content = action(tmp_dir)
2939
if new_content:
30-
action["new_content"] = normalize_content(new_content, normalize)
40+
action.new_content = normalize_content(new_content, normalize)
3141
needed_updates.append(action)
3242
return needed_updates
3343

3444

35-
def print_diff(action):
36-
content, out = action["content"], action["new_content"]
45+
def print_diff(action: Action, console: Console) -> None:
46+
content, out = action.content, action.new_content
47+
assert out is not None
48+
3749
if out != content:
3850
import difflib
3951

@@ -42,74 +54,88 @@ def print_diff(action):
4254
contl = content.splitlines(True)
4355
lines = differ.compare(contl, outl)
4456

45-
mapping = {"+": "green", "-": "red", "?": "blue"}
57+
styles = {"+": "bold green", "-": "bold red", "?": "bold blue"}
4658
if lines:
47-
click.secho("$ " + action["target"], bold=True, fg="blue")
59+
console.print("$", action.target, style="bold blue")
4860
for line in lines:
49-
color = mapping.get(line[0])
50-
if color:
51-
click.secho(line, fg=color, bold=True, nl=False)
52-
else:
53-
click.echo(line, nl=False)
61+
style = styles.get(line[0])
62+
console.print(line, style=style, end="")
5463

5564

56-
class Substituter(object):
57-
def __init__(self, match, replace):
58-
self.match = match
59-
self.replace = replace
65+
def mktemp(rootdir: Path | None, name: str) -> Path:
66+
if rootdir is not None:
67+
return Path(rootdir)
68+
root = tempfile.gettempdir()
69+
rootdir = Path(root, "regendoc-tmpdirs")
70+
rootdir.mkdir(exist_ok=True)
71+
return Path(tempfile.mkdtemp(prefix=name + "-", dir=rootdir))
6072

61-
@classmethod
62-
def parse(cls, s):
63-
parts = s.split(s[0])
64-
assert len(parts) == 4
65-
return cls(match=parts[1], replace=parts[2])
6673

67-
def __call__(self, line):
68-
return re.sub(self.match, self.replace, line)
74+
@contextlib.contextmanager
75+
def ux_setup(verbose: bool) -> Generator[Progress, None, None]:
6976

70-
def __repr__(self):
71-
return "<Substituter {self.match!r} to {self.replace!r}>".format(self=self)
77+
columns = [
78+
TextColumn("[progress.description]{task.description}"),
79+
TextColumn("[progress.percentage]{task.completed:3.0f}/{task.total:3.0f}"),
80+
BarColumn(),
81+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
82+
TimeRemainingColumn(),
83+
]
84+
with Progress(
85+
*columns,
86+
) as progress:
87+
88+
handler = RichHandler(
89+
markup=True,
90+
rich_tracebacks=True,
91+
show_path=False,
92+
log_time_format="[%H:%m]",
93+
console=progress.console,
94+
)
95+
log.addHandler(handler)
96+
log.setLevel(logging.DEBUG if verbose else logging.INFO)
7297

98+
yield progress
7399

74-
def default_substituters(targetdir):
75-
return [
76-
Substituter(match=re.escape(targetdir), replace="/path/to/example"),
77-
Substituter(match=re.escape(os.getcwd()), replace="$PWD"),
78-
Substituter(match=r"at 0x[0-9a-f]+>", replace="at 0xdeadbeef>"),
79-
Substituter(match=re.escape(sys.prefix), replace='$PYTHON_PREFIX'),
80-
]
81100

101+
def _main(
102+
files: list[Path],
103+
update: bool = typer.Option(False, "--update"),
104+
normalize: list[str] = typer.Option(default=[]),
105+
rootdir: Path | None = None,
106+
def_name: str | None = None,
107+
verbose: bool = typer.Option(False, "--verbose"),
108+
) -> None:
82109

83-
@click.command()
84-
@click.argument("files", nargs=-1)
85-
@click.option("--update", is_flag=True)
86-
@click.option("--normalize", type=Substituter.parse, multiple=True)
87-
@click.option("--verbose", default=False, is_flag=True)
88-
def main(files, update, normalize=(), rootdir=None, verbose=False):
89-
tmpdir = rootdir or tempfile.mkdtemp(prefix="regendoc-exec-")
90-
total = len(files)
91-
for num, name in enumerate(files, 1):
92-
targetdir = os.path.join(tmpdir, "%s-%d" % (os.path.basename(name), num))
93-
with io.open(name, encoding="UTF-8") as fp:
94-
content = list(fp)
95-
os.mkdir(targetdir)
96-
click.secho(
97-
"#[{num:3d}/{total:3d}] {name}".format(num=num, total=total, name=name),
98-
bold=True,
99-
)
100-
updates = check_file(
101-
name=name,
102-
content=content,
103-
tmp_dir=targetdir,
104-
normalize=default_substituters(targetdir) + list(normalize),
105-
verbose=verbose,
106-
)
107-
for action in updates:
108-
if action["content"] is None or action["new_content"] is None:
109-
continue
110-
111-
print_diff(action)
112-
if update:
113-
corrected = correct_content(content, updates)
114-
with io.open(name, "w", encoding="UTF-8") as f:
115-
f.writelines(corrected)
110+
parsed_normalize: list[SubstituteRegex | SubstituteAddress] = [
111+
SubstituteRegex.parse(s) for s in normalize
112+
]
113+
114+
cwd = Path.cwd()
115+
tmpdir: Path = mktemp(rootdir, cwd.name)
116+
117+
with ux_setup(verbose) as progress:
118+
task_id = progress.add_task(description="progressing files")
119+
for num, name in enumerate(progress.track(files, task_id=task_id)):
120+
121+
targetdir = tmpdir.joinpath("%s-%d" % (os.path.basename(name), num))
122+
with open(name) as fp:
123+
content = list(fp)
124+
targetdir.mkdir()
125+
log.info(f"[bold]{name}[/bold]")
126+
updates = check_file(
127+
path=name,
128+
content=content,
129+
tmp_dir=targetdir,
130+
normalize=default_substituters(targetdir) + parsed_normalize,
131+
)
132+
for action in updates:
133+
print_diff(action, progress.console)
134+
if update:
135+
corrected = correct_content(content, updates)
136+
with open(name, "w") as f:
137+
f.writelines(corrected)
138+
139+
140+
def main() -> None:
141+
typer.run(_main)

regendoc/actions.py

Lines changed: 49 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,79 @@
1+
from __future__ import annotations
12
import os
2-
import click
33
import subprocess
44
import shutil
5+
from pathlib import Path
56

7+
from dataclasses import dataclass
8+
from typing import Callable
9+
from logging import getLogger
610

7-
def write(name, target_dir, action, verbose):
11+
log = getLogger(__name__)
12+
13+
14+
def write(target_dir: Path, action: Action) -> None:
815
# XXX: insecure
9-
if verbose:
10-
click.echo("write to %(target)s" % action)
11-
target = os.path.join(target_dir, action["target"])
12-
target_dir = os.path.dirname(target)
13-
if not os.path.isdir(target_dir):
14-
os.makedirs(target_dir)
15-
with open(target, "w") as fp:
16-
fp.write(action["content"])
16+
assert not action.target.startswith(os.path.sep), action.target
17+
log.debug("write to [bold]%s[/]", action.target)
18+
target = target_dir.joinpath(action.target)
19+
target.parent.mkdir(exist_ok=True, parents=True)
20+
target.write_text(action.content)
1721

1822

19-
def process(name, target_dir, action, verbose):
20-
if action["cwd"]:
23+
def process(target_dir: Path, action: Action) -> str:
24+
if action.cwd:
25+
2126
# the cwd option is insecure and used for examples
2227
# that already have all files in place
2328
# like an examples folder for example
24-
if action['cwd'] == '.':
25-
src = os.path.abspath(os.path.dirname(action['file']))
26-
27-
target_dir = os.path.join(target_dir, 'CWD')
29+
if action.cwd is not None and action.cwd == Path("."):
30+
assert action.file is not None
31+
src = action.file.parent
32+
target_dir = target_dir / "CWD"
2833
else:
29-
src = os.path.join(
30-
os.path.abspath(os.path.dirname(action['file'])),
31-
action['cwd'])
32-
33-
target_dir = os.path.join(target_dir, action['cwd'])
34+
src = action.file.parent.joinpath(action.cwd)
3435

36+
target_dir = target_dir.joinpath(action.cwd)
3537
shutil.copytree(src, target_dir)
3638

3739
if not os.path.isdir(target_dir):
3840
os.makedirs(target_dir)
39-
40-
if verbose:
41-
click.echo("popen %(target)s" % action)
42-
process = subprocess.Popen(
43-
action["target"],
41+
target = action.target
42+
log.debug("popen %r\n cwd=%s", target, target_dir)
43+
output = subprocess.run(
44+
target,
4445
shell=True,
4546
cwd=target_dir,
4647
stdout=subprocess.PIPE,
4748
stderr=subprocess.STDOUT,
4849
bufsize=0,
50+
encoding="utf-8",
4951
)
50-
out, err = process.communicate()
51-
out = out.decode("utf-8")
52-
assert not err
53-
return out
52+
53+
return output.stdout
5454

5555

56-
def wipe(name, target_dir, action, verbose):
57-
if verbose:
58-
click.secho("wiping targetdir {} of {}".format(target_dir, name), bold=True)
56+
def wipe(target_dir: Path, action: Action) -> None:
57+
log.debug("wiping targetdir [bold warning]%s[/]", target_dir)
5958
shutil.rmtree(target_dir)
6059
os.mkdir(target_dir)
6160

6261

6362
ACTIONS = {"shell": process, "wipe": wipe, "write": write}
63+
64+
COMMAND_TYPE = Callable[[Path, "Action"], "str|None"]
65+
66+
67+
@dataclass
68+
class Action:
69+
command: COMMAND_TYPE
70+
target: str
71+
content: str
72+
file: Path
73+
new_content: str | None = None
74+
cwd: Path | None = None
75+
indent: int = 0
76+
line: int = 0
77+
78+
def __call__(self, target_dir: Path) -> str | None:
79+
return self.command(target_dir, self)

0 commit comments

Comments
 (0)