making-with-code-cli 5.2.0__py3-none-any.whl → 5.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,
@@ -8,6 +8,12 @@ import json
8
8
  import os
9
9
  import requests
10
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
+ )
11
17
  from making_with_code_cli.styles import (
12
18
  address,
13
19
  confirm,
@@ -18,6 +24,10 @@ from making_with_code_cli.styles import (
18
24
  from making_with_code_cli.errors import (
19
25
  MissingSetting,
20
26
  GitServerNotAvailable,
27
+ GitError,
28
+ GitNetworkError,
29
+ GitAuthError,
30
+ GitCorruptError,
21
31
  )
22
32
 
23
33
  EDITOR_COMMANDS = {
@@ -29,17 +39,55 @@ EDITOR_COMMANDS = {
29
39
  "emacs": "emacs",
30
40
  }
31
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
+
32
73
  class MWCBackend(GitBackend):
33
- """A Github backend. Students own their own repos and grant teachers access via token.
34
- """
74
+ """A Github backend. Students own their own repos and grant teachers access via token."""
35
75
 
36
76
  MWC_GIT_PROTOCOL = "https"
37
77
  MWC_GIT_SERVER = "git.makingwithcode.org"
38
78
  COMMIT_TEMPLATE = ".commit_template"
39
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
+
40
89
  def init_module(self, module, modpath):
41
- """Creates the named repo from a template.
42
- """
90
+ """Creates the named repo from a template."""
43
91
  self.check_settings()
44
92
  server, repo_owner, repo_name = self.parse_repo_url(module["repo_url"])
45
93
 
@@ -53,8 +101,9 @@ class MWCBackend(GitBackend):
53
101
  if (modpath / self.COMMIT_TEMPLATE).exists():
54
102
  run(["git", "config", "--local", "commit.template", self.COMMIT_TEMPLATE],
55
103
  check=True, cwd=modpath)
56
- run("uv venv", shell=True, check=True, cwd=modpath, capture_output=True)
57
- self.init_direnv(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)
58
107
 
59
108
  def configure_git(self, modpath):
60
109
  """Configures git user settings locally for the repo."""
@@ -69,25 +118,117 @@ class MWCBackend(GitBackend):
69
118
 
70
119
  def update(self, module, modpath, install=True):
71
120
  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)
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
+ )
91
232
 
92
233
  def init_direnv(self, modpath):
93
234
  if not (modpath / ".envrc").exists():
@@ -95,8 +236,7 @@ class MWCBackend(GitBackend):
95
236
  run("direnv allow", shell=True, check=True, cwd=modpath)
96
237
 
97
238
  def user_has_repo(self, repo_name, username=None):
98
- """Checks to see whether a user already has the named repo.
99
- """
239
+ """Checks to see whether a user already has the named repo."""
100
240
  username = username or self.settings['mwc_username']
101
241
  url = f"/repos/{username}/{repo_name}"
102
242
  response = self.authenticated_mwc_request('get', url)
@@ -115,7 +255,7 @@ class MWCBackend(GitBackend):
115
255
  def clone_repo(self, repo_name):
116
256
  user = self.settings['mwc_username']
117
257
  auth = user + ':' + self.settings['mwc_git_token']
118
- url = f"{self.MWC_GIT_PROTOCOL}://{auth}@{self.MWC_GIT_SERVER}/{user}/{repo_name}.git"
258
+ url = f"{self.MWC_GIT_PROTOCOL}://{auth}@{self.git_server}/{user}/{repo_name}.git"
119
259
  run(["git", "clone", url], check=True, capture_output=True)
120
260
 
121
261
  def check_settings(self):
@@ -135,7 +275,7 @@ class MWCBackend(GitBackend):
135
275
  return (server, repo_owner, repo_name)
136
276
 
137
277
  def authenticated_mwc_request(self, method_name, urlpath, data=None, params=None):
138
- server = self.MWC_GIT_PROTOCOL + '://' + self.MWC_GIT_SERVER
278
+ server = self.MWC_GIT_PROTOCOL + '://' + self.git_server
139
279
  args = {
140
280
  'url': server + "/api/v1" + urlpath,
141
281
  'auth': (self.settings['mwc_username'], self.settings['mwc_git_token']),
@@ -151,8 +291,7 @@ class MWCBackend(GitBackend):
151
291
  return response
152
292
 
153
293
  def relocate_existing_directory(self, path):
154
- """Moves an existing directory out of the way.
155
- """
294
+ """Moves an existing directory out of the way."""
156
295
  new_path = path.parent / path.name + '_old'
157
296
  while new_path.exists():
158
297
  new_path = new_path.parent / new_path.name + '_old'
@@ -1,6 +1,8 @@
1
+ from pathlib import Path
1
2
  from subprocess import run, CalledProcessError
2
3
  from making_with_code_cli.helpers import cd
3
4
 
5
+
4
6
  def in_repo():
5
7
  """Checks whether currently in repo"""
6
8
  try:
@@ -9,9 +11,11 @@ def in_repo():
9
11
  except CalledProcessError:
10
12
  return False
11
13
 
14
+
12
15
  def repo_has_changes():
13
16
  return len(changed_files()) > 0
14
17
 
18
+
15
19
  def changed_files():
16
20
  "Returns a list of (status, filename) tuples for changed files"
17
21
  cmd = "git status --porcelain"
@@ -23,3 +27,98 @@ def changed_files():
23
27
  return files
24
28
  else:
25
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
  """
@@ -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", str(Path.home() / ".config"))
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):
@@ -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)
@@ -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):
@@ -3,16 +3,35 @@ from subprocess import run, CalledProcessError
3
3
  from making_with_code_cli.git_wrapper import (
4
4
  in_repo,
5
5
  repo_has_changes,
6
+ has_unpushed_commits,
7
+ abort_in_progress_operation,
8
+ recover_detached_head,
9
+ clear_stale_index_lock,
6
10
  )
7
11
  from making_with_code_cli.styles import (
8
12
  address,
9
- question,
10
13
  info,
11
- debug as debug_fmt,
12
14
  confirm,
15
+ debug as debug_fmt,
13
16
  error,
14
17
  )
15
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
+
16
35
  @click.command()
17
36
  def submit():
18
37
  """Submit your work.
@@ -21,17 +40,123 @@ def submit():
21
40
  if not in_repo():
22
41
  click.echo(error("You are not in a lab, problem set, or project folder."))
23
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
+
24
57
  if not repo_has_changes():
25
58
  click.echo(info("Everything is already up to date."))
26
59
  return
60
+
27
61
  run("git add --all", shell=True, capture_output=True, check=True)
28
62
  run("git --no-pager diff --staged", shell=True, check=True)
63
+
29
64
  if not click.confirm(address("Here are the current changes. Looks OK?")):
65
+ run("git restore --staged .", shell=True, capture_output=True)
30
66
  click.echo(info("Cancelled the submit for now."))
31
67
  return
68
+
32
69
  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)
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()
35
90
  click.echo(address("Nice job! All your work in this module has been submitted."))
36
91
 
37
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)
@@ -19,7 +19,7 @@ from making_with_code_cli.styles import (
19
19
  @click.option("--curriculum-site-url", help="URL for curriculum website. e.g. https://makingwithcode.org")
20
20
  @click.option("--course-name", help="MWC course name")
21
21
  @click.option("--code", help="Code students can use to join the section")
22
- @click.option("--roster", click.Path(exists=True), help="csv file containing student information")
22
+ @click.option("--roster", type=click.Path(exists=True), help="csv file containing student information")
23
23
  @click.option('-r', "--remove", is_flag=True, help="When using a roster, remove existing students not on roster")
24
24
  @click.option('-f', "--force", is_flag=True, help="When using a roster, update existing student passwords")
25
25
  @handle_mwc_errors
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: making-with-code-cli
3
- Version: 5.2.0
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.
@@ -1,20 +1,20 @@
1
1
  making_with_code_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  making_with_code_cli/cli.py,sha256=KuSWKXYKK651tIXpeZ3nIVynRATal8NV-jSArYXEbo0,443
3
- making_with_code_cli/curriculum.py,sha256=gp0ValDb3ZfKfHV8B33-i16Q7u49PZfWpNFRFgQO2vQ,1180
3
+ making_with_code_cli/curriculum.py,sha256=LewUkk2BHZsQhbZMXi3JiYm1ROcxpQL1u-nsd43x_hw,1192
4
4
  making_with_code_cli/decorators.py,sha256=d-fLMfi2BX6jaLAqaHbQFBUGyHaCijqq6EpSCAl3YCk,583
5
- making_with_code_cli/errors.py,sha256=N-i-aurPrqyo0ORBKNpMDxl2woyFAUZEDrMO1vJ5iF4,990
5
+ making_with_code_cli/errors.py,sha256=V0JfwUPMEoU16cDxJh_SR-xQbLEtA5EwsV9RoI6l6C0,1152
6
6
  making_with_code_cli/git_backend/__init__.py,sha256=UleXH-1HbEglNL8y_d7-JpQfLRDZ2pBpLYzy9-eH_k0,112
7
- making_with_code_cli/git_backend/base_backend.py,sha256=Y9n-fn2o6tsMSBy-72_0e-g59rBNZO1zHxToeLOcLUw,907
8
- making_with_code_cli/git_backend/mwc_backend.py,sha256=wXlM8PyEtfdXB2DBBF8cQSPD1UtFpkYTAdWqsBCVvMg,6430
9
- making_with_code_cli/git_wrapper.py,sha256=qSkFdZG2OAww_-eBzAg3tYJODdHS91tEmvRJtYim4b4,727
7
+ making_with_code_cli/git_backend/base_backend.py,sha256=zxk3PxI6y35s2pYh1nl5Rz9c2S9BPF2M6HSge63WGyo,841
8
+ making_with_code_cli/git_backend/mwc_backend.py,sha256=O5_cz3eEK90yc4h5So9zmSGuCcEdCM2NKHdsClbVcQI,11358
9
+ making_with_code_cli/git_wrapper.py,sha256=M2J5k9HheNPd91GR-sGaWiYgTnG9vNfjxSbEGswSjdo,3898
10
10
  making_with_code_cli/helpers.py,sha256=h4-gkT0y61GGzV08xcaEqLGJ9N9IQPEMa2P_7_g2sY0,1167
11
- making_with_code_cli/mwc_accounts_api.py,sha256=y0B--ybCdTv7mZN12pnK30hdbYPsFy3qV1hXmO7vEYY,3792
12
- making_with_code_cli/settings.py,sha256=nj0JILi8J6h28nrgBI7dVJ-XjzXepqiDnGLwfCOBbYI,1787
13
- making_with_code_cli/setup/__init__.py,sha256=yHUKuOUTKdUVkiwDFoM8F9O48w0Gpy0hpou4rkroiCY,4426
14
- making_with_code_cli/setup/tasks.py,sha256=ztlslomK6MkhXhVjCklj0GVW_zLNUpudmuE-qVufU_8,15536
11
+ making_with_code_cli/mwc_accounts_api.py,sha256=INq0ezDY3bDYTLt7cAFp5JmJ4rkr9LcDnz6YYp5d-VQ,4641
12
+ making_with_code_cli/settings.py,sha256=c_sWjBgjrugXx-11rWOOVKFcxiU5WfFENR0WhZs7LLI,1809
13
+ making_with_code_cli/setup/__init__.py,sha256=2mABWsAEhf7rx9Yx3WfxAOMce1A-AYQuByIWZboZaI0,4533
14
+ making_with_code_cli/setup/tasks.py,sha256=jTlcDiviuzXyhIcdYhGhWLXH8zVvKqW_7i79GXZOtNg,16382
15
15
  making_with_code_cli/styles.py,sha256=ZKbs-Grq1aF3wV8scwQxMO1UjnphuIccdNzksLxAo_A,910
16
- making_with_code_cli/submit.py,sha256=gWnueoyvZVdJdSLxOBSS0Qvx_8OdQB5VonQ1p7hjYSA,1207
17
- making_with_code_cli/teach/__init__.py,sha256=mK2cswI1NiVULx3WszZmSyn7HComDDcHvyqL2Y8defI,691
16
+ making_with_code_cli/submit.py,sha256=3rit0F9ott27JezXhujYBUJSOopRJ_xEcm1oACigP88,5496
17
+ making_with_code_cli/teach/__init__.py,sha256=qra5g986DFipl0GkYOW3Nnoi1QUGpatS_U9eFoVVsgw,779
18
18
  making_with_code_cli/teach/assess.py,sha256=2--XhfMRsyMoEaP7EAeRD4uk5tynB3wLE3KE5pdYC30,1625
19
19
  making_with_code_cli/teach/check/__init__.py,sha256=wlUNHdHI5NwUbGGTHCM87xdJ4Ts3bwMfBF8wXoK2Z68,1787
20
20
  making_with_code_cli/teach/check/check_module.py,sha256=TBtebZoeYSqPLOiMo3prNAXZZFbs1UywN-_XVOPQ4Co,3215
@@ -25,20 +25,24 @@ making_with_code_cli/teach/patch.py,sha256=icuMU0EM8RZb3w1RU3HLDyN_CLSh2HiYRC0OQ
25
25
  making_with_code_cli/teach/section/__init__.py,sha256=gHq-y_vRP0Q2sV5j58QJ0h_jU4oOjv26VnxLtFA5g3E,2294
26
26
  making_with_code_cli/teach/section/create.py,sha256=jC-4CKsZ5I-v_2xtXq3nX-2vSXv1tMDo1BqNGs5afgM,3745
27
27
  making_with_code_cli/teach/section/delete.py,sha256=Z9uljVCqaKDw4-SzoAl-jxs4SHCB3Q8fKZVXNLHHzQ8,773
28
- making_with_code_cli/teach/section/edit.py,sha256=mp97mj5x3OGEN5diix316cFF95b-P9l4SXkvi-UTwwQ,2152
28
+ making_with_code_cli/teach/section/edit.py,sha256=OKub7wJ7yINBw4qkf-L_urrkB4nlXRauIcNbxuLTjGk,2157
29
29
  making_with_code_cli/teach/section/show.py,sha256=YZ1fJr3LSQl0E9hgkV9-gndyYn0FnUbPa5ChO2CWbb8,2260
30
- making_with_code_cli/teach/setup.py,sha256=bK7nxH748rMripUUIqpYJBc54kEOomsV4uF3lB4eXfI,3050
30
+ making_with_code_cli/teach/setup.py,sha256=sF6j-aPOsQ4vJwOb7I_t57LZF5dDmOib15hpET9HAsI,3149
31
31
  making_with_code_cli/teach/status.py,sha256=HIMdHCG0JUrrQBu9EDKxOrg52_zYUNj1w6BfFYonds8,4520
32
32
  making_with_code_cli/teach/student/__init__.py,sha256=VamqMDxH-ZuGZWTR5g8jYQ3XkgWYQqmunVorpZ00fbM,402
33
- making_with_code_cli/teach/student/create.py,sha256=JJ9tjcIgYfEkQyzUqG-cBemSnnrxyZdqLfMt4M2zWWs,1119
33
+ making_with_code_cli/teach/student/create.py,sha256=Oa78OSUyeDGepjgg6ivycTpzYQl70anc6PEXrvT2s_s,1822
34
34
  making_with_code_cli/teach/student/edit.py,sha256=EhP-x7QNXFDh4YGnW8CNjggDbUOAyfZqxa9ZM2gpffY,1257
35
35
  making_with_code_cli/teach/student/remove.py,sha256=z6Y_n1Q53qGeauSCaqzjO852pRkMDoioVoPS1TChMcM,1300
36
36
  making_with_code_cli/teach/student_repo_functions.py,sha256=-Qm-SgkiCW_d1jnuNi5I5B3YBeWqBybqrL5sgXwQ77Q,3633
37
37
  making_with_code_cli/teach/student_repos.py,sha256=TRUxpgyrGjsiY58dIpPLGOzytw9sL9zgS-kUquXd39Q,2614
38
38
  making_with_code_cli/teach/update.py,sha256=bhGpZudmTrH0pSq-hLfmKOAgMBsmMZG-nfB7zA79tkg,1860
39
- making_with_code_cli/update/__init__.py,sha256=5qtyD_YN1_sTsJ2g0ayIFtpXFZommXGJBetRs9wyW0g,3506
39
+ making_with_code_cli/teach/workspace/__init__.py,sha256=FDe1ggbBYVC5EWH1NBjWdyGgxqjlSQxf_iKDDYqyOgA,1298
40
+ making_with_code_cli/teach/workspace/create.py,sha256=t0hIwJwU7ZEF3QJl0Cwbve3jD1ilYex680dRCvhbkr0,1320
41
+ making_with_code_cli/teach/workspace/delete.py,sha256=126VQaFMvulpXY_ch1yXNN4fCLL_bQfAnkRwnOL9PYk,952
42
+ making_with_code_cli/teach/workspace/edit.py,sha256=BndmJrItJMB3JzW6MNCfqyLPTV6UaPh2T6hQa73TcJQ,1369
43
+ making_with_code_cli/update/__init__.py,sha256=13Sy6mYojZANKF6YOB5GkWvxwbPLWaZfvjCfqk9Nztw,3737
40
44
  making_with_code_cli/version.py,sha256=4t7TOR5OlBrLFy7jrtS_d6FtIReco1NDdVPQoCb6Jx8,286
41
- making_with_code_cli-5.2.0.dist-info/WHEEL,sha256=iHtWm8nRfs0VRdCYVXocAWFW8ppjHL-uTJkAdZJKOBM,80
42
- making_with_code_cli-5.2.0.dist-info/entry_points.txt,sha256=0nHqTE6I8cKRoVK_tbRNYI9nY0GFYLDJDZemvDqIHeM,54
43
- making_with_code_cli-5.2.0.dist-info/METADATA,sha256=-8X_swu4XRYGtkeo9vJJcKT51QkdiOpOxgC8h0f3GtA,2318
44
- making_with_code_cli-5.2.0.dist-info/RECORD,,
45
+ making_with_code_cli-5.4.0.dist-info/WHEEL,sha256=Qb5DWjqM6GZuPp3VmTlAFSGqNRK8vceyVqRFxrfa8YA,80
46
+ making_with_code_cli-5.4.0.dist-info/entry_points.txt,sha256=0nHqTE6I8cKRoVK_tbRNYI9nY0GFYLDJDZemvDqIHeM,54
47
+ making_with_code_cli-5.4.0.dist-info/METADATA,sha256=Z7qbd43JmUV2Aw75kw9Yy146keZY6Zx7wxporkZwra4,2321
48
+ making_with_code_cli-5.4.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.9.30
2
+ Generator: uv 0.11.4
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any