Skip to content

Commit a1fa5a7

Browse files
authored
Merge pull request #8 from git-mastery/sub-folder-structure
New exercise config structure
2 parents 6b14379 + 7dde81b commit a1fa5a7

File tree

5 files changed

+272
-46
lines changed

5 files changed

+272
-46
lines changed

.github/workflows/ci.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: CI
2+
on:
3+
pull_request:
4+
branches:
5+
- main
6+
push:
7+
branches:
8+
- main
9+
workflow_dispatch:
10+
11+
jobs:
12+
validate_exercise_configurations:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- name: Checkout repository
16+
uses: actions/checkout@v4
17+
- name: Setup Python
18+
uses: actions/setup-python@v5
19+
with:
20+
python-version: "3.13"
21+
- name: Run validation script
22+
run: |
23+
python validate-exercise-config.py

README.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,27 @@
1-
# exercises
1+
# exercises
2+
3+
## Git-Mastery exercise structure
4+
5+
When you download an exercise, you will get the following folder structure:
6+
7+
8+
## `.gitmastery-exercise.json`
9+
10+
Configuration fields for each exercise:
11+
12+
1. `exercise_name`: name of exercise
13+
2. `tags`: list of tags for exercise
14+
3. `requires_git`: downloading the exercise will check that Git is installed and that `git config` is already done
15+
4. `requires_github`: downloading the exercise will check that Github and Github CLI is installed
16+
5. `base_files`: provides the files to be included outside of the repository, along with `.gitmastery-exercise.json` and `README.md`, most often used for `answers.txt`
17+
6. `exercise_repo`: configuration for what the exercise repository would look like
18+
1. `type`: `custom` (creates and initializes the folder as a Git repository) or `link` (reference a repository on Github)
19+
2. `name`: name of repository during cloning
20+
3. `link`: (only read if `link` is present) link of repository on Github
21+
4. `create_fork`: (only read if `link` is present) flag to determine if we need to fork the repository to the student's machine, otherwise it just clones the repository
22+
5. `init`: (only read if `custom` is present) flag to determine if we will call `git init` on the exercise repository (useful if we don't want to start out with a Git repository)
23+
24+
25+
## TODOs
26+
27+
- [ ] Add validation for exercise configuration (e.g. cannot fork + not require Github) - to run as CI

amateur_detective/.gitmastery-exercise.json

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@
33
"tags": [
44
"git-status"
55
],
6-
"is_downloadable": true,
7-
"requires_repo": true,
8-
"resources": {
6+
"requires_git": true,
7+
"requires_github": false,
8+
"base_files": {
99
"answers.txt": "answers.txt"
10+
},
11+
"exercise_repo": {
12+
"repo_type": "custom",
13+
"repo_name": "crime-scene",
14+
"init": true
1015
}
1116
}

new-exercise.py

Lines changed: 116 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,100 @@
11
import json
22
import os
33
import pathlib
4+
import sys
45
import textwrap
6+
from dataclasses import dataclass
7+
from typing import Dict, List, Literal, Optional
58

6-
import yaml
79

10+
@dataclass
11+
class ExerciseConfig:
12+
@dataclass
13+
class ExerciseRepoConfig:
14+
repo_type: Literal["local", "remote"]
15+
repo_name: str
16+
repo_title: Optional[str]
17+
create_fork: Optional[bool]
18+
init: Optional[bool]
819

9-
def main():
20+
exercise_name: str
21+
tags: List[str]
22+
requires_git: bool
23+
requires_github: bool
24+
base_files: Dict[str, str]
25+
exercise_repo: ExerciseRepoConfig
26+
27+
def to_json(self) -> str:
28+
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=False, indent=2)
29+
30+
@property
31+
def exercise_dir(self) -> pathlib.Path:
32+
cur_path = pathlib.Path(os.getcwd())
33+
exercise_dir_name = self.exercise_name.replace("-", "_")
34+
exercise_dir = cur_path / exercise_dir_name
35+
return exercise_dir
36+
37+
38+
def confirm(prompt: str, default: bool) -> bool:
39+
str_result = input(f"{prompt} (defaults to {'y' if default else 'N'}) [y/N]: ")
40+
bool_value = default if str_result.strip() == "" else str_result.lower() == "y"
41+
return bool_value
42+
43+
44+
def prompt(prompt: str, default: str) -> str:
45+
str_result = input(f"{prompt} (defaults to '{default}'): ")
46+
if str_result.strip() == "":
47+
return default
48+
return str_result.strip()
49+
50+
51+
def get_exercise_config() -> ExerciseConfig:
1052
exercise_name = input("Exercise name: ")
1153
tags_str = input("Tags (space separated): ")
1254
tags = [] if tags_str.strip() == "" else tags_str.split(" ")
13-
requires_repo_str = input("Requires repo? (defaults to y) y/N: ")
14-
requires_repo = (
15-
requires_repo_str.strip() == ""
16-
or requires_repo_str == "y"
17-
or requires_repo_str == "Y"
18-
)
19-
requires_github_str = input("Requires Github? (defaults to y) y/N: ")
20-
requires_github = (
21-
requires_github_str.strip() == ""
22-
or requires_github_str == "y"
23-
or requires_github_str == "Y"
55+
requires_git = confirm("Requires Git?", True)
56+
requires_github = confirm("Requires Github?", True)
57+
exercise_repo_type = prompt("Exercise repo type (local or remote)", "local").lower()
58+
59+
if exercise_repo_type != "local" and exercise_repo_type != "remote":
60+
print("Invalid exercise_repo_type, only local and remote allowed")
61+
sys.exit(1)
62+
63+
exercise_repo_name = prompt("Exercise repo name", exercise_name.replace("-", "_"))
64+
65+
init: Optional[bool] = None
66+
create_fork: Optional[bool] = None
67+
repo_title: Optional[str] = None
68+
if exercise_repo_type == "local":
69+
init = confirm("Initialize exercise repo as Git repository?", True)
70+
elif exercise_repo_type == "remote":
71+
repo_title = prompt("Git-Mastery Github repository title", "")
72+
create_fork = confirm("Create fork of repository?", True)
73+
return ExerciseConfig(
74+
exercise_name=exercise_name,
75+
tags=tags,
76+
requires_git=requires_git,
77+
requires_github=requires_github,
78+
base_files={},
79+
exercise_repo=ExerciseConfig.ExerciseRepoConfig(
80+
repo_type=exercise_repo_type, # type: ignore
81+
repo_name=exercise_repo_name,
82+
repo_title=repo_title,
83+
create_fork=create_fork,
84+
init=init,
85+
),
2486
)
2587

26-
cur_path = pathlib.Path(os.getcwd())
27-
exercise_dir_name = exercise_name.replace("-", "_")
28-
exercise_dir = cur_path / exercise_dir_name
29-
os.makedirs(exercise_dir)
30-
with open(exercise_dir / ".gitmastery-exercise.json", "w") as exercise_config_file:
31-
exercise_config = {
32-
"exercise_name": exercise_name,
33-
"tags": tags,
34-
"is_downloadable": True,
35-
"requires_repo": requires_repo,
36-
"requires_github": requires_github,
37-
"resources": {},
38-
}
39-
exercise_config_str = json.dumps(exercise_config, indent=2)
40-
exercise_config_file.write(exercise_config_str)
41-
42-
with open(exercise_dir / "README.md", "w") as readme_file:
88+
89+
def create_exercise_config_file(config: ExerciseConfig) -> None:
90+
with open(".gitmastery-exercise.json", "w") as exercise_config_file:
91+
exercise_config_file.write(config.to_json())
92+
93+
94+
def create_readme_file(config: ExerciseConfig) -> None:
95+
with open("README.md", "w") as readme_file:
4396
readme = f"""
44-
# {exercise_name}
97+
# {config.exercise_name}
4598
4699
<!--- Insert exercise description -->
47100
@@ -52,7 +105,7 @@ def main():
52105
## Hints
53106
54107
<!--- Insert hints here -->
55-
<!---
108+
<!---
56109
Use Github Markdown's collapsible content:
57110
<details>
58111
<summary>...</summary>
@@ -62,8 +115,10 @@ def main():
62115
"""
63116
readme_file.write(textwrap.dedent(readme).lstrip())
64117

118+
119+
def create_download_py_file() -> None:
65120
# TODO: conditionally add the git tagging only when requires_repo is True
66-
with open(exercise_dir / "download.py", "w") as download_script_file:
121+
with open("download.py", "w") as download_script_file:
67122
download_script = """
68123
import subprocess
69124
from sys import exit
@@ -100,9 +155,9 @@ def setup(verbose: bool = False):
100155
"""
101156
download_script_file.write(textwrap.dedent(download_script).lstrip())
102157

103-
os.makedirs(exercise_dir / "res", exist_ok=True)
104158

105-
with open(exercise_dir / "verify.py", "w") as verify_script_file:
159+
def create_verify_py_file() -> None:
160+
with open("verify.py", "w") as verify_script_file:
106161
verify_script = """
107162
from typing import List
108163
@@ -118,19 +173,25 @@ def verify(repo: GitAutograderRepo) -> GitAutograderOutput:
118173
"""
119174
verify_script_file.write(textwrap.dedent(verify_script).lstrip())
120175

121-
open(exercise_dir / "__init__.py", "a").close()
122176

123-
tests_dir = exercise_dir / "tests"
177+
def create_init_py_file() -> None:
178+
open("__init__.py", "a").close()
179+
180+
181+
def create_test_dir(config: ExerciseConfig) -> None:
182+
tests_dir = "tests"
124183
os.makedirs(tests_dir, exist_ok=True)
125-
open(tests_dir / "__init__.py", "a").close()
184+
os.chdir(tests_dir)
185+
186+
create_init_py_file()
126187

127-
with open(tests_dir / "test_verify.py", "w") as test_grade_file:
188+
with open("test_verify.py", "w") as test_grade_file:
128189
test_grade = f"""
129190
from git_autograder import GitAutograderTestLoader
130191
131192
from ..verify import verify
132193
133-
REPOSITORY_NAME = "{exercise_name}"
194+
REPOSITORY_NAME = "{config.exercise_name}"
134195
135196
loader = GitAutograderTestLoader(__file__, REPOSITORY_NAME, verify)
136197
@@ -141,8 +202,8 @@ def test():
141202
"""
142203
test_grade_file.write(textwrap.dedent(test_grade).lstrip())
143204

144-
os.makedirs(tests_dir / "specs", exist_ok=True)
145-
with open(tests_dir / "specs" / "base.yml", "w") as base_spec_file:
205+
os.makedirs("specs", exist_ok=True)
206+
with open("specs/base.yml", "w") as base_spec_file:
146207
base_spec = """
147208
initialization:
148209
steps:
@@ -154,5 +215,18 @@ def test():
154215
base_spec_file.write(textwrap.dedent(base_spec).lstrip())
155216

156217

218+
def main():
219+
config = get_exercise_config()
220+
os.makedirs(config.exercise_dir)
221+
os.chdir(config.exercise_dir)
222+
223+
os.makedirs("res", exist_ok=True)
224+
create_exercise_config_file(config)
225+
create_readme_file(config)
226+
create_download_py_file()
227+
create_verify_py_file()
228+
create_test_dir(config)
229+
230+
157231
if __name__ == "__main__":
158232
main()

validate-exercise-config.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Script to verify that all exercise configurations are compliant with the expected format
2+
import json
3+
import os
4+
import pathlib
5+
import subprocess
6+
import sys
7+
from dataclasses import dataclass
8+
from typing import List
9+
10+
# List of exercises to exempt, maybe because these have not been updated or are deprecated exercises
11+
EXEMPTION_LIST = {
12+
"grocery_shopping",
13+
"ignoring_somethings",
14+
"link_me",
15+
"nothing_to_hide",
16+
"push_over",
17+
"remote_control",
18+
"stage_fright",
19+
"under_control",
20+
}
21+
22+
23+
@dataclass
24+
class ValidationIssue:
25+
dir_name: str
26+
issue: str
27+
28+
29+
def main() -> None:
30+
issues: List[ValidationIssue] = []
31+
for dir in os.listdir("."):
32+
if dir in EXEMPTION_LIST or not os.path.isfile(
33+
pathlib.Path(dir) / ".gitmastery-exercise.json"
34+
):
35+
continue
36+
config = {}
37+
with open(pathlib.Path(dir) / ".gitmastery-exercise.json", "r") as config_file:
38+
config = json.loads(config_file.read())
39+
40+
if config["exercise_name"].strip() == "":
41+
issues.append(
42+
ValidationIssue(dir, "Empty exercise_name is not permitted")
43+
)
44+
45+
if config.get("exercise_repo", {}).get(
46+
"repo_type", "local"
47+
) == "remote" and not config.get("requires_github", False):
48+
issues.append(
49+
ValidationIssue(
50+
dir,
51+
"Cannot use 'remote' repo_type if require_github is disabled",
52+
)
53+
)
54+
55+
if (
56+
config.get("exercise_repo", {}).get("repo_type", "local") == "local"
57+
and not config.get("requires_git", False)
58+
and config.get("exercise_repo", {}).get("init", False)
59+
):
60+
issues.append(
61+
ValidationIssue(
62+
dir,
63+
"Cannot use 'local' repo_type with init: true if require_git is disabled",
64+
)
65+
)
66+
67+
if (
68+
config.get("exercise_repo", {}).get("repo_type", "local") == "remote"
69+
and subprocess.call(
70+
[
71+
"git",
72+
"ls-remote",
73+
f"https://github.com/git-mastery/{config['exercise_repo']['repo_title']}",
74+
"--quiet",
75+
]
76+
)
77+
!= 0
78+
):
79+
issues.append(
80+
ValidationIssue(
81+
dir, "Missing Github repository to fetch for remote exercise"
82+
)
83+
)
84+
85+
for file in config["base_files"].keys():
86+
if not os.path.isfile(pathlib.Path(dir) / "res" / file):
87+
issues.append(
88+
ValidationIssue(dir, f"Missing file {file} from res/")
89+
)
90+
91+
if len(issues) > 0:
92+
for issue in issues:
93+
print(f"- {issue.dir_name}: {issue.issue}")
94+
sys.exit(1)
95+
96+
97+
if __name__ == "__main__":
98+
main()

0 commit comments

Comments
 (0)