Skip to content

Commit

Permalink
Representer Updates and Golden Tests (#45)
Browse files Browse the repository at this point in the history
* Add practice exericse golden files.

* Add concept exercise golden files.

* Add example test golden files.

* Created run in docker script to build docker container and run golden tests.

* Added CI workflow to test with golden files.

* Changed Dockerfile to use Alpine and updated run scripts and requirements.

* Removed astor lib and replaced it with Python 3.11 AST Lib
Changed representation.txt to be a non-indented AST string
Changed representation.out to have AST converted to code
Added Normalizers for print removal, __main__ removal, and generator support

* Added metadata property, and adjusted script to output metadata.json.

* Added golden tests for concept, practice, and example exercises.

* Small adjustments.

* Removed unneeded example.py files.

* Re-ran golden files for practice exercises.

* Re-ran golden files for concept exercises.

* Re-ran golden files for example tests.

* Pinned Ubuntu version to 22.04

* Removed curl, bash, and apk updates from dockerfile.

* Added cache clean, but thought better of it.

* Moved normalized code to first position in representer.out file.

* changed job name to test from build.
  • Loading branch information
BethanyG authored Mar 6, 2024
1 parent cef8769 commit 11a3a38
Show file tree
Hide file tree
Showing 1,033 changed files with 298,434 additions and 496 deletions.
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@
.gitattributes
.dockerignore
Dockerfile
test/
bin/
!bin/run.py
!bin/run.sh
38 changes: 25 additions & 13 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,36 @@ on:
branches:
- main
paths-ignore:
- '.gitignore'
- 'LICENSE'
- '**.md'
- ".gitignore"
- "LICENSE"
- "**.md"
push:
paths-ignore:
- '.gitignore'
- 'LICENSE'
- '**.md'

- ".gitignore"
- "LICENSE"
- "**.md"
jobs:
test:
name: Test Representer
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e
- name: Checkout code
uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4c0219f9ac95b02789c1075625400b2acbff50b1
with:
install: true

- name: Build Docker image and store in cache
uses: docker/build-push-action@2eb1c1961a95fc15694676618e422e8ba1d63825
with:
context: .
push: false
load: true
tags: exercism/python-representer
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Build Docker Image
run: docker build -f Dockerfile -t python-representer .

- name: Run Tests
run: docker run --entrypoint "pytest" python-representer
- name: Run Tests in Docker
run: bin/run-tests-in-docker.sh
21 changes: 2 additions & 19 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,26 +1,9 @@
FROM python:3.11.2-slim as base

FROM base as builder

RUN mkdir /install

WORKDIR /install
FROM python:3.11.5-alpine3.18

COPY requirements.txt /requirements.txt
COPY dev-requirements.txt /dev-requirements.txt

RUN pip install --prefix=/install --no-warn-script-location -r /requirements.txt -r /dev-requirements.txt

FROM base

COPY --from=builder /install /usr/local


RUN apt-get update \
&& apt-get install curl -y \
&& apt-get remove curl -y \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
RUN pip install -r /requirements.txt -r /dev-requirements.txt

COPY . /opt/representer

Expand Down
9 changes: 7 additions & 2 deletions bin/run-in-docker.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/usr/bin/env bash
#!/usr/bin/env sh
set -e

# Synopsis:
Expand Down Expand Up @@ -32,8 +32,13 @@ docker build --rm --no-cache -t python-representer .
output_dir="$3"
mkdir -p "$output_dir"

# run image passing the arguments
# run image passing the arguments
docker run \
--rm \
--network none \
--read-only \
--mount type=bind,src=$PWD/$2,dst=/solution \
--mount type=bind,src=$PWD/$output_dir,dst=/output \
python-representer $1 /solution/ /output/
--mount type=tmpfs,destination=/tmp \
exercism/python-representer $1 /solution/ /output/
30 changes: 30 additions & 0 deletions bin/run-tests-in-docker.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env sh

# Synopsis:
# Test the test runner Docker image by running it against a predefined set of
# solutions with an expected output.
# The test runner Docker image is built automatically.

# Output:
# Outputs the diff of the expected test results against the actual test results
# generated by the test runner Docker image.

# Example:
# ./bin/run-tests-in-docker.sh

# Stop executing when a command returns a non-zero return code
set -e

# Build the Docker image
docker build --rm -t exercism/python-representer .

# Run the Docker image using the settings mimicking the production environment
docker run \
--rm \
--network none \
--read-only \
--mount type=bind,src="${PWD}/test",dst=/opt/representer/test \
--mount type=tmpfs,dst=/tmp \
--workdir /opt/representer \
--entrypoint pytest \
exercism/python-representer -v --disable-warnings
5 changes: 3 additions & 2 deletions bin/run.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#! /bin/sh
#! /usr/bin/env sh

root="$( dirname "$( cd "$( dirname "$0" )" >/dev/null 2>&1 && pwd )" )"
export PYTHONPATH="$root:$PYTHONPATH"
python bin/run.py "$@"
/usr/bin/env python3 bin/run.py "$@"
25 changes: 22 additions & 3 deletions representer/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
Representer for Python.
"""

import json
from typing import Dict

Expand All @@ -17,6 +18,7 @@ class Representer:
def __init__(self, source: str) -> None:
self._tree = utils.parse(source)
self._normalizer = Normalizer()
self.metadata = {"version" : 2}

def normalize(self) -> None:
"""
Expand All @@ -30,6 +32,12 @@ def dump_tree(self) -> str:
"""
return utils.dump_tree(self._tree)

def dump_ast(self) -> str:
"""
Dump the current stat of the tree without indents.
"""
return utils.dump_ast(self._tree)

def dump_code(self, reformat=True) -> str:
"""
Dump the current tree as generate code.
Expand All @@ -52,6 +60,12 @@ def dump_map(self) -> str:
"""
return utils.to_json(self.mapping)

def dump_metadata(self) -> Dict[str, int]:
"""
Dump the representer metadata.
"""
return utils.to_json(self.metadata)


def represent(slug: utils.Slug, input: utils.Directory, output: utils.Directory) -> None:
"""
Expand Down Expand Up @@ -80,21 +94,26 @@ def represent(slug: utils.Slug, input: utils.Directory, output: utils.Directory)
out_dst = output.joinpath("representation.out")
txt_dst = output.joinpath("representation.txt")
map_dst = output.joinpath("mapping.json")
metadata_dst = output.joinpath("representation.json")


# parse the tree from the file contents
representation = Representer(src.read_text())

# save dump of the initial tree for debug
out = ["# BEGIN TREE BEFORE", representation.dump_tree(), ""]
out = ['## BEGIN TREE BEFORE ##', representation.dump_tree(), '## END TREE BEFORE ##', '']

# normalize the tree
representation.normalize()

# save dump of normalized code for debug (from un-parsing the normalized AST).
out[0:0] = ['## BEGIN NORMALIZED CODE ##', representation.dump_code(), "## END NORMALIZED CODE ##", '']

# save dump of the normalized tree for debug
out.extend(["# BEGIN TREE AFTER", representation.dump_tree()])
out.extend(['## BEGIN NORMALIZED TREE ##', representation.dump_tree(), '## END NORMALIZED TREE ##'])

# dump the representation files
out_dst.write_text("\n".join(out))
txt_dst.write_text(representation.dump_code())
txt_dst.write_text(representation.dump_ast())
map_dst.write_text(representation.dump_map())
metadata_dst.write_text(representation.dump_metadata())
66 changes: 55 additions & 11 deletions representer/normalizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,28 @@
Assign,
AsyncFunctionDef,
Attribute,
Call,
ClassDef,
Compare,
Constant,
DictComp,
Eq,
ExceptHandler,
Expr,
FunctionDef,
GeneratorExp,
Global,
If,
ListComp,
Load,
Module,
Name,
NodeTransformer,
Nonlocal,
SetComp,
Str,
Store,
Yield,
alias,
arg,
get_docstring,
Expand Down Expand Up @@ -140,6 +146,7 @@ def visit_AsyncFunctionDef(self, node: AsyncFunctionDef) -> AsyncFunctionDef:
def visit_arg(self, node: arg) -> arg:
"""
Arguments in definition signatures.
Drops type annotations.
"""
node.arg = self.add_placeholder(node.arg)
if node.annotation:
Expand Down Expand Up @@ -190,17 +197,6 @@ def visit_Nonlocal(self, node: Nonlocal) -> Nonlocal:
self.generic_visit(node)
return node

def visit_Expr(self, node: Expr) -> Optional[Expr]:
"""
Expressions not assigned to an identifier.
"""
if isinstance(node.value, Constant):
# eliminate registered docstrings
if utils.md5sum(node.value.value) in self._docstring_cache:
return None
self.generic_visit(node)
return node

@overload
def _visit_identifier(self, node: Name) -> Name:
...
Expand Down Expand Up @@ -231,6 +227,7 @@ def visit_Attribute(self, node: Attribute) -> Attribute:
"""
return self._visit_identifier(node)


@overload
def _visit_comprehension(self, node: ListComp) -> ListComp:
...
Expand Down Expand Up @@ -280,5 +277,52 @@ def visit_DictComp(self, node: DictComp) -> DictComp:
"""
return self._visit_comprehension(node)

@overload
def _visit_If(self, node: If) -> If:
...

def visit_If(self, node: If) -> None:
"""Remove if __name__ == '__main__' nodes.
Looks for ast.If that includes __name__ == __main__ checks
and removes the block.
"""
if isinstance(node.test, Compare):
if not (isinstance(node.test.left, Name) and node.test.left.id == '__name__'):
self.generic_visit(node)
return node
else:
if node.test.comparators[0].value == '__main__':
return None

self.generic_visit(node)
return node

def visit_Expr(self, node: Expr) -> Optional[Expr]:
"""Expressions not assigned to an identifier.
Removes print statements and docstrings from
the representation.
"""

# Remove print() statements from representation.
if (isinstance(node.value, Call) and
isinstance(node.value.func, Name)):
if node.value.func.id == 'print':
return None

# Pass through generator code.
if isinstance(node.value, Yield):
return node


# Eliminate previously registered docstrings
if not isinstance(node.value, Call):
if utils.md5sum(node.value.value) in self._docstring_cache:
return None

self.generic_visit(node)
return node


__all__ = ["Normalizer"]
21 changes: 15 additions & 6 deletions representer/utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""
Representer for Python.
Representer Utilities for Python track.
"""


import ast
import errno
import json
Expand All @@ -11,12 +13,10 @@
from pathlib import Path
from typing import NewType

import astor
import black


Slug = NewType("Slug", str)

SLUG_RE = re.compile(r"^[a-z]+(-[a-z]+)*$")


Expand All @@ -41,6 +41,7 @@ def directory(string: str) -> Directory:
err = errno.ENOENT
msg = os.strerror(err)
raise FileNotFoundError(err, f"{msg}: {string!r}")

if not os.access(path, os.R_OK | os.W_OK):
err = errno.EACCES
msg = os.strerror(err)
Expand Down Expand Up @@ -80,19 +81,27 @@ def to_json(data: dict) -> str:
def parse(source: str) -> ast.AST:
"""
Wrapper around ast.parse.
Preserves type annotations.
"""
return ast.parse(source)
return ast.parse(source, type_comments=False, feature_version=(3,11))


def dump_tree(tree: ast.AST) -> str:
"""
Dump a formatted string of the AST.
"""
return astor.dump_tree(tree, indentation=" ", maxline=88)
return ast.dump(tree, annotate_fields=False, include_attributes=True, indent=2)


def dump_ast(tree: ast.AST) -> str:
"""
dump an un-formatted string of the AST.
"""
return ast.dump(tree, annotate_fields=False, include_attributes=True)


def to_source(tree: ast.AST) -> str:
"""
Dump the AST to generated source doe.
"""
return astor.to_source(tree)
return ast.unparse(tree)
Loading

0 comments on commit 11a3a38

Please sign in to comment.