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.
- {making_with_code_cli-1.2.10 → making_with_code_cli-1.3.1}/PKG-INFO +4 -1
- making_with_code_cli-1.3.1/making_with_code_cli/cli.py +16 -0
- making_with_code_cli-1.3.1/making_with_code_cli/curriculum.py +24 -0
- {making_with_code_cli-1.2.10 → making_with_code_cli-1.3.1}/making_with_code_cli/errors.py +5 -0
- making_with_code_cli-1.3.1/making_with_code_cli/git_backend/__init__.py +6 -0
- {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
- {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
- {making_with_code_cli-1.2.10 → making_with_code_cli-1.3.1}/making_with_code_cli/mwc_accounts_api.py +6 -0
- {making_with_code_cli-1.2.10 → making_with_code_cli-1.3.1}/making_with_code_cli/settings.py +1 -1
- making_with_code_cli-1.3.1/making_with_code_cli/setup/__init__.py +117 -0
- 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
- {making_with_code_cli-1.2.10 → making_with_code_cli-1.3.1}/making_with_code_cli/styles.py +1 -0
- making_with_code_cli-1.3.1/making_with_code_cli/submit.py +37 -0
- making_with_code_cli-1.3.1/making_with_code_cli/teach/__init__.py +16 -0
- making_with_code_cli-1.3.1/making_with_code_cli/teach/check.py +25 -0
- making_with_code_cli-1.3.1/making_with_code_cli/teach/gitea_api/api.py +75 -0
- making_with_code_cli-1.3.1/making_with_code_cli/teach/gitea_api/exceptions.py +10 -0
- making_with_code_cli-1.3.1/making_with_code_cli/teach/log.py +82 -0
- making_with_code_cli-1.3.1/making_with_code_cli/teach/setup.py +77 -0
- making_with_code_cli-1.3.1/making_with_code_cli/teach/status.py +99 -0
- making_with_code_cli-1.3.1/making_with_code_cli/teach/student_repo_functions.py +92 -0
- making_with_code_cli-1.3.1/making_with_code_cli/teach/student_repos.py +56 -0
- making_with_code_cli-1.3.1/making_with_code_cli/teach/test/__init__.py +14 -0
- making_with_code_cli-1.3.1/making_with_code_cli/teach/test/test_module.py +9 -0
- making_with_code_cli-1.3.1/making_with_code_cli/teach/update.py +48 -0
- making_with_code_cli-1.3.1/making_with_code_cli/update/__init__.py +59 -0
- making_with_code_cli-1.3.1/making_with_code_cli/version.py +11 -0
- {making_with_code_cli-1.2.10 → making_with_code_cli-1.3.1}/pyproject.toml +4 -1
- making_with_code_cli-1.2.10/making_with_code_cli/cli.py +0 -238
- making_with_code_cli-1.2.10/making_with_code_cli/curriculum.py +0 -16
- making_with_code_cli-1.2.10/making_with_code_cli/git_backend/__init__.py +0 -10
- making_with_code_cli-1.2.10/making_with_code_cli/git_backend/github_backend.py +0 -79
- making_with_code_cli-1.2.10/making_with_code_cli/git_backend/github_org_backend.py +0 -85
- {making_with_code_cli-1.2.10 → making_with_code_cli-1.3.1}/README.md +0 -0
- {making_with_code_cli-1.2.10 → making_with_code_cli-1.3.1}/making_with_code_cli/git_wrapper.py +0 -0
- {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.
|
|
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&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}"
|
|
@@ -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.
|
|
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
|
-
|
|
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:
|
{making_with_code_cli-1.2.10 → making_with_code_cli-1.3.1}/making_with_code_cli/mwc_accounts_api.py
RENAMED
|
@@ -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" /
|
|
91
|
+
return Path.home() / "Desktop" / dirname
|
|
91
92
|
else:
|
|
92
|
-
return Path.home() /
|
|
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(
|
|
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
|
+
|