Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
.DS_Store
*.log
.#*
bin/*
_build/*
closet/track-configs.fasl
closet/json/*
*~
*.html
ci
problem-specifications/*
!bin/fetch-configlet
!bin/verify-exercises
bin/configlet

.idea/
205 changes: 205 additions & 0 deletions bin/generate_tests.py
Comment thread
colinleach marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
#!/usr/bin/env python

"""Test generator v1."""

import argparse
import datetime
import json
import os
import pathlib
import re
import shlex
import subprocess
import sys
import textwrap
import tomllib

import jinja2


def problem_spec_dir() -> pathlib.Path:
"""Detect and return the problem specs."""
cache_dir = os.getenv("XDG_CACHE_HOME", os.getenv("HOME") + "/.cache")
specs = pathlib.Path(cache_dir) / "exercism/configlet/problem-specifications"
if specs.exists():
return specs
cur = pathlib.Path(os.getcwd())
for i in cur.parents:
if i.name == "problem-specifications":
return i
raise LookupError("Could not find problem specs")


def flatten_cases(cases: list[dict]) -> list[tuple[list[str], dict]]:
"""Recursive flatten test cases, returning individual cases with parent descriptions."""
for case_or_group in cases:
if "cases" in case_or_group:
for groups, child_case in flatten_cases(case_or_group["cases"]):
yield ([case_or_group["description"]] + groups, child_case)
else:
yield ([], case_or_group)


def get_cases(specs: pathlib.Path, exercise: pathlib.Path) -> list[dict]:
"""Return flattened, filtered cases with additional metadata attached."""
canonical_path = specs / "exercises" / exercise.name / "canonical-data.json"
with open(canonical_path, "r", encoding="utf-8") as f:
canonical = json.load(f)
with open(exercise / ".meta" / "tests.toml", "rb") as f:
tests = tomllib.load(f)

reimplemented = {
test["reimplements"]
for test in tests.values()
if test.get("include", True) and "reimplements" in test
}
cases = []
for groups, case in flatten_cases(canonical["cases"]):
# Filter out test cases with include=false or not listed.
if case["uuid"] not in tests or case["uuid"] in reimplemented:
continue
if not tests[case["uuid"]].get("include", True):
continue
# Add metadata.
case["descriptions"] = groups + [case["description"]]
case["expect_error"] = isinstance(case["expected"], dict) and "error" in case["expected"]
if case["expect_error"]:
case["expect_error_msg"] = case["expected"]["error"]
cases.append(case)
return cases


def filter_tojson(data, separators=(',', ':'), indent=None) -> str:
"""Filter `tojson` that JSON encodes a string with flexible settings."""
return json.dumps(data, separators=separators, indent=indent)


def filter_escape_whitespace_and_quote(data: str) -> str:
"""Escape whitespace using CARRIAGE_RETURN, TAB and NEWLINE, then quote."""
data = f'"{data}"'
for char, replacement in [("\n", "NEWLINE"), ("\t", "TAB"), ("\r", "CARRIAGE_RETURN")]:
data = data.replace(char, f'" // {replacement} // "')
while '// "" //' in data:
data = data.replace('// "" //', "//")
return data


def jinja_env(exercise: pathlib.Path) -> jinja2.Environment:
"""Return a configured Jinja env with filters added."""
env = jinja2.Environment(loader=jinja2.FileSystemLoader(exercise / ".meta"))
# Shell quoting
env.filters["quote"] = shlex.quote
# JSON formatting, default to compact form (`jq -c`).
env.filters["tojson"] = filter_tojson
# String escaping, ANSI-C style.
env.filters["repr"] = repr
# Return a dict with only specified keys kepts.
env.filters["camel_to_snake"] = lambda x: re.sub(r"([a-z])([A-Z])", (lambda m: f"{m.group(1)}_{m.group(2).lower()}"), x)
env.filters["format_list"] = lambda x: shlex.quote(
"[" + ",".join(f'"{i}"' if isinstance(i, str) else str(i) for i in x) + "]"
)
# Replace whitespace with chars and concatenation.
env.filters["escape_quote_whitespace"] = filter_escape_whitespace_and_quote

return env


def bool_to_str(obj):
"""Recursively convert boolean values to strings in collections."""
if isinstance(obj, dict):
return {key: bool_to_str(val) for key, val in obj.items()}
if isinstance(obj, list):
return [bool_to_str(val) for val in obj]
if obj is True:
return "#t"
if obj is False:
return "#f"
return obj


def generate(specs: pathlib.Path, exercise: pathlib.Path) -> None:
"""Generate and write test file for a given spec and exercise."""
cases = get_cases(specs, exercise)
for case in cases:
case["expected"] = bool_to_str(case["expected"])

timestamp = datetime.datetime.now(tz=datetime.UTC).replace(microsecond=0).isoformat()
header = f"""; These tests are auto-generated with test data from:
; https://github.com/exercism/problem-specifications/blob/main/exercises/{exercise.name}/canonical-data.json
; File last updated on {timestamp}"""
data = {
"cases": cases,
"header": header,
"solution": json.loads((exercise / ".meta/config.json").read_text())["files"]["solution"][0],
"name": exercise.name,
}

# Render the template.
try:
template = jinja_env(exercise).get_template("template.j2")
out = template.render(data)
except jinja2.exceptions.TemplateAssertionError as e:
e.add_note(f"Error rendering template for {exercise.name}")
raise

# Check for changes or the lack thereof.
test_file = exercise / json.loads((exercise / ".meta/config.json").read_text())["files"]["test"][0]
if test_file.exists():
old_content = [i for i in test_file.read_text().strip().splitlines() if "generated on" not in i]
new_content = [i for i in out.splitlines() if "generated on" not in i]
if old_content == new_content:
return

# Write the test file.
test_file.write_text(out + "\n")


def argparser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser()
parser.add_argument(
"--no-pull",
action="store_false",
dest="pull",
help="Do not run `git pull` on the problem specs repo",
)
parser.add_argument(
"exercises",
nargs="*",
help="exercises to generate tests; if none supplied, generate all"
)
return parser


def main():
"""Main entrypoint."""
specs = problem_spec_dir()
args = argparser().parse_args()
if args.pull:
subprocess.check_call(["git", "pull"], cwd=specs)
exercises = args.exercises
# Generate all exercises with templates if none are specified as args.
if not exercises:
exercises = [
i.parent.parent
for i in pathlib.Path("exercises/practice").glob("*/.meta/template.j2")
]
else:
# Turn strings to paths and make them relative to the practice exercises.
out = []
practice = pathlib.Path("exercises/practice")
for exercise in exercises:
path = pathlib.Path(exercise)
if not path.is_relative_to(practice):
path = practice / path
out.append(path)
exercises = out

for exercise in exercises:
exercise_path = pathlib.Path(exercise)
if not exercise_path.exists():
raise ValueError(f"Exercise {exercise_path} does not exist")
generate(specs, exercise_path)


if __name__ == "__main__":
main()
124 changes: 124 additions & 0 deletions bin/update-new-exercise-files.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env python3

"""

See docs/TEST.md for usage of this script

Use this script to create an initial <exercise>/test.scm file
which can be used as a skeleton for your test. Typically, you will
have to replace 'response' in the generated file with the right
function call.

Prerequisites
- Python 3.x (it may work with Python 2.x, but that is untested)
- Up-to-date local version of the https://github.com/exercism/problem-specifications.git repo

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that configlet caches the repo in $XDG_CACHE_DIR/exercism/configlet/proglem-specifications

XDG_CACHE_DIR is by default $HOME/.cache

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my (partial) defence, @IsaacG wrote that bit. Actually, he wrote most of the Python...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't recall writing the update-new-exercise-files.py file. Just the generate_tests.py. That file has a problem_spec_dir() which does mimic the configlet behavior IIUC. It may be possible to reuse some logic across the Python files.


Workflow for creating a new test
- pull latest changes from exercism/problem-specifications
- run this script for the example you want to create
- copy test-util.ss and Makefile for exercise directory
- implement working exercise
- fix potential problematic tests (see eg. exercise/bob "Test 20" and "Test 24")
- open a pull request with your changes

For bob example:

$ python3 bin/update-new-exercise-files.py --exercise_path exercises/practice/bob
$ cp config/CMakeLists.txt exercises/practice/bob/.
$ cd exercises/practice/bob
"""

import argparse
import json
import os
import pathlib
import textwrap


class Exercise:
"""Exercise holds exercise data."""

def __init__(self, exercise_path):
exercise_path = pathlib.Path(exercise_path)
if not exercise_path.exists():
raise ValueError(f"{exercise_path} does not exist")
if not exercise_path.is_dir():
raise ValueError(f"{exercise_path} is not a directory")

slug = exercise_path.name
self.config = json.loads((exercise_path / ".meta" / "config.json").read_text())

self.slug = slug
self.name = slug.replace("-", "_")
self.path = exercise_path

def config_file(self, filetype):
if len(self.config["files"][filetype]) != 1:
raise ValueError(f"Expected exactly one {filetype} file; got {self.config["files"][filetype]!r}")
return self.path / self.config["files"][filetype][0]

def write_file(self, filepath, content):
if filepath.exists():
print(f"{filepath} already exists; will not replace.")
else:
filepath.write_text(content.strip() + "\n")
print(f"Wrote to {filepath}")

def write_stub_file(self, content):
self.write_file(self.config_file("solution"), content)

def write_example_file(self, content):
self.write_file(self.config_file("example"), content)

def write_test_template(self, content):
filepath = self.path / ".meta/template.j2"
self.write_file(filepath, content)


def stub_implementation(exercise_name: str) -> str:
return textwrap.dedent("""\
module {exercise_name}
implicit none
contains

logical function dummy()
dummy=.true.
end function

end module
""")


def template_stub() -> str:
return textwrap.dedent("""\
{{ header }}
Comment on lines +92 to +94

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In some of my repos, like exercism/moonscript, I store the generator template in /exercises/shared/templates -- that shared directory is underused IMO


(load "test-util.ss")

(define test-cases
`(
{%- for case in cases %}
{%- if not case["expect_error"] %}
(test-success "{{ loop.index }}. {{ case["description"] }}" = {{ case["property"] }} '({{ case["input"]["..."] }}) {{ case["expected"] }})
{%- else %}
(test-error "{{ case["description"] }}" {{ case["property"] }} '({{ case["input"]["..."] }}))
{%- endif %}
{%- endfor %}
))

(run-with-cli "{{ name }}.scm" (list test-cases))
""")


if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Create exercise")
parser.add_argument("-e", "--exercise_path", help="The path of the exercise")

args = parser.parse_args()
exercise = Exercise(args.exercise_path)

exercise.write_stub_file(stub_implementation(exercise.name))
exercise.write_example_file(stub_implementation(exercise.name))
exercise.write_test_template(template_stub())

print(f"Use './bin/generate_tests {exercise.slug}' to generate the test file from the Jinja template ({exercise.path / ".meta/template.j2"}).")
17 changes: 17 additions & 0 deletions exercises/practice/armstrong-numbers/.meta/template.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{{ header }}

(load "test-util.ss")

(define test-cases
`(
{%- for case in cases %}
{%- if not case["expect_error"] %}
(test-success "{{ loop.index }}. {{ case["description"] }}"
equal? armstrong-number? '({{ case["input"]["number"] }}) {{ case["expected"] }})
{%- else %}
(test-error "{{ case["description"] }}" armstrong-number? '({{ case["input"]["number"] }}))
{%- endif %}
{% endfor %}
))

(run-with-cli "{{ name }}.scm" (list test-cases))
Loading
Loading