making-with-code-cli 1.2.10__tar.gz → 1.3.1__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 (36) hide show
  1. {making_with_code_cli-1.2.10 → making_with_code_cli-1.3.1}/PKG-INFO +4 -1
  2. making_with_code_cli-1.3.1/making_with_code_cli/cli.py +16 -0
  3. making_with_code_cli-1.3.1/making_with_code_cli/curriculum.py +24 -0
  4. {making_with_code_cli-1.2.10 → making_with_code_cli-1.3.1}/making_with_code_cli/errors.py +5 -0
  5. making_with_code_cli-1.3.1/making_with_code_cli/git_backend/__init__.py +6 -0
  6. {making_with_code_cli-1.2.10 → making_with_code_cli-1.3.1}/making_with_code_cli/git_backend/base_backend.py +3 -8
  7. {making_with_code_cli-1.2.10 → making_with_code_cli-1.3.1}/making_with_code_cli/git_backend/mwc_backend.py +7 -7
  8. {making_with_code_cli-1.2.10 → making_with_code_cli-1.3.1}/making_with_code_cli/mwc_accounts_api.py +6 -0
  9. {making_with_code_cli-1.2.10 → making_with_code_cli-1.3.1}/making_with_code_cli/settings.py +1 -1
  10. making_with_code_cli-1.3.1/making_with_code_cli/setup/__init__.py +117 -0
  11. making_with_code_cli-1.2.10/making_with_code_cli/cli_setup.py → making_with_code_cli-1.3.1/making_with_code_cli/setup/tasks.py +12 -6
  12. {making_with_code_cli-1.2.10 → making_with_code_cli-1.3.1}/making_with_code_cli/styles.py +1 -0
  13. making_with_code_cli-1.3.1/making_with_code_cli/submit.py +37 -0
  14. making_with_code_cli-1.3.1/making_with_code_cli/teach/__init__.py +16 -0
  15. making_with_code_cli-1.3.1/making_with_code_cli/teach/check.py +25 -0
  16. making_with_code_cli-1.3.1/making_with_code_cli/teach/gitea_api/api.py +75 -0
  17. making_with_code_cli-1.3.1/making_with_code_cli/teach/gitea_api/exceptions.py +10 -0
  18. making_with_code_cli-1.3.1/making_with_code_cli/teach/log.py +82 -0
  19. making_with_code_cli-1.3.1/making_with_code_cli/teach/setup.py +77 -0
  20. making_with_code_cli-1.3.1/making_with_code_cli/teach/status.py +99 -0
  21. making_with_code_cli-1.3.1/making_with_code_cli/teach/student_repo_functions.py +92 -0
  22. making_with_code_cli-1.3.1/making_with_code_cli/teach/student_repos.py +56 -0
  23. making_with_code_cli-1.3.1/making_with_code_cli/teach/test/__init__.py +14 -0
  24. making_with_code_cli-1.3.1/making_with_code_cli/teach/test/test_module.py +9 -0
  25. making_with_code_cli-1.3.1/making_with_code_cli/teach/update.py +48 -0
  26. making_with_code_cli-1.3.1/making_with_code_cli/update/__init__.py +59 -0
  27. making_with_code_cli-1.3.1/making_with_code_cli/version.py +11 -0
  28. {making_with_code_cli-1.2.10 → making_with_code_cli-1.3.1}/pyproject.toml +4 -1
  29. making_with_code_cli-1.2.10/making_with_code_cli/cli.py +0 -238
  30. making_with_code_cli-1.2.10/making_with_code_cli/curriculum.py +0 -16
  31. making_with_code_cli-1.2.10/making_with_code_cli/git_backend/__init__.py +0 -10
  32. making_with_code_cli-1.2.10/making_with_code_cli/git_backend/github_backend.py +0 -79
  33. making_with_code_cli-1.2.10/making_with_code_cli/git_backend/github_org_backend.py +0 -85
  34. {making_with_code_cli-1.2.10 → making_with_code_cli-1.3.1}/README.md +0 -0
  35. {making_with_code_cli-1.2.10 → making_with_code_cli-1.3.1}/making_with_code_cli/git_wrapper.py +0 -0
  36. {making_with_code_cli-1.2.10 → making_with_code_cli-1.3.1}/making_with_code_cli/helpers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: making-with-code-cli
3
- Version: 1.2.10
3
+ Version: 1.3.1
4
4
  Summary: Courseware for Making With Code
5
5
  Home-page: https://github.com/cproctor/making-with-code-courseware
6
6
  License: MIT
@@ -13,7 +13,10 @@ Classifier: Programming Language :: Python :: 3.10
13
13
  Classifier: Programming Language :: Python :: 3.11
14
14
  Requires-Dist: PyYAML (>=6.0,<7.0)
15
15
  Requires-Dist: click (>=8.0.3,<9.0.0)
16
+ Requires-Dist: dateparser (>=1.1.8,<2.0.0)
17
+ Requires-Dist: gitpython (>=3.1.32,<4.0.0)
16
18
  Requires-Dist: requests (>=2.27.1,<3.0.0)
19
+ Requires-Dist: tabulate (>=0.9.0,<0.10.0)
17
20
  Requires-Dist: toml (>=0.10.2,<0.11.0)
18
21
  Project-URL: issues, https://github.com/cproctor/making-with-code-courseware/issues
19
22
  Description-Content-Type: text/markdown
@@ -0,0 +1,16 @@
1
+ import click
2
+ from making_with_code_cli.version import version
3
+ from making_with_code_cli.setup import setup
4
+ from making_with_code_cli.update import update
5
+ from making_with_code_cli.submit import submit
6
+ from making_with_code_cli.teach import teach
7
+
8
+ @click.group()
9
+ def cli():
10
+ "Command line interface for Making with Code"
11
+
12
+ cli.add_command(version)
13
+ cli.add_command(setup)
14
+ cli.add_command(update)
15
+ cli.add_command(submit)
16
+ cli.add_command(teach)
@@ -0,0 +1,24 @@
1
+ import requests
2
+ import json
3
+ from making_with_code_cli.errors import (
4
+ CurriculumSiteNotAvailable,
5
+ CurriculumNotFound,
6
+ )
7
+
8
+ LIVE_RELOAD = '<script src="/livereload.js?port=1024&amp;mindelay=10"></script>'
9
+
10
+ def get_curriculum(mwc_site_url, course_name):
11
+ """Fetches curriculum metadata from the site url specified in settings.
12
+ Returns the curriculum metadata for course_name.
13
+ """
14
+ url = mwc_site_url + "/manifest"
15
+ response = requests.get(url)
16
+ if response.ok:
17
+ text = response.text.strip(LIVE_RELOAD)
18
+ metadata = json.loads(text)
19
+ for course in metadata["courses"]:
20
+ if course["name"] == course_name:
21
+ return course
22
+ raise CurriculumNotFound(mwc_site_url, course_name)
23
+ else:
24
+ raise CurriculumSiteNotAvailable(mwc_site_url)
@@ -6,6 +6,11 @@ class CurriculumSiteNotAvailable(MWCError):
6
6
  msg = f"Error reading curriculum metadata from {site_url}"
7
7
  super().__init__(msg)
8
8
 
9
+ class CurriculumNotFound(MWCError):
10
+ def __init__(self, site_url, course_name, *args, **kwargs):
11
+ msg = f"The curriculum site for {course_name} ({site_url}) does not have curriculum metadata for {course_name}. Ask your teacher for help."
12
+ super().__init__(msg)
13
+
9
14
  class GitServerNotAvailable(MWCError):
10
15
  def __init__(self, server_url, *args, **kwargs):
11
16
  msg = f"Error connecting to the git server at {server_url}"
@@ -0,0 +1,6 @@
1
+ from .mwc_backend import MWCBackend
2
+
3
+ def get_backend(name):
4
+ return {
5
+ 'mwc': MWCBackend,
6
+ }[name]
@@ -2,7 +2,7 @@ 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.cli_setup import WORK_DIR_PERMISSIONS
5
+ from making_with_code_cli.setup.tasks import WORK_DIR_PERMISSIONS
6
6
  from making_with_code_cli.styles import (
7
7
  address,
8
8
  info,
@@ -16,18 +16,13 @@ class GitBackend:
16
16
  server and the strategies for completing tasks vary by backend.
17
17
  """
18
18
 
19
- @classmethod
20
- def extend_settings(self, settings):
21
- "A hook for git backends to collect additional information from the user"
22
- return settings
23
-
24
19
  def __init__(self, settings):
25
20
  self.settings = settings
26
21
 
27
22
  def init_module(self, module, modpath):
28
23
  raise NotImplemented()
29
24
 
30
- def update(self, module, modpath):
25
+ def update(self, module, modpath, install=True):
31
26
  if (modpath / ".git").is_dir():
32
27
  with cd(modpath):
33
28
  relpath = self.relative_path(modpath)
@@ -36,7 +31,7 @@ class GitBackend:
36
31
  gitresult = run("git pull", shell=True, check=True, capture_output=True,
37
32
  text=True)
38
33
  click.echo(info(gitresult.stdout))
39
- if Path("pyproject.toml").exists():
34
+ if install and Path("pyproject.toml").exists():
40
35
  result = run("poetry install", shell=True, check=True,
41
36
  capture_output=True, text=True)
42
37
  click.echo(info(result.stdout, preformatted=True))
@@ -15,8 +15,6 @@ from making_with_code_cli.errors import (
15
15
  GitServerNotAvailable,
16
16
  )
17
17
 
18
- COMMIT_TEMPLATE = ".commit_template"
19
-
20
18
  class MWCBackend(GitBackend):
21
19
  """A Github backend. Students own their own repos and grant teachers access via token.
22
20
  Note that this gives the teacher account access to the student's entire github account,
@@ -25,6 +23,7 @@ class MWCBackend(GitBackend):
25
23
 
26
24
  MWC_GIT_PROTOCOL = "https"
27
25
  MWC_GIT_SERVER = "git.makingwithcode.org"
26
+ COMMIT_TEMPLATE = ".commit_template"
28
27
 
29
28
  def init_module(self, module, modpath):
30
29
  """Creates the named repo from a template.
@@ -38,14 +37,15 @@ class MWCBackend(GitBackend):
38
37
  self.create_from_template(repo_owner, repo_name)
39
38
  with cd(modpath.parent):
40
39
  self.clone_repo(repo_name)
41
- if (modpath / COMMIT_TEMPLATE).exists():
40
+ if (modpath / self.COMMIT_TEMPLATE).exists():
42
41
  with cd(modpath):
43
- run(f"git config commit.template {COMMIT_TEMPLATE}", shell=True, check=True)
42
+ run(f"git config commit.template {self.COMMIT_TEMPLATE}", shell=True, check=True)
44
43
 
45
- def user_has_repo(self, repo_name):
44
+ def user_has_repo(self, repo_name, username=None):
46
45
  """Checks to see whether a user already has the named repo.
47
46
  """
48
- url = f"/repos/{self.settings['mwc_username']}/{repo_name}"
47
+ username = username or self.settings['mwc_username']
48
+ url = f"/repos/{username}/{repo_name}"
49
49
  response = self.authenticated_mwc_request('get', url)
50
50
  return response.ok
51
51
 
@@ -63,7 +63,7 @@ class MWCBackend(GitBackend):
63
63
  user = self.settings['mwc_username']
64
64
  auth = user + ':' + self.settings['mwc_git_token']
65
65
  url = f"{self.MWC_GIT_PROTOCOL}://{auth}@{self.MWC_GIT_SERVER}/{user}/{repo_name}.git"
66
- run(f"git clone {url}", shell=True, check=True)
66
+ run(f"git clone {url}", shell=True, check=True, capture_output=True)
67
67
 
68
68
  def check_settings(self):
69
69
  if "mwc_username" not in self.settings:
@@ -31,6 +31,12 @@ class MWCAccountsAPI:
31
31
  response = requests.get(url, headers=headers)
32
32
  return self.handle_response(response)
33
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
+
34
40
  def handle_response(self, response):
35
41
  if response.ok:
36
42
  return response.json()
@@ -41,7 +41,7 @@ def iter_settings(settings, prefix=None):
41
41
  def check_settings(settings):
42
42
  """Checks that all settings match SETTINGS_FORMAT"""
43
43
  errors = []
44
-
44
+
45
45
  def write_settings(settings, settings_path=None):
46
46
  """Writes the settings to the settings file."""
47
47
  sp = get_settings_path(settings_path)
@@ -0,0 +1,117 @@
1
+ import click
2
+ from subprocess import run
3
+ import traceback
4
+ import yaml
5
+ from making_with_code_cli.mwc_accounts_api import MWCAccountsAPI
6
+ from making_with_code_cli.git_backend import get_backend
7
+ from making_with_code_cli.update import update
8
+ from making_with_code_cli.settings import (
9
+ get_settings_path,
10
+ read_settings,
11
+ write_settings,
12
+ )
13
+ from making_with_code_cli.styles import (
14
+ address,
15
+ question,
16
+ info,
17
+ debug as debug_fmt,
18
+ confirm,
19
+ error,
20
+ )
21
+ from making_with_code_cli.setup.tasks import (
22
+ INTRO_MESSAGE,
23
+ INTRO_NOTES,
24
+ WORK_DIR_PERMISSIONS,
25
+ Platform,
26
+ choose_mwc_username,
27
+ prompt_mwc_password,
28
+ choose_work_dir,
29
+ choose_course,
30
+ choose_editor,
31
+ MWCShellConfig,
32
+ InstallCurl,
33
+ InstallHomebrew,
34
+ InstallXCode,
35
+ WriteShellConfig,
36
+ InstallPoetry,
37
+ InstallGit,
38
+ InstallTree,
39
+ InstallVSCode,
40
+ InstallImageMagick,
41
+ InstallHttpie,
42
+ InstallScipy,
43
+ GitConfiguration,
44
+ )
45
+
46
+ @click.command()
47
+ @click.option("--config", help="Path to config file (default: ~/.mwc)")
48
+ @click.option("--debug", is_flag=True, help="Show debug-level output")
49
+ @click.pass_context
50
+ def setup(ctx, config, debug):
51
+ """Set up the MWC command line interface"""
52
+ settings = read_settings(config)
53
+ if debug:
54
+ sp = get_settings_path(config)
55
+ click.echo(debug_fmt(f"Reading settings from {sp}"))
56
+ rc_tasks = []
57
+ click.echo(address(INTRO_MESSAGE))
58
+ for note in INTRO_NOTES:
59
+ click.echo(address(note, list_format=True))
60
+ click.echo()
61
+ settings['mwc_username'] = choose_mwc_username(settings.get("mwc_username"))
62
+ api = MWCAccountsAPI()
63
+ if settings.get('mwc_accounts_token'):
64
+ try:
65
+ status = api.get_status(settings['mwc_accounts_token'])
66
+ except api.RequestFailed as bad_token:
67
+ token = prompt_mwc_password(settings['mwc_username'])
68
+ settings['mwc_accounts_token'] = token
69
+ status = api.get_status(token)
70
+ else:
71
+ token = prompt_mwc_password(settings['mwc_username'])
72
+ settings['mwc_accounts_token'] = token
73
+ status = api.get_status(token)
74
+ if debug:
75
+ click.echo(debug_fmt("MWC Accounts Server status:"))
76
+ click.echo(debug_fmt(str(status)))
77
+ settings['mwc_git_token'] = status['git_token']
78
+ settings['work_dir'] = str(choose_work_dir(settings.get("work_dir")).resolve())
79
+ if Platform.detect() & (Platform.MAC | Platform.UBUNTU):
80
+ settings['editor'] = choose_editor(settings.get('editor', 'code'))
81
+ if debug:
82
+ click.echo(info("MWC settings:"))
83
+ click.echo(info(yaml.dump(settings), preformatted=True))
84
+ write_settings(settings, config)
85
+
86
+ task_classes = [
87
+ MWCShellConfig,
88
+ InstallCurl,
89
+ InstallHomebrew,
90
+ InstallXCode,
91
+ InstallPoetry,
92
+ WriteShellConfig,
93
+ InstallGit,
94
+ InstallTree,
95
+ InstallVSCode,
96
+ InstallImageMagick,
97
+ InstallHttpie,
98
+ #InstallScipy,
99
+ GitConfiguration,
100
+ ]
101
+ errors = []
102
+ for task_class in task_classes:
103
+ try:
104
+ task = task_class(settings, debug=debug)
105
+ task.run_task_if_needed()
106
+ except Exception as e:
107
+ errors.append(task)
108
+ click.echo(error('-' * 80))
109
+ click.echo(error(f"{task.description} failed"))
110
+ if debug:
111
+ click.echo(debug_fmt(traceback.format_exc(), preformatted=True))
112
+ if errors:
113
+ click.echo(error(f"{len(errors)} setup tasks failed:"))
114
+ for task in errors:
115
+ click.echo(error(f"- {task.description}"))
116
+ else:
117
+ ctx.invoke(update, config=config)
@@ -85,11 +85,12 @@ class Platform(Flag):
85
85
  else:
86
86
  raise cls.NotSupported()
87
87
 
88
- def default_work_dir():
88
+ def default_work_dir(teacher=False):
89
+ dirname = "making_with_code_teacher" if teacher else "making_with_code"
89
90
  if (Path.home() / "Desktop").exists():
90
- return Path.home() / "Desktop" / "making_with_code"
91
+ return Path.home() / "Desktop" / dirname
91
92
  else:
92
- return Path.home() / "making_with_code"
93
+ return Path.home() / dirname
93
94
 
94
95
  def choose_mwc_username(default=None):
95
96
  """Asks the user to choose their MWC username."""
@@ -109,17 +110,22 @@ def prompt_mwc_password(username):
109
110
  except api.RequestFailed:
110
111
  click.echo(error(f"Sorry, that's not the right password for {username}."))
111
112
 
112
- def choose_work_dir(default=None):
113
+ def choose_work_dir(default=None, teacher=False):
113
114
  """Asks the user to choose where to save their work.
114
115
  Loops until a valid choice is made, prompts if the directory is to be created,
115
116
  and sets file permissions to 755 (u+rwx, g+x, o+x).
116
117
  """
118
+ if teacher:
119
+ prompt_text = "Where do you want to save MWC student repositories?"
120
+ else:
121
+ prompt_text = "Where do you want to save your MWC work?"
117
122
  while True:
118
123
  work_dir = click.prompt(
119
- question("Where do you want to save your MWC work?"),
120
- default=default or default_work_dir(),
124
+ question(prompt_text),
125
+ default=default or default_work_dir(teacher=teacher),
121
126
  type=click.Path(path_type=Path),
122
127
  )
128
+ work_dir = work_dir.expanduser()
123
129
  if work_dir.is_file():
124
130
  click.echo(error("There's already a file at that location."))
125
131
  elif work_dir.exists():
@@ -7,6 +7,7 @@ def formatter(**style_args):
7
7
  """A factory function which returns a formatting function.
8
8
  """
9
9
  def format_message(message, preformatted=False, list_format=False):
10
+ message = str(message)
10
11
  if preformatted:
11
12
  if list_format:
12
13
  raise ValueError("preformatted and list_format are incompatible options")
@@ -0,0 +1,37 @@
1
+ import click
2
+ from subprocess import run, CalledProcessError
3
+ from making_with_code_cli.git_wrapper import (
4
+ in_repo,
5
+ repo_has_changes,
6
+ )
7
+ from making_with_code_cli.styles import (
8
+ address,
9
+ question,
10
+ info,
11
+ debug as debug_fmt,
12
+ confirm,
13
+ error,
14
+ )
15
+
16
+ @click.command()
17
+ def submit():
18
+ """Submit your work.
19
+ (This is a wrapper for the basic git workflow.)
20
+ """
21
+ if not in_repo():
22
+ click.echo(error("You are not in a lab, problem set, or project folder."))
23
+ return
24
+ if not repo_has_changes():
25
+ click.echo(info("Everything is already up to date."))
26
+ return
27
+ run("git add --all", shell=True, capture_output=True, check=True)
28
+ run("git --no-pager diff --staged", shell=True, check=True)
29
+ if not click.confirm(address("Here are the current changes. Looks OK?")):
30
+ click.echo(info("Cancelled the submit for now."))
31
+ return
32
+ click.echo(info("Write your commit message, then save and exit the window..."))
33
+ run("git commit", shell=True, capture_output=True, check=True)
34
+ run("git push", shell=True, capture_output=True, check=True)
35
+ click.echo(address("Nice job! All your work in this module has been submitted."))
36
+
37
+
@@ -0,0 +1,16 @@
1
+ import click
2
+ from making_with_code_cli.teach.setup import setup
3
+ from making_with_code_cli.teach.update import update
4
+ from making_with_code_cli.teach.status import status
5
+ from making_with_code_cli.teach.log import log
6
+ from making_with_code_cli.teach.test import test
7
+
8
+ @click.group()
9
+ def teach():
10
+ "Commands for teachers"
11
+
12
+ teach.add_command(setup)
13
+ teach.add_command(update)
14
+ teach.add_command(status)
15
+ teach.add_command(log)
16
+ teach.add_command(test)
@@ -0,0 +1,25 @@
1
+ # Checks setup of
2
+
3
+ # - Curriculum
4
+ # - Check out each template directory
5
+ # - Main branch?
6
+ # - Has .commit_template?
7
+ # - Is template?
8
+
9
+ class MWCModuleTests:
10
+
11
+ {'teacher_groups': [{'code': 'lai676',
12
+ 'course_name': 'Making With Code I',
13
+ 'curriculum_site_url': 'https://makingwithcode.org',
14
+ 'group_name': 'LAI 676 Summer 2023',
15
+ 'student_tokens': {'cchung': 'b6f7bbeb34a076c1da7c6a58c9d41e1c1faecb9e',
16
+ 'finn': '320abfad1c97c78a274a66d1f7cbd651151d7f7d',
17
+ 'jtoombs': 'aae00b2af1722517efadd5cfc58156696de9626c',
18
+ 'kodell-hamilton': '66054d1f27ff7b793c3d19b96a1e207105938149',
19
+ 'lcooper': '31d0d64ecb7182eef437fd33f8eeb7e45f4c32ad',
20
+ 'mgunsolus': '1f9549e36f44fc710bac82153d972e41b0df2b0f',
21
+ 'mhall': '8dbab4a23a988bbfab71ff96ee673a03224f90ac',
22
+ 'pwick': '3e28563be3eba4e9fcd8e9d8ce5abb494fe27d94',
23
+ 'test_user_lai676': '87bbf8e278b6fa5ead76467810f8e48d93b1b46e',
24
+ 'tnaber': '00456bd8df3e513e787d113df8992d5c66475c3f'}}],
25
+ 'username': 'chris'}
@@ -0,0 +1,75 @@
1
+ # teach/gitea_api/api.py
2
+ # ------------------
3
+ # Offers an api to gitea.
4
+ # Currently, this api competes with git_backend. The tension
5
+ # reflects uncertainty on whether MWC will support multiple backends.
6
+
7
+ import requests
8
+ from making_with_code_cli.teach.gitea_api.exceptions import (
9
+ GiteaServerUnavailable,
10
+ RequestFailed,
11
+ )
12
+
13
+ class GiteaTeacherApi:
14
+ """Provides an API to the Gitea instance.
15
+ Initialize with a username and token, or by default the admin values
16
+ will be used from settings.
17
+ """
18
+ GITEA_URL = "https://git.makingwithcode.org"
19
+
20
+ GET = "GET"
21
+ POST = "POST"
22
+ PATCH = "PATCH"
23
+ DELETE = "DELETE"
24
+
25
+ methods = {
26
+ "GET": requests.get,
27
+ "POST": requests.post,
28
+ "PATCH": requests.patch,
29
+ "DELETE": requests.delete,
30
+ }
31
+ def __init__(self, debug=False):
32
+ self.debug = debug
33
+
34
+ def user_has_repo(self, username, repo_name, token):
35
+ response = self.get(f"/repos/{username}/{repo_name}", username, token)
36
+ return response.ok
37
+
38
+ def get_user_repos(self, username, token):
39
+ response = self.get(f"/users/{username}/repos", username, token)
40
+ return response
41
+
42
+ def get(self, url, username, token, params=None, sudo=None, check=False):
43
+ return self.authenticated_request(self.GET, url, username, token, params=params, sudo=sudo, check=check)
44
+
45
+ def authenticated_request(self, method_name, url, username, token,
46
+ data=None, params=None, sudo=None, check=True):
47
+ msg = f"Gitea request: {method_name} {url}"
48
+ if data:
49
+ msg += f" data={data}"
50
+ if params:
51
+ msg += f" params={params}"
52
+ if data and method_name not in (self.POST, self.PATCH, self.DELETE):
53
+ raise ValueError("Data is only supported on POST, PATCH, or DELETE requests")
54
+ if params and method_name != self.GET:
55
+ raise ValueError("Params are only supported on GET requests")
56
+ args = {
57
+ 'url': self.GITEA_URL + "/api/v1" + url,
58
+ 'auth': (username, token),
59
+ }
60
+ if data:
61
+ args['data'] = data
62
+ if params:
63
+ args['params'] = params
64
+ if sudo:
65
+ args['headers'] = {'Sudo': sudo}
66
+ method = self.methods[method_name]
67
+ response = method(**args)
68
+ if response.status_code >= 500 or response.status_code == 403:
69
+ raise GiteaServerUnavailable()
70
+ if check and not response.ok:
71
+ raise RequestFailed(response)
72
+ if self.debug:
73
+ print(msg)
74
+ print(response)
75
+ return response
@@ -0,0 +1,10 @@
1
+ class GiteaServerUnavailable(Exception):
2
+ pass
3
+
4
+ class RequestFailed(Exception):
5
+ def __init__(self, response, *args, **kwargs):
6
+ status_code = response.status_code
7
+ detail = response.content
8
+ msg = f"Gitea server request failed ({status_code}): {detail}"
9
+ super().__init__(msg)
10
+
@@ -0,0 +1,82 @@
1
+ # teach/log.py
2
+ # ---------------
3
+ # Implements `mwc teach log`.
4
+ # This task iterates over all student repos and clones or pulls them
5
+ # as appropriate.
6
+
7
+ import click
8
+ from git import Repo
9
+ from pathlib import Path
10
+ from threading import Thread, Semaphore
11
+ from textwrap import wrap
12
+ import dateparser
13
+ from making_with_code_cli.teach.student_repos import StudentRepos
14
+ from making_with_code_cli.settings import read_settings
15
+ from making_with_code_cli.teach.setup import check_required_teacher_settings
16
+ from making_with_code_cli.styles import (
17
+ address,
18
+ question,
19
+ info,
20
+ debug as debug_fmt,
21
+ confirm,
22
+ error,
23
+ )
24
+
25
+ def date_string(arg):
26
+ return dateparser.parse(arg, settings={'RETURN_AS_TIMEZONE_AWARE': True})
27
+
28
+ @click.command()
29
+ @click.option("--config", help="Path to config file (default: ~/.mwc)")
30
+ @click.option("--group", help="Filter by group name")
31
+ @click.option("--course", help="Filter by course name")
32
+ @click.option("--user", help="Filter by username")
33
+ @click.option("--unit", help="Filter by unit name/slug")
34
+ @click.option("--module", help="Filter by module name/slug")
35
+ @click.option("--start", type=date_string, help="Start datetime")
36
+ @click.option("--end", type=date_string, help="End datetime")
37
+ @click.option("--update", is_flag=True, help="Update repos first")
38
+ @click.option("--threads", type=int, default=8, help="Maximum simultaneous threads")
39
+ def log(config, group, course, user, unit, module, start, end, update, threads):
40
+ "Show repo logs"
41
+ if update:
42
+ from making_with_code_cli.teach.update import update as update_task
43
+ update_task.callback(config, group, course, user, unit, module, threads)
44
+ settings = read_settings(config)
45
+ if not check_required_teacher_settings(settings):
46
+ return
47
+ repos = StudentRepos(settings, threads)
48
+ get_commits = get_repo_commits_factory(start, end)
49
+ results = repos.apply(get_commits, group=group, course=course, user=user,
50
+ unit=unit, module=module, status_message="Collecting logs")
51
+ for repo in results:
52
+ click.echo(address('-' * 80))
53
+ click.echo(address(repo['path'], preformatted=True))
54
+ for commit in repo['commits']:
55
+ click.echo(info(format_commit(commit), preformatted=True))
56
+
57
+ def get_repo_commits_factory(start, end):
58
+ def get_repo_commits(semaphore, results, group, username, path, token):
59
+ "Gets commits from repo"
60
+ semaphore.acquire()
61
+ if path.exists():
62
+ repo = Repo(path)
63
+ commits = []
64
+ for commit in repo.iter_commits():
65
+ if in_bounds(commit.committed_datetime, start, end):
66
+ commits.append(commit)
67
+ if commits:
68
+ results.append({"path": path, "commits": commits})
69
+ semaphore.release()
70
+ return get_repo_commits
71
+
72
+ def in_bounds(value, minimum=None, maximum=None):
73
+ return (not minimum or value >= minimum) and (not maximum or value <= maximum)
74
+
75
+ def format_commit(commit):
76
+ return '\n'.join([
77
+ f"Author: {commit.author}",
78
+ f"Date: {commit.committed_datetime.isoformat()}",
79
+ "",
80
+ '\n'.join(' ' + line for line in commit.message.split('\n'))
81
+ ])
82
+