making-with-code-cli 5.2.1__tar.gz → 5.4.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 (50) hide show
  1. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/PKG-INFO +2 -2
  2. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/README.md +1 -1
  3. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/pyproject.toml +1 -1
  4. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/curriculum.py +1 -1
  5. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/errors.py +12 -0
  6. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/git_backend/base_backend.py +0 -1
  7. making_with_code_cli-5.4.0/src/making_with_code_cli/git_backend/mwc_backend.py +299 -0
  8. making_with_code_cli-5.4.0/src/making_with_code_cli/git_wrapper.py +124 -0
  9. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/mwc_accounts_api.py +20 -0
  10. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/settings.py +1 -1
  11. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/setup/__init__.py +3 -1
  12. making_with_code_cli-5.4.0/src/making_with_code_cli/submit.py +162 -0
  13. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/__init__.py +2 -0
  14. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/setup.py +2 -0
  15. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/student/create.py +24 -3
  16. making_with_code_cli-5.4.0/src/making_with_code_cli/teach/workspace/__init__.py +31 -0
  17. making_with_code_cli-5.4.0/src/making_with_code_cli/teach/workspace/create.py +28 -0
  18. making_with_code_cli-5.4.0/src/making_with_code_cli/teach/workspace/delete.py +21 -0
  19. making_with_code_cli-5.4.0/src/making_with_code_cli/teach/workspace/edit.py +34 -0
  20. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/update/__init__.py +7 -2
  21. making_with_code_cli-5.2.1/src/making_with_code_cli/git_backend/mwc_backend.py +0 -160
  22. making_with_code_cli-5.2.1/src/making_with_code_cli/git_wrapper.py +0 -25
  23. making_with_code_cli-5.2.1/src/making_with_code_cli/submit.py +0 -37
  24. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/__init__.py +0 -0
  25. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/cli.py +0 -0
  26. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/decorators.py +0 -0
  27. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/git_backend/__init__.py +0 -0
  28. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/helpers.py +0 -0
  29. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/setup/tasks.py +0 -0
  30. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/styles.py +0 -0
  31. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/assess.py +0 -0
  32. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/check/__init__.py +0 -0
  33. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/check/check_module.py +0 -0
  34. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/gitea_api/api.py +0 -0
  35. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/gitea_api/exceptions.py +0 -0
  36. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/log.py +0 -0
  37. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/patch.py +0 -0
  38. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/section/__init__.py +0 -0
  39. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/section/create.py +0 -0
  40. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/section/delete.py +0 -0
  41. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/section/edit.py +0 -0
  42. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/section/show.py +0 -0
  43. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/status.py +0 -0
  44. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/student/__init__.py +0 -0
  45. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/student/edit.py +0 -0
  46. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/student/remove.py +0 -0
  47. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/student_repo_functions.py +0 -0
  48. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/student_repos.py +0 -0
  49. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/update.py +0 -0
  50. {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.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.2.1
3
+ Version: 5.4.0
4
4
  Summary: Courseware for Making With Code
5
5
  Author: Chris Proctor
6
6
  Author-email: Chris Proctor <chris@chrisproctor.net>
@@ -42,7 +42,7 @@ Making With Code, there is a separate bootstrapping process you should follow.
42
42
  Please consult your curriculum website (or ask your teacher) for detailed instructions
43
43
  on how and why to use this tool.
44
44
 
45
- pip3 install making-with-code-cli
45
+ uv tool install making-with-code-cli
46
46
 
47
47
  Once installed, you can set up your computer using the `mwc setup` command. Run
48
48
  `mwc --help` to see all the commands available.
@@ -17,7 +17,7 @@ Making With Code, there is a separate bootstrapping process you should follow.
17
17
  Please consult your curriculum website (or ask your teacher) for detailed instructions
18
18
  on how and why to use this tool.
19
19
 
20
- pip3 install making-with-code-cli
20
+ uv tool install making-with-code-cli
21
21
 
22
22
  Once installed, you can set up your computer using the `mwc setup` command. Run
23
23
  `mwc --help` to see all the commands available.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "making-with-code-cli"
3
- version = "5.2.1"
3
+ version = "5.4.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"
@@ -11,7 +11,7 @@ def get_curriculum(mwc_site_url, course_name=None):
11
11
  """Fetches curriculum metadata from the site url specified in settings.
12
12
  Returns the curriculum metadata for course_name.
13
13
  """
14
- url = mwc_site_url + "/manifest"
14
+ url = mwc_site_url.rstrip("/") + "/manifest"
15
15
  try:
16
16
  response = requests.get(url, timeout=15)
17
17
  except requests.exceptions.RequestException as e:
@@ -26,3 +26,15 @@ class SoftwareInstallationError(MWCError):
26
26
 
27
27
  class NoCurriculaAvailable(MWCError):
28
28
  pass
29
+
30
+ class GitError(MWCError):
31
+ pass
32
+
33
+ class GitNetworkError(GitError):
34
+ pass
35
+
36
+ class GitAuthError(GitError):
37
+ pass
38
+
39
+ class GitCorruptError(GitError):
40
+ pass
@@ -2,7 +2,6 @@ from pathlib import Path
2
2
  from subprocess import run, CalledProcessError
3
3
  import click
4
4
  from making_with_code_cli.helpers import cd
5
- from making_with_code_cli.setup.tasks import WORK_DIR_PERMISSIONS
6
5
  from making_with_code_cli.styles import (
7
6
  address,
8
7
  info,
@@ -0,0 +1,299 @@
1
+ from .base_backend import GitBackend
2
+ from subprocess import run, CalledProcessError
3
+ import traceback
4
+ from pathlib import Path
5
+ from urllib.parse import urlparse
6
+ import click
7
+ import json
8
+ import os
9
+ import requests
10
+ from making_with_code_cli.helpers import cd
11
+ from making_with_code_cli.git_wrapper import (
12
+ abort_in_progress_operation,
13
+ recover_detached_head,
14
+ clear_stale_index_lock,
15
+ _get_default_branch,
16
+ )
17
+ from making_with_code_cli.styles import (
18
+ address,
19
+ confirm,
20
+ debug,
21
+ error,
22
+ info,
23
+ )
24
+ from making_with_code_cli.errors import (
25
+ MissingSetting,
26
+ GitServerNotAvailable,
27
+ GitError,
28
+ GitNetworkError,
29
+ GitAuthError,
30
+ GitCorruptError,
31
+ )
32
+
33
+ EDITOR_COMMANDS = {
34
+ "atom": "atom --wait",
35
+ "code": "code --wait",
36
+ "subl": "subl -n -w",
37
+ "mate": "mate -w",
38
+ "vim": "vim",
39
+ "emacs": "emacs",
40
+ }
41
+
42
+ # Phrases in git stderr that identify each failure mode
43
+ _NETWORK_PHRASES = [
44
+ "Could not resolve host",
45
+ "Connection timed out",
46
+ "unable to connect",
47
+ "Failed to connect",
48
+ "network is unreachable",
49
+ "Connection refused",
50
+ ]
51
+ _AUTH_PHRASES = [
52
+ "Authentication failed",
53
+ "403",
54
+ "invalid credentials",
55
+ "could not read Username",
56
+ "terminal prompts disabled",
57
+ "Repository not found",
58
+ ]
59
+ _CORRUPT_PHRASES = [
60
+ "corrupt",
61
+ "bad object",
62
+ "loose object",
63
+ "fatal: bad",
64
+ "object file is empty",
65
+ ]
66
+ _NO_UPSTREAM_PHRASES = [
67
+ "no tracking information",
68
+ "has no upstream",
69
+ "There is no tracking information",
70
+ ]
71
+
72
+
73
+ class MWCBackend(GitBackend):
74
+ """A Github backend. Students own their own repos and grant teachers access via token."""
75
+
76
+ MWC_GIT_PROTOCOL = "https"
77
+ MWC_GIT_SERVER = "git.makingwithcode.org"
78
+ COMMIT_TEMPLATE = ".commit_template"
79
+
80
+ @property
81
+ def git_server(self):
82
+ """Returns the git server hostname, respecting git_server_url setting if set."""
83
+ url = self.settings.get('git_server_url')
84
+ if url:
85
+ parsed = urlparse(url)
86
+ return parsed.netloc
87
+ return self.MWC_GIT_SERVER
88
+
89
+ def init_module(self, module, modpath):
90
+ """Creates the named repo from a template."""
91
+ self.check_settings()
92
+ server, repo_owner, repo_name = self.parse_repo_url(module["repo_url"])
93
+
94
+ if modpath.exists():
95
+ self.relocate_existing_directory(modpath)
96
+ if not self.user_has_repo(repo_name):
97
+ self.create_from_template(repo_owner, repo_name)
98
+ with cd(modpath.parent):
99
+ self.clone_repo(repo_name)
100
+ self.configure_git(modpath)
101
+ if (modpath / self.COMMIT_TEMPLATE).exists():
102
+ run(["git", "config", "--local", "commit.template", self.COMMIT_TEMPLATE],
103
+ check=True, cwd=modpath)
104
+ if self.settings.get('manage_python_environment', True):
105
+ run("uv venv", shell=True, check=True, cwd=modpath, capture_output=True)
106
+ self.init_direnv(modpath)
107
+
108
+ def configure_git(self, modpath):
109
+ """Configures git user settings locally for the repo."""
110
+ name = self.settings['mwc_username']
111
+ run(["git", "config", "--local", "user.name", name], check=True, cwd=modpath)
112
+ run(["git", "config", "--local", "user.email", "nobody@makingwithcode.org"],
113
+ check=True, cwd=modpath)
114
+ 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)
118
+
119
+ def update(self, module, modpath, install=True):
120
+ if (modpath / ".git").is_dir():
121
+ relpath = self.relative_path(modpath)
122
+ click.echo(address(f"Checking {relpath} for updates.", preformatted=True))
123
+ pull_output = self.safe_pull(modpath)
124
+ if pull_output:
125
+ click.echo(info(pull_output))
126
+ if install and (modpath / "pyproject.toml").exists() and self.settings.get('manage_python_environment', True):
127
+ result = run("uv sync", shell=True, capture_output=True, text=True, cwd=modpath)
128
+ if result.returncode != 0:
129
+ click.echo(debug("Error running uv sync:"))
130
+ click.echo(debug(result.stderr, preformatted=True))
131
+ click.echo(error(f"There was a problem updating {relpath}. Ask a teacher."))
132
+ else:
133
+ self.init_direnv(modpath)
134
+
135
+ def safe_pull(self, modpath):
136
+ """Pulls from origin with robust recovery for common git error states.
137
+ Returns the git output string on success. Raises GitError subclasses on
138
+ unrecoverable failures.
139
+ """
140
+ # Fix bad repo states before attempting the pull
141
+ aborted = abort_in_progress_operation(modpath)
142
+ if aborted:
143
+ click.echo(confirm(
144
+ f"Cleared an incomplete git {aborted} — this shouldn't affect your work."
145
+ ))
146
+
147
+ branch = recover_detached_head(modpath)
148
+ if branch:
149
+ click.echo(confirm(
150
+ f"Your repo was not on any branch. Switched back to '{branch}'."
151
+ ))
152
+
153
+ clear_stale_index_lock(modpath)
154
+
155
+ # U1: uncommitted changes that would be overwritten — auto-commit first
156
+ status = run(
157
+ "git status --porcelain",
158
+ shell=True, capture_output=True, text=True, cwd=modpath
159
+ )
160
+ if status.stdout.strip():
161
+ probe = run(
162
+ "git fetch origin",
163
+ shell=True, capture_output=True, text=True, cwd=modpath
164
+ )
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
+
182
+ result = self._run_pull(modpath)
183
+
184
+ if result.returncode == 0:
185
+ return result.stdout
186
+
187
+ combined = result.stderr + result.stdout
188
+
189
+ # U8: no upstream tracking branch — set it and retry
190
+ if any(p in combined for p in _NO_UPSTREAM_PHRASES):
191
+ default_branch = _get_default_branch(modpath)
192
+ run(
193
+ f"git branch --set-upstream-to=origin/{default_branch} {default_branch}",
194
+ shell=True, capture_output=True, cwd=modpath
195
+ )
196
+ result = self._run_pull(modpath)
197
+ if result.returncode == 0:
198
+ return result.stdout
199
+ combined = result.stderr + result.stdout
200
+
201
+ click.echo(debug(combined.strip(), preformatted=True))
202
+ self._raise_pull_error(combined)
203
+
204
+ def _run_pull(self, modpath):
205
+ # -X theirs resolves merge conflicts in favor of incoming (teacher) changes,
206
+ # and also handles diverged-history cases (U2, U3) without extra configuration.
207
+ return run(
208
+ "git pull -X theirs",
209
+ shell=True, capture_output=True, text=True, cwd=modpath
210
+ )
211
+
212
+ def _raise_pull_error(self, stderr):
213
+ if any(p in stderr for p in _NETWORK_PHRASES):
214
+ raise GitNetworkError(
215
+ "Could not connect to the MWC server. "
216
+ "Check your internet connection and try again."
217
+ )
218
+ if any(p in stderr for p in _AUTH_PHRASES):
219
+ raise GitAuthError(
220
+ "Your login credentials are no longer valid. "
221
+ "Run `mwc setup` to refresh them, then run `mwc update` again."
222
+ )
223
+ if any(p in stderr for p in _CORRUPT_PHRASES):
224
+ raise GitCorruptError(
225
+ "Your local copy of this module appears to be damaged. "
226
+ "Ask your teacher for help."
227
+ )
228
+ raise GitError(
229
+ "Could not update this module. "
230
+ "The details are shown above. If you're not sure what to do, ask your teacher."
231
+ )
232
+
233
+ def init_direnv(self, modpath):
234
+ if not (modpath / ".envrc").exists():
235
+ (modpath / ".envrc").write_text("source .venv/bin/activate")
236
+ run("direnv allow", shell=True, check=True, cwd=modpath)
237
+
238
+ def user_has_repo(self, repo_name, username=None):
239
+ """Checks to see whether a user already has the named repo."""
240
+ username = username or self.settings['mwc_username']
241
+ url = f"/repos/{username}/{repo_name}"
242
+ response = self.authenticated_mwc_request('get', url)
243
+ return response.ok
244
+
245
+ def create_from_template(self, template_owner, template_name):
246
+ url = f"/repos/{template_owner}/{template_name}/generate"
247
+ data = {
248
+ "name": template_name,
249
+ "owner": self.settings['mwc_username'],
250
+ "git_content": True
251
+ }
252
+ response = self.authenticated_mwc_request('post', url, data=data)
253
+ return response
254
+
255
+ def clone_repo(self, repo_name):
256
+ user = self.settings['mwc_username']
257
+ auth = user + ':' + self.settings['mwc_git_token']
258
+ url = f"{self.MWC_GIT_PROTOCOL}://{auth}@{self.git_server}/{user}/{repo_name}.git"
259
+ run(["git", "clone", url], check=True, capture_output=True)
260
+
261
+ def check_settings(self):
262
+ if "mwc_username" not in self.settings:
263
+ raise MissingSetting('mwc_username')
264
+ if "mwc_git_token" not in self.settings:
265
+ raise MissingSetting('mwc_git_token')
266
+
267
+ def parse_repo_url(self, url):
268
+ """Parses a MWC url like "https://git.makingwithcode.org/mwc/lab_pipes.git"
269
+ Returns ("https://git.makingwithcode.org", "mwc", "lab_pipes")
270
+ """
271
+ result = urlparse(url)
272
+ server = f"{result.scheme}://{result.netloc}"
273
+ path, suffix = result.path.split('.')
274
+ repo_owner, repo_name = path.strip('/').split('/')
275
+ return (server, repo_owner, repo_name)
276
+
277
+ def authenticated_mwc_request(self, method_name, urlpath, data=None, params=None):
278
+ server = self.MWC_GIT_PROTOCOL + '://' + self.git_server
279
+ args = {
280
+ 'url': server + "/api/v1" + urlpath,
281
+ 'auth': (self.settings['mwc_username'], self.settings['mwc_git_token']),
282
+ }
283
+ if data:
284
+ args['data'] = data
285
+ if params:
286
+ args['params'] = params
287
+ method = getattr(requests, method_name)
288
+ response = method(**args)
289
+ if response.status_code >= 500 or response.status_code == 403:
290
+ raise GitServerNotAvailable(server)
291
+ return response
292
+
293
+ def relocate_existing_directory(self, path):
294
+ """Moves an existing directory out of the way."""
295
+ new_path = path.parent / path.name + '_old'
296
+ while new_path.exists():
297
+ new_path = new_path.parent / new_path.name + '_old'
298
+ click.echo(confirm(f"Moving existing directory {path} to {new_path}."))
299
+ os.rename(path, new_path)
@@ -0,0 +1,124 @@
1
+ from pathlib import Path
2
+ from subprocess import run, CalledProcessError
3
+ from making_with_code_cli.helpers import cd
4
+
5
+
6
+ def in_repo():
7
+ """Checks whether currently in repo"""
8
+ try:
9
+ run("git status", shell=True, capture_output=True, check=True)
10
+ return True
11
+ except CalledProcessError:
12
+ return False
13
+
14
+
15
+ def repo_has_changes():
16
+ return len(changed_files()) > 0
17
+
18
+
19
+ def changed_files():
20
+ "Returns a list of (status, filename) tuples for changed files"
21
+ cmd = "git status --porcelain"
22
+ result = run(cmd, shell=True, capture_output=True, text=True).stdout
23
+ files = []
24
+ if result:
25
+ for line in result.split('\n'):
26
+ files.append((line[:2], line[3:]))
27
+ return files
28
+ else:
29
+ return []
30
+
31
+
32
+ def has_unpushed_commits(cwd=None):
33
+ """Returns True if there are committed but unpushed local changes."""
34
+ result = run(
35
+ "git log @{u}..HEAD --oneline",
36
+ shell=True, capture_output=True, text=True, cwd=cwd
37
+ )
38
+ if result.returncode != 0:
39
+ return False
40
+ return bool(result.stdout.strip())
41
+
42
+
43
+ def get_repo_state(cwd=None):
44
+ """Returns 'merge', 'rebase', 'cherry-pick', 'detached', or 'clean'."""
45
+ git_dir = _git_dir(cwd)
46
+ if (git_dir / "MERGE_HEAD").exists():
47
+ return "merge"
48
+ if (git_dir / "rebase-merge").exists() or (git_dir / "rebase-apply").exists():
49
+ return "rebase"
50
+ if (git_dir / "CHERRY_PICK_HEAD").exists():
51
+ return "cherry-pick"
52
+ result = run(
53
+ "git branch --show-current",
54
+ shell=True, capture_output=True, text=True, cwd=cwd
55
+ )
56
+ if result.returncode == 0 and not result.stdout.strip():
57
+ return "detached"
58
+ return "clean"
59
+
60
+
61
+ def abort_in_progress_operation(cwd=None):
62
+ """Aborts any in-progress merge, rebase, or cherry-pick.
63
+ Returns the operation name that was aborted, or None if nothing to abort.
64
+ """
65
+ state = get_repo_state(cwd)
66
+ commands = {
67
+ "merge": "git merge --abort",
68
+ "rebase": "git rebase --abort",
69
+ "cherry-pick": "git cherry-pick --abort",
70
+ }
71
+ if state in commands:
72
+ run(commands[state], shell=True, capture_output=True, cwd=cwd)
73
+ return state
74
+ return None
75
+
76
+
77
+ def recover_detached_head(cwd=None):
78
+ """If in detached HEAD, checks out the default branch and returns its name.
79
+ Returns None if no action was needed.
80
+ """
81
+ if get_repo_state(cwd) != "detached":
82
+ return None
83
+ branch = _get_default_branch(cwd)
84
+ # Stash any uncommitted work so the checkout doesn't fail
85
+ stash_result = run(
86
+ "git stash", shell=True, capture_output=True, text=True, cwd=cwd
87
+ )
88
+ had_stash = "No local changes to save" not in stash_result.stdout
89
+ run(["git", "checkout", branch], capture_output=True, cwd=cwd)
90
+ if had_stash:
91
+ pop_result = run(
92
+ "git stash pop", shell=True, capture_output=True, text=True, cwd=cwd
93
+ )
94
+ # If pop conflicts (extreme edge case), drop the stash
95
+ if pop_result.returncode != 0:
96
+ run("git stash drop", shell=True, capture_output=True, cwd=cwd)
97
+ return branch
98
+
99
+
100
+ def clear_stale_index_lock(cwd=None):
101
+ """Removes .git/index.lock if present. Returns True if a lock was cleared."""
102
+ lock = _git_dir(cwd) / "index.lock"
103
+ if lock.exists():
104
+ lock.unlink()
105
+ return True
106
+ return False
107
+
108
+
109
+ def _get_default_branch(cwd=None):
110
+ """Returns the remote default branch name, falling back to 'main'."""
111
+ result = run(
112
+ "git symbolic-ref refs/remotes/origin/HEAD",
113
+ shell=True, capture_output=True, text=True, cwd=cwd
114
+ )
115
+ if result.returncode == 0:
116
+ return result.stdout.strip().replace("refs/remotes/origin/", "")
117
+ return "main"
118
+
119
+
120
+ def _git_dir(cwd=None):
121
+ """Returns the Path to the .git directory for the given working dir."""
122
+ if cwd is None:
123
+ return Path(".git")
124
+ return Path(cwd) / ".git"
@@ -45,6 +45,26 @@ class MWCAccountsAPI:
45
45
  response = self.put("/students", data=params, token=token)
46
46
  return self.handle_response(response)
47
47
 
48
+ def sync_workspace(self, token, workspace_id):
49
+ response = self.post(f"/workspaces/{workspace_id}/sync", token=token)
50
+ return self.handle_response(response)
51
+
52
+ def list_workspaces(self, token):
53
+ response = self.get("/workspaces", token=token)
54
+ return self.handle_response(response)
55
+
56
+ def create_workspace(self, token, params):
57
+ response = self.post("/workspaces", data=params, token=token)
58
+ return self.handle_response(response)
59
+
60
+ def update_workspace(self, token, workspace_id, params):
61
+ response = self.put(f"/workspaces/{workspace_id}", data=params, token=token)
62
+ return self.handle_response(response)
63
+
64
+ def delete_workspace(self, token, workspace_id):
65
+ response = self.delete(f"/workspaces/{workspace_id}", token=token)
66
+ return self.handle_response(response)
67
+
48
68
  def create_section(self, token, params):
49
69
  response = self.post("/sections", data=params, token=token)
50
70
  return self.handle_response(response)
@@ -5,7 +5,7 @@ import os
5
5
 
6
6
  def get_settings_path(settings_path=None):
7
7
  """Determines the settings path, based on what is passed and environment.
8
- "The default location for the MWC configuration file is ~/.mwc.
8
+ "The default location for the MWC configuration file is ~/.config/mwc/settings.toml.
9
9
  This can be specified (e.g. to support parallel installations) using
10
10
  the MWC_CONFIG environment variable or the --config flag.
11
11
  """
@@ -4,7 +4,6 @@ from pprint import pformat
4
4
  import traceback
5
5
  from making_with_code_cli.mwc_accounts_api import MWCAccountsAPI
6
6
  from making_with_code_cli.git_backend import get_backend
7
- from making_with_code_cli.update import update
8
7
  from making_with_code_cli.decorators import handle_mwc_errors
9
8
  from making_with_code_cli.settings import (
10
9
  get_settings_path,
@@ -86,6 +85,8 @@ def setup(ctx, config, debug, mwc_accounts_url):
86
85
  token = prompt_mwc_password(settings['mwc_username'], api)
87
86
  settings['mwc_accounts_token'] = token
88
87
  settings['mwc_git_token'] = status['git_token']
88
+ if status.get('git_server_url'):
89
+ settings['git_server_url'] = status['git_server_url']
89
90
  settings['work_dir'] = str(choose_work_dir(settings.get("work_dir")).resolve())
90
91
  settings['editor'] = choose_editor(settings.get('editor', 'code'))
91
92
  if debug:
@@ -128,4 +129,5 @@ def setup(ctx, config, debug, mwc_accounts_url):
128
129
  for task in errors:
129
130
  click.echo(error(f"- {task.description}"))
130
131
  else:
132
+ from making_with_code_cli.update import update
131
133
  ctx.invoke(update, config=config)
@@ -0,0 +1,162 @@
1
+ import click
2
+ from subprocess import run, CalledProcessError
3
+ from making_with_code_cli.git_wrapper import (
4
+ in_repo,
5
+ repo_has_changes,
6
+ has_unpushed_commits,
7
+ abort_in_progress_operation,
8
+ recover_detached_head,
9
+ clear_stale_index_lock,
10
+ )
11
+ from making_with_code_cli.styles import (
12
+ address,
13
+ info,
14
+ confirm,
15
+ debug as debug_fmt,
16
+ error,
17
+ )
18
+
19
+ _NETWORK_PHRASES = [
20
+ "Could not resolve host",
21
+ "Connection timed out",
22
+ "unable to connect",
23
+ "Failed to connect",
24
+ "network is unreachable",
25
+ ]
26
+ _AUTH_PHRASES = [
27
+ "Authentication failed",
28
+ "403",
29
+ "invalid credentials",
30
+ "could not read Username",
31
+ "terminal prompts disabled",
32
+ ]
33
+
34
+
35
+ @click.command()
36
+ def submit():
37
+ """Submit your work.
38
+ (This is a wrapper for the basic git workflow.)
39
+ """
40
+ if not in_repo():
41
+ click.echo(error("You are not in a lab, problem set, or project folder."))
42
+ return
43
+
44
+ _preflight_repo()
45
+ clear_stale_index_lock()
46
+
47
+ # S1: committed but not yet pushed — skip straight to push
48
+ if not repo_has_changes() and has_unpushed_commits():
49
+ click.echo(confirm(
50
+ "Your work was already saved locally but not yet sent to the server. "
51
+ "Submitting now..."
52
+ ))
53
+ _push()
54
+ click.echo(address("Nice job! All your work in this module has been submitted."))
55
+ return
56
+
57
+ if not repo_has_changes():
58
+ click.echo(info("Everything is already up to date."))
59
+ return
60
+
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
+
69
+ click.echo(info("Write your commit message, then save and exit the window..."))
70
+ commit_result = run("git commit", shell=True, capture_output=True, text=True)
71
+
72
+ if commit_result.returncode != 0:
73
+ combined = commit_result.stderr + commit_result.stdout
74
+ run("git restore --staged .", shell=True, capture_output=True)
75
+ # S2: student saved without writing a message
76
+ if "empty commit message" in combined or "Aborting commit" in combined:
77
+ click.echo(error(
78
+ "You didn't write a commit message. "
79
+ "Run `mwc submit` again and describe what you worked on before saving."
80
+ ))
81
+ else:
82
+ click.echo(debug_fmt(combined.strip(), preformatted=True))
83
+ click.echo(error(
84
+ "Could not save your work. "
85
+ "If you're not sure what to do, ask your teacher."
86
+ ))
87
+ return
88
+
89
+ _push()
90
+ click.echo(address("Nice job! All your work in this module has been submitted."))
91
+
92
+
93
+ def _preflight_repo():
94
+ """Abort any in-progress git operations and recover from detached HEAD."""
95
+ aborted = abort_in_progress_operation()
96
+ if aborted:
97
+ click.echo(confirm(
98
+ f"Your repo was in an incomplete state (interrupted {aborted}). "
99
+ "This has been cleared automatically."
100
+ ))
101
+ branch = recover_detached_head()
102
+ if branch:
103
+ click.echo(confirm(
104
+ f"Your repo was not on any branch. Switched back to '{branch}'."
105
+ ))
106
+
107
+
108
+ def _push():
109
+ """Push to origin, with automatic recovery from non-fast-forward rejection."""
110
+ push_result = run("git push", shell=True, capture_output=True, text=True)
111
+
112
+ if push_result.returncode == 0:
113
+ return
114
+
115
+ combined = push_result.stderr + push_result.stdout
116
+
117
+ # S3: teacher pushed changes since the student last pulled
118
+ if "non-fast-forward" in combined or (
119
+ "rejected" in combined and "fetch first" in combined
120
+ ):
121
+ click.echo(confirm(
122
+ "Your teacher made some changes while you were working. "
123
+ "Merging them in and resubmitting..."
124
+ ))
125
+ # -X ours: student's committed code wins any content conflict
126
+ pull_result = run(
127
+ "git pull -X ours", shell=True, capture_output=True, text=True
128
+ )
129
+ if pull_result.returncode != 0:
130
+ click.echo(debug_fmt(pull_result.stderr.strip(), preformatted=True))
131
+ click.echo(error(
132
+ "Could not automatically merge your teacher's changes. "
133
+ "Ask your teacher for help."
134
+ ))
135
+ return
136
+ retry = run("git push", shell=True, capture_output=True, text=True)
137
+ if retry.returncode == 0:
138
+ return
139
+ combined = retry.stderr + retry.stdout
140
+
141
+ # S4: network error — commit is safe locally, tell them so
142
+ if any(p in combined for p in _NETWORK_PHRASES):
143
+ click.echo(error(
144
+ "Your work was saved locally but could not be sent to the server "
145
+ "(network error). Check your internet connection and run "
146
+ "`mwc submit` again — you won't lose any work."
147
+ ))
148
+ return
149
+
150
+ # S5: authentication error
151
+ if any(p in combined for p in _AUTH_PHRASES):
152
+ click.echo(error(
153
+ "Your login credentials are no longer valid. "
154
+ "Run `mwc setup` to refresh them, then run `mwc submit` again."
155
+ ))
156
+ return
157
+
158
+ click.echo(debug_fmt(combined.strip(), preformatted=True))
159
+ click.echo(error(
160
+ "Could not send your work to the server. "
161
+ "If the problem persists, ask your teacher for help."
162
+ ))
@@ -7,6 +7,7 @@ from making_with_code_cli.teach.patch import patch
7
7
  from making_with_code_cli.teach.check import check
8
8
  from making_with_code_cli.teach.section import section
9
9
  from making_with_code_cli.teach.student import student
10
+ from making_with_code_cli.teach.workspace import workspace
10
11
 
11
12
  @click.group()
12
13
  def teach():
@@ -20,3 +21,4 @@ teach.add_command(patch)
20
21
  teach.add_command(check)
21
22
  teach.add_command(section)
22
23
  teach.add_command(student)
24
+ teach.add_command(workspace)
@@ -72,6 +72,8 @@ def setup(config, debug, mwc_accounts_url):
72
72
  token = prompt_mwc_password(settings['mwc_username'], api)
73
73
  settings['mwc_accounts_token'] = token
74
74
  settings['mwc_git_token'] = status['git_token']
75
+ if status.get('git_server_url'):
76
+ settings['git_server_url'] = status['git_server_url']
75
77
  settings['work_dir'] = str(choose_work_dir(settings.get("work_dir")))
76
78
  settings['editor'] = choose_editor(settings.get('editor', 'code'))
77
79
  settings['teacher_work_dir'] = str(choose_work_dir(
@@ -16,18 +16,39 @@ def create_student(username, password, section, config):
16
16
  "Create a student user"
17
17
  settings = read_settings(config)
18
18
  api = MWCAccountsAPI(settings.get('mwc_accounts_url'))
19
+ token = settings.get('mwc_accounts_token')
19
20
  params = {
20
- "username": username,
21
- "password": password,
21
+ "username": username,
22
+ "password": password,
22
23
  "section": section,
23
24
  }
24
25
  try:
25
- response = api.create_student(settings.get('mwc_accounts_token'), params)
26
+ response = api.create_student(token, params)
26
27
  click.echo(info(f"Created student {response['username']} in {section}."))
27
28
  except api.RequestFailed as err:
28
29
  click.echo(error(f"Could not create {params['username']}:"))
29
30
  print(err)
30
31
  for field, problems in err.data.items():
31
32
  click.echo(error(f" - {field}: {'; '.join(problems)}"))
33
+ return
34
+
35
+ workspace_id = _get_section_workspace_id(api, token, section)
36
+ if workspace_id:
37
+ try:
38
+ api.sync_workspace(token, workspace_id)
39
+ click.echo(info(f"Provisioned {username} on workspace."))
40
+ except (api.RequestFailed, api.ServerError) as err:
41
+ click.echo(error(f"Could not provision {username} on workspace: {err}"))
42
+
43
+
44
+ def _get_section_workspace_id(api, token, section_slug):
45
+ try:
46
+ roster = api.get_roster(token)
47
+ for sec in roster.get('teacher_sections', []):
48
+ if sec['slug'] == section_slug:
49
+ return sec.get('workspace_id')
50
+ except Exception:
51
+ pass
52
+ return None
32
53
 
33
54
 
@@ -0,0 +1,31 @@
1
+ import click
2
+ from tabulate import tabulate
3
+ from making_with_code_cli.settings import read_settings
4
+ from making_with_code_cli.mwc_accounts_api import MWCAccountsAPI
5
+ from making_with_code_cli.styles import info, error, question
6
+ from making_with_code_cli.teach.setup import check_required_teacher_settings
7
+ from making_with_code_cli.teach.workspace.create import create_workspace
8
+ from making_with_code_cli.teach.workspace.edit import edit_workspace
9
+ from making_with_code_cli.teach.workspace.delete import delete_workspace
10
+
11
+
12
+ @click.group(invoke_without_command=True)
13
+ @click.pass_context
14
+ @click.option("--config", help="Path to config file (default: ~/.mwc)")
15
+ def workspace(ctx, config):
16
+ "Manage workspaces"
17
+ if ctx.invoked_subcommand is None:
18
+ settings = read_settings(config)
19
+ if not check_required_teacher_settings(settings):
20
+ return
21
+ api = MWCAccountsAPI(settings.get('mwc_accounts_url'))
22
+ workspaces = api.list_workspaces(settings['mwc_accounts_token'])
23
+ if workspaces:
24
+ click.echo(info(tabulate(workspaces, headers="keys"), preformatted=True))
25
+ else:
26
+ click.echo(info("You have no workspaces."))
27
+
28
+
29
+ workspace.add_command(create_workspace)
30
+ workspace.add_command(edit_workspace)
31
+ workspace.add_command(delete_workspace)
@@ -0,0 +1,28 @@
1
+ import click
2
+ from tabulate import tabulate
3
+ from making_with_code_cli.settings import read_settings
4
+ from making_with_code_cli.mwc_accounts_api import MWCAccountsAPI
5
+ from making_with_code_cli.styles import info, error, question
6
+
7
+
8
+ @click.command('create')
9
+ @click.option("--config", help="Path to config file (default: ~/.mwc)")
10
+ @click.option("--name", help="Workspace name")
11
+ @click.option("--url", help="JupyterHub URL (e.g. https://hub.example.org)")
12
+ @click.option("--api-token", help="JupyterHub API token")
13
+ def create_workspace(config, name, url, api_token):
14
+ "Create a new workspace"
15
+ settings = read_settings(config)
16
+ api = MWCAccountsAPI(settings.get('mwc_accounts_url'))
17
+ params = {
18
+ "name": name or click.prompt(question("Workspace name")),
19
+ "url": url or click.prompt(question("JupyterHub URL")),
20
+ "api_token": api_token or click.prompt(question("JupyterHub API token"), hide_input=True),
21
+ }
22
+ try:
23
+ response = api.create_workspace(settings.get('mwc_accounts_token'), params)
24
+ click.echo(info(tabulate([response], headers="keys"), preformatted=True))
25
+ except api.RequestFailed as err:
26
+ click.echo(error("Could not create workspace:"))
27
+ for field, problems in err.data.items():
28
+ click.echo(error(f" - {field}: {'; '.join(problems)}"))
@@ -0,0 +1,21 @@
1
+ import click
2
+ from making_with_code_cli.settings import read_settings
3
+ from making_with_code_cli.mwc_accounts_api import MWCAccountsAPI
4
+ from making_with_code_cli.styles import info, error, confirm as confirm_fmt
5
+
6
+
7
+ @click.command('delete')
8
+ @click.argument("workspace_id", type=int)
9
+ @click.option("--config", help="Path to config file (default: ~/.mwc)")
10
+ @click.option("--yes", is_flag=True, help="Skip confirmation prompt")
11
+ def delete_workspace(workspace_id, config, yes):
12
+ "Delete a workspace"
13
+ settings = read_settings(config)
14
+ api = MWCAccountsAPI(settings.get('mwc_accounts_url'))
15
+ if not yes:
16
+ click.confirm(confirm_fmt(f"Delete workspace {workspace_id}?"), abort=True)
17
+ try:
18
+ api.delete_workspace(settings.get('mwc_accounts_token'), workspace_id)
19
+ click.echo(info(f"Deleted workspace {workspace_id}."))
20
+ except api.RequestFailed as err:
21
+ click.echo(error(f"Could not delete workspace {workspace_id}."))
@@ -0,0 +1,34 @@
1
+ import click
2
+ from tabulate import tabulate
3
+ from making_with_code_cli.settings import read_settings
4
+ from making_with_code_cli.mwc_accounts_api import MWCAccountsAPI
5
+ from making_with_code_cli.styles import info, error, question
6
+
7
+
8
+ @click.command('edit')
9
+ @click.argument("workspace_id", type=int)
10
+ @click.option("--config", help="Path to config file (default: ~/.mwc)")
11
+ @click.option("--name", help="Workspace name")
12
+ @click.option("--url", help="JupyterHub URL")
13
+ @click.option("--api-token", help="JupyterHub API token")
14
+ def edit_workspace(workspace_id, config, name, url, api_token):
15
+ "Edit a workspace"
16
+ settings = read_settings(config)
17
+ api = MWCAccountsAPI(settings.get('mwc_accounts_url'))
18
+ params = {}
19
+ if name:
20
+ params["name"] = name
21
+ if url:
22
+ params["url"] = url
23
+ if api_token:
24
+ params["api_token"] = api_token
25
+ if not params:
26
+ click.echo(error("No fields to update. Provide at least one option."))
27
+ return
28
+ try:
29
+ response = api.update_workspace(settings.get('mwc_accounts_token'), workspace_id, params)
30
+ click.echo(info(tabulate([response], headers="keys"), preformatted=True))
31
+ except api.RequestFailed as err:
32
+ click.echo(error("Could not update workspace:"))
33
+ for field, problems in err.data.items():
34
+ click.echo(error(f" - {field}: {'; '.join(problems)}"))
@@ -7,6 +7,7 @@ from making_with_code_cli.curriculum import get_curriculum
7
7
  from making_with_code_cli.git_backend import get_backend
8
8
  from making_with_code_cli.mwc_accounts_api import MWCAccountsAPI
9
9
  from making_with_code_cli.setup.tasks import WORK_DIR_PERMISSIONS
10
+ from making_with_code_cli.errors import GitError
10
11
  from making_with_code_cli.styles import (
11
12
  address,
12
13
  question,
@@ -61,8 +62,10 @@ def update(config, debug):
61
62
  if module_dir.exists():
62
63
  try:
63
64
  git_backend.update(module, module_dir)
65
+ except GitError as e:
66
+ click.echo(error(str(e)))
64
67
  except Exception as e:
65
- msg = traceback.format_exception(type(e), e, e.__traceback__)
68
+ msg = traceback.format_exception(type(e), e, e.__traceback__)
66
69
  click.echo(error(''.join(msg), preformatted=True))
67
70
  else:
68
71
  rel_dir = module_dir.resolve().relative_to(mwc_home)
@@ -70,6 +73,8 @@ def update(config, debug):
70
73
  click.echo(info(f"See {module['url']} for details."))
71
74
  try:
72
75
  git_backend.init_module(module, module_dir)
76
+ except GitError as e:
77
+ click.echo(error(str(e)))
73
78
  except Exception as e:
74
- msg = traceback.format_exception(type(e), e, e.__traceback__)
79
+ msg = traceback.format_exception(type(e), e, e.__traceback__)
75
80
  click.echo(error(''.join(msg), preformatted=True))
@@ -1,160 +0,0 @@
1
- from .base_backend import GitBackend
2
- from subprocess import run, CalledProcessError
3
- import traceback
4
- from pathlib import Path
5
- from urllib.parse import urlparse
6
- import click
7
- import json
8
- import os
9
- import requests
10
- from making_with_code_cli.helpers import cd
11
- from making_with_code_cli.styles import (
12
- address,
13
- confirm,
14
- debug,
15
- error,
16
- info,
17
- )
18
- from making_with_code_cli.errors import (
19
- MissingSetting,
20
- GitServerNotAvailable,
21
- )
22
-
23
- EDITOR_COMMANDS = {
24
- "atom": "atom --wait",
25
- "code": "code --wait",
26
- "subl": "subl -n -w",
27
- "mate": "mate -w",
28
- "vim": "vim",
29
- "emacs": "emacs",
30
- }
31
-
32
- class MWCBackend(GitBackend):
33
- """A Github backend. Students own their own repos and grant teachers access via token.
34
- """
35
-
36
- MWC_GIT_PROTOCOL = "https"
37
- MWC_GIT_SERVER = "git.makingwithcode.org"
38
- COMMIT_TEMPLATE = ".commit_template"
39
-
40
- def init_module(self, module, modpath):
41
- """Creates the named repo from a template.
42
- """
43
- self.check_settings()
44
- server, repo_owner, repo_name = self.parse_repo_url(module["repo_url"])
45
-
46
- if modpath.exists():
47
- self.relocate_existing_directory(modpath)
48
- if not self.user_has_repo(repo_name):
49
- self.create_from_template(repo_owner, repo_name)
50
- with cd(modpath.parent):
51
- self.clone_repo(repo_name)
52
- self.configure_git(modpath)
53
- if (modpath / self.COMMIT_TEMPLATE).exists():
54
- run(["git", "config", "--local", "commit.template", self.COMMIT_TEMPLATE],
55
- check=True, cwd=modpath)
56
- run("uv venv", shell=True, check=True, cwd=modpath, capture_output=True)
57
- self.init_direnv(modpath)
58
-
59
- def configure_git(self, modpath):
60
- """Configures git user settings locally for the repo."""
61
- name = self.settings['mwc_username']
62
- run(["git", "config", "--local", "user.name", name], check=True, cwd=modpath)
63
- run(["git", "config", "--local", "user.email", "nobody@makingwithcode.org"],
64
- check=True, cwd=modpath)
65
- editor = self.settings.get('editor')
66
- if editor in EDITOR_COMMANDS:
67
- run(["git", "config", "--local", "core.editor", EDITOR_COMMANDS[editor]],
68
- check=True, cwd=modpath)
69
-
70
- def update(self, module, modpath, install=True):
71
- if (modpath / ".git").is_dir():
72
- with cd(modpath):
73
- relpath = self.relative_path(modpath)
74
- click.echo(address(f"Checking {relpath} for updates.", preformatted=True))
75
- gitresult = run("git pull", shell=True, capture_output=True, text=True)
76
- if gitresult.returncode != 0:
77
- click.echo(error(
78
- f"Could not update {relpath}: {gitresult.stderr.strip()}\n"
79
- "You may have local changes that conflict. Ask a teacher for help."
80
- ))
81
- return
82
- click.echo(info(gitresult.stdout))
83
- if install and Path("pyproject.toml").exists():
84
- result = run("uv sync", shell=True, capture_output=True, text=True, cwd=modpath)
85
- if result.returncode != 0:
86
- click.echo(debug("Error running uv sync:"))
87
- click.echo(debug(result.stderr, preformatted=True))
88
- click.echo(error(f"There was a problem updating {relpath}. Ask a teacher."))
89
- else:
90
- self.init_direnv(modpath)
91
-
92
- def init_direnv(self, modpath):
93
- if not (modpath / ".envrc").exists():
94
- (modpath / ".envrc").write_text("source .venv/bin/activate")
95
- run("direnv allow", shell=True, check=True, cwd=modpath)
96
-
97
- def user_has_repo(self, repo_name, username=None):
98
- """Checks to see whether a user already has the named repo.
99
- """
100
- username = username or self.settings['mwc_username']
101
- url = f"/repos/{username}/{repo_name}"
102
- response = self.authenticated_mwc_request('get', url)
103
- return response.ok
104
-
105
- def create_from_template(self, template_owner, template_name):
106
- url = f"/repos/{template_owner}/{template_name}/generate"
107
- data = {
108
- "name": template_name,
109
- "owner": self.settings['mwc_username'],
110
- "git_content": True
111
- }
112
- response = self.authenticated_mwc_request('post', url, data=data)
113
- return response
114
-
115
- def clone_repo(self, repo_name):
116
- user = self.settings['mwc_username']
117
- auth = user + ':' + self.settings['mwc_git_token']
118
- url = f"{self.MWC_GIT_PROTOCOL}://{auth}@{self.MWC_GIT_SERVER}/{user}/{repo_name}.git"
119
- run(["git", "clone", url], check=True, capture_output=True)
120
-
121
- def check_settings(self):
122
- if "mwc_username" not in self.settings:
123
- raise MissingSetting('mwc_username')
124
- if "mwc_git_token" not in self.settings:
125
- raise MissingSetting('mwc_git_token')
126
-
127
- def parse_repo_url(self, url):
128
- """Parses a MWC url like "https://git.makingwithcode.org/mwc/lab_pipes.git"
129
- Returns ("https://git.makingwithcode.org", "mwc", "lab_pipes")
130
- """
131
- result = urlparse(url)
132
- server = f"{result.scheme}://{result.netloc}"
133
- path, suffix = result.path.split('.')
134
- repo_owner, repo_name = path.strip('/').split('/')
135
- return (server, repo_owner, repo_name)
136
-
137
- def authenticated_mwc_request(self, method_name, urlpath, data=None, params=None):
138
- server = self.MWC_GIT_PROTOCOL + '://' + self.MWC_GIT_SERVER
139
- args = {
140
- 'url': server + "/api/v1" + urlpath,
141
- 'auth': (self.settings['mwc_username'], self.settings['mwc_git_token']),
142
- }
143
- if data:
144
- args['data'] = data
145
- if params:
146
- args['params'] = params
147
- method = getattr(requests, method_name)
148
- response = method(**args)
149
- if response.status_code >= 500 or response.status_code == 403:
150
- raise GitServerNotAvailable(server)
151
- return response
152
-
153
- def relocate_existing_directory(self, path):
154
- """Moves an existing directory out of the way.
155
- """
156
- new_path = path.parent / path.name + '_old'
157
- while new_path.exists():
158
- new_path = new_path.parent / new_path.name + '_old'
159
- click.echo(confirm(f"Moving existing directory {path} to {new_path}."))
160
- os.rename(path, new_path)
@@ -1,25 +0,0 @@
1
- from subprocess import run, CalledProcessError
2
- from making_with_code_cli.helpers import cd
3
-
4
- def in_repo():
5
- """Checks whether currently in repo"""
6
- try:
7
- run("git status", shell=True, capture_output=True, check=True)
8
- return True
9
- except CalledProcessError:
10
- return False
11
-
12
- def repo_has_changes():
13
- return len(changed_files()) > 0
14
-
15
- def changed_files():
16
- "Returns a list of (status, filename) tuples for changed files"
17
- cmd = "git status --porcelain"
18
- result = run(cmd, shell=True, capture_output=True, text=True).stdout
19
- files = []
20
- if result:
21
- for line in result.split('\n'):
22
- files.append((line[:2], line[3:]))
23
- return files
24
- else:
25
- return []
@@ -1,37 +0,0 @@
1
- import click
2
- from subprocess import run, CalledProcessError
3
- from making_with_code_cli.git_wrapper import (
4
- in_repo,
5
- repo_has_changes,
6
- )
7
- from making_with_code_cli.styles import (
8
- address,
9
- question,
10
- info,
11
- debug as debug_fmt,
12
- confirm,
13
- error,
14
- )
15
-
16
- @click.command()
17
- def submit():
18
- """Submit your work.
19
- (This is a wrapper for the basic git workflow.)
20
- """
21
- if not in_repo():
22
- click.echo(error("You are not in a lab, problem set, or project folder."))
23
- return
24
- if not repo_has_changes():
25
- click.echo(info("Everything is already up to date."))
26
- return
27
- run("git add --all", shell=True, capture_output=True, check=True)
28
- run("git --no-pager diff --staged", shell=True, check=True)
29
- if not click.confirm(address("Here are the current changes. Looks OK?")):
30
- click.echo(info("Cancelled the submit for now."))
31
- return
32
- click.echo(info("Write your commit message, then save and exit the window..."))
33
- run("git commit", shell=True, capture_output=True, check=True)
34
- run("git push", shell=True, capture_output=True, check=True)
35
- click.echo(address("Nice job! All your work in this module has been submitted."))
36
-
37
-