Skip to content

Commit ae3c81f

Browse files
authored
[Tooling, CI, & Tests] Upgrade to python 3.13 & fix related bugs (#60)
* First pass upgrading representer and CI to python 3.13 with tooling changes. * Renamed run tests in docker job * Renamed run tests in docker job * Due to AST changes in Python 3.13, re-recorded stests for repo. This adds new Python 3.13 representation files for all tests. * Adding back run.sh - accidentially deleted. * Added legacy test folders to gitignore. * Added str type guard for md5 hash function to prevent it being passed the Ellipsis object. Updated parse params from python 11.5 to 13.5. * Added clean_up_normalization function and call to add pass to function bodies that bbecome empty when the docstring is removed. * - Added fix_missing_bodies method to parse the AST and add [Pass()] anywhere there was an empty list for the function or class body. This corrects an error where the normalization that removes docstrings then produces invalid code where the docstring was the only valid expression for the class or function. - Added documentation notes for upcomming AST object depriciations in Python 3.14. Alsot commented specifically in the import list. - Modified the AssAssign visitor to insert an assigned value of None where a node had an AnnAssign without a value. This covers the bug where a DataClass or other Class or Function has its tupehint removed, but had no default value when written. Dataclasses and other parsing steps at runtime assign these values, but the written code does not. Our normalization process removes the typehint, so produces invalid code. Assigning the value to None during parsing avoids this probblem. Since normalization is for grouping purposes only and does not produce executable code, this was considered a valid change. * Modified and added test casees. Regenerated golden files to accomodated code changes and new cases. * Annotated test file and reran goldent results for Python 3.13. * Added new test case for class normalization and created golden files for tests. * Added new scenarios for datatclasses and recreated golden test file.
1 parent 6c23194 commit ae3c81f

File tree

364 files changed

+17894
-14059
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

364 files changed

+17894
-14059
lines changed

.github/workflows/main.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ on:
1616
- "**.md"
1717
jobs:
1818
test:
19-
name: Test Representer
19+
name: Test Python Representer
2020
runs-on: ubuntu-24.04
2121
steps:
2222
- name: Checkout code
@@ -37,5 +37,5 @@ jobs:
3737
cache-from: type=gha
3838
cache-to: type=gha,mode=max
3939

40-
- name: Run Tests in Docker
40+
- name: Run Tests for Python Representer in Docker
4141
run: bin/run-tests-in-docker.sh

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ coverage.xml
4747
.hypothesis/
4848
.pytest_cache/
4949

50+
# Legacy test directories
51+
test/*/OLD_output
52+
test/*/rerun
53+
5054
# Translations
5155
*.mo
5256
*.pot
@@ -102,3 +106,8 @@ venv.bak/
102106

103107
# mypy
104108
.mypy_cache/
109+
110+
# mac directory files
111+
.DS_Store
112+
test/.DS_Store
113+
*/.DS_Store

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM python:3.11.5-alpine3.18
1+
FROM python:3.13.5-alpine3.22
22

33
COPY requirements.txt /requirements.txt
44
COPY dev-requirements.txt /dev-requirements.txt

bin/run-tests-in-docker.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,4 @@ docker run \
2727
--mount type=tmpfs,dst=/tmp \
2828
--workdir /opt/representer \
2929
--entrypoint pytest \
30-
exercism/python-representer -v --disable-warnings
30+
exercism/python-representer -vv --disable-warnings

dev-requirements.txt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
pytest~=7.2.2
2-
pytest-subtests~=0.10.0
1+
black<=25.1.0
2+
pytest~=8.4.0
3+
pytest-subtests~=0.14.2

representer/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ def normalize(self) -> None:
2626
"""
2727
self._tree = self._normalizer.visit(self._tree)
2828

29+
def clean_up_normalization(self) -> None:
30+
"""
31+
Clean up the tree normalization, replacing empty ClassDef
32+
and Function Def bodies with pass, so that black is happy
33+
formatting them.
34+
"""
35+
self._tree = self._normalizer.fix_empty_bodies(self._tree)
36+
2937
def dump_tree(self) -> str:
3038
"""
3139
Dump the current state of the tree for printing.
@@ -106,6 +114,9 @@ def represent(slug: utils.Slug, input: utils.Directory, output: utils.Directory)
106114
# normalize the tree
107115
representation.normalize()
108116

117+
# clean up missing function and class bodies with pass
118+
representation.clean_up_normalization()
119+
109120
# save dump of normalized code for debug (from un-parsing the normalized AST).
110121
out[0:0] = ['## BEGIN NORMALIZED CODE ##', representation.dump_code(), "## END NORMALIZED CODE ##", '']
111122

representer/normalizer.py

Lines changed: 70 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
"""
22
Representer for Python.
3+
4+
The following features have been deprecated in documentation since
5+
Python 3.8, now cause a DeprecationWarning to be emitted at runtime
6+
when they are accessed or used, and
7+
***will be removed in Python 3.14***:
8+
9+
ast.Num
10+
ast.Str
11+
ast.Bytes
12+
ast.NameConstant
13+
ast.Ellipsis
14+
15+
Use ast.Constant instead.
316
"""
417
import builtins
518
from itertools import count
@@ -20,6 +33,7 @@
2033
Eq,
2134
ExceptHandler,
2235
Expr,
36+
Ellipsis, # <-- deprecated in 3.14
2337
FunctionDef,
2438
GeneratorExp,
2539
Global,
@@ -31,15 +45,18 @@
3145
Name,
3246
NodeTransformer,
3347
Nonlocal,
48+
Pass,
3449
SetComp,
35-
Str,
50+
Str, # <-- deprecated in 3.14
3651
Store,
3752
UnaryOp,
3853
Yield,
3954
YieldFrom,
4055
alias,
4156
arg,
57+
fix_missing_locations,
4258
get_docstring,
59+
iter_child_nodes,
4360
keyword,
4461
)
4562

@@ -82,11 +99,25 @@ def get_placeholders(self) -> Dict[str, str]:
8299
"""
83100
return {value: key for key, value in self._placeholder_cache.items()}
84101

102+
103+
def fix_empty_bodies(self, node):
104+
def _fix(node):
105+
if isinstance(getattr(node, 'body', None), list) and not node.body:
106+
node.body = [Pass()]
107+
for child in iter_child_nodes(node):
108+
_fix(child)
109+
110+
_fix(node)
111+
return node
112+
113+
85114
def register_docstring(self, node: AST) -> None:
86115
"""
87116
Register the docstring for this node.
88117
"""
118+
89119
docstring = get_docstring(node, clean=False)
120+
90121
if docstring:
91122
self._docstring_cache.add(utils.md5sum(docstring))
92123

@@ -116,31 +147,49 @@ def _visit_definition(self, node):
116147
self.generic_visit(node)
117148
return node
118149

150+
151+
def visit_AnnAssign(self, node: AnnAssign) -> Assign:
152+
"""
153+
Any type-annotated assignment
154+
155+
Converts type-annotated assignments to regular assignments.
156+
157+
In a class decorated by a dataclass decorator or where there
158+
is an unassigned but annotated class variable,
159+
the "missing" value is assigned None for the purposes of
160+
this representation.
161+
162+
This is to avoid ast parsing errors caused by annotation removal
163+
pre dataclass decorator (the decorator fills values in at runtime).
164+
Otherwise, the ast lib tosses an error because there are no node._fields
165+
for it to iterate through.
166+
"""
167+
168+
new_assign = Assign(targets=[node.target],
169+
value=node.value if node.value else Constant(value=None),
170+
lineno=node.lineno)
171+
172+
self.generic_visit(new_assign)
173+
return new_assign
174+
175+
119176
def visit_ClassDef(self, node: ClassDef) -> ClassDef:
120177
"""
121178
Any `class name` definition.
122179
"""
180+
123181
return self._visit_definition(node)
124182

183+
125184
def visit_FunctionDef(self, node: FunctionDef) -> FunctionDef:
126185
"""
127186
Any `def name` definition.
128187
"""
188+
129189
if node.returns:
130190
node.returns = None
131-
return self._visit_definition(node)
132-
133-
def visit_AnnAssign(self, node: AnnAssign) -> Assign:
134-
"""
135-
Any type-annotated assignment
136191

137-
Converts type-annotated assignments to regular assignments.
138-
"""
139-
new_assign = Assign(targets=[node.target],
140-
value=node.value,
141-
lineno=node.lineno)
142-
self.generic_visit(new_assign)
143-
return new_assign
192+
return self._visit_definition(node)
144193

145194
def visit_AsyncFunctionDef(self, node: AsyncFunctionDef) -> AsyncFunctionDef:
146195
"""
@@ -154,6 +203,7 @@ def visit_arg(self, node: arg) -> arg:
154203
Drops type annotations.
155204
"""
156205
node.arg = self.add_placeholder(node.arg)
206+
157207
if node.annotation:
158208
node.annotation = None
159209
self.generic_visit(node)
@@ -303,6 +353,7 @@ def visit_If(self, node: If) -> None:
303353
self.generic_visit(node)
304354
return node
305355

356+
306357
def visit_Expr(self, node: Expr) -> Optional[Expr]:
307358
"""Expressions not assigned to an identifier.
308359
@@ -316,10 +367,13 @@ def visit_Expr(self, node: Expr) -> Optional[Expr]:
316367
if node.value.func.id == 'print':
317368
return None
318369

370+
# Eliminate registered docstrings
371+
# Added guard to utils.py to pass on anything
372+
# that's not a string so that ellipsis doesn't
373+
# cause the md5sum function to barf.
319374
if isinstance(node.value, Constant) and not isinstance(node.value, Call):
320-
# eliminate registered docstrings
321-
if utils.md5sum(node.value.value) in self._docstring_cache:
322-
return None
375+
if utils.md5sum(node.value.value) in self._docstring_cache:
376+
return None
323377

324378
self.generic_visit(node)
325379
return node

representer/utils.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,21 @@ def directory(string: str) -> Directory:
5353
def md5sum(data: str) -> str:
5454
"""
5555
Return the md5 sum of the given string.
56+
57+
Somehow, the normalizer code is passing Ellipsis objects
58+
to this function. This causes issues with encoding:
59+
AttributeError: 'ellipsis' object has no attribute 'encode',
60+
requiring the guard below until we can stop the normalizer from
61+
passing the object.
62+
63+
See L360 in normalizer.py for the TODO.
5664
"""
57-
return md5(data.encode("utf-8")).hexdigest()
65+
66+
if not isinstance(data, str):
67+
return
68+
69+
else:
70+
return md5(data.encode("utf-8")).hexdigest()
5871

5972

6073
def single_space(text: str) -> str:
@@ -83,7 +96,7 @@ def parse(source: str) -> ast.AST:
8396
Wrapper around ast.parse.
8497
Preserves type annotations.
8598
"""
86-
return ast.parse(source, type_comments=False, feature_version=(3,11))
99+
return ast.parse(source, type_comments=True, feature_version=(3,13))
87100

88101

89102
def dump_tree(tree: ast.AST) -> str:

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
black<=22.3.0
1+
black<=25.1.0

0 commit comments

Comments
 (0)