making-with-code-cli 5.4.0__py3-none-any.whl → 5.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- making_with_code_cli/assessment.py +173 -0
- making_with_code_cli/cli.py +2 -0
- making_with_code_cli/git_backend/mwc_backend.py +95 -27
- making_with_code_cli/git_wrapper.py +50 -8
- making_with_code_cli/setup/__init__.py +4 -0
- making_with_code_cli/setup/tasks.py +39 -3
- making_with_code_cli/status.py +156 -0
- making_with_code_cli/submit.py +83 -26
- making_with_code_cli/teach/status.py +59 -28
- making_with_code_cli/teach/student_repo_functions.py +79 -3
- making_with_code_cli/update/__init__.py +16 -4
- {making_with_code_cli-5.4.0.dist-info → making_with_code_cli-5.6.0.dist-info}/METADATA +1 -1
- {making_with_code_cli-5.4.0.dist-info → making_with_code_cli-5.6.0.dist-info}/RECORD +15 -13
- {making_with_code_cli-5.4.0.dist-info → making_with_code_cli-5.6.0.dist-info}/WHEEL +0 -0
- {making_with_code_cli-5.4.0.dist-info → making_with_code_cli-5.6.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Shared spec and validation for assessment.md.
|
|
2
|
+
|
|
3
|
+
A student module repo may contain `assessment.md`, written by a teacher (by hand or
|
|
4
|
+
with a script -- assessment *tooling* is being designed and built separately from this
|
|
5
|
+
spec) to record how the module was assessed. The file has TOML front matter (delimited
|
|
6
|
+
with `+++`, matching the convention MWC's own site uses for module front matter)
|
|
7
|
+
holding the *current* assessment -- evergreen, not a history. Re-assessment overwrites
|
|
8
|
+
the front matter in place; the running history of what changed and why is written by
|
|
9
|
+
the teacher as prose in the file's body (a letter to the student, in the style of the
|
|
10
|
+
assessment-rubric skill), not as structured data.
|
|
11
|
+
|
|
12
|
+
This module is the single place that knows the shape of that front matter, so that
|
|
13
|
+
every reader and writer of `assessment.md` -- `mwc status` (student-facing),
|
|
14
|
+
`mwc teach status` (teacher-facing), any future assessment tooling -- agrees on it and
|
|
15
|
+
validates against the same rules, rather than each parsing it their own way.
|
|
16
|
+
|
|
17
|
+
The front matter always has all four keys:
|
|
18
|
+
- `assessment_type`: `"completion"` or `"score"`
|
|
19
|
+
- `timestamp`: when the current assessment happened (authoritative for "how recent is
|
|
20
|
+
this assessment" -- deliberately the TOML value, not any git commit metadata, so
|
|
21
|
+
everything relevant stays in-band in the file itself)
|
|
22
|
+
- `score`: an integer, `0 <= score <= points_possible`
|
|
23
|
+
- `points_possible`: an integer; always `1` for `"completion"` (so score is `0` or `1`)
|
|
24
|
+
|
|
25
|
+
Rubric-based assessment doesn't get its own structured type -- the rubric detail
|
|
26
|
+
(per-criterion feedback) lives in the prose body, and the front matter just records
|
|
27
|
+
a summary `"score"` that downstream consumers can turn into a grade.
|
|
28
|
+
|
|
29
|
+
+++
|
|
30
|
+
assessment_type = "score"
|
|
31
|
+
timestamp = 2026-03-15T09:30:00Z
|
|
32
|
+
score = 8
|
|
33
|
+
points_possible = 10
|
|
34
|
+
+++
|
|
35
|
+
|
|
36
|
+
Dear student, ...
|
|
37
|
+
"""
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from datetime import datetime
|
|
40
|
+
from numbers import Real
|
|
41
|
+
|
|
42
|
+
import tomlkit
|
|
43
|
+
from tomlkit.exceptions import TOMLKitError
|
|
44
|
+
|
|
45
|
+
from making_with_code_cli.errors import MWCError
|
|
46
|
+
|
|
47
|
+
FRONT_MATTER_DELIMITER = "+++"
|
|
48
|
+
ASSESSMENT_TYPES = ("completion", "score")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AssessmentValidationError(MWCError):
|
|
52
|
+
"""Raised when assessment.md front matter doesn't conform to the spec."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Assessment:
|
|
57
|
+
"A module's current assessment, evergreen (overwritten on re-assessment)."
|
|
58
|
+
assessment_type: str # "completion" | "score"
|
|
59
|
+
timestamp: datetime
|
|
60
|
+
score: int
|
|
61
|
+
points_possible: int
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def extract_front_matter(markdown_text):
|
|
65
|
+
"""Splits a +++...+++-delimited file into (front_matter_toml, body_markdown).
|
|
66
|
+
Returns ("", markdown_text) if there's no front matter at all -- a module that
|
|
67
|
+
hasn't been assessed yet may have no assessment.md, or an assessment.md with no
|
|
68
|
+
front matter yet, and that should parse as "not assessed," not an error.
|
|
69
|
+
"""
|
|
70
|
+
lines = markdown_text.splitlines(keepends=True)
|
|
71
|
+
if not lines or lines[0].strip() != FRONT_MATTER_DELIMITER:
|
|
72
|
+
return "", markdown_text
|
|
73
|
+
for i, line in enumerate(lines[1:], start=1):
|
|
74
|
+
if line.strip() == FRONT_MATTER_DELIMITER:
|
|
75
|
+
return "".join(lines[1:i]), "".join(lines[i + 1:])
|
|
76
|
+
# Opening delimiter with no closing delimiter -- treat as malformed, not fatal;
|
|
77
|
+
# callers just get "not assessed" back rather than a crash.
|
|
78
|
+
return "", markdown_text
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse_assessment(toml_text):
|
|
82
|
+
"""Parses assessment.md's front matter into an Assessment, or None if it's
|
|
83
|
+
empty/missing `assessment_type` (not assessed yet -- a valid, unremarkable
|
|
84
|
+
state, not an error). Raises AssessmentValidationError if `assessment_type`
|
|
85
|
+
is present but the rest of the data doesn't conform to the spec.
|
|
86
|
+
"""
|
|
87
|
+
if not toml_text.strip():
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
doc = tomlkit.parse(toml_text)
|
|
91
|
+
except TOMLKitError as e:
|
|
92
|
+
raise AssessmentValidationError(f"assessment.md front matter is not valid TOML: {e}")
|
|
93
|
+
|
|
94
|
+
if "assessment_type" not in doc:
|
|
95
|
+
return None
|
|
96
|
+
return _parse_assessment_table(doc)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_assessment_file(markdown_text):
|
|
100
|
+
"""extract_front_matter + parse_assessment, for reading a whole assessment.md
|
|
101
|
+
file (its text content, not a path -- callers decide whether that text comes
|
|
102
|
+
from the working tree or `git show <ref>:assessment.md`)."""
|
|
103
|
+
front_matter, _ = extract_front_matter(markdown_text)
|
|
104
|
+
return parse_assessment(front_matter)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def format_score(assessment):
|
|
108
|
+
"""Compact single-line rendering of an assessment's score, for table display.
|
|
109
|
+
ASCII only (no unicode symbols), matching mwc teach status's convention.
|
|
110
|
+
"""
|
|
111
|
+
if assessment.assessment_type == "completion":
|
|
112
|
+
return "Y" if assessment.score else "N"
|
|
113
|
+
return f"{assessment.score}/{assessment.points_possible}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def set_assessment(markdown_text, assessment):
|
|
117
|
+
"""Returns markdown_text with its front matter replaced by `assessment`
|
|
118
|
+
(creating the front matter if markdown_text has none). The body is left
|
|
119
|
+
untouched -- the teacher writes their own log of the (re-)assessment there;
|
|
120
|
+
this function only ever owns the evergreen front matter, never the prose.
|
|
121
|
+
Uses tomlkit so any other keys already in the front matter are preserved.
|
|
122
|
+
"""
|
|
123
|
+
front_matter, body = extract_front_matter(markdown_text)
|
|
124
|
+
doc = tomlkit.parse(front_matter) if front_matter.strip() else tomlkit.document()
|
|
125
|
+
doc["assessment_type"] = assessment.assessment_type
|
|
126
|
+
doc["timestamp"] = assessment.timestamp
|
|
127
|
+
doc["score"] = assessment.score
|
|
128
|
+
doc["points_possible"] = assessment.points_possible
|
|
129
|
+
new_front_matter = tomlkit.dumps(doc)
|
|
130
|
+
return f"{FRONT_MATTER_DELIMITER}\n{new_front_matter}{FRONT_MATTER_DELIMITER}\n{body}"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _parse_assessment_table(raw):
|
|
134
|
+
"Validates and converts a raw TOML mapping into an Assessment."
|
|
135
|
+
assessment_type = raw.get("assessment_type")
|
|
136
|
+
if assessment_type not in ASSESSMENT_TYPES:
|
|
137
|
+
raise AssessmentValidationError(
|
|
138
|
+
f"assessment_type must be one of {ASSESSMENT_TYPES}, got {assessment_type!r}"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
timestamp = raw.get("timestamp")
|
|
142
|
+
if not isinstance(timestamp, datetime):
|
|
143
|
+
raise AssessmentValidationError("timestamp is required and must be a date/time")
|
|
144
|
+
|
|
145
|
+
if "score" not in raw:
|
|
146
|
+
raise AssessmentValidationError("score is required")
|
|
147
|
+
score = raw["score"]
|
|
148
|
+
if isinstance(score, bool) or not isinstance(score, Real):
|
|
149
|
+
raise AssessmentValidationError(f"score must be a number, got {score!r}")
|
|
150
|
+
|
|
151
|
+
if "points_possible" not in raw:
|
|
152
|
+
raise AssessmentValidationError("points_possible is required")
|
|
153
|
+
points_possible = raw["points_possible"]
|
|
154
|
+
if isinstance(points_possible, bool) or not isinstance(points_possible, Real):
|
|
155
|
+
raise AssessmentValidationError(f"points_possible must be a number, got {points_possible!r}")
|
|
156
|
+
if points_possible <= 0:
|
|
157
|
+
raise AssessmentValidationError("points_possible must be positive")
|
|
158
|
+
|
|
159
|
+
if assessment_type == "completion" and points_possible != 1:
|
|
160
|
+
raise AssessmentValidationError(
|
|
161
|
+
f"points_possible must be 1 for a completion assessment, got {points_possible!r}"
|
|
162
|
+
)
|
|
163
|
+
if not (0 <= score <= points_possible):
|
|
164
|
+
raise AssessmentValidationError(
|
|
165
|
+
f"score ({score}) must be between 0 and points_possible ({points_possible})"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return Assessment(
|
|
169
|
+
assessment_type=assessment_type,
|
|
170
|
+
timestamp=timestamp,
|
|
171
|
+
score=score,
|
|
172
|
+
points_possible=points_possible,
|
|
173
|
+
)
|
making_with_code_cli/cli.py
CHANGED
|
@@ -3,6 +3,7 @@ from making_with_code_cli.version import version
|
|
|
3
3
|
from making_with_code_cli.setup import setup
|
|
4
4
|
from making_with_code_cli.update import update
|
|
5
5
|
from making_with_code_cli.submit import submit
|
|
6
|
+
from making_with_code_cli.status import status
|
|
6
7
|
from making_with_code_cli.teach import teach
|
|
7
8
|
|
|
8
9
|
@click.group()
|
|
@@ -13,4 +14,5 @@ cli.add_command(version)
|
|
|
13
14
|
cli.add_command(setup)
|
|
14
15
|
cli.add_command(update)
|
|
15
16
|
cli.add_command(submit)
|
|
17
|
+
cli.add_command(status)
|
|
16
18
|
cli.add_command(teach)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from .base_backend import GitBackend
|
|
2
2
|
from subprocess import run, CalledProcessError
|
|
3
|
+
import re
|
|
3
4
|
import traceback
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from urllib.parse import urlparse
|
|
@@ -111,13 +112,22 @@ class MWCBackend(GitBackend):
|
|
|
111
112
|
run(["git", "config", "--local", "user.name", name], check=True, cwd=modpath)
|
|
112
113
|
run(["git", "config", "--local", "user.email", "nobody@makingwithcode.org"],
|
|
113
114
|
check=True, cwd=modpath)
|
|
115
|
+
# EDITOR_COMMANDS covers the GUI editors that need an explicit "wait for
|
|
116
|
+
# the tab to close" flag. Anything else the student typed at setup was
|
|
117
|
+
# already verified to resolve on $PATH (see choose_editor), so pass it
|
|
118
|
+
# through as-is -- students should be able to use editors we don't know
|
|
119
|
+
# about by name. Only fall back to nano if editor is somehow unset.
|
|
114
120
|
editor = self.settings.get('editor')
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
121
|
+
editor_command = EDITOR_COMMANDS.get(editor, editor) or "nano"
|
|
122
|
+
run(["git", "config", "--local", "core.editor", editor_command],
|
|
123
|
+
check=True, cwd=modpath)
|
|
118
124
|
|
|
119
125
|
def update(self, module, modpath, install=True):
|
|
120
126
|
if (modpath / ".git").is_dir():
|
|
127
|
+
# Idempotent -- re-applies user/editor config so repos created
|
|
128
|
+
# before this config existed (or with a stale editor choice)
|
|
129
|
+
# still get it.
|
|
130
|
+
self.configure_git(modpath)
|
|
121
131
|
relpath = self.relative_path(modpath)
|
|
122
132
|
click.echo(address(f"Checking {relpath} for updates.", preformatted=True))
|
|
123
133
|
pull_output = self.safe_pull(modpath)
|
|
@@ -144,44 +154,57 @@ class MWCBackend(GitBackend):
|
|
|
144
154
|
f"Cleared an incomplete git {aborted} — this shouldn't affect your work."
|
|
145
155
|
))
|
|
146
156
|
|
|
147
|
-
|
|
148
|
-
if
|
|
157
|
+
recovered = recover_detached_head(modpath)
|
|
158
|
+
if recovered:
|
|
159
|
+
branch, stash_unresolved = recovered
|
|
149
160
|
click.echo(confirm(
|
|
150
161
|
f"Your repo was not on any branch. Switched back to '{branch}'."
|
|
151
162
|
))
|
|
163
|
+
if stash_unresolved:
|
|
164
|
+
click.echo(error(
|
|
165
|
+
"Some of your uncommitted work couldn't be automatically restored "
|
|
166
|
+
"and is saved in a git stash instead of your files. "
|
|
167
|
+
"Ask your teacher for help recovering it."
|
|
168
|
+
))
|
|
152
169
|
|
|
153
170
|
clear_stale_index_lock(modpath)
|
|
154
171
|
|
|
155
|
-
|
|
172
|
+
run("git fetch origin", shell=True, capture_output=True, cwd=modpath)
|
|
173
|
+
|
|
174
|
+
# Detect (without applying) any genuine content conflicts between
|
|
175
|
+
# FETCH_HEAD and the repo's current state -- both the working tree
|
|
176
|
+
# (if dirty) and the student's committed history. -X ours/-X theirs
|
|
177
|
+
# auto-resolve conflicts silently, so this is the only way to know
|
|
178
|
+
# when that's about to happen.
|
|
179
|
+
conflicted = self._conflicting_files(modpath, "FETCH_HEAD")
|
|
180
|
+
|
|
181
|
+
# U1: uncommitted changes that would genuinely conflict with the
|
|
182
|
+
# update — cancel and have the student save their own work first,
|
|
183
|
+
# rather than guessing at a commit message on their behalf. Handling
|
|
184
|
+
# one thing at a time (submit, then update) is clearer for students
|
|
185
|
+
# than a merge that silently combines both, and avoids a commit
|
|
186
|
+
# showing up in their history that they didn't write.
|
|
156
187
|
status = run(
|
|
157
188
|
"git status --porcelain",
|
|
158
189
|
shell=True, capture_output=True, text=True, cwd=modpath
|
|
159
190
|
)
|
|
160
|
-
if status.stdout.strip():
|
|
161
|
-
|
|
162
|
-
"
|
|
163
|
-
|
|
191
|
+
if status.stdout.strip() and conflicted:
|
|
192
|
+
raise GitError(
|
|
193
|
+
"You have unsaved changes here that would conflict with an "
|
|
194
|
+
"update. Run `mwc submit` to save your work, then run "
|
|
195
|
+
"`mwc update` again."
|
|
164
196
|
)
|
|
165
|
-
merge_check = run(
|
|
166
|
-
"git merge --no-commit --no-ff FETCH_HEAD",
|
|
167
|
-
shell=True, capture_output=True, text=True, cwd=modpath
|
|
168
|
-
)
|
|
169
|
-
# Always abort the probe merge so we start clean
|
|
170
|
-
run("git merge --abort", shell=True, capture_output=True, cwd=modpath)
|
|
171
|
-
if "CONFLICT" in merge_check.stdout or "CONFLICT" in merge_check.stderr:
|
|
172
|
-
run(["git", "add", "--all"], capture_output=True, cwd=modpath)
|
|
173
|
-
run(
|
|
174
|
-
["git", "commit", "-m", "[auto-saved before update]"],
|
|
175
|
-
capture_output=True, cwd=modpath
|
|
176
|
-
)
|
|
177
|
-
click.echo(confirm(
|
|
178
|
-
"Your unsaved changes were auto-saved in git history "
|
|
179
|
-
"before applying the update."
|
|
180
|
-
))
|
|
181
197
|
|
|
182
198
|
result = self._run_pull(modpath)
|
|
183
199
|
|
|
184
200
|
if result.returncode == 0:
|
|
201
|
+
if conflicted:
|
|
202
|
+
files = ", ".join(f"`{f}`" for f in conflicted)
|
|
203
|
+
click.echo(error(
|
|
204
|
+
f"Your changes to {files} conflicted with an update — the "
|
|
205
|
+
"update's version was kept for the conflicting lines. Ask "
|
|
206
|
+
"your teacher if you're not sure that's right."
|
|
207
|
+
))
|
|
185
208
|
return result.stdout
|
|
186
209
|
|
|
187
210
|
combined = result.stderr + result.stdout
|
|
@@ -209,6 +232,43 @@ class MWCBackend(GitBackend):
|
|
|
209
232
|
shell=True, capture_output=True, text=True, cwd=modpath
|
|
210
233
|
)
|
|
211
234
|
|
|
235
|
+
def _conflicting_files(self, modpath, ref):
|
|
236
|
+
"""Returns the list of files that can't be cleanly auto-merged when
|
|
237
|
+
merging `ref` into HEAD, without actually applying or committing
|
|
238
|
+
anything. -X ours/-X theirs auto-resolve conflicts and don't report
|
|
239
|
+
them, so this is the only way to tell whether an automatic merge is
|
|
240
|
+
about to silently pick a side on real conflicting content (as opposed
|
|
241
|
+
to just cleanly combining separate, non-overlapping changes).
|
|
242
|
+
|
|
243
|
+
Covers two distinct git failure modes: real textual conflicts
|
|
244
|
+
(reported as "CONFLICT", with the files showing up in
|
|
245
|
+
`git diff --diff-filter=U`), and a dirty working tree touching the
|
|
246
|
+
same file as the incoming change (git refuses outright with "local
|
|
247
|
+
changes ... would be overwritten by merge" and never starts a merge
|
|
248
|
+
at all, so there's nothing in the index to look up -- the filenames
|
|
249
|
+
have to be parsed out of that message instead).
|
|
250
|
+
"""
|
|
251
|
+
merge_check = run(
|
|
252
|
+
f"git merge --no-commit --no-ff {ref}",
|
|
253
|
+
shell=True, capture_output=True, text=True, cwd=modpath
|
|
254
|
+
)
|
|
255
|
+
conflicts = []
|
|
256
|
+
if merge_check.returncode != 0:
|
|
257
|
+
diff = run(
|
|
258
|
+
"git diff --name-only --diff-filter=U",
|
|
259
|
+
shell=True, capture_output=True, text=True, cwd=modpath
|
|
260
|
+
)
|
|
261
|
+
conflicts = [f for f in diff.stdout.splitlines() if f.strip()]
|
|
262
|
+
if not conflicts:
|
|
263
|
+
combined = merge_check.stdout + merge_check.stderr
|
|
264
|
+
conflicts = re.findall(r"^\t(.+)$", combined, re.MULTILINE)
|
|
265
|
+
# Always abort the probe merge so we start clean. This is a no-op
|
|
266
|
+
# (and prints a harmless "no merge to abort" to stderr, which we
|
|
267
|
+
# discard) in the "would be overwritten" case, since no merge ever
|
|
268
|
+
# started there.
|
|
269
|
+
run("git merge --abort", shell=True, capture_output=True, cwd=modpath)
|
|
270
|
+
return conflicts
|
|
271
|
+
|
|
212
272
|
def _raise_pull_error(self, stderr):
|
|
213
273
|
if any(p in stderr for p in _NETWORK_PHRASES):
|
|
214
274
|
raise GitNetworkError(
|
|
@@ -233,7 +293,15 @@ class MWCBackend(GitBackend):
|
|
|
233
293
|
def init_direnv(self, modpath):
|
|
234
294
|
if not (modpath / ".envrc").exists():
|
|
235
295
|
(modpath / ".envrc").write_text("source .venv/bin/activate")
|
|
236
|
-
|
|
296
|
+
try:
|
|
297
|
+
run("direnv allow", shell=True, check=True, cwd=modpath, capture_output=True)
|
|
298
|
+
except CalledProcessError:
|
|
299
|
+
raise GitError(
|
|
300
|
+
"Could not run `direnv allow` -- direnv doesn't seem to be installed "
|
|
301
|
+
"or isn't on your PATH. Try closing and reopening your terminal (so it "
|
|
302
|
+
"picks up changes from setup), then run `mwc update` again. If that "
|
|
303
|
+
"doesn't help, run `mwc setup` again or ask your teacher for help."
|
|
304
|
+
)
|
|
237
305
|
|
|
238
306
|
def user_has_repo(self, repo_name, username=None):
|
|
239
307
|
"""Checks to see whether a user already has the named repo."""
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
from datetime import datetime
|
|
1
2
|
from pathlib import Path
|
|
2
3
|
from subprocess import run, CalledProcessError
|
|
3
4
|
from making_with_code_cli.helpers import cd
|
|
4
5
|
|
|
6
|
+
# Field separator for git log --format parsing below -- chosen because it can't
|
|
7
|
+
# appear in an author name/email, unlike "|" or ",".
|
|
8
|
+
_FIELD_SEP = "\x1f"
|
|
9
|
+
|
|
5
10
|
|
|
6
11
|
def in_repo():
|
|
7
12
|
"""Checks whether currently in repo"""
|
|
@@ -12,14 +17,14 @@ def in_repo():
|
|
|
12
17
|
return False
|
|
13
18
|
|
|
14
19
|
|
|
15
|
-
def repo_has_changes():
|
|
16
|
-
return len(changed_files()) > 0
|
|
20
|
+
def repo_has_changes(cwd=None):
|
|
21
|
+
return len(changed_files(cwd=cwd)) > 0
|
|
17
22
|
|
|
18
23
|
|
|
19
|
-
def changed_files():
|
|
24
|
+
def changed_files(cwd=None):
|
|
20
25
|
"Returns a list of (status, filename) tuples for changed files"
|
|
21
26
|
cmd = "git status --porcelain"
|
|
22
|
-
result = run(cmd, shell=True, capture_output=True, text=True).stdout
|
|
27
|
+
result = run(cmd, shell=True, capture_output=True, text=True, cwd=cwd).stdout
|
|
23
28
|
files = []
|
|
24
29
|
if result:
|
|
25
30
|
for line in result.split('\n'):
|
|
@@ -76,7 +81,13 @@ def abort_in_progress_operation(cwd=None):
|
|
|
76
81
|
|
|
77
82
|
def recover_detached_head(cwd=None):
|
|
78
83
|
"""If in detached HEAD, checks out the default branch and returns its name.
|
|
79
|
-
Returns None if no action was needed
|
|
84
|
+
Returns None if no action was needed, otherwise (branch, stash_unresolved),
|
|
85
|
+
where stash_unresolved is True if uncommitted work couldn't be automatically
|
|
86
|
+
restored and was left in the stash rather than being discarded. Detached HEAD
|
|
87
|
+
is never something a student does on purpose (they never use branches) --
|
|
88
|
+
it comes from pasting a git command found online, or a GUI tool's "checkout
|
|
89
|
+
this commit" action, so there's no real "their branch" to speak of, just the
|
|
90
|
+
one branch every student repo has.
|
|
80
91
|
"""
|
|
81
92
|
if get_repo_state(cwd) != "detached":
|
|
82
93
|
return None
|
|
@@ -87,14 +98,22 @@ def recover_detached_head(cwd=None):
|
|
|
87
98
|
)
|
|
88
99
|
had_stash = "No local changes to save" not in stash_result.stdout
|
|
89
100
|
run(["git", "checkout", branch], capture_output=True, cwd=cwd)
|
|
101
|
+
stash_unresolved = False
|
|
90
102
|
if had_stash:
|
|
91
103
|
pop_result = run(
|
|
92
104
|
"git stash pop", shell=True, capture_output=True, text=True, cwd=cwd
|
|
93
105
|
)
|
|
94
|
-
# If pop conflicts
|
|
106
|
+
# If pop conflicts, leave the stash in place rather than dropping it --
|
|
107
|
+
# dropping it would silently and permanently destroy the student's
|
|
108
|
+
# uncommitted work with no way to recover it. A failed pop also
|
|
109
|
+
# leaves raw conflict markers in the working tree (git leaking
|
|
110
|
+
# through as confusing, not just destructive), so reset the tree back
|
|
111
|
+
# to a clean state; the stash is still intact and is the record of
|
|
112
|
+
# what didn't come back automatically.
|
|
95
113
|
if pop_result.returncode != 0:
|
|
96
|
-
|
|
97
|
-
|
|
114
|
+
stash_unresolved = True
|
|
115
|
+
run(["git", "reset", "--hard"], capture_output=True, cwd=cwd)
|
|
116
|
+
return (branch, stash_unresolved)
|
|
98
117
|
|
|
99
118
|
|
|
100
119
|
def clear_stale_index_lock(cwd=None):
|
|
@@ -106,6 +125,29 @@ def clear_stale_index_lock(cwd=None):
|
|
|
106
125
|
return False
|
|
107
126
|
|
|
108
127
|
|
|
128
|
+
def commits_since(since, author_name, author_email, ref="HEAD", cwd=None):
|
|
129
|
+
"""Returns True if there's a commit on `ref`, authored by exactly
|
|
130
|
+
(author_name, author_email), with an author date after `since` (a
|
|
131
|
+
timezone-aware datetime). Used to detect work submitted by a specific
|
|
132
|
+
student after a given cutoff -- e.g. "has anything been pushed since this
|
|
133
|
+
was last assessed" -- without caring about commits from anyone else
|
|
134
|
+
(a teacher, a co-author, etc.) that might also be on the same ref.
|
|
135
|
+
"""
|
|
136
|
+
result = run(
|
|
137
|
+
f"git log {ref} --format=%an{_FIELD_SEP}%ae{_FIELD_SEP}%aI",
|
|
138
|
+
shell=True, capture_output=True, text=True, cwd=cwd
|
|
139
|
+
)
|
|
140
|
+
if result.returncode != 0:
|
|
141
|
+
return False
|
|
142
|
+
for line in result.stdout.strip().splitlines():
|
|
143
|
+
name, _, rest = line.partition(_FIELD_SEP)
|
|
144
|
+
email, _, when = rest.partition(_FIELD_SEP)
|
|
145
|
+
if name == author_name and email == author_email:
|
|
146
|
+
if datetime.fromisoformat(when) > since:
|
|
147
|
+
return True
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
|
|
109
151
|
def _get_default_branch(cwd=None):
|
|
110
152
|
"""Returns the remote default branch name, falling back to 'main'."""
|
|
111
153
|
result = run(
|
|
@@ -39,9 +39,11 @@ from making_with_code_cli.setup.tasks import (
|
|
|
39
39
|
InstallImageMagick,
|
|
40
40
|
InstallHttpie,
|
|
41
41
|
InstallScipy,
|
|
42
|
+
InstallZstd,
|
|
42
43
|
InstallOllama,
|
|
43
44
|
InstallPandoc,
|
|
44
45
|
InstallPoppler,
|
|
46
|
+
InstallPythonBuildDeps,
|
|
45
47
|
)
|
|
46
48
|
|
|
47
49
|
@click.command()
|
|
@@ -109,9 +111,11 @@ def setup(ctx, config, debug, mwc_accounts_url):
|
|
|
109
111
|
InstallVSCode,
|
|
110
112
|
InstallImageMagick,
|
|
111
113
|
InstallHttpie,
|
|
114
|
+
InstallZstd,
|
|
112
115
|
InstallOllama,
|
|
113
116
|
InstallPandoc,
|
|
114
117
|
InstallPoppler,
|
|
118
|
+
InstallPythonBuildDeps,
|
|
115
119
|
]
|
|
116
120
|
errors = []
|
|
117
121
|
for task_class in task_classes:
|
|
@@ -84,7 +84,7 @@ class Platform(Flag):
|
|
|
84
84
|
else:
|
|
85
85
|
return "brew install "
|
|
86
86
|
elif p in (cls.UBUNTU, cls.WSL):
|
|
87
|
-
return "sudo apt install "
|
|
87
|
+
return "sudo apt install -y "
|
|
88
88
|
else:
|
|
89
89
|
raise PlatformNotSupported()
|
|
90
90
|
|
|
@@ -262,8 +262,10 @@ class WriteMWCShellConfig(SetupTask):
|
|
|
262
262
|
"Generates the shell configuration file contents"
|
|
263
263
|
f = ""
|
|
264
264
|
shell = get_shell_name()
|
|
265
|
-
f += "# Making With Code RC File\n"
|
|
265
|
+
f += "# Making With Code RC File\n"
|
|
266
266
|
f += f"# See {self.docs_url}\n\n"
|
|
267
|
+
f += "## Make sure user-local installs (e.g. direnv) are on $PATH\n"
|
|
268
|
+
f += 'export PATH="$HOME/.local/bin:$PATH"\n\n'
|
|
267
269
|
f += "## Hook direnv into shell\n"
|
|
268
270
|
f += f'eval "$(direnv hook {shell})"\n\n'
|
|
269
271
|
|
|
@@ -388,7 +390,15 @@ class InstallPackageFromInstaller(InstallPackage):
|
|
|
388
390
|
|
|
389
391
|
class InstallDirenv(InstallPackageFromInstaller):
|
|
390
392
|
executable_name = "direnv"
|
|
391
|
-
|
|
393
|
+
# direnv's installer defaults to dropping the binary in "./bin", relative
|
|
394
|
+
# to whatever directory this happens to be run from, which usually isn't
|
|
395
|
+
# on $PATH. Pin bin_dir to ~/.local/bin, which WriteMWCShellConfig adds
|
|
396
|
+
# to $PATH, so the installed binary is reliably found afterward.
|
|
397
|
+
install_command = 'curl -sfL https://direnv.net/install.sh | bin_dir="$HOME/.local/bin" bash'
|
|
398
|
+
|
|
399
|
+
def run_task(self):
|
|
400
|
+
Path.home().joinpath(".local", "bin").mkdir(parents=True, exist_ok=True)
|
|
401
|
+
super().run_task()
|
|
392
402
|
|
|
393
403
|
class InstallGit(InstallPackage):
|
|
394
404
|
platform = Platform.MAC | Platform.UBUNTU
|
|
@@ -444,6 +454,14 @@ class InstallScipy(InstallPackage):
|
|
|
444
454
|
def is_complete(self):
|
|
445
455
|
return find_spec("scipy") is not None
|
|
446
456
|
|
|
457
|
+
class InstallZstd(InstallPackage):
|
|
458
|
+
"""zstd is a prerequisite for ollama's Linux install script, which
|
|
459
|
+
extracts a .tar.zst bundle. Not needed on Mac (ollama ships as a .zip
|
|
460
|
+
there, and the curl|sh installer isn't used).
|
|
461
|
+
"""
|
|
462
|
+
platform = Platform.UBUNTU | Platform.WSL
|
|
463
|
+
executable_name = apt_name = "zstd"
|
|
464
|
+
|
|
447
465
|
class InstallOllama(InstallPackage):
|
|
448
466
|
executable_name = "ollama"
|
|
449
467
|
brew_name = "ollama"
|
|
@@ -463,5 +481,23 @@ class InstallPoppler(InstallPackage):
|
|
|
463
481
|
brew_name = "poppler"
|
|
464
482
|
apt_name = "poppler-utils"
|
|
465
483
|
|
|
484
|
+
class InstallPythonBuildDeps(InstallPackage):
|
|
485
|
+
"""Installs system packages needed to compile Python C-extension
|
|
486
|
+
dependencies (lxml, Levenshtein) from source.
|
|
487
|
+
|
|
488
|
+
On Mac, prebuilt wheels are normally available, so this is a no-op there.
|
|
489
|
+
On Ubuntu/WSL, if no prebuilt wheel matches the student's Python version,
|
|
490
|
+
`uv`/`pip` falls back to building from source, which needs headers, a
|
|
491
|
+
compiler, and (for Levenshtein's rapidfuzz-cpp backend) cmake/ninja.
|
|
492
|
+
"""
|
|
493
|
+
platform = Platform.UBUNTU | Platform.WSL
|
|
494
|
+
description = "Install build dependencies for lxml and Levenshtein"
|
|
495
|
+
apt_name = "libxml2-dev libxslt1-dev python3-dev build-essential cmake ninja-build"
|
|
496
|
+
|
|
497
|
+
def is_complete(self):
|
|
498
|
+
return all(
|
|
499
|
+
run(["dpkg", "-s", package], capture_output=True).returncode == 0
|
|
500
|
+
for package in self.apt_name.split()
|
|
501
|
+
)
|
|
466
502
|
|
|
467
503
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from csv import DictWriter
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from subprocess import run
|
|
6
|
+
from tabulate import tabulate
|
|
7
|
+
|
|
8
|
+
from making_with_code_cli.settings import read_settings
|
|
9
|
+
from making_with_code_cli.curriculum import get_curriculum
|
|
10
|
+
from making_with_code_cli.mwc_accounts_api import MWCAccountsAPI
|
|
11
|
+
from making_with_code_cli.assessment import (
|
|
12
|
+
parse_assessment_file,
|
|
13
|
+
format_score,
|
|
14
|
+
AssessmentValidationError,
|
|
15
|
+
)
|
|
16
|
+
from making_with_code_cli.git_wrapper import (
|
|
17
|
+
repo_has_changes,
|
|
18
|
+
has_unpushed_commits,
|
|
19
|
+
commits_since,
|
|
20
|
+
_get_default_branch,
|
|
21
|
+
)
|
|
22
|
+
from making_with_code_cli.styles import (
|
|
23
|
+
info,
|
|
24
|
+
error,
|
|
25
|
+
warn,
|
|
26
|
+
debug as debug_fmt,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# The identity configure_git() sets locally for every student -- matched as a
|
|
30
|
+
# whitelist so we don't need to know anything about teacher/other identities
|
|
31
|
+
# to tell whether a commit is the student's own work.
|
|
32
|
+
STUDENT_EMAIL = "nobody@makingwithcode.org"
|
|
33
|
+
EPOCH = datetime.min.replace(tzinfo=timezone.utc)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@click.command()
|
|
37
|
+
@click.option("--config", help="Path to config file (default: ~/.mwc)")
|
|
38
|
+
@click.option("-c", "--course", help="Filter by course name/slug")
|
|
39
|
+
@click.option("-n", "--unit", help="Filter by unit name/slug")
|
|
40
|
+
@click.option("-o", "--outfile", help="Save results as csv")
|
|
41
|
+
@click.option("--debug", is_flag=True, help="Show debug-level output")
|
|
42
|
+
def status(config, course, unit, outfile, debug):
|
|
43
|
+
"""Show the status of your labs, problem sets, and projects"""
|
|
44
|
+
settings = read_settings(config)
|
|
45
|
+
if not settings:
|
|
46
|
+
click.echo(error("Please run mwc setup first."))
|
|
47
|
+
return
|
|
48
|
+
mwc_home = Path(settings["work_dir"])
|
|
49
|
+
username = settings.get("mwc_username")
|
|
50
|
+
api = MWCAccountsAPI(settings.get("mwc_accounts_url"))
|
|
51
|
+
try:
|
|
52
|
+
api_status = api.get_status(settings.get("mwc_accounts_token"))
|
|
53
|
+
except api.RequestFailed:
|
|
54
|
+
click.echo(error("Error logging into MWC accounts server. Please run mwc setup."))
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
if len(api_status["student_section_memberships"]) == 0:
|
|
58
|
+
click.echo(info("You are not a student in any courses."))
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
rows = []
|
|
62
|
+
for membership in api_status["student_section_memberships"]:
|
|
63
|
+
curr = get_curriculum(membership["curriculum_site_url"], membership["course_name"])
|
|
64
|
+
if course and course not in curr["name"] and course not in curr["slug"]:
|
|
65
|
+
continue
|
|
66
|
+
course_dir = mwc_home / curr["slug"]
|
|
67
|
+
for u in curr["units"]:
|
|
68
|
+
if unit and unit not in u["name"] and unit not in u["slug"]:
|
|
69
|
+
continue
|
|
70
|
+
unit_dir = course_dir / u["slug"]
|
|
71
|
+
for module in u["modules"]:
|
|
72
|
+
module_dir = unit_dir / module["slug"]
|
|
73
|
+
label = f"{curr['slug']}/{u['slug']}/{module['slug']}"
|
|
74
|
+
rows.append(_module_row(label, module_dir, username, debug))
|
|
75
|
+
|
|
76
|
+
if not rows:
|
|
77
|
+
click.echo(info("No modules matched."))
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
if outfile:
|
|
81
|
+
_write_csv(rows, outfile)
|
|
82
|
+
click.echo(info(f"Saved to {outfile}"))
|
|
83
|
+
_print_table(rows)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _module_row(label, module_dir, username, debug=False):
|
|
87
|
+
"""Computes one module's status: (label, score, not_yet_assessed, unsubmitted).
|
|
88
|
+
Read-only -- fetches (updates hidden refs only) but never merges, checks
|
|
89
|
+
out, or otherwise touches a module's working tree or history.
|
|
90
|
+
"""
|
|
91
|
+
if not module_dir.exists():
|
|
92
|
+
return (label, "", False, False)
|
|
93
|
+
|
|
94
|
+
run("git fetch origin", shell=True, capture_output=True, cwd=module_dir)
|
|
95
|
+
branch = _get_default_branch(module_dir)
|
|
96
|
+
ref = f"origin/{branch}"
|
|
97
|
+
|
|
98
|
+
assessment = None
|
|
99
|
+
show = run(
|
|
100
|
+
f"git show {ref}:assessment.md",
|
|
101
|
+
shell=True, capture_output=True, text=True, cwd=module_dir,
|
|
102
|
+
)
|
|
103
|
+
score = ""
|
|
104
|
+
if show.returncode == 0:
|
|
105
|
+
try:
|
|
106
|
+
assessment = parse_assessment_file(show.stdout)
|
|
107
|
+
if assessment:
|
|
108
|
+
score = format_score(assessment)
|
|
109
|
+
except AssessmentValidationError as e:
|
|
110
|
+
score = "ERR"
|
|
111
|
+
if debug:
|
|
112
|
+
click.echo(debug_fmt(f"{label}: {e}"))
|
|
113
|
+
|
|
114
|
+
cutoff = assessment.timestamp if assessment else EPOCH
|
|
115
|
+
not_yet_assessed = commits_since(cutoff, username, STUDENT_EMAIL, ref=ref, cwd=module_dir)
|
|
116
|
+
unsubmitted = repo_has_changes(cwd=module_dir) or has_unpushed_commits(cwd=module_dir)
|
|
117
|
+
|
|
118
|
+
return (label, score, not_yet_assessed, unsubmitted)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _write_csv(rows, outfile):
|
|
122
|
+
with open(outfile, "w", newline="") as fh:
|
|
123
|
+
writer = DictWriter(fh, fieldnames=["module", "score", "not_yet_assessed", "unsubmitted_changes"])
|
|
124
|
+
writer.writeheader()
|
|
125
|
+
for label, score, not_yet_assessed, unsubmitted in rows:
|
|
126
|
+
writer.writerow({
|
|
127
|
+
"module": label,
|
|
128
|
+
"score": score,
|
|
129
|
+
"not_yet_assessed": not_yet_assessed,
|
|
130
|
+
"unsubmitted_changes": unsubmitted,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _print_table(rows):
|
|
135
|
+
table_rows = [
|
|
136
|
+
[label, score, "S" if not_yet_assessed else "", "U" if unsubmitted else ""]
|
|
137
|
+
for label, score, not_yet_assessed, unsubmitted in rows
|
|
138
|
+
]
|
|
139
|
+
table = tabulate(table_rows, headers=["Module", "Score", "S", "U"])
|
|
140
|
+
# Color whole lines rather than individual cells: tabulate computes column
|
|
141
|
+
# widths from raw string length, so ANSI codes embedded in a single cell
|
|
142
|
+
# before tabulate runs would throw off its padding and misalign every
|
|
143
|
+
# column after it. Coloring a line *after* tabulate has already rendered
|
|
144
|
+
# it doesn't have this problem, since nothing re-measures it afterward.
|
|
145
|
+
# (Also: don't wrap the whole table in another style() call on top of
|
|
146
|
+
# this -- nesting click.style calls means the inner reset code cuts off
|
|
147
|
+
# the outer color partway through, breaking the styling on every line
|
|
148
|
+
# after the first colored one.)
|
|
149
|
+
lines = table.split("\n")
|
|
150
|
+
for i, (label, score, not_yet_assessed, unsubmitted) in enumerate(rows):
|
|
151
|
+
line_index = i + 2 # header + separator line
|
|
152
|
+
if unsubmitted:
|
|
153
|
+
lines[line_index] = error(lines[line_index], preformatted=True)
|
|
154
|
+
elif not_yet_assessed:
|
|
155
|
+
lines[line_index] = warn(lines[line_index], preformatted=True)
|
|
156
|
+
click.echo("\n".join(lines))
|
making_with_code_cli/submit.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import click
|
|
2
|
-
from subprocess import run,
|
|
2
|
+
from subprocess import run, PIPE
|
|
3
3
|
from making_with_code_cli.git_wrapper import (
|
|
4
4
|
in_repo,
|
|
5
5
|
repo_has_changes,
|
|
@@ -50,8 +50,8 @@ def submit():
|
|
|
50
50
|
"Your work was already saved locally but not yet sent to the server. "
|
|
51
51
|
"Submitting now..."
|
|
52
52
|
))
|
|
53
|
-
_push()
|
|
54
|
-
|
|
53
|
+
if _push():
|
|
54
|
+
click.echo(address("Nice job! All your work in this module has been submitted."))
|
|
55
55
|
return
|
|
56
56
|
|
|
57
57
|
if not repo_has_changes():
|
|
@@ -59,18 +59,20 @@ def submit():
|
|
|
59
59
|
return
|
|
60
60
|
|
|
61
61
|
run("git add --all", shell=True, capture_output=True, check=True)
|
|
62
|
-
run("git --no-pager diff --staged", shell=True, check=True)
|
|
63
|
-
|
|
64
|
-
if not click.confirm(address("Here are the current changes. Looks OK?")):
|
|
65
|
-
run("git restore --staged .", shell=True, capture_output=True)
|
|
66
|
-
click.echo(info("Cancelled the submit for now."))
|
|
67
|
-
return
|
|
68
62
|
|
|
69
63
|
click.echo(info("Write your commit message, then save and exit the window..."))
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
64
|
+
before_head = _current_head()
|
|
65
|
+
# Only stderr is captured here. Capturing stdout too (as this used to do)
|
|
66
|
+
# disconnects the interactive editor from the real terminal, which can
|
|
67
|
+
# break editing entirely depending on the student's editor. git's own
|
|
68
|
+
# post-editor messages (e.g. "Aborting commit...") go to stderr, so we
|
|
69
|
+
# can still detect that reliably.
|
|
70
|
+
commit_result = run("git commit", shell=True, stderr=PIPE, text=True)
|
|
71
|
+
|
|
72
|
+
# Never trust the editor/git return code alone -- confirm a commit was
|
|
73
|
+
# actually created before reporting success.
|
|
74
|
+
if _current_head() == before_head:
|
|
75
|
+
combined = commit_result.stderr or ""
|
|
74
76
|
run("git restore --staged .", shell=True, capture_output=True)
|
|
75
77
|
# S2: student saved without writing a message
|
|
76
78
|
if "empty commit message" in combined or "Aborting commit" in combined:
|
|
@@ -78,16 +80,52 @@ def submit():
|
|
|
78
80
|
"You didn't write a commit message. "
|
|
79
81
|
"Run `mwc submit` again and describe what you worked on before saving."
|
|
80
82
|
))
|
|
83
|
+
# S6: configured editor is missing/broken (uninstalled, moved off PATH, ...)
|
|
84
|
+
elif "unable to start editor" in combined:
|
|
85
|
+
click.echo(error(
|
|
86
|
+
"Your configured code editor couldn't be launched -- it may have been "
|
|
87
|
+
"uninstalled or moved. Run `mwc setup` again to choose an editor, then "
|
|
88
|
+
"run `mwc submit` again."
|
|
89
|
+
))
|
|
81
90
|
else:
|
|
82
|
-
|
|
91
|
+
if combined.strip():
|
|
92
|
+
click.echo(debug_fmt(combined.strip(), preformatted=True))
|
|
83
93
|
click.echo(error(
|
|
84
|
-
"
|
|
85
|
-
"
|
|
94
|
+
"Your work wasn't saved. "
|
|
95
|
+
"Run `mwc submit` again. If this keeps happening, ask your teacher for help."
|
|
86
96
|
))
|
|
87
97
|
return
|
|
88
98
|
|
|
89
|
-
_push()
|
|
90
|
-
|
|
99
|
+
if _push():
|
|
100
|
+
click.echo(address("Nice job! All your work in this module has been submitted."))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _current_head():
|
|
104
|
+
"""Returns the current commit hash, or None if there isn't one yet."""
|
|
105
|
+
result = run("git rev-parse HEAD", shell=True, capture_output=True, text=True)
|
|
106
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _conflicting_files(ref):
|
|
110
|
+
"""Returns the list of files that can't be cleanly auto-merged when
|
|
111
|
+
merging `ref` into HEAD, without actually applying or committing
|
|
112
|
+
anything. -X ours auto-resolves conflicts and doesn't report them, so
|
|
113
|
+
this is the only way to tell whether the automatic merge is about to
|
|
114
|
+
silently pick a side on real conflicting content. (This is only called
|
|
115
|
+
once a commit already exists and the working tree is clean, so the only
|
|
116
|
+
failure mode possible here is a genuine textual conflict.)
|
|
117
|
+
"""
|
|
118
|
+
merge_check = run(
|
|
119
|
+
f"git merge --no-commit --no-ff {ref}", shell=True, capture_output=True, text=True
|
|
120
|
+
)
|
|
121
|
+
conflicts = []
|
|
122
|
+
if merge_check.returncode != 0:
|
|
123
|
+
diff = run(
|
|
124
|
+
"git diff --name-only --diff-filter=U", shell=True, capture_output=True, text=True
|
|
125
|
+
)
|
|
126
|
+
conflicts = [f for f in diff.stdout.splitlines() if f.strip()]
|
|
127
|
+
run("git merge --abort", shell=True, capture_output=True)
|
|
128
|
+
return conflicts
|
|
91
129
|
|
|
92
130
|
|
|
93
131
|
def _preflight_repo():
|
|
@@ -98,19 +136,28 @@ def _preflight_repo():
|
|
|
98
136
|
f"Your repo was in an incomplete state (interrupted {aborted}). "
|
|
99
137
|
"This has been cleared automatically."
|
|
100
138
|
))
|
|
101
|
-
|
|
102
|
-
if
|
|
139
|
+
recovered = recover_detached_head()
|
|
140
|
+
if recovered:
|
|
141
|
+
branch, stash_unresolved = recovered
|
|
103
142
|
click.echo(confirm(
|
|
104
143
|
f"Your repo was not on any branch. Switched back to '{branch}'."
|
|
105
144
|
))
|
|
145
|
+
if stash_unresolved:
|
|
146
|
+
click.echo(error(
|
|
147
|
+
"Some of your uncommitted work couldn't be automatically restored "
|
|
148
|
+
"and is saved in a git stash instead of your files. "
|
|
149
|
+
"Ask your teacher for help recovering it."
|
|
150
|
+
))
|
|
106
151
|
|
|
107
152
|
|
|
108
153
|
def _push():
|
|
109
|
-
"""Push to origin, with automatic recovery from non-fast-forward rejection.
|
|
154
|
+
"""Push to origin, with automatic recovery from non-fast-forward rejection.
|
|
155
|
+
Returns True only if the push was actually confirmed to succeed.
|
|
156
|
+
"""
|
|
110
157
|
push_result = run("git push", shell=True, capture_output=True, text=True)
|
|
111
158
|
|
|
112
159
|
if push_result.returncode == 0:
|
|
113
|
-
return
|
|
160
|
+
return True
|
|
114
161
|
|
|
115
162
|
combined = push_result.stderr + push_result.stdout
|
|
116
163
|
|
|
@@ -122,6 +169,8 @@ def _push():
|
|
|
122
169
|
"Your teacher made some changes while you were working. "
|
|
123
170
|
"Merging them in and resubmitting..."
|
|
124
171
|
))
|
|
172
|
+
run("git fetch origin", shell=True, capture_output=True)
|
|
173
|
+
conflicted = _conflicting_files("FETCH_HEAD")
|
|
125
174
|
# -X ours: student's committed code wins any content conflict
|
|
126
175
|
pull_result = run(
|
|
127
176
|
"git pull -X ours", shell=True, capture_output=True, text=True
|
|
@@ -132,10 +181,17 @@ def _push():
|
|
|
132
181
|
"Could not automatically merge your teacher's changes. "
|
|
133
182
|
"Ask your teacher for help."
|
|
134
183
|
))
|
|
135
|
-
return
|
|
184
|
+
return False
|
|
185
|
+
if conflicted:
|
|
186
|
+
files = ", ".join(f"`{f}`" for f in conflicted)
|
|
187
|
+
click.echo(error(
|
|
188
|
+
f"Your teacher's changes to {files} conflicted with your own "
|
|
189
|
+
"changes to the same lines — your version was kept. Ask your "
|
|
190
|
+
"teacher if you're not sure that's right."
|
|
191
|
+
))
|
|
136
192
|
retry = run("git push", shell=True, capture_output=True, text=True)
|
|
137
193
|
if retry.returncode == 0:
|
|
138
|
-
return
|
|
194
|
+
return True
|
|
139
195
|
combined = retry.stderr + retry.stdout
|
|
140
196
|
|
|
141
197
|
# S4: network error — commit is safe locally, tell them so
|
|
@@ -145,7 +201,7 @@ def _push():
|
|
|
145
201
|
"(network error). Check your internet connection and run "
|
|
146
202
|
"`mwc submit` again — you won't lose any work."
|
|
147
203
|
))
|
|
148
|
-
return
|
|
204
|
+
return False
|
|
149
205
|
|
|
150
206
|
# S5: authentication error
|
|
151
207
|
if any(p in combined for p in _AUTH_PHRASES):
|
|
@@ -153,10 +209,11 @@ def _push():
|
|
|
153
209
|
"Your login credentials are no longer valid. "
|
|
154
210
|
"Run `mwc setup` to refresh them, then run `mwc submit` again."
|
|
155
211
|
))
|
|
156
|
-
return
|
|
212
|
+
return False
|
|
157
213
|
|
|
158
214
|
click.echo(debug_fmt(combined.strip(), preformatted=True))
|
|
159
215
|
click.echo(error(
|
|
160
216
|
"Could not send your work to the server. "
|
|
161
217
|
"If the problem persists, ask your teacher for help."
|
|
162
218
|
))
|
|
219
|
+
return False
|
|
@@ -3,6 +3,7 @@ from csv import DictWriter
|
|
|
3
3
|
from collections import defaultdict
|
|
4
4
|
from tabulate import tabulate
|
|
5
5
|
from making_with_code_cli.settings import read_settings
|
|
6
|
+
from making_with_code_cli.curriculum import get_curriculum
|
|
6
7
|
from making_with_code_cli.teach.setup import check_required_teacher_settings
|
|
7
8
|
from making_with_code_cli.teach.student_repos import StudentRepos
|
|
8
9
|
from making_with_code_cli.helpers import date_string
|
|
@@ -11,6 +12,8 @@ from making_with_code_cli.teach.student_repo_functions import (
|
|
|
11
12
|
count_changed_py_lines,
|
|
12
13
|
count_changed_md_lines,
|
|
13
14
|
module_completion,
|
|
15
|
+
assessment_status,
|
|
16
|
+
unassessed,
|
|
14
17
|
)
|
|
15
18
|
from making_with_code_cli.styles import (
|
|
16
19
|
address,
|
|
@@ -22,10 +25,12 @@ from making_with_code_cli.styles import (
|
|
|
22
25
|
)
|
|
23
26
|
|
|
24
27
|
measures = {
|
|
28
|
+
"assessment": ("Reading assessment status", assessment_status),
|
|
25
29
|
"commits": ("Counting commits", count_commits),
|
|
26
30
|
"py-lines": ("Counting changed lines in Python files", count_changed_py_lines),
|
|
27
31
|
"md-lines": ("Counting changed lines in Markdown files", count_changed_md_lines),
|
|
28
32
|
"completion": ("Running tests", module_completion),
|
|
33
|
+
"unassessed": ("Checking for unassessed work", unassessed),
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
@click.command()
|
|
@@ -35,8 +40,8 @@ measures = {
|
|
|
35
40
|
@click.option("-u", "--user", help="Filter by username")
|
|
36
41
|
@click.option("-n", "--unit", help="Filter by unit name/slug")
|
|
37
42
|
@click.option("-m", "--module", help="Filter by module name/slug")
|
|
38
|
-
@click.option("-v", "--measure", default="
|
|
39
|
-
help="Measure to show")
|
|
43
|
+
@click.option("-v", "--measure", default="assessment", type=click.Choice(measures.keys()),
|
|
44
|
+
help="Measure to show (default: current assessment status)")
|
|
40
45
|
@click.option("-a", "--anonymous", is_flag=True, help="Hide usernames")
|
|
41
46
|
@click.option("-x", "--short", is_flag=True, help="Show short module names")
|
|
42
47
|
@click.option("-o", "--outfile", help="Save results as csv")
|
|
@@ -44,7 +49,7 @@ measures = {
|
|
|
44
49
|
@click.option('-t', "--threads", type=int, default=8, help="Maximum simultaneous threads")
|
|
45
50
|
@click.option('-B', "--begin", type=date_string, help="Begin date")
|
|
46
51
|
@click.option('-C', "--end", type=date_string, help="End date")
|
|
47
|
-
def status(config, section, course, user, unit, module, measure, anonymous, short,
|
|
52
|
+
def status(config, section, course, user, unit, module, measure, anonymous, short,
|
|
48
53
|
outfile, update, threads, begin, end):
|
|
49
54
|
"Show status of student repos by module"
|
|
50
55
|
if update:
|
|
@@ -56,56 +61,82 @@ def status(config, section, course, user, unit, module, measure, anonymous, shor
|
|
|
56
61
|
repos = StudentRepos(settings, threads)
|
|
57
62
|
measure_status_message, measure_fn = measures[measure]
|
|
58
63
|
results = repos.apply(
|
|
59
|
-
measure_fn,
|
|
60
|
-
section=section,
|
|
61
|
-
course=course,
|
|
62
|
-
user=user,
|
|
63
|
-
unit=unit,
|
|
64
|
-
module=module,
|
|
64
|
+
measure_fn,
|
|
65
|
+
section=section,
|
|
66
|
+
course=course,
|
|
67
|
+
user=user,
|
|
68
|
+
unit=unit,
|
|
69
|
+
module=module,
|
|
65
70
|
begin=begin,
|
|
66
71
|
end=end,
|
|
67
72
|
status_message=measure_status_message
|
|
68
73
|
)
|
|
69
74
|
recursive_dict = lambda: defaultdict(recursive_dict)
|
|
70
75
|
results_dict = recursive_dict()
|
|
76
|
+
section_objects = {}
|
|
71
77
|
for r in results:
|
|
72
78
|
s = r['section']['name'] + ' | ' + r['section']['course_name']
|
|
79
|
+
section_objects[s] = r['section']
|
|
73
80
|
results_dict[s][r['username']][r['module']] = r['score']
|
|
74
81
|
all_scores = []
|
|
75
|
-
|
|
82
|
+
all_headers = None
|
|
83
|
+
for section_key, users in results_dict.items():
|
|
76
84
|
if users:
|
|
77
|
-
|
|
78
|
-
|
|
85
|
+
module_order = curriculum_module_order(section_objects[section_key])
|
|
86
|
+
ordered_modules = ordered_module_headers(users, module_order)
|
|
87
|
+
headers = ['username'] + [
|
|
88
|
+
shorten_module_name(m) if short else m for m in ordered_modules
|
|
89
|
+
]
|
|
90
|
+
section_scores = format_user_scores(users, ordered_modules, anonymous, short)
|
|
79
91
|
all_scores += section_scores
|
|
92
|
+
all_headers = headers
|
|
80
93
|
if not outfile:
|
|
81
|
-
click.echo(address(
|
|
82
|
-
click.echo(address(tabulate(section_scores, headers='keys'),
|
|
94
|
+
click.echo(address(section_key))
|
|
95
|
+
click.echo(address(tabulate(section_scores, headers='keys'),
|
|
83
96
|
preformatted=True))
|
|
84
97
|
if outfile and all_scores:
|
|
85
98
|
with open(outfile, 'w') as fh:
|
|
86
|
-
writer = DictWriter(fh,
|
|
99
|
+
writer = DictWriter(fh, all_headers)
|
|
87
100
|
writer.writeheader()
|
|
88
101
|
writer.writerows(all_scores)
|
|
89
102
|
|
|
103
|
+
def curriculum_module_order(section):
|
|
104
|
+
"""Returns module slugs in curriculum (manifest) order for a section, so
|
|
105
|
+
tables can be sorted consistently with mwc status rather than by
|
|
106
|
+
whatever order threaded results happened to arrive in.
|
|
107
|
+
"""
|
|
108
|
+
curr = get_curriculum(section['curriculum_site_url'], section['course_name'])
|
|
109
|
+
return [m['slug'] for u in curr['units'] for m in u['modules']]
|
|
110
|
+
|
|
111
|
+
def ordered_module_headers(users, module_order):
|
|
112
|
+
"""Returns every module slug that shows up for any user in `users`,
|
|
113
|
+
ordered to match `module_order` first, with anything not in the
|
|
114
|
+
curriculum (a stale/renamed module) appended alphabetically after.
|
|
115
|
+
"""
|
|
116
|
+
present = set()
|
|
117
|
+
for user_scores in users.values():
|
|
118
|
+
present.update(user_scores.keys())
|
|
119
|
+
ordered = [m for m in module_order if m in present]
|
|
120
|
+
ordered += sorted(present - set(module_order))
|
|
121
|
+
return ordered
|
|
122
|
+
|
|
90
123
|
def shorten_module_name(name):
|
|
91
124
|
return name.replace("lab_", "").replace("project_", "").replace("problemset_", "")
|
|
92
125
|
|
|
93
|
-
def format_user_scores(scores, anonymous=False, short=False):
|
|
126
|
+
def format_user_scores(scores, ordered_modules, anonymous=False, short=False):
|
|
94
127
|
formatted_scores = []
|
|
95
128
|
for i, (username, user_scores) in enumerate(scores.items()):
|
|
96
|
-
if anonymous:
|
|
129
|
+
if anonymous:
|
|
97
130
|
username_dict = {"username": f"student_{i}"}
|
|
98
131
|
else:
|
|
99
132
|
username_dict = {"username": username}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
133
|
+
# Every row gets the exact same keys, in the exact same order, so
|
|
134
|
+
# that tabulate's headers='keys' (which unions keys in first-seen
|
|
135
|
+
# order across rows) reflects curriculum order regardless of which
|
|
136
|
+
# specific modules any one student happens to have touched.
|
|
137
|
+
row = {
|
|
138
|
+
(shorten_module_name(m) if short else m): user_scores.get(m, "")
|
|
139
|
+
for m in ordered_modules
|
|
140
|
+
}
|
|
141
|
+
formatted_scores.append(username_dict | row)
|
|
108
142
|
return sorted(formatted_scores, key=lambda user_scores_dict: user_scores_dict['username'])
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
import os
|
|
2
|
+
from datetime import datetime, timezone
|
|
2
3
|
from subprocess import run
|
|
3
4
|
from git import Repo
|
|
4
5
|
from making_with_code_cli.teach.gitea_api.api import GiteaTeacherApi
|
|
5
6
|
from making_with_code_cli.helpers import in_bounds
|
|
7
|
+
from making_with_code_cli.assessment import (
|
|
8
|
+
parse_assessment_file,
|
|
9
|
+
format_score,
|
|
10
|
+
AssessmentValidationError,
|
|
11
|
+
)
|
|
12
|
+
from making_with_code_cli.git_wrapper import commits_since
|
|
6
13
|
|
|
7
14
|
MWC_GIT_PROTOCOL = "https"
|
|
8
15
|
MWC_GIT_SERVER = "git.makingwithcode.org"
|
|
9
16
|
|
|
17
|
+
# The identity configure_git() sets locally for every student -- matched as a
|
|
18
|
+
# whitelist so "unassessed work" only ever means the student's own commits.
|
|
19
|
+
STUDENT_EMAIL = "nobody@makingwithcode.org"
|
|
20
|
+
EPOCH = datetime.min.replace(tzinfo=timezone.utc)
|
|
21
|
+
|
|
10
22
|
def run_in_repo(command, cwd):
|
|
11
23
|
return run(command, cwd=cwd, shell=True, capture_output=True, text=True)
|
|
12
24
|
|
|
@@ -92,10 +104,74 @@ def module_completion(semaphore, results, section, username, begin, end, path, t
|
|
|
92
104
|
semaphore.acquire()
|
|
93
105
|
if path.exists():
|
|
94
106
|
results.append({
|
|
95
|
-
"section": section,
|
|
96
|
-
"username": username,
|
|
97
|
-
"module": path.name,
|
|
107
|
+
"section": section,
|
|
108
|
+
"username": username,
|
|
109
|
+
"module": path.name,
|
|
98
110
|
"score": 0
|
|
99
111
|
})
|
|
100
112
|
semaphore.release()
|
|
101
113
|
|
|
114
|
+
def _read_local_assessment(path):
|
|
115
|
+
"""Reads and parses assessment.md from a local module clone (the teacher's
|
|
116
|
+
own pulled copy, from `path` -- not a remote ref, unlike mwc status, since
|
|
117
|
+
the teacher already has an up-to-date local clone via `--update`).
|
|
118
|
+
Returns (assessment_or_None, error_bool).
|
|
119
|
+
"""
|
|
120
|
+
assessment_path = path / "assessment.md"
|
|
121
|
+
if not assessment_path.exists():
|
|
122
|
+
return None, False
|
|
123
|
+
try:
|
|
124
|
+
return parse_assessment_file(assessment_path.read_text()), False
|
|
125
|
+
except AssessmentValidationError:
|
|
126
|
+
return None, True
|
|
127
|
+
|
|
128
|
+
def _assessment_state(path, username):
|
|
129
|
+
"Returns (assessment_or_None, has_unassessed_work, error_bool) for one module."
|
|
130
|
+
assessment, err = _read_local_assessment(path)
|
|
131
|
+
if err:
|
|
132
|
+
return None, False, True
|
|
133
|
+
cutoff = assessment.timestamp if assessment else EPOCH
|
|
134
|
+
has_unassessed = commits_since(cutoff, username, STUDENT_EMAIL, cwd=path)
|
|
135
|
+
return assessment, has_unassessed, False
|
|
136
|
+
|
|
137
|
+
def assessment_status(semaphore, results, section, username, begin, end, path, token):
|
|
138
|
+
"""Default mwc teach status measure: the module's current assessment state.
|
|
139
|
+
A `*` after a score means there's unassessed work since that assessment
|
|
140
|
+
(the score is stale, not wrong). `U` alone means nothing has been
|
|
141
|
+
assessed yet, but something has been submitted. Blank means neither --
|
|
142
|
+
nothing to report, assessed or not.
|
|
143
|
+
"""
|
|
144
|
+
semaphore.acquire()
|
|
145
|
+
if path.exists():
|
|
146
|
+
assessment, has_unassessed, err = _assessment_state(path, username)
|
|
147
|
+
if err:
|
|
148
|
+
score = "ERR"
|
|
149
|
+
elif assessment and has_unassessed:
|
|
150
|
+
score = format_score(assessment) + "*"
|
|
151
|
+
elif assessment:
|
|
152
|
+
score = format_score(assessment)
|
|
153
|
+
elif has_unassessed:
|
|
154
|
+
score = "U"
|
|
155
|
+
else:
|
|
156
|
+
score = ""
|
|
157
|
+
results.append({
|
|
158
|
+
"section": section,
|
|
159
|
+
"username": username,
|
|
160
|
+
"module": path.name,
|
|
161
|
+
"score": score,
|
|
162
|
+
})
|
|
163
|
+
semaphore.release()
|
|
164
|
+
|
|
165
|
+
def unassessed(semaphore, results, section, username, begin, end, path, token):
|
|
166
|
+
"Boolean flag: does this module have work submitted since its last assessment?"
|
|
167
|
+
semaphore.acquire()
|
|
168
|
+
if path.exists():
|
|
169
|
+
_, has_unassessed, err = _assessment_state(path, username)
|
|
170
|
+
results.append({
|
|
171
|
+
"section": section,
|
|
172
|
+
"username": username,
|
|
173
|
+
"module": path.name,
|
|
174
|
+
"score": "ERR" if err else int(has_unassessed),
|
|
175
|
+
})
|
|
176
|
+
semaphore.release()
|
|
177
|
+
|
|
@@ -65,8 +65,14 @@ def update(config, debug):
|
|
|
65
65
|
except GitError as e:
|
|
66
66
|
click.echo(error(str(e)))
|
|
67
67
|
except Exception as e:
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
if debug:
|
|
69
|
+
msg = traceback.format_exception(type(e), e, e.__traceback__)
|
|
70
|
+
click.echo(error(''.join(msg), preformatted=True))
|
|
71
|
+
else:
|
|
72
|
+
click.echo(error(
|
|
73
|
+
f"There was a problem updating {module['slug']}. "
|
|
74
|
+
"Run with --debug for details, or ask your teacher for help."
|
|
75
|
+
))
|
|
70
76
|
else:
|
|
71
77
|
rel_dir = module_dir.resolve().relative_to(mwc_home)
|
|
72
78
|
click.echo(confirm(f"Initializing {module['slug']} at {rel_dir}."))
|
|
@@ -76,5 +82,11 @@ def update(config, debug):
|
|
|
76
82
|
except GitError as e:
|
|
77
83
|
click.echo(error(str(e)))
|
|
78
84
|
except Exception as e:
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
if debug:
|
|
86
|
+
msg = traceback.format_exception(type(e), e, e.__traceback__)
|
|
87
|
+
click.echo(error(''.join(msg), preformatted=True))
|
|
88
|
+
else:
|
|
89
|
+
click.echo(error(
|
|
90
|
+
f"There was a problem setting up {module['slug']}. "
|
|
91
|
+
"Run with --debug for details, or ask your teacher for help."
|
|
92
|
+
))
|
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
making_with_code_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
making_with_code_cli/
|
|
2
|
+
making_with_code_cli/assessment.py,sha256=Ud00ATjt9V5qIcWutBK5L0yr_ciY1LvMpD22ZSXari4,7462
|
|
3
|
+
making_with_code_cli/cli.py,sha256=PSrNB8kwkS7tGlpMTdHJoa4whUKDEYgANJaG37zNd7Q,514
|
|
3
4
|
making_with_code_cli/curriculum.py,sha256=LewUkk2BHZsQhbZMXi3JiYm1ROcxpQL1u-nsd43x_hw,1192
|
|
4
5
|
making_with_code_cli/decorators.py,sha256=d-fLMfi2BX6jaLAqaHbQFBUGyHaCijqq6EpSCAl3YCk,583
|
|
5
6
|
making_with_code_cli/errors.py,sha256=V0JfwUPMEoU16cDxJh_SR-xQbLEtA5EwsV9RoI6l6C0,1152
|
|
6
7
|
making_with_code_cli/git_backend/__init__.py,sha256=UleXH-1HbEglNL8y_d7-JpQfLRDZ2pBpLYzy9-eH_k0,112
|
|
7
8
|
making_with_code_cli/git_backend/base_backend.py,sha256=zxk3PxI6y35s2pYh1nl5Rz9c2S9BPF2M6HSge63WGyo,841
|
|
8
|
-
making_with_code_cli/git_backend/mwc_backend.py,sha256=
|
|
9
|
-
making_with_code_cli/git_wrapper.py,sha256=
|
|
9
|
+
making_with_code_cli/git_backend/mwc_backend.py,sha256=W0u63CRVaK3W1k9omS0qTcEzcyr64wNc824wJ2qi7Gg,15288
|
|
10
|
+
making_with_code_cli/git_wrapper.py,sha256=1QYiPIsFuFzumYcujYMhRgSzGObImpyyM0mwXbbI23Q,6147
|
|
10
11
|
making_with_code_cli/helpers.py,sha256=h4-gkT0y61GGzV08xcaEqLGJ9N9IQPEMa2P_7_g2sY0,1167
|
|
11
12
|
making_with_code_cli/mwc_accounts_api.py,sha256=INq0ezDY3bDYTLt7cAFp5JmJ4rkr9LcDnz6YYp5d-VQ,4641
|
|
12
13
|
making_with_code_cli/settings.py,sha256=c_sWjBgjrugXx-11rWOOVKFcxiU5WfFENR0WhZs7LLI,1809
|
|
13
|
-
making_with_code_cli/setup/__init__.py,sha256=
|
|
14
|
-
making_with_code_cli/setup/tasks.py,sha256=
|
|
14
|
+
making_with_code_cli/setup/__init__.py,sha256=RjNRrtBduvFmS6gNhxN03DtJ9Jb0qt7pPdVtQl1Vn08,4631
|
|
15
|
+
making_with_code_cli/setup/tasks.py,sha256=9G7SI4jHORpGbs1ghf4sdRFthRAcCQj4NrqkEO1KrkI,18170
|
|
16
|
+
making_with_code_cli/status.py,sha256=N7zaZ7xr-Oklh0Q76ektNulL0R8QLKSSh47YNRK7Zlg,6189
|
|
15
17
|
making_with_code_cli/styles.py,sha256=ZKbs-Grq1aF3wV8scwQxMO1UjnphuIccdNzksLxAo_A,910
|
|
16
|
-
making_with_code_cli/submit.py,sha256=
|
|
18
|
+
making_with_code_cli/submit.py,sha256=pV4lCV_hQotFhk2LkKa5M1hlvOZ0FmbO9e5SJf8Ga00,8315
|
|
17
19
|
making_with_code_cli/teach/__init__.py,sha256=qra5g986DFipl0GkYOW3Nnoi1QUGpatS_U9eFoVVsgw,779
|
|
18
20
|
making_with_code_cli/teach/assess.py,sha256=2--XhfMRsyMoEaP7EAeRD4uk5tynB3wLE3KE5pdYC30,1625
|
|
19
21
|
making_with_code_cli/teach/check/__init__.py,sha256=wlUNHdHI5NwUbGGTHCM87xdJ4Ts3bwMfBF8wXoK2Z68,1787
|
|
@@ -28,21 +30,21 @@ making_with_code_cli/teach/section/delete.py,sha256=Z9uljVCqaKDw4-SzoAl-jxs4SHCB
|
|
|
28
30
|
making_with_code_cli/teach/section/edit.py,sha256=OKub7wJ7yINBw4qkf-L_urrkB4nlXRauIcNbxuLTjGk,2157
|
|
29
31
|
making_with_code_cli/teach/section/show.py,sha256=YZ1fJr3LSQl0E9hgkV9-gndyYn0FnUbPa5ChO2CWbb8,2260
|
|
30
32
|
making_with_code_cli/teach/setup.py,sha256=sF6j-aPOsQ4vJwOb7I_t57LZF5dDmOib15hpET9HAsI,3149
|
|
31
|
-
making_with_code_cli/teach/status.py,sha256=
|
|
33
|
+
making_with_code_cli/teach/status.py,sha256=TNwZVF7nI5qKFcTW6Tg7QKRyWrQSPiWG03pba5aLZMs,6240
|
|
32
34
|
making_with_code_cli/teach/student/__init__.py,sha256=VamqMDxH-ZuGZWTR5g8jYQ3XkgWYQqmunVorpZ00fbM,402
|
|
33
35
|
making_with_code_cli/teach/student/create.py,sha256=Oa78OSUyeDGepjgg6ivycTpzYQl70anc6PEXrvT2s_s,1822
|
|
34
36
|
making_with_code_cli/teach/student/edit.py,sha256=EhP-x7QNXFDh4YGnW8CNjggDbUOAyfZqxa9ZM2gpffY,1257
|
|
35
37
|
making_with_code_cli/teach/student/remove.py,sha256=z6Y_n1Q53qGeauSCaqzjO852pRkMDoioVoPS1TChMcM,1300
|
|
36
|
-
making_with_code_cli/teach/student_repo_functions.py,sha256
|
|
38
|
+
making_with_code_cli/teach/student_repo_functions.py,sha256=HiwcqeBLB5GpMbUbnX0YP1HIKCutf2iDaPVGvW3v-uI,6617
|
|
37
39
|
making_with_code_cli/teach/student_repos.py,sha256=TRUxpgyrGjsiY58dIpPLGOzytw9sL9zgS-kUquXd39Q,2614
|
|
38
40
|
making_with_code_cli/teach/update.py,sha256=bhGpZudmTrH0pSq-hLfmKOAgMBsmMZG-nfB7zA79tkg,1860
|
|
39
41
|
making_with_code_cli/teach/workspace/__init__.py,sha256=FDe1ggbBYVC5EWH1NBjWdyGgxqjlSQxf_iKDDYqyOgA,1298
|
|
40
42
|
making_with_code_cli/teach/workspace/create.py,sha256=t0hIwJwU7ZEF3QJl0Cwbve3jD1ilYex680dRCvhbkr0,1320
|
|
41
43
|
making_with_code_cli/teach/workspace/delete.py,sha256=126VQaFMvulpXY_ch1yXNN4fCLL_bQfAnkRwnOL9PYk,952
|
|
42
44
|
making_with_code_cli/teach/workspace/edit.py,sha256=BndmJrItJMB3JzW6MNCfqyLPTV6UaPh2T6hQa73TcJQ,1369
|
|
43
|
-
making_with_code_cli/update/__init__.py,sha256=
|
|
45
|
+
making_with_code_cli/update/__init__.py,sha256=kNbCKchXDQZUsmwpLWkJDoacMm2fry5IK-NRnkcwgB0,4391
|
|
44
46
|
making_with_code_cli/version.py,sha256=4t7TOR5OlBrLFy7jrtS_d6FtIReco1NDdVPQoCb6Jx8,286
|
|
45
|
-
making_with_code_cli-5.
|
|
46
|
-
making_with_code_cli-5.
|
|
47
|
-
making_with_code_cli-5.
|
|
48
|
-
making_with_code_cli-5.
|
|
47
|
+
making_with_code_cli-5.6.0.dist-info/WHEEL,sha256=Qb5DWjqM6GZuPp3VmTlAFSGqNRK8vceyVqRFxrfa8YA,80
|
|
48
|
+
making_with_code_cli-5.6.0.dist-info/entry_points.txt,sha256=0nHqTE6I8cKRoVK_tbRNYI9nY0GFYLDJDZemvDqIHeM,54
|
|
49
|
+
making_with_code_cli-5.6.0.dist-info/METADATA,sha256=rXfrt7bK6BxY0KSm3RiBK9zXbCnjdE1jH7u0dmhgV3c,2321
|
|
50
|
+
making_with_code_cli-5.6.0.dist-info/RECORD,,
|
|
File without changes
|
{making_with_code_cli-5.4.0.dist-info → making_with_code_cli-5.6.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|