making-with-code-cli 3.1.0__tar.gz → 4.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/PKG-INFO +2 -3
  2. making_with_code_cli-4.0.0/making_with_code_cli/decorators.py +19 -0
  3. making_with_code_cli-4.0.0/making_with_code_cli/mwc_accounts_api.py +89 -0
  4. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/setup/__init__.py +34 -15
  5. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/setup/tasks.py +3 -4
  6. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/teach/__init__.py +4 -0
  7. making_with_code_cli-4.0.0/making_with_code_cli/teach/section/__init__.py +45 -0
  8. making_with_code_cli-4.0.0/making_with_code_cli/teach/section/create.py +88 -0
  9. making_with_code_cli-4.0.0/making_with_code_cli/teach/section/edit.py +41 -0
  10. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/teach/setup.py +30 -14
  11. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/teach/status.py +12 -12
  12. making_with_code_cli-4.0.0/making_with_code_cli/teach/student/__init__.py +10 -0
  13. making_with_code_cli-4.0.0/making_with_code_cli/teach/student/create.py +33 -0
  14. making_with_code_cli-4.0.0/making_with_code_cli/teach/student/update.py +29 -0
  15. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/teach/student_repo_functions.py +9 -9
  16. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/teach/student_repos.py +11 -11
  17. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/teach/update.py +3 -3
  18. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/update/__init__.py +2 -2
  19. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/pyproject.toml +5 -5
  20. making_with_code_cli-3.1.0/making_with_code_cli/mwc_accounts_api.py +0 -61
  21. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/README.md +0 -0
  22. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/cli.py +0 -0
  23. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/curriculum.py +0 -0
  24. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/errors.py +0 -0
  25. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/git_backend/__init__.py +0 -0
  26. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/git_backend/base_backend.py +0 -0
  27. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/git_backend/mwc_backend.py +0 -0
  28. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/git_wrapper.py +0 -0
  29. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/helpers.py +0 -0
  30. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/settings.py +0 -0
  31. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/styles.py +0 -0
  32. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/submit.py +0 -0
  33. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/teach/assess.py +0 -0
  34. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/teach/check/__init__.py +0 -0
  35. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/teach/check/check_module.py +0 -0
  36. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/teach/gitea_api/api.py +0 -0
  37. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/teach/gitea_api/exceptions.py +0 -0
  38. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/teach/log.py +0 -0
  39. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/teach/patch.py +0 -0
  40. {making_with_code_cli-3.1.0 → making_with_code_cli-4.0.0}/making_with_code_cli/version.py +0 -0
@@ -1,14 +1,13 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: making-with-code-cli
3
- Version: 3.1.0
3
+ Version: 4.0.0
4
4
  Summary: Courseware for Making With Code
5
5
  License: MIT
6
6
  Author: Chris Proctor
7
7
  Author-email: chris@chrisproctor.net
8
- Requires-Python: >=3.10,<4.0
8
+ Requires-Python: >=3.11,<4.0
9
9
  Classifier: License :: OSI Approved :: MIT License
10
10
  Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.10
12
11
  Classifier: Programming Language :: Python :: 3.11
13
12
  Classifier: Programming Language :: Python :: 3.12
14
13
  Classifier: Programming Language :: Python :: 3.13
@@ -0,0 +1,19 @@
1
+ from functools import update_wrapper
2
+ import click
3
+ import sys
4
+ from making_with_code_cli.styles import error
5
+ from making_with_code_cli.errors import MWCError
6
+
7
+
8
+ def handle_mwc_errors(f):
9
+ """Decorator declaring a click command.
10
+ Wraps execution in a try/catch block, so that MWCErrors can be handled with
11
+ graceful output.
12
+ """
13
+ def command(*args, **kwargs):
14
+ try:
15
+ return f(*args, **kwargs)
16
+ except MWCError as e:
17
+ click.echo(error(str(e), preformatted=True), err=True)
18
+ sys.exit(1)
19
+ return update_wrapper(command, f)
@@ -0,0 +1,89 @@
1
+ import requests
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+ from urllib.parse import urljoin
6
+ from getpass import getpass
7
+ from making_with_code_cli.errors import MWCError
8
+
9
+ MWC_ACCOUNTS_SERVER = "https://accounts.makingwithcode.org"
10
+
11
+ class MWCAccountsAPI:
12
+ def __init__(self, mwc_accounts_server=None):
13
+ self.mwc_accounts_server = mwc_accounts_server or MWC_ACCOUNTS_SERVER
14
+
15
+ def login(self, username, password):
16
+ "Authenticates with a username and password, returning an auth token"
17
+ data = {"username": username, "password": password}
18
+ response = self.post("/login", data=data)
19
+ return self.handle_response(response)
20
+
21
+ def logout(self, token):
22
+ response = self.post("/logout", token=token)
23
+ return self.handle_response(response)
24
+
25
+ def get_status(self, token):
26
+ response = self.get("/status", token=token)
27
+ return self.handle_response(response)
28
+
29
+ def get_roster(self, token):
30
+ response = self.get("/roster", token=token)
31
+ return self.handle_response(response)
32
+
33
+ def create_student(self, token, params):
34
+ response = self.post("/students", data=params, token=token)
35
+ return self.handle_response(response)
36
+
37
+ def update_student(self, token, params):
38
+ response = self.put("/students", data=params, token=token)
39
+ return self.handle_response(response)
40
+
41
+ def create_section(self, token, params):
42
+ response = self.post("/sections", data=params, token=token)
43
+ return self.handle_response(response)
44
+
45
+ def update_section(self, token, params):
46
+ response = self.put("/sections", data=params, token=token)
47
+ return self.handle_response(response)
48
+
49
+ def get(self, url, data=None, token=None):
50
+ return self.http_request("get", url, data=data, token=token)
51
+
52
+ def post(self, url, data=None, token=None):
53
+ return self.http_request("post", url, data=data, token=token)
54
+
55
+ def put(self, url, data=None, token=None):
56
+ return self.http_request("put", url, data=data, token=token)
57
+
58
+ def http_request(self, method, url, data=None, token=None):
59
+ fn = getattr(requests, method)
60
+ headers = {"Authorization": f"Token {token}"} if token else None
61
+ try:
62
+ return fn(self.mwc_accounts_server + url, data=data, headers=headers)
63
+ except requests.exceptions.ConnectionError:
64
+ raise self.ServerError("Could not connect to server")
65
+
66
+ def handle_response(self, response):
67
+ if response.ok:
68
+ return response.json()
69
+ elif response.status_code == 500:
70
+ raise self.ServerError("Error 500")
71
+ else:
72
+ try:
73
+ rj = response.json()
74
+ raise self.RequestFailed(rj, data=rj)
75
+ except requests.exceptions.JSONDecodeError:
76
+ raise self.RequestFailed(response)
77
+
78
+ class RequestFailed(MWCError):
79
+ def __init__(self, *args, data=None, **kwargs):
80
+ super().__init__(*args, **kwargs)
81
+ self.data = data
82
+
83
+ class ServerError(MWCError):
84
+ pass
85
+
86
+
87
+
88
+
89
+
@@ -5,6 +5,7 @@ import yaml
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
7
  from making_with_code_cli.update import update
8
+ from making_with_code_cli.decorators import handle_mwc_errors
8
9
  from making_with_code_cli.settings import (
9
10
  get_settings_path,
10
11
  read_settings,
@@ -42,39 +43,57 @@ from making_with_code_cli.setup.tasks import (
42
43
  @click.command()
43
44
  @click.option("--config", help="Path to config file (default: ~/.mwc)")
44
45
  @click.option("--debug", is_flag=True, help="Show debug-level output")
46
+ @click.option("--git-name", help="Set git name")
47
+ @click.option("--git-email", help="Set git email address")
48
+ @click.option("--mwc-accounts-url", help="Set URL for MWC accounts server")
45
49
  @click.pass_context
46
- def setup(ctx, config, debug):
50
+ @handle_mwc_errors
51
+ def setup(ctx, config, debug, git_name, git_email, mwc_accounts_url):
47
52
  """Set up the MWC command line interface"""
48
53
  settings = read_settings(config)
54
+ sp = get_settings_path(config)
49
55
  if debug:
50
- sp = get_settings_path(config)
51
56
  click.echo(debug_fmt(f"Reading settings from {sp}"))
57
+ if not sp.parent.exists():
58
+ if click.confirm(confirm(f"Directory {sp.parent} doesn't exist. Create it?")):
59
+ sp.parent.mkdir(parents=True)
60
+ else:
61
+ click.error(f"Could not save config file at {sp}.")
62
+ return
52
63
  rc_tasks = []
53
64
  click.echo(address(INTRO_MESSAGE))
54
65
  for note in INTRO_NOTES:
55
66
  click.echo(address(note, list_format=True))
56
67
  click.echo()
68
+ if git_name:
69
+ settings['git_name'] = git_name
70
+ if git_email:
71
+ settings['git_email'] = git_email
72
+ if mwc_accounts_url:
73
+ settings['mwc_accounts_url'] = mwc_accounts_url
57
74
  settings['mwc_username'] = choose_mwc_username(settings.get("mwc_username"))
58
- api = MWCAccountsAPI()
59
- if settings.get('mwc_accounts_token'):
75
+ api = MWCAccountsAPI(settings.get('mwc_accounts_url'))
76
+ if not 'mwc_accounts_token' in settings:
77
+ token = prompt_mwc_password(settings['mwc_username'], api)
78
+ settings['mwc_accounts_token'] = token
79
+ while True:
60
80
  try:
61
81
  status = api.get_status(settings['mwc_accounts_token'])
82
+ break
62
83
  except api.RequestFailed as bad_token:
63
- token = prompt_mwc_password(settings['mwc_username'])
84
+ click.echo(error("Sorry, there was an error logging in."))
85
+ if debug:
86
+ click.echo(debug_fmt(bad_token))
87
+ token = prompt_mwc_password(settings['mwc_username'], api)
64
88
  settings['mwc_accounts_token'] = token
65
- status = api.get_status(token)
66
- else:
67
- token = prompt_mwc_password(settings['mwc_username'])
68
- settings['mwc_accounts_token'] = token
69
- status = api.get_status(token)
70
- if debug:
71
- click.echo(debug_fmt("MWC Accounts Server status:"))
72
- click.echo(debug_fmt(str(status)))
73
89
  settings['mwc_git_token'] = status['git_token']
74
90
  settings['work_dir'] = str(choose_work_dir(settings.get("work_dir")).resolve())
75
- if Platform.detect() & (Platform.MAC | Platform.UBUNTU):
76
- settings['editor'] = choose_editor(settings.get('editor', 'code'))
91
+ settings['editor'] = choose_editor(settings.get('editor', 'code'))
77
92
  if debug:
93
+ if settings.get('mwc_accounts_url'):
94
+ click.echo(debug_fmt(f"Using custom MWC accounts server: {settings['mwc_accounts_url']}"))
95
+ click.echo(debug_fmt("MWC Accounts Server status:"))
96
+ click.echo(debug_fmt(str(status)))
78
97
  click.echo(info("MWC settings:"))
79
98
  click.echo(info(yaml.dump(settings), preformatted=True))
80
99
  write_settings(settings, config)
@@ -94,9 +94,8 @@ def choose_mwc_username(default=None):
94
94
  default=default
95
95
  )
96
96
 
97
- def prompt_mwc_password(username):
97
+ def prompt_mwc_password(username, api):
98
98
  "Asks for the password. Returns a token when successful."
99
- api = MWCAccountsAPI()
100
99
  while True:
101
100
  password = click.prompt(question("What is your MWC password?"), hide_input=True)
102
101
  try:
@@ -464,8 +463,8 @@ class GitConfiguration(SetupTask):
464
463
  git_config["core.editor"] = self.editorcmds[self.settings.get('editor')]
465
464
  if self.settings.get('git_name'):
466
465
  git_config["user.name"] = self.settings['git_name']
467
- if self.settings.get('github_email'):
468
- git_config["user.email"] = self.settings['github_email']
466
+ if self.settings.get('git_email'):
467
+ git_config["user.email"] = self.settings['git_email']
469
468
  return git_config
470
469
 
471
470
 
@@ -5,6 +5,8 @@ from making_with_code_cli.teach.status import status
5
5
  from making_with_code_cli.teach.log import log
6
6
  from making_with_code_cli.teach.patch import patch
7
7
  from making_with_code_cli.teach.check import check
8
+ from making_with_code_cli.teach.section import section
9
+ from making_with_code_cli.teach.student import student
8
10
 
9
11
  @click.group()
10
12
  def teach():
@@ -16,3 +18,5 @@ teach.add_command(status)
16
18
  teach.add_command(log)
17
19
  teach.add_command(patch)
18
20
  teach.add_command(check)
21
+ teach.add_command(section)
22
+ teach.add_command(student)
@@ -0,0 +1,45 @@
1
+ import click
2
+ from csv import DictWriter
3
+ from tabulate import tabulate
4
+ from making_with_code_cli.settings import read_settings
5
+ from making_with_code_cli.mwc_accounts_api import MWCAccountsAPI
6
+ from making_with_code_cli.styles import (
7
+ info,
8
+ )
9
+ from making_with_code_cli.teach.setup import check_required_teacher_settings
10
+ from making_with_code_cli.teach.section.create import create_section
11
+ from making_with_code_cli.teach.section.edit import edit_section
12
+
13
+ @click.group(invoke_without_command=True)
14
+ @click.pass_context
15
+ @click.option("--config", help="Path to config file (default: ~/.mwc)")
16
+ @click.option("-o", "--outfile", help="Save results as csv")
17
+ def section(ctx, config, outfile):
18
+ "Manage sections of students"
19
+ if ctx.invoked_subcommand is None:
20
+ settings = read_settings(config)
21
+ if not check_required_teacher_settings(settings):
22
+ return
23
+ api = MWCAccountsAPI(settings.get('mwc_accounts_url'))
24
+ roster = api.get_roster(settings['mwc_accounts_token'])
25
+ if roster['teacher_sections']:
26
+ data = summarize_sections(roster['teacher_sections'])
27
+ if outfile:
28
+ with open(outfile, "w") as fh:
29
+ writer = DictWriter(fh, data[0].keys())
30
+ writer.writeheader()
31
+ writer.writerows(data)
32
+ else:
33
+ click.echo(info(tabulate(data, headers="keys"), preformatted=True))
34
+ else:
35
+ click.echo(info("You have no sections."))
36
+
37
+ def summarize_sections(section_data):
38
+ for section in section_data:
39
+ section["students"] = len(section["student_tokens"])
40
+ del section["student_tokens"]
41
+ return section_data
42
+
43
+ section.add_command(create_section)
44
+ section.add_command(edit_section)
45
+
@@ -0,0 +1,88 @@
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.curriculum import get_curriculum
6
+ from making_with_code_cli.errors import CurriculumSiteNotAvailable, MWCError
7
+ from making_with_code_cli.styles import (
8
+ address,
9
+ error,
10
+ info,
11
+ question,
12
+ )
13
+
14
+ MWC_CURRICULUM_URL = "https://makingwithcode.org"
15
+
16
+ @click.command('create')
17
+ @click.option("--config", help="Path to config file (default: ~/.mwc)")
18
+ @click.option("--slug", help="Unique short identifier for the section")
19
+ @click.option("--name", help="Section name")
20
+ @click.option("--curriculum-site-url", help="URL for curriculum website. e.g. https://makingwithcode.org")
21
+ @click.option("--course-name", help="MWC course name")
22
+ @click.option("--code", help="Code students can use to join the section")
23
+ def create_section(config, slug, name, curriculum_site_url, course_name, code):
24
+ "Create a new student section"
25
+ settings = read_settings(config)
26
+ api = MWCAccountsAPI(settings.get('mwc_accounts_url'))
27
+ params = {
28
+ "name": name,
29
+ "slug": slug,
30
+ "curriculum_site_url": curriculum_site_url,
31
+ "course_name": course_name,
32
+ "code": code,
33
+ }
34
+ if not params_complete(params):
35
+ params = prompt_for_params(params)
36
+ try:
37
+ response = api.create_section(settings.get('mwc_accounts_token'), params)
38
+ click.echo(info(tabulate([response], headers="keys"), preformatted=True))
39
+ except api.RequestFailed as err:
40
+ click.echo(error("Could not create new section:"))
41
+ for field, problems in err.data.items():
42
+ click.echo(error(f" - {field}: {'; '.join(problems)}"))
43
+
44
+ def params_complete(params):
45
+ """Check whether all params are present.
46
+ """
47
+ required_params = ["name", "slug", "curriculum_site_url", "course_name", "code"]
48
+ if all(params.get(p) for p in required_params):
49
+ curriculum = get_curriculum(params["curriculum_site_url"])
50
+ courses = [c["name"] for c in curriculum.get("courses", [])]
51
+ if params["course_name"] in courses:
52
+ return True
53
+ return False
54
+
55
+ def prompt_for_params(params):
56
+ """Interactively prompt user for missing params.
57
+ """
58
+ params['name'] = click.prompt(question("Section name"), default=params.get('name'))
59
+ params['slug'] = click.prompt(question("Section slug"), default=params.get('slug'))
60
+ while True:
61
+ url = click.prompt(
62
+ question("Curriculum site URL"),
63
+ default=params.get('curriculum_site_url') or MWC_CURRICULUM_URL
64
+ )
65
+ try:
66
+ curriculum = get_curriculum(url)
67
+ params['curriculum_site_url'] = url
68
+ if not curriculum.get('courses'):
69
+ raise MWCError("Found the curriculum site, but no courses are published.")
70
+ break
71
+ except CurriculumSiteNotAvailable as err:
72
+ click.echo(error(err))
73
+ course_names = [c['name'] for c in curriculum['courses']]
74
+ if params.get('course_name') and params.get('course_name') not in course_names:
75
+ click.echo(info(f"Course name {params['course_name']} is invalid."))
76
+ del params['course_name']
77
+ if not params.get('course_name'):
78
+ click.echo(info(f"The following courses are published at {params['curriculum_site_url']}:"))
79
+ for name in course_names:
80
+ click.echo(info(f" - {name}"))
81
+ params['course_name'] = click.prompt(
82
+ question("Course name"),
83
+ type=click.Choice(course_names),
84
+ default=params.get('course_name')
85
+ )
86
+ params['code'] = click.prompt(question("Join code"), default=params.get("code"))
87
+ return params
88
+
@@ -0,0 +1,41 @@
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.curriculum import get_curriculum
6
+ from making_with_code_cli.errors import CurriculumSiteNotAvailable, MWCError
7
+ from making_with_code_cli.styles import (
8
+ address,
9
+ error,
10
+ info,
11
+ question,
12
+ )
13
+
14
+ @click.command('edit')
15
+ @click.argument("slug")
16
+ @click.option("--config", help="Path to config file (default: ~/.mwc)")
17
+ @click.option("--name", help="Section name")
18
+ @click.option("--curriculum-site-url", help="URL for curriculum website. e.g. https://makingwithcode.org")
19
+ @click.option("--course-name", help="MWC course name")
20
+ @click.option("--code", help="Code students can use to join the section")
21
+ @click.option("--roster", help="csv file containing student information")
22
+ def edit_section(config, slug, name, curriculum_site_url, course_name, code, roster):
23
+ "Edit an existing section"
24
+ settings = read_settings(config)
25
+ api = MWCAccountsAPI(settings.get('mwc_accounts_url'))
26
+ params = {"slug": slug}
27
+ if name:
28
+ params["name"] = name
29
+ if curriculum_site_url:
30
+ params["curriculum_site_url"] = curriculum_site_url
31
+ if course_name:
32
+ params["course_name"] = course_name
33
+ if code:
34
+ params["code"] = code
35
+ try:
36
+ response = api.update_section(settings.get('mwc_accounts_token'), params)
37
+ click.echo(info(tabulate([response], headers="keys"), preformatted=True))
38
+ except api.RequestFailed as err:
39
+ click.echo(error(f"Could not edit section {params['slug']}:"))
40
+ for field, problems in err.data.items():
41
+ click.echo(error(f" - {field}: {'; '.join(problems)}"))
@@ -8,6 +8,7 @@ from making_with_code_cli.setup.tasks import (
8
8
  choose_mwc_username,
9
9
  prompt_mwc_password,
10
10
  choose_work_dir,
11
+ choose_editor,
11
12
  )
12
13
  from making_with_code_cli.settings import (
13
14
  get_settings_path,
@@ -45,33 +46,48 @@ def check_required_teacher_settings(settings):
45
46
  @click.command
46
47
  @click.option("--config", help="Path to config file (default: ~/.mwc)")
47
48
  @click.option("--debug", is_flag=True, help="Show debug-level output")
48
- def setup(config, debug):
49
+ @click.option("--git-name", help="Set git name")
50
+ @click.option("--git-email", help="Set git email address")
51
+ @click.option("--mwc-accounts-url", help="Set URL for MWC accounts server")
52
+ def setup(config, debug, git_name, git_email, mwc_accounts_url):
49
53
  """Configure teacher settings"""
50
- click.echo(address(INTRO_MESSAGE))
51
- click.echo()
52
54
  settings = read_settings(config)
53
55
  if debug:
54
56
  sp = get_settings_path(config)
55
57
  click.echo(debug_fmt(f"Reading settings from {sp}"))
58
+ click.echo(address(INTRO_MESSAGE))
59
+ click.echo()
60
+ if git_name:
61
+ settings['git_name'] = git_name
62
+ if git_email:
63
+ settings['git_email'] = git_email
64
+ if mwc_accounts_url:
65
+ settings['mwc_accounts_url'] = mwc_accounts_url
56
66
  settings['mwc_username'] = choose_mwc_username(settings.get("mwc_username"))
57
- api = MWCAccountsAPI()
58
- if settings.get('mwc_accounts_token'):
67
+ api = MWCAccountsAPI(settings.get('mwc_accounts_url'))
68
+ if not 'mwc_accounts_token' in settings:
69
+ token = prompt_mwc_password(settings['mwc_username'], api)
70
+ settings['mwc_accounts_token'] = token
71
+ while True:
59
72
  try:
60
73
  status = api.get_status(settings['mwc_accounts_token'])
74
+ break
61
75
  except api.RequestFailed as bad_token:
62
- token = prompt_mwc_password(settings['mwc_username'])
76
+ if debug:
77
+ click.echo(debug_fmt(bad_token))
78
+ token = prompt_mwc_password(settings['mwc_username'], api)
63
79
  settings['mwc_accounts_token'] = token
64
- status = api.get_status(token)
65
- else:
66
- token = prompt_mwc_password(settings['mwc_username'])
67
- settings['mwc_accounts_token'] = token
68
- status = api.get_status(token)
69
- if debug:
70
- click.echo(debug_fmt("MWC Accounts Server status:"))
71
- click.echo(debug_fmt(str(status)))
80
+ settings['mwc_git_token'] = status['git_token']
81
+ settings['work_dir'] = str(choose_work_dir(settings.get("work_dir")).resolve())
82
+ settings['editor'] = choose_editor(settings.get('editor', 'code'))
72
83
  settings['teacher_work_dir'] = str(choose_work_dir(
73
84
  settings.get("teacher_work_dir"),
74
85
  teacher=True
75
86
  ))
87
+ if debug:
88
+ if settings.get('mwc_accounts_url'):
89
+ click.echo(debug_fmt(f"Using custom MWC accounts server: {settings['mwc_accounts_url']}"))
90
+ click.echo(debug_fmt("MWC Accounts Server status:"))
91
+ click.echo(debug_fmt(str(status)))
76
92
  write_settings(settings, config)
77
93
 
@@ -29,7 +29,7 @@ measures = {
29
29
 
30
30
  @click.command()
31
31
  @click.option("--config", help="Path to config file (default: ~/.mwc)")
32
- @click.option("-g", "--group", help="Filter by group name")
32
+ @click.option("-s", "--section", help="Filter by section name/slug")
33
33
  @click.option("-c", "--course", help="Filter by course name")
34
34
  @click.option("-u", "--user", help="Filter by username")
35
35
  @click.option("-n", "--unit", help="Filter by unit name/slug")
@@ -37,37 +37,37 @@ measures = {
37
37
  @click.option("-v", "--measure", default="commits", type=click.Choice(measures.keys()),
38
38
  help="Measure to show")
39
39
  @click.option("-a", "--anonymous", is_flag=True, help="Hide usernames")
40
- @click.option("-s", "--short", is_flag=True, help="Show short module names")
40
+ @click.option("-x", "--short", is_flag=True, help="Show short module names")
41
41
  @click.option("-o", "--outfile", help="Save results as csv")
42
42
  @click.option('-U', "--update", is_flag=True, help="Update repos first")
43
43
  @click.option('-t', "--threads", type=int, default=8, help="Maximum simultaneous threads")
44
- def status(config, group, course, user, unit, module, measure, anonymous, short,
44
+ def status(config, section, course, user, unit, module, measure, anonymous, short,
45
45
  outfile, update, threads):
46
46
  "Show status of student repos by module"
47
47
  if update:
48
48
  from making_with_code_cli.teach.update import update as update_task
49
- update_task.callback(config, group, course, user, unit, module, threads)
49
+ update_task.callback(config, section, course, user, unit, module, threads)
50
50
  settings = read_settings(config)
51
51
  if not check_required_teacher_settings(settings):
52
52
  return
53
53
  repos = StudentRepos(settings, threads)
54
54
  measure_status_message, measure_fn = measures[measure]
55
- results = repos.apply(measure_fn, group=group, course=course, user=user,
55
+ results = repos.apply(measure_fn, section=section, course=course, user=user,
56
56
  unit=unit, module=module, status_message=measure_status_message)
57
57
  recursive_dict = lambda: defaultdict(recursive_dict)
58
58
  results_dict = recursive_dict()
59
59
  for r in results:
60
- g = r['group']['group_name'] + ' | ' + r['group']['course_name']
61
- results_dict[g][r['username']][r['module']] = r['score']
60
+ s = r['section']['name'] + ' | ' + r['section']['course_name']
61
+ results_dict[s][r['username']][r['module']] = r['score']
62
62
  all_scores = []
63
- for group, users in results_dict.items():
63
+ for section, users in results_dict.items():
64
64
  if users:
65
65
  headers = ['username'] + sorted(next(iter(users.values())).keys())
66
- group_scores = format_user_scores(users, anonymous, short)
67
- all_scores += group_scores
66
+ section_scores = format_user_scores(users, anonymous, short)
67
+ all_scores += section_scores
68
68
  if not outfile:
69
- click.echo(address(group))
70
- click.echo(address(tabulate(group_scores, headers='keys'),
69
+ click.echo(address(section))
70
+ click.echo(address(tabulate(section_scores, headers='keys'),
71
71
  preformatted=True))
72
72
  if outfile and all_scores:
73
73
  with open(outfile, 'w') as fh:
@@ -0,0 +1,10 @@
1
+ import click
2
+ from making_with_code_cli.teach.student.create import create_student
3
+ from making_with_code_cli.teach.student.update import update_student
4
+
5
+ @click.group()
6
+ def student():
7
+ "Manage students"
8
+
9
+ student.add_command(create_student)
10
+ student.add_command(update_student)
@@ -0,0 +1,33 @@
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 (
6
+ info,
7
+ error,
8
+ )
9
+
10
+ @click.command("create")
11
+ @click.argument("username")
12
+ @click.argument("password")
13
+ @click.argument("section")
14
+ @click.option("--config", help="Path to config file (default: ~/.mwc)")
15
+ def create_student(username, password, section, config):
16
+ "Create a student user"
17
+ settings = read_settings(config)
18
+ api = MWCAccountsAPI(settings.get('mwc_accounts_url'))
19
+ params = {
20
+ "username": username,
21
+ "password": password,
22
+ "section": section,
23
+ }
24
+ try:
25
+ response = api.create_student(settings.get('mwc_accounts_token'), params)
26
+ click.echo(info(f"Created student {response['username']} in {section}."))
27
+ except api.RequestFailed as err:
28
+ click.echo(error(f"Could not create {params['username']}:"))
29
+ print(err)
30
+ for field, problems in err.data.items():
31
+ click.echo(error(f" - {field}: {'; '.join(problems)}"))
32
+
33
+
@@ -0,0 +1,29 @@
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 (
6
+ info,
7
+ error,
8
+ )
9
+
10
+ @click.command("update")
11
+ @click.argument("username")
12
+ @click.argument("password")
13
+ @click.option("--config", help="Path to config file (default: ~/.mwc)")
14
+ def update_student(username, password, config):
15
+ "Update a student's password"
16
+ settings = read_settings(config)
17
+ api = MWCAccountsAPI(settings.get('mwc_accounts_url'))
18
+ params = {
19
+ "username": username,
20
+ "password": password,
21
+ }
22
+ try:
23
+ response = api.update_student(settings.get('mwc_accounts_token'), params)
24
+ click.echo(info(f"Updated student {response['username']}."))
25
+ except api.RequestFailed as err:
26
+ click.echo(error(f"Could not update {params['username']}:"))
27
+ for field, problems in err.data.items():
28
+ click.echo(error(f" - {field}: {'; '.join(problems)}"))
29
+
@@ -9,7 +9,7 @@ MWC_GIT_SERVER = "git.makingwithcode.org"
9
9
  def run_in_repo(command, cwd):
10
10
  return run(command, cwd=cwd, shell=True, capture_output=True, text=True)
11
11
 
12
- def update_repo(semaphore, results, group, username, path, token):
12
+ def update_repo(semaphore, results, section, username, path, token):
13
13
  semaphore.acquire()
14
14
  git_api = GiteaTeacherApi()
15
15
  if path.exists():
@@ -25,20 +25,20 @@ def update_repo(semaphore, results, group, username, path, token):
25
25
  results.append({"action": "clone", "path": path, "process": process})
26
26
  semaphore.release()
27
27
 
28
- def count_commits(semaphore, results, group, username, path, token):
28
+ def count_commits(semaphore, results, section, username, path, token):
29
29
  "Counts commits in repo"
30
30
  semaphore.acquire()
31
31
  if path.exists():
32
32
  repo = Repo(path)
33
33
  results.append({
34
- "group": group,
34
+ "section": section,
35
35
  "username": username,
36
36
  "module": path.name,
37
37
  "score": len(list(repo.iter_commits())) - 1
38
38
  })
39
39
  semaphore.release()
40
40
 
41
- def count_changed_py_lines(semaphore, results, group, username, path, token):
41
+ def count_changed_py_lines(semaphore, results, section, username, path, token):
42
42
  "Counts lines changed in Python files across all commits in repo"
43
43
  semaphore.acquire()
44
44
  if path.exists():
@@ -51,14 +51,14 @@ def count_changed_py_lines(semaphore, results, group, username, path, token):
51
51
  if f.endswith(".py"):
52
52
  changed_lines += stats['lines']
53
53
  results.append({
54
- "group": group,
54
+ "section": section,
55
55
  "username": username,
56
56
  "module": path.name,
57
57
  "score": changed_lines
58
58
  })
59
59
  semaphore.release()
60
60
 
61
- def count_changed_md_lines(semaphore, results, group, username, path, token):
61
+ def count_changed_md_lines(semaphore, results, section, username, path, token):
62
62
  "Counts lines changed in Python files across all commits in repo"
63
63
  semaphore.acquire()
64
64
  if path.exists():
@@ -71,19 +71,19 @@ def count_changed_md_lines(semaphore, results, group, username, path, token):
71
71
  if f.endswith(".md"):
72
72
  changed_lines += stats['lines']
73
73
  results.append({
74
- "group": group,
74
+ "section": section,
75
75
  "username": username,
76
76
  "module": path.name,
77
77
  "score": changed_lines
78
78
  })
79
79
  semaphore.release()
80
80
 
81
- def module_completion(semaphore, results, group, username, path, token):
81
+ def module_completion(semaphore, results, section, username, path, token):
82
82
  "Returns a [0..1] ratio of module completion, based on tests."
83
83
  semaphore.acquire()
84
84
  if path.exists():
85
85
  results.append({
86
- "group": group,
86
+ "section": section,
87
87
  "username": username,
88
88
  "module": path.name,
89
89
  "score": 0
@@ -13,7 +13,7 @@ class StudentRepos:
13
13
  self.settings = settings
14
14
  self.max_threads = max_threads
15
15
 
16
- def apply(self, function, group=None, course=None, user=None,
16
+ def apply(self, function, section=None, course=None, user=None,
17
17
  unit=None, module=None, status_message=""):
18
18
  """Applies function to repo paths matching args, and returns the result.
19
19
  The function should take (sem, result, g, user, path, token).
@@ -22,7 +22,7 @@ class StudentRepos:
22
22
  sem = Semaphore(self.max_threads)
23
23
  results = []
24
24
  threads = []
25
- for g, user, path, token in self.iter_repos(group, course, user, unit, module):
25
+ for g, user, path, token in self.iter_repos(section, course, user, unit, module):
26
26
  thread = Thread(target=function, args=(sem, results, g, user, path, token))
27
27
  thread.start()
28
28
  threads.append(thread)
@@ -32,17 +32,17 @@ class StudentRepos:
32
32
  print()
33
33
  return results
34
34
 
35
- def iter_repos(self, group=None, course=None, user=None, unit=None, module=None):
35
+ def iter_repos(self, section=None, course=None, user=None, unit=None, module=None):
36
36
  root = Path(self.settings['teacher_work_dir']).resolve()
37
- api = MWCAccountsAPI()
37
+ api = MWCAccountsAPI(self.settings.get('mwc_accounts_url'))
38
38
  roster = api.get_roster(self.settings['mwc_accounts_token'])
39
- for g in roster['teacher_groups']:
40
- if group and (group not in g['group_name']) and (group not in g['group_slug']):
39
+ for s in roster['teacher_sections']:
40
+ if section and (section not in s['name']) and (section not in s['slug']):
41
41
  continue
42
- if course and course not in g['course_name']:
42
+ if course and course not in s['course_name']:
43
43
  continue
44
- curriculum = get_curriculum(g['curriculum_site_url'], g['course_name'])
45
- for username, token in g['student_tokens'].items():
44
+ curriculum = get_curriculum(s['curriculum_site_url'], s['course_name'])
45
+ for username, token in s['student_tokens'].items():
46
46
  if user and user not in username:
47
47
  continue
48
48
  for u in curriculum['units']:
@@ -51,6 +51,6 @@ class StudentRepos:
51
51
  for m in u['modules']:
52
52
  if module and (module not in m['name']) and (module not in m['slug']):
53
53
  continue
54
- path = root / g['group_slug'] / username / u['slug'] / m['slug']
55
- yield g, username, path, token
54
+ path = root / s['slug'] / username / u['slug'] / m['slug']
55
+ yield s, username, path, token
56
56
 
@@ -26,19 +26,19 @@ from making_with_code_cli.styles import (
26
26
 
27
27
  @click.command()
28
28
  @click.option("--config", help="Path to config file (default: ~/.mwc)")
29
- @click.option("--group", help="Filter by group name")
29
+ @click.option("--section", help="Filter by section slug")
30
30
  @click.option("--course", help="Filter by course name")
31
31
  @click.option("--user", help="Filter by username")
32
32
  @click.option("--unit", help="Filter by unit name/slug")
33
33
  @click.option("--module", help="Filter by module name/slug")
34
34
  @click.option("--threads", type=int, default=8, help="Maximum simultaneous threads")
35
- def update(config, group, course, user, unit, module, threads):
35
+ def update(config, section, course, user, unit, module, threads):
36
36
  "Update student repos"
37
37
  settings = read_settings(config)
38
38
  if not check_required_teacher_settings(settings):
39
39
  return
40
40
  repos = StudentRepos(settings, threads)
41
- results = repos.apply(update_repo, group=group, course=course, user=user,
41
+ results = repos.apply(update_repo, section=section, course=course, user=user,
42
42
  unit=unit, module=module, status_message="Updating repos")
43
43
  for result in results:
44
44
  if result['process'].returncode != 0:
@@ -26,13 +26,13 @@ def update(config):
26
26
  mwc_home = Path(settings["work_dir"])
27
27
  if not mwc_home.exists():
28
28
  mwc_home.mkdir(mode=WORK_DIR_PERMISSIONS, parents=True)
29
- api = MWCAccountsAPI()
29
+ api = MWCAccountsAPI(settings.get('mwc_accounts_url'))
30
30
  try:
31
31
  status = api.get_status(settings.get('mwc_accounts_token'))
32
32
  except api.RequestFailed as bad_token:
33
33
  click.echo(error(f"Error logging into MWC accounts server. Please run mwc setup."))
34
34
  return
35
- for course in status['student_group_memberships']:
35
+ for course in status['student_section_memberships']:
36
36
  curr = get_curriculum(course['curriculum_site_url'], course['course_name'])
37
37
  git_backend = get_backend(curr['git_backend'])(settings)
38
38
  course_dir = mwc_home / curr['slug']
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "making-with-code-cli"
3
- version = "3.1.0"
3
+ version = "4.0.0"
4
4
  description = "Courseware for Making With Code"
5
5
  authors = [
6
6
  {name = "Chris Proctor",email = "chris@chrisproctor.net"}
@@ -8,7 +8,7 @@ authors = [
8
8
  license = {text = "MIT"}
9
9
  readme = "README.md"
10
10
  homepage = "https://github.com/cproctor/making-with-code-courseware"
11
- requires-python = ">=3.10,<4.0"
11
+ requires-python = ">=3.11,<4.0"
12
12
  dependencies = [
13
13
  "pyyaml (>=6.0.2,<7.0.0)",
14
14
  "click (>=8.1.8,<9.0.0)",
@@ -17,7 +17,7 @@ dependencies = [
17
17
  "tabulate (>=0.9.0,<0.10.0)",
18
18
  "gitpython (>=3.1.44,<4.0.0)",
19
19
  "dateparser (>=1.2.0,<2.0.0)",
20
- "tqdm (>=4.67.1,<5.0.0)"
20
+ "tqdm (>=4.67.1,<5.0.0)",
21
21
  ]
22
22
 
23
23
  [project.urls]
@@ -34,5 +34,5 @@ build-backend = "poetry.core.masonry.api"
34
34
  optional = true
35
35
 
36
36
  [tool.poetry.group.docs.dependencies]
37
- sphinx = "^7.3.7"
38
- sphinx-rtd-theme = "^2.0.0"
37
+ sphinx = "^8.2.3"
38
+ sphinx-rtd-theme = "^3.0.2"
@@ -1,61 +0,0 @@
1
- import requests
2
- import json
3
- import os
4
- from pathlib import Path
5
- from urllib.parse import urljoin
6
- from getpass import getpass
7
-
8
- LOCALHOST = "http://localhost:8000"
9
- MWC_ACCOUNTS_SERVER = "https://accounts.makingwithcode.org"
10
-
11
- class MWCAccountsAPI:
12
- def __init__(self, mwc_accounts_server=MWC_ACCOUNTS_SERVER):
13
- self.mwc_accounts_server = mwc_accounts_server
14
-
15
- def login(self, username, password):
16
- "Authenticates with a username and password, returning an auth token"
17
- url = self.mwc_accounts_server + "/login"
18
- data = {"username": username, "password": password}
19
- response = requests.post(url, data=data)
20
- return self.handle_response(response)
21
-
22
- def logout(self, token):
23
- url = self.mwc_accounts_server + "/logout"
24
- headers = {"Authorization": f"Token {token}"}
25
- response = requests.post(url, headers=headers)
26
- return self.handle_response(response)
27
-
28
- def get_status(self, token):
29
- url = self.mwc_accounts_server + "/status"
30
- headers = {"Authorization": f"Token {token}"}
31
- response = requests.get(url, headers=headers)
32
- return self.handle_response(response)
33
-
34
- def get_roster(self, token):
35
- url = self.mwc_accounts_server + "/roster"
36
- headers = {"Authorization": f"Token {token}"}
37
- response = requests.get(url, headers=headers)
38
- return self.handle_response(response)
39
-
40
- def handle_response(self, response):
41
- if response.ok:
42
- return response.json()
43
- elif response.status_code == 500:
44
- raise self.ServerError("Error 500")
45
- else:
46
- try:
47
- rj = response.json()
48
- raise self.RequestFailed(rj)
49
- except requests.exceptions.JSONDecodeError:
50
- raise self.RequestFailed(response)
51
-
52
- class RequestFailed(Exception):
53
- pass
54
-
55
- class ServerError(Exception):
56
- pass
57
-
58
-
59
-
60
-
61
-