Skip to content

Commit 24ee3ee

Browse files
committed
Merge remote-tracking branch 'origin/develop'
2 parents f743ceb + dd549c8 commit 24ee3ee

File tree

10 files changed

+126
-29
lines changed

10 files changed

+126
-29
lines changed

.github/workflows/continuous_integration.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
strategy:
2222
# Define OS and Python versions to use. 3.x is the latest minor version.
2323
matrix:
24-
python-version: ["3.6", "3.x"] # 3.x is the latest minor version
24+
python-version: ["3.7", "3.x"] # 3.x is the latest minor version
2525
os: [ubuntu-latest]
2626

2727
# Sequence of tasks for this job

agiocli/__main__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,19 +145,22 @@ def projects(ctx, project_arg, course_arg, show_list, web, config): # noqa: D30
145145
help="Project pk, name, or shorthand.")
146146
@click.option("-l", "--list", "show_list", is_flag=True,
147147
help="List groups and exit.")
148+
@click.option("-j", "--list-json", "list_json", is_flag=True,
149+
help="List groups in JSON format (2D array) and exit.")
148150
@click.option("-w", "--web", is_flag=True, help="Open group in browser.")
149151
@click.pass_context
150152
# The \b character in the docstring prevents Click from rewraping a paragraph.
151153
# We need to tell pycodestyle to ignore it.
152154
# https://click.palletsprojects.com/en/8.0.x/documentation/#preventing-rewrapping
153-
def groups(ctx, group_arg, project_arg, course_arg, show_list, web): # noqa: D301
155+
def groups(ctx, group_arg, project_arg, course_arg, show_list, list_json, web): # noqa: D301
154156
"""Show group detail or list groups.
155157
156158
GROUP_ARG is a primary key, name, or member uniqname.
157159
158160
\b
159161
EXAMPLES:
160162
agio groups --list
163+
agio groups --list-json
161164
agio groups
162165
agio groups 246965
163166
agio groups awdeorio
@@ -181,6 +184,14 @@ def groups(ctx, group_arg, project_arg, course_arg, show_list, web): # noqa: D3
181184
print(utils.group_str(i))
182185
return
183186

187+
# Handle --queue: list groups in OH Queue format and exit
188+
if list_json:
189+
project = utils.get_project_smart(project_arg, course_arg, client)
190+
group_list = utils.get_group_list(project, client)
191+
output = [utils.group_emails(group) for group in group_list]
192+
print(utils.dict_str(output))
193+
return
194+
184195
# Select a group and print or open it
185196
group = utils.get_group_smart(group_arg, project_arg, course_arg, client)
186197
if web:

agiocli/utils.py

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -232,14 +232,14 @@ def get_course_smart(course_arg, client):
232232
if not courses:
233233
sys.exit("Error: No current courses, try 'agio courses -l'")
234234
else:
235+
options = [pick.Option(course_str(x), x) for x in courses]
235236
selected_courses = pick.pick(
236-
options=courses,
237+
options=options,
237238
title=("Select a course:"),
238-
options_map_func=course_str,
239239
multiselect=False,
240240
)
241241
assert selected_courses
242-
return selected_courses[0]
242+
return selected_courses[0].value
243243

244244
# Try to match a course
245245
matches = course_match(course_arg, courses)
@@ -258,6 +258,10 @@ def get_course_smart(course_arg, client):
258258
return matches[0]
259259

260260

261+
class UnsupportedAssignmentError(Exception):
262+
"""Raised if the assignment string cannot be parsed."""
263+
264+
261265
def parse_project_string(user_input):
262266
"""Return assignment type, number, and subtitle from a user input string.
263267
@@ -303,7 +307,7 @@ def parse_project_string(user_input):
303307
asstype_abbrev = match.group("asstype").lower()
304308
if asstype_abbrev not in assignment_types:
305309
asstypes = ", ".join(assignment_types.keys())
306-
sys.exit(
310+
raise UnsupportedAssignmentError(
307311
f"Error: unsupported assignment type: '{asstype_abbrev}'. "
308312
f"Recognized shortcuts: {asstypes}"
309313
)
@@ -316,6 +320,14 @@ def parse_project_string(user_input):
316320
return asstype, num, subtitle
317321

318322

323+
def parse_project_string_skipper(user_input):
324+
"""Wrap parse_project_string to skip errors."""
325+
try:
326+
return parse_project_string(user_input)
327+
except UnsupportedAssignmentError:
328+
return None
329+
330+
319331
def project_str(project):
320332
"""Format project as a string."""
321333
return f"[{project['pk']}] {project['name']}"
@@ -326,6 +338,11 @@ def project_match(search, projects):
326338
assert projects
327339
asstype, num, subtitle = parse_project_string(search)
328340

341+
# Filter for only parsable projects
342+
projects = filter(
343+
lambda x: parse_project_string_skipper(x["name"]), projects
344+
)
345+
329346
# Remove projects with an assignment type mismatch (Lab vs. Project, etc.)
330347
if asstype:
331348
projects = filter(
@@ -382,14 +399,14 @@ def get_project_smart(project_arg, course_arg, client):
382399
# No project input from the user. Show all projects for current course and
383400
# and prompt the user.
384401
if not project_arg:
402+
options = [pick.Option(project_str(x), x) for x in projects]
385403
selected_projects = pick.pick(
386-
options=projects,
404+
options=options,
387405
title="Select a project:",
388-
options_map_func=project_str,
389406
multiselect=False,
390407
)
391408
assert selected_projects
392-
return selected_projects[0]
409+
return selected_projects[0].value
393410

394411
# User provides strings, try to match a project
395412
matches = project_match(project_arg, projects)
@@ -415,10 +432,15 @@ def group_str(group):
415432
return f"[{group['pk']}] {uniqnames_str}"
416433

417434

435+
def group_emails(group):
436+
"""Return group member email addresses."""
437+
members = group["members"]
438+
return [x["username"] for x in members]
439+
440+
418441
def group_uniqnames(group):
419442
"""Return group member uniqnames."""
420-
members = group["members"]
421-
return [x["username"].replace("@umich.edu", "") for x in members]
443+
return [x.replace("@umich.edu", "") for x in group_emails(group)]
422444

423445

424446
def is_group_member(uniqname, group):
@@ -565,7 +587,6 @@ def get_submission_smart(
565587

566588
# Get a group
567589
group = get_group_smart(group_arg, project_arg, course_arg, client)
568-
569590
# User provides "best"
570591
if submission_arg == "best":
571592
return client.get(f"/api/groups/{group['pk']}/ultimate_submission/")
@@ -578,14 +599,14 @@ def get_submission_smart(
578599
# No submissions input from the user. Show all submissions for this group
579600
# and prompt the user.
580601
if not submission_arg:
602+
options = [pick.Option(submission_str(x), x) for x in submissions]
581603
selected_submissions = pick.pick(
582-
options=submissions,
604+
options=options,
583605
title="Select a submission:",
584-
options_map_func=submission_str,
585606
multiselect=False,
586607
)
587608
assert selected_submissions
588-
return selected_submissions[0]
609+
return selected_submissions[0].value
589610

590611
# User provides string "last"
591612
if submission_arg == "last":

setup.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
description="A command line interface to autograder.io",
1616
long_description=LONG_DESCRIPTION,
1717
long_description_content_type="text/markdown",
18-
version="0.4.0",
18+
version="0.5.0",
1919
author="Andrew DeOrio",
2020
author_email="[email protected]",
2121
url="https://github.com/eecs485staff/agio-cli/",
@@ -27,7 +27,7 @@
2727
],
2828
install_requires=[
2929
"click",
30-
"pick",
30+
"pick>=2.0.0",
3131
"python-dateutil",
3232
"requests",
3333
],
@@ -49,7 +49,7 @@
4949
"requests-mock",
5050
],
5151
},
52-
python_requires='>=3.6',
52+
python_requires='>=3.7',
5353
entry_points={
5454
"console_scripts": [
5555
"agio = agiocli.__main__:main",

tests/test_courses.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import click
99
import click.testing
1010
import freezegun
11+
from pick import Option
1112
from agiocli.__main__ import main
1213

1314

@@ -55,7 +56,7 @@ def test_courses_empty(api_mock, mocker):
5556
'allowed_guest_domain': '@umich.edu',
5657
'last_modified': '2021-04-07T02:19:22.818992Z'
5758
}
58-
mocker.patch("pick.pick", return_value=(course_109, 1))
59+
mocker.patch("pick.pick", return_value=(Option(course_109, course_109), 1))
5960

6061
# Run agio
6162
runner = click.testing.CliRunner()

tests/test_groups.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import json
77
import click
88
import click.testing
9+
from pick import Option
910
from agiocli.__main__ import main
1011

1112

@@ -36,6 +37,30 @@ def test_groups_list(api_mock):
3637
assert "[246965] awdeorio" in result.output
3738

3839

40+
def test_groups_list_json(api_mock):
41+
"""Verify agio groups queue option when project is specified.
42+
43+
$ agio groups --list-json --project 1005
44+
45+
api_mock is a shared test fixture that mocks responses to REST API
46+
requests. It is implemented in conftest.py.
47+
48+
"""
49+
runner = click.testing.CliRunner()
50+
result = runner.invoke(
51+
main, [
52+
"groups",
53+
"--list-json",
54+
"--project", "1005",
55+
],
56+
catch_exceptions=False,
57+
)
58+
assert result.exit_code == 0, result.output
59+
result_list = json.loads(result.output)
60+
assert ["[email protected]"] in result_list
61+
assert ["[email protected]"] in result_list
62+
63+
3964
def test_groups_pk(api_mock):
4065
"""Verify groups subcommand with primary key input.
4166
@@ -102,8 +127,10 @@ def test_groups_empty(api_mock, mocker, constants):
102127
# These are constants in conftest.py. Mock input "awdeorio", which selects
103128
# a group.
104129
mocker.patch("pick.pick", side_effect=[
105-
(constants["COURSE_109"], 1), # First call to pick() selects course
106-
(constants["PROJECT_1005"], 0), # Second call selects project
130+
# First call to pick() selects course
131+
(Option(constants["COURSE_109"], constants["COURSE_109"]), 1),
132+
# Second call selects project
133+
(Option(constants["PROJECT_1005"], constants["PROJECT_1005"]), 0),
107134
])
108135
mocker.patch("builtins.input", return_value="awdeorio")
109136

tests/test_matching.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,24 @@ def test_project_match_pattern(search, expected_project_pk):
167167
assert project["pk"] == expected_project_pk
168168

169169

170+
@pytest.mark.parametrize(
171+
"search, expected_project_pk",
172+
[
173+
("p1", 1527),
174+
("p2", 1525),
175+
("p3", 1524),
176+
("p4", 1526),
177+
("p5", 1523),
178+
]
179+
)
180+
def test_project_match_pattern_include_invalid(search, expected_project_pk):
181+
"""Many supported input patterns."""
182+
matches = utils.project_match(search, PROJECTS_INCLUDING_INVALID)
183+
assert len(matches) == 1
184+
project = matches[0]
185+
assert project["pk"] == expected_project_pk
186+
187+
170188
@pytest.mark.parametrize(
171189
"search",
172190
[
@@ -327,3 +345,14 @@ def test_project_match_bad_num(search):
327345
{"pk": 434, "name": "Project 5 - Machine Learning"},
328346
{"pk": 426, "name": "Lab 06 - Container ADTs"},
329347
]
348+
349+
# These projects are from EECS 485 Fall 2022
350+
PROJECTS_INCLUDING_INVALID = [
351+
{"pk": 1527, "name": "Project 1 - Templated Static Site Generator"},
352+
{"pk": 1525, "name": "Project 2 - Server-side Dynamic Pages"},
353+
{"pk": 1524, "name": "Project 3 - Client-side Dynamic Pages"},
354+
{"pk": 1526, "name": "Project 4 - MapReduce"},
355+
{"pk": 1523, "name": "Project 5 - Search Engine"},
356+
{"pk": 1749, "name": "Testing JVM errors 20.04"},
357+
{"pk": 1748, "name": "Testing JVM errors 22.04"},
358+
]

tests/test_projects.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import click
1010
import click.testing
1111
import utils
12+
from pick import Option
1213
from agiocli.__main__ import main
1314

1415

@@ -116,7 +117,8 @@ def test_projects_no_course(api_mock, mocker, constants):
116117
"""
117118
# Mock user-selection menu, users selects course 109. This constant is
118119
# defined in conftest.py
119-
mocker.patch("pick.pick", return_value=(constants["COURSE_109"], 1))
120+
mocker.patch("pick.pick", return_value=(
121+
Option(constants["COURSE_109"], constants["COURSE_109"]), 1))
120122

121123
# Run agio
122124
runner = click.testing.CliRunner()
@@ -140,8 +142,10 @@ def test_projects_empty(api_mock, mocker, constants):
140142
# Mock user-selection menu, users selects course 109, then project 1005.
141143
# These constants are defined in conftest.py
142144
mocker.patch("pick.pick", side_effect=[
143-
(constants["COURSE_109"], 1), # First call to pick() selects course
144-
(constants["PROJECT_1005"], 0), # Second call selects project
145+
# First call to pick() selects course
146+
(Option(constants["COURSE_109"], constants["COURSE_109"]), 1),
147+
# Second call selects project
148+
(Option(constants["PROJECT_1005"], constants["PROJECT_1005"]), 0),
145149
])
146150

147151
# Run agio

tests/test_submissions.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import json
77
import click
88
import click.testing
9+
from pick import Option
910
from agiocli.__main__ import main
1011

1112

@@ -126,9 +127,13 @@ def test_submissions_empty(api_mock, mocker, constants):
126127
# then submission 1128572. These are constants in conftest.py. Mock input
127128
# "awdeorio", which selects a group.
128129
mocker.patch("pick.pick", side_effect=[
129-
(constants["COURSE_109"], 1), # First call to pick() selects course
130-
(constants["PROJECT_1005"], 0), # Second call selects project
131-
(constants["SUBMISSION_1128572"], 0), # Third call selects submission
130+
# First call to pick() selects course
131+
(Option(constants["COURSE_109"], constants["COURSE_109"]), 1),
132+
# Second call selects project
133+
(Option(constants["PROJECT_1005"], constants["PROJECT_1005"]), 0),
134+
# Third call selects submission
135+
(Option(constants["SUBMISSION_1128572"],
136+
constants["SUBMISSION_1128572"]), 0),
132137
])
133138
mocker.patch("builtins.input", return_value="awdeorio")
134139

tox.ini

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
# Local host configuration with one Python 3 version
22
[tox]
3-
envlist = py36, py37, py38, py39, py310
3+
envlist = py37, py38, py39, py310
44

55
# GitHub Actions configuration with multiple Python versions
66
# https://github.com/ymyzk/tox-gh-actions#tox-gh-actions-configuration
77
[gh-actions]
88
python =
9-
3.6: py36
109
3.7: py37
1110
3.8: py38
1211
3.9: py39

0 commit comments

Comments
 (0)