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.
- {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/PKG-INFO +2 -2
- {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/README.md +1 -1
- {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/pyproject.toml +1 -1
- {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/curriculum.py +1 -1
- {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/errors.py +12 -0
- {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
- making_with_code_cli-5.4.0/src/making_with_code_cli/git_backend/mwc_backend.py +299 -0
- making_with_code_cli-5.4.0/src/making_with_code_cli/git_wrapper.py +124 -0
- {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
- {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/settings.py +1 -1
- {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/setup/__init__.py +3 -1
- making_with_code_cli-5.4.0/src/making_with_code_cli/submit.py +162 -0
- {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/__init__.py +2 -0
- {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/setup.py +2 -0
- {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
- making_with_code_cli-5.4.0/src/making_with_code_cli/teach/workspace/__init__.py +31 -0
- making_with_code_cli-5.4.0/src/making_with_code_cli/teach/workspace/create.py +28 -0
- making_with_code_cli-5.4.0/src/making_with_code_cli/teach/workspace/delete.py +21 -0
- making_with_code_cli-5.4.0/src/making_with_code_cli/teach/workspace/edit.py +34 -0
- {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/update/__init__.py +7 -2
- making_with_code_cli-5.2.1/src/making_with_code_cli/git_backend/mwc_backend.py +0 -160
- making_with_code_cli-5.2.1/src/making_with_code_cli/git_wrapper.py +0 -25
- making_with_code_cli-5.2.1/src/making_with_code_cli/submit.py +0 -37
- {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/__init__.py +0 -0
- {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/cli.py +0 -0
- {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/decorators.py +0 -0
- {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
- {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/helpers.py +0 -0
- {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/setup/tasks.py +0 -0
- {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/styles.py +0 -0
- {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/assess.py +0 -0
- {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
- {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
- {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
- {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
- {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/log.py +0 -0
- {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/patch.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/status.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/update.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
{making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/curriculum.py
RENAMED
|
@@ -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:
|
{making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/errors.py
RENAMED
|
@@ -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
|
|
@@ -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)
|
{making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/settings.py
RENAMED
|
@@ -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
|
"""
|
{making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/setup/__init__.py
RENAMED
|
@@ -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
|
+
))
|
{making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/__init__.py
RENAMED
|
@@ -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)
|
{making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/setup.py
RENAMED
|
@@ -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(
|
|
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 =
|
|
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 =
|
|
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
|
-
|
{making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/decorators.py
RENAMED
|
File without changes
|
|
File without changes
|
{making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/helpers.py
RENAMED
|
File without changes
|
{making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/setup/tasks.py
RENAMED
|
File without changes
|
{making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/styles.py
RENAMED
|
File without changes
|
{making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/assess.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/log.py
RENAMED
|
File without changes
|
{making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/patch.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/status.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/update.py
RENAMED
|
File without changes
|
{making_with_code_cli-5.2.1 → making_with_code_cli-5.4.0}/src/making_with_code_cli/version.py
RENAMED
|
File without changes
|