making-with-code-cli 5.2.0__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.0 → making_with_code_cli-5.4.0}/PKG-INFO +2 -2
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/README.md +1 -1
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/pyproject.toml +3 -1
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/curriculum.py +1 -1
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/errors.py +12 -0
- {making_with_code_cli-5.2.0 → 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.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/mwc_accounts_api.py +20 -0
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/settings.py +2 -2
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/setup/__init__.py +3 -1
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/setup/tasks.py +18 -0
- making_with_code_cli-5.4.0/src/making_with_code_cli/submit.py +162 -0
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/__init__.py +2 -0
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/section/edit.py +1 -1
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/setup.py +2 -0
- {making_with_code_cli-5.2.0 → 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.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/update/__init__.py +7 -2
- making_with_code_cli-5.2.0/src/making_with_code_cli/git_backend/mwc_backend.py +0 -160
- making_with_code_cli-5.2.0/src/making_with_code_cli/git_wrapper.py +0 -25
- making_with_code_cli-5.2.0/src/making_with_code_cli/submit.py +0 -37
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/__init__.py +0 -0
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/cli.py +0 -0
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/decorators.py +0 -0
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/git_backend/__init__.py +0 -0
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/helpers.py +0 -0
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/styles.py +0 -0
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/assess.py +0 -0
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/check/__init__.py +0 -0
- {making_with_code_cli-5.2.0 → 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.0 → 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.0 → 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.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/log.py +0 -0
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/patch.py +0 -0
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/section/__init__.py +0 -0
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/section/create.py +0 -0
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/section/delete.py +0 -0
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/section/show.py +0 -0
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/status.py +0 -0
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/student/__init__.py +0 -0
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/student/edit.py +0 -0
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/student/remove.py +0 -0
- {making_with_code_cli-5.2.0 → 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.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/student_repos.py +0 -0
- {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/update.py +0 -0
- {making_with_code_cli-5.2.0 → 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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "making-with-code-cli"
|
|
3
|
-
version = "5.
|
|
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"
|
|
@@ -41,3 +41,5 @@ docs = [
|
|
|
41
41
|
"sphinx>=8.2.3,<9",
|
|
42
42
|
"sphinx-rtd-theme>=3.0.2,<4",
|
|
43
43
|
]
|
|
44
|
+
[tool.uv]
|
|
45
|
+
check-url = "https://pypi.org/simple/"
|
{making_with_code_cli-5.2.0 → 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.0 → 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.0 → 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
|
"""
|
|
@@ -14,7 +14,7 @@ def get_settings_path(settings_path=None):
|
|
|
14
14
|
elif "MWC_CONFIG" in os.environ:
|
|
15
15
|
return Path(os.environ["MWC_CONFIG"])
|
|
16
16
|
else:
|
|
17
|
-
xdg_config_home = os.environ.get("XDG_CONFIG_HOME",
|
|
17
|
+
xdg_config_home = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
18
18
|
return xdg_config_home / "mwc" / "settings.toml"
|
|
19
19
|
|
|
20
20
|
def read_settings(settings_path=None):
|
{making_with_code_cli-5.2.0 → 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)
|
{making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/setup/tasks.py
RENAMED
|
@@ -403,10 +403,28 @@ class InstallVSCode(InstallPackage):
|
|
|
403
403
|
brew_name = "visual-studio-code"
|
|
404
404
|
brew_cask = True
|
|
405
405
|
|
|
406
|
+
def is_complete(self):
|
|
407
|
+
if Platform.detect() == Platform.MAC:
|
|
408
|
+
app_locations = [
|
|
409
|
+
Path("/Applications/Visual Studio Code.app"),
|
|
410
|
+
Path.home() / "Applications/Visual Studio Code.app",
|
|
411
|
+
]
|
|
412
|
+
return self.executable_on_path("code") or any(p.exists() for p in app_locations)
|
|
413
|
+
return self.executable_on_path("code")
|
|
414
|
+
|
|
406
415
|
def run_task(self):
|
|
407
416
|
if Platform.detect() in (Platform.UBUNTU, Platform.WSL):
|
|
408
417
|
run("sudo snap install --classic code", shell=True, check=True)
|
|
409
418
|
else:
|
|
419
|
+
# If brew has a stale record of VS Code but the app is missing,
|
|
420
|
+
# the upgrade will fail. Zap the cask first to clear brew's state.
|
|
421
|
+
stale = run(
|
|
422
|
+
"brew list --cask visual-studio-code",
|
|
423
|
+
shell=True, capture_output=True
|
|
424
|
+
).returncode == 0
|
|
425
|
+
if stale:
|
|
426
|
+
run("brew uninstall --cask --zap --force visual-studio-code",
|
|
427
|
+
shell=True, check=True)
|
|
410
428
|
super().run_task()
|
|
411
429
|
|
|
412
430
|
class InstallImageMagick(InstallPackage):
|