Skip to content

Commit 8d8af2c

Browse files
authored
Support Python 3.8 (#14)
* support Python 3.8
1 parent bff718a commit 8d8af2c

6 files changed

Lines changed: 253 additions & 162 deletions

File tree

.github/workflows/CI.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@ jobs:
2727
fail-fast: true
2828
matrix:
2929
python-version:
30+
- "3.8"
3031
- "3.9"
3132
- "3.10"
3233
- "3.11"
33-
- "3.12-dev"
34+
- "3.12"
3435
runs-on: ubuntu-latest
3536
steps:
3637
- name: Check out repository

poetry.lock

Lines changed: 145 additions & 135 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ license = "MIT"
77
readme = "README.md"
88

99
[tool.poetry.dependencies]
10-
python = "^3.9"
10+
python = "^3.8"
1111
libcst = "^1.0.0"
1212
click = "^8.1.7"
13-
rich = "^13.5.2"
14-
anyio = "^3.7.0"
15-
msgspec = "^0.17.0"
13+
rich = "^13.6.0"
14+
anyio = "^4.0.0"
15+
msgspec = "^0.18.4"
1616
tomli-w = "^1.0.0"
1717
rich-click = "^1.6.1"
1818
ruff = {version = "*", optional = true}
@@ -44,7 +44,7 @@ select = [
4444
"TCH", # flake8-type-checking
4545
"UP", # pyupgrade
4646
]
47-
target-version = "py39"
47+
target-version = "py38"
4848

4949
[tool.mypy]
5050
strict = true

test/test_cli.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -406,12 +406,12 @@ def test_transform_source_and_target_changed_no_transformation(
406406
runner: CliRunner, source_file: Path, target_file: Path, mock_transform: MagicMock
407407
) -> None:
408408
args = [f"{source_file}:{target_file}"]
409-
result_1 = runner.invoke(main, args)
409+
result_1 = runner.invoke(main, args, catch_exceptions=False)
410410

411411
target_file.write_text(TEST_TRANSFORMED_CONTENT + "\n\nimport foo")
412412
source_file.write_text(TEST_CONTENT + "\n\nimport foo")
413413

414-
result_2 = runner.invoke(main, args)
414+
result_2 = runner.invoke(main, args, catch_exceptions=False)
415415

416416
assert result_1.exit_code == 1
417417
assert result_2.exit_code == 0
@@ -421,7 +421,11 @@ def test_transform_source_and_target_changed_no_transformation(
421421
def test_transform_add_editors_note(
422422
runner: CliRunner, source_file: Path, target_file: Path
423423
) -> None:
424-
result = runner.invoke(main, [f"{source_file}:{target_file}", "--add-editors-note"])
424+
result = runner.invoke(
425+
main,
426+
[f"{source_file}:{target_file}", "--add-editors-note"],
427+
catch_exceptions=False,
428+
)
425429

426430
assert result.exit_code == 1
427431
expected_content = (

unasyncd/cli.py

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import asyncio
44
import time
5+
from contextlib import nullcontext
56
from pathlib import Path
67

78
import rich
@@ -20,22 +21,18 @@ async def _run(*, config: Config, check_only: bool, verbose: bool) -> bool:
2021
files_changed = 0
2122
files_unchanged = 0
2223

23-
status = console.status("Processing")
24-
if not verbose:
25-
status.start()
24+
status = console.status("Processing") if not verbose else nullcontext()
25+
with status: # type: ignore[attr-defined]
26+
async for result in unasync_files(config=config):
27+
if not result.transformed:
28+
files_unchanged += 1
29+
continue
2630

27-
async for result in unasync_files(config=config):
28-
if not result.transformed:
29-
files_unchanged += 1
30-
continue
31+
files_changed += 1
3132

32-
files_changed += 1
33-
34-
verbose_console.print(
35-
f"Transformed [yellow]{result.source}[/] > [green]{result.target}[/]"
36-
)
37-
38-
status.stop()
33+
verbose_console.print(
34+
f"Transformed [yellow]{result.source}[/] > [green]{result.target}[/]"
35+
)
3936

4037
console.print(f"Finished in {round(time.perf_counter() - start, 2)} seconds")
4138
console.print(

unasyncd/main.py

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import textwrap
88
from concurrent.futures import ProcessPoolExecutor
99
from dataclasses import dataclass
10-
from typing import TYPE_CHECKING, Any
10+
from typing import TYPE_CHECKING, Any, Dict
1111

1212
import anyio
1313
import anyio.to_process
@@ -16,17 +16,96 @@
1616
from anyio import Path
1717

1818
if TYPE_CHECKING:
19-
from collections.abc import AsyncGenerator
19+
from collections.abc import AsyncGenerator, Iterator
2020

2121
from .config import Config
2222

2323

2424
def _hash_ast(source: str) -> str:
2525
return hashlib.sha1(
26-
ast.unparse(ast.parse(source, type_comments=True)).encode("utf-8")
26+
"".join(_stringify_ast(ast.parse(source, type_comments=True))).encode("utf-8")
2727
).hexdigest()
2828

2929

30+
def _normalize(lineend: str, value: str) -> str:
31+
# To normalize, we strip any leading and trailing space from
32+
# each line...
33+
stripped = [i.strip() for i in value.splitlines()]
34+
normalized = lineend.join(stripped)
35+
# ...and remove any blank lines at the beginning and end of
36+
# the whole string
37+
return normalized.strip()
38+
39+
40+
def _stringify_ast(node: ast.AST, depth: int = 0) -> Iterator[str]:
41+
# this was taken from black
42+
# https://github.com/psf/black/blob/ddfecf06c13dd86205c851e340124e325ed82c5c/src/black/parsing.py#L143-L216
43+
# and serves as a workaround for generating a hash of an AST, because ast.unparse
44+
# is not available until Python 3.9
45+
"""Simple visitor generating strings to compare ASTs by content."""
46+
47+
if (
48+
isinstance(node, ast.Constant)
49+
and isinstance(node.value, str)
50+
and node.kind == "u"
51+
):
52+
# It's a quirk of history that we strip the u prefix over here. We used to
53+
# rewrite the AST nodes for Python version compatibility and we never copied
54+
# over the kind
55+
node.kind = None
56+
57+
yield f"{' ' * depth}{node.__class__.__name__}("
58+
59+
for field in sorted(node._fields):
60+
# TypeIgnore has only one field 'lineno' which breaks this comparison
61+
if isinstance(node, ast.TypeIgnore):
62+
break
63+
64+
try:
65+
value = getattr(node, field)
66+
except AttributeError:
67+
continue
68+
69+
yield f"{' ' * (depth+1)}{field}="
70+
71+
if isinstance(value, list):
72+
for item in value:
73+
# Ignore nested tuples within del statements, because we may insert
74+
# parentheses and they change the AST.
75+
if (
76+
field == "targets"
77+
and isinstance(node, ast.Delete)
78+
and isinstance(item, ast.Tuple)
79+
):
80+
for elt in item.elts:
81+
yield from _stringify_ast(elt, depth + 2)
82+
83+
elif isinstance(item, ast.AST):
84+
yield from _stringify_ast(item, depth + 2)
85+
86+
elif isinstance(value, ast.AST):
87+
yield from _stringify_ast(value, depth + 2)
88+
89+
else:
90+
if (
91+
isinstance(node, ast.Constant)
92+
and field == "value"
93+
and isinstance(value, str)
94+
):
95+
# Constant strings may be indented across newlines, if they are
96+
# docstrings; fold spaces after newlines when comparing. Similarly,
97+
# trailing and leading space may be removed.
98+
normalized = _normalize("\n", value)
99+
elif field == "type_comment" and isinstance(value, str):
100+
# Trailing whitespace in type comments is removed.
101+
normalized = value.rstrip()
102+
else:
103+
normalized = value
104+
yield f"{' ' * (depth+2)}{normalized!r}, # {value.__class__.__name__}"
105+
106+
yield f"{' ' * depth}) # /{node.__class__.__name__}"
107+
108+
30109
@dataclass
31110
class TransformationResult:
32111
source: Path
@@ -147,7 +226,7 @@ async def get_meta(self, file: File) -> FileMeta | None:
147226
"""
148227
if self._meta is None:
149228
if meta_raw := await self.cache.get("meta.json"):
150-
self._meta = msgspec.json.decode(meta_raw, type=dict[str, FileMeta])
229+
self._meta = msgspec.json.decode(meta_raw, type=Dict[str, FileMeta])
151230
else:
152231
self._meta = {}
153232

0 commit comments

Comments
 (0)