Repository navigation
Add Jinja2 templating #351
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2033416
5a1961c
dde72da
7eb42e4
cb558df
88b68e7
ce1fc68
2ec2e7c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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/ |
| 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() |
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note that configlet caches the repo in XDG_CACHE_DIR is by default
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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...
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't recall writing the |
||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"}).") | ||
| 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)) |
Uh oh!
There was an error while loading. Please reload this page.