making-with-code-cli 5.4.0__tar.gz → 5.6.0__tar.gz

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.
Files changed (49) hide show
  1. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/PKG-INFO +1 -1
  2. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/pyproject.toml +8 -2
  3. making_with_code_cli-5.6.0/src/making_with_code_cli/assessment.py +173 -0
  4. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/cli.py +2 -0
  5. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/git_backend/mwc_backend.py +95 -27
  6. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/git_wrapper.py +50 -8
  7. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/setup/__init__.py +4 -0
  8. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/setup/tasks.py +39 -3
  9. making_with_code_cli-5.6.0/src/making_with_code_cli/status.py +156 -0
  10. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/submit.py +83 -26
  11. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/status.py +59 -28
  12. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/student_repo_functions.py +79 -3
  13. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/update/__init__.py +16 -4
  14. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/README.md +0 -0
  15. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/__init__.py +0 -0
  16. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/curriculum.py +0 -0
  17. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/decorators.py +0 -0
  18. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/errors.py +0 -0
  19. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/git_backend/__init__.py +0 -0
  20. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/git_backend/base_backend.py +0 -0
  21. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/helpers.py +0 -0
  22. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/mwc_accounts_api.py +0 -0
  23. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/settings.py +0 -0
  24. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/styles.py +0 -0
  25. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/__init__.py +0 -0
  26. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/assess.py +0 -0
  27. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/check/__init__.py +0 -0
  28. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/check/check_module.py +0 -0
  29. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/gitea_api/api.py +0 -0
  30. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/gitea_api/exceptions.py +0 -0
  31. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/log.py +0 -0
  32. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/patch.py +0 -0
  33. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/section/__init__.py +0 -0
  34. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/section/create.py +0 -0
  35. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/section/delete.py +0 -0
  36. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/section/edit.py +0 -0
  37. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/section/show.py +0 -0
  38. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/setup.py +0 -0
  39. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/student/__init__.py +0 -0
  40. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/student/create.py +0 -0
  41. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/student/edit.py +0 -0
  42. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/student/remove.py +0 -0
  43. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/student_repos.py +0 -0
  44. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/update.py +0 -0
  45. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/workspace/__init__.py +0 -0
  46. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/workspace/create.py +0 -0
  47. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/workspace/delete.py +0 -0
  48. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/teach/workspace/edit.py +0 -0
  49. {making_with_code_cli-5.4.0 → making_with_code_cli-5.6.0}/src/making_with_code_cli/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: making-with-code-cli
3
- Version: 5.4.0
3
+ Version: 5.6.0
4
4
  Summary: Courseware for Making With Code
5
5
  Author: Chris Proctor
6
6
  Author-email: Chris Proctor <chris@chrisproctor.net>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "making-with-code-cli"
3
- version = "5.4.0"
3
+ version = "5.6.0"
4
4
  description = "Courseware for Making With Code"
5
5
  authors = [{ name = "Chris Proctor", email = "chris@chrisproctor.net" }]
6
6
  requires-python = ">=3.11,<4.0"
@@ -33,7 +33,7 @@ issues = "https://github.com/cproctor/making-with-code-courseware/issues"
33
33
  mwc = "making_with_code_cli.cli:cli"
34
34
 
35
35
  [build-system]
36
- requires = ["uv_build>=0.9.0,<0.10.0"]
36
+ requires = ["uv_build>=0.11.0,<0.12.0"]
37
37
  build-backend = "uv_build"
38
38
 
39
39
  [dependency-groups]
@@ -41,5 +41,11 @@ docs = [
41
41
  "sphinx>=8.2.3,<9",
42
42
  "sphinx-rtd-theme>=3.0.2,<4",
43
43
  ]
44
+ test = [
45
+ "pytest>=8.0.0,<9",
46
+ ]
44
47
  [tool.uv]
45
48
  check-url = "https://pypi.org/simple/"
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
@@ -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
+ )
@@ -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
- if editor in EDITOR_COMMANDS:
116
- run(["git", "config", "--local", "core.editor", EDITOR_COMMANDS[editor]],
117
- check=True, cwd=modpath)
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
- branch = recover_detached_head(modpath)
148
- if branch:
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
- # U1: uncommitted changes that would be overwritten — auto-commit first
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
- probe = run(
162
- "git fetch origin",
163
- shell=True, capture_output=True, text=True, cwd=modpath
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
- run("direnv allow", shell=True, check=True, cwd=modpath)
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 (extreme edge case), drop the stash
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
- run("git stash drop", shell=True, capture_output=True, cwd=cwd)
97
- return branch
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
- install_command = "curl -sfL https://direnv.net/install.sh | bash"
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