Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/develop'
Browse files Browse the repository at this point in the history
  • Loading branch information
awdeorio committed Sep 26, 2022
2 parents f743ceb + dd549c8 commit 24ee3ee
Show file tree
Hide file tree
Showing 10 changed files with 126 additions and 29 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/continuous_integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
strategy:
# Define OS and Python versions to use. 3.x is the latest minor version.
matrix:
python-version: ["3.6", "3.x"] # 3.x is the latest minor version
python-version: ["3.7", "3.x"] # 3.x is the latest minor version
os: [ubuntu-latest]

# Sequence of tasks for this job
Expand Down
13 changes: 12 additions & 1 deletion agiocli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,19 +145,22 @@ def projects(ctx, project_arg, course_arg, show_list, web, config): # noqa: D30
help="Project pk, name, or shorthand.")
@click.option("-l", "--list", "show_list", is_flag=True,
help="List groups and exit.")
@click.option("-j", "--list-json", "list_json", is_flag=True,
help="List groups in JSON format (2D array) and exit.")
@click.option("-w", "--web", is_flag=True, help="Open group in browser.")
@click.pass_context
# The \b character in the docstring prevents Click from rewraping a paragraph.
# We need to tell pycodestyle to ignore it.
# https://click.palletsprojects.com/en/8.0.x/documentation/#preventing-rewrapping
def groups(ctx, group_arg, project_arg, course_arg, show_list, web): # noqa: D301
def groups(ctx, group_arg, project_arg, course_arg, show_list, list_json, web): # noqa: D301
"""Show group detail or list groups.
GROUP_ARG is a primary key, name, or member uniqname.
\b
EXAMPLES:
agio groups --list
agio groups --list-json
agio groups
agio groups 246965
agio groups awdeorio
Expand All @@ -181,6 +184,14 @@ def groups(ctx, group_arg, project_arg, course_arg, show_list, web): # noqa: D3
print(utils.group_str(i))
return

# Handle --queue: list groups in OH Queue format and exit
if list_json:
project = utils.get_project_smart(project_arg, course_arg, client)
group_list = utils.get_group_list(project, client)
output = [utils.group_emails(group) for group in group_list]
print(utils.dict_str(output))
return

# Select a group and print or open it
group = utils.get_group_smart(group_arg, project_arg, course_arg, client)
if web:
Expand Down
47 changes: 34 additions & 13 deletions agiocli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,14 +232,14 @@ def get_course_smart(course_arg, client):
if not courses:
sys.exit("Error: No current courses, try 'agio courses -l'")
else:
options = [pick.Option(course_str(x), x) for x in courses]
selected_courses = pick.pick(
options=courses,
options=options,
title=("Select a course:"),
options_map_func=course_str,
multiselect=False,
)
assert selected_courses
return selected_courses[0]
return selected_courses[0].value

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


class UnsupportedAssignmentError(Exception):
"""Raised if the assignment string cannot be parsed."""


def parse_project_string(user_input):
"""Return assignment type, number, and subtitle from a user input string.
Expand Down Expand Up @@ -303,7 +307,7 @@ def parse_project_string(user_input):
asstype_abbrev = match.group("asstype").lower()
if asstype_abbrev not in assignment_types:
asstypes = ", ".join(assignment_types.keys())
sys.exit(
raise UnsupportedAssignmentError(
f"Error: unsupported assignment type: '{asstype_abbrev}'. "
f"Recognized shortcuts: {asstypes}"
)
Expand All @@ -316,6 +320,14 @@ def parse_project_string(user_input):
return asstype, num, subtitle


def parse_project_string_skipper(user_input):
"""Wrap parse_project_string to skip errors."""
try:
return parse_project_string(user_input)
except UnsupportedAssignmentError:
return None


def project_str(project):
"""Format project as a string."""
return f"[{project['pk']}] {project['name']}"
Expand All @@ -326,6 +338,11 @@ def project_match(search, projects):
assert projects
asstype, num, subtitle = parse_project_string(search)

# Filter for only parsable projects
projects = filter(
lambda x: parse_project_string_skipper(x["name"]), projects
)

# Remove projects with an assignment type mismatch (Lab vs. Project, etc.)
if asstype:
projects = filter(
Expand Down Expand Up @@ -382,14 +399,14 @@ def get_project_smart(project_arg, course_arg, client):
# No project input from the user. Show all projects for current course and
# and prompt the user.
if not project_arg:
options = [pick.Option(project_str(x), x) for x in projects]
selected_projects = pick.pick(
options=projects,
options=options,
title="Select a project:",
options_map_func=project_str,
multiselect=False,
)
assert selected_projects
return selected_projects[0]
return selected_projects[0].value

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


def group_emails(group):
"""Return group member email addresses."""
members = group["members"]
return [x["username"] for x in members]


def group_uniqnames(group):
"""Return group member uniqnames."""
members = group["members"]
return [x["username"].replace("@umich.edu", "") for x in members]
return [x.replace("@umich.edu", "") for x in group_emails(group)]


def is_group_member(uniqname, group):
Expand Down Expand Up @@ -565,7 +587,6 @@ def get_submission_smart(

# Get a group
group = get_group_smart(group_arg, project_arg, course_arg, client)

# User provides "best"
if submission_arg == "best":
return client.get(f"/api/groups/{group['pk']}/ultimate_submission/")
Expand All @@ -578,14 +599,14 @@ def get_submission_smart(
# No submissions input from the user. Show all submissions for this group
# and prompt the user.
if not submission_arg:
options = [pick.Option(submission_str(x), x) for x in submissions]
selected_submissions = pick.pick(
options=submissions,
options=options,
title="Select a submission:",
options_map_func=submission_str,
multiselect=False,
)
assert selected_submissions
return selected_submissions[0]
return selected_submissions[0].value

# User provides string "last"
if submission_arg == "last":
Expand Down
6 changes: 3 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
description="A command line interface to autograder.io",
long_description=LONG_DESCRIPTION,
long_description_content_type="text/markdown",
version="0.4.0",
version="0.5.0",
author="Andrew DeOrio",
author_email="[email protected]",
url="https://github.com/eecs485staff/agio-cli/",
Expand All @@ -27,7 +27,7 @@
],
install_requires=[
"click",
"pick",
"pick>=2.0.0",
"python-dateutil",
"requests",
],
Expand All @@ -49,7 +49,7 @@
"requests-mock",
],
},
python_requires='>=3.6',
python_requires='>=3.7',
entry_points={
"console_scripts": [
"agio = agiocli.__main__:main",
Expand Down
3 changes: 2 additions & 1 deletion tests/test_courses.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import click
import click.testing
import freezegun
from pick import Option
from agiocli.__main__ import main


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

# Run agio
runner = click.testing.CliRunner()
Expand Down
31 changes: 29 additions & 2 deletions tests/test_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import json
import click
import click.testing
from pick import Option
from agiocli.__main__ import main


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


def test_groups_list_json(api_mock):
"""Verify agio groups queue option when project is specified.
$ agio groups --list-json --project 1005
api_mock is a shared test fixture that mocks responses to REST API
requests. It is implemented in conftest.py.
"""
runner = click.testing.CliRunner()
result = runner.invoke(
main, [
"groups",
"--list-json",
"--project", "1005",
],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
result_list = json.loads(result.output)
assert ["[email protected]"] in result_list
assert ["[email protected]"] in result_list


def test_groups_pk(api_mock):
"""Verify groups subcommand with primary key input.
Expand Down Expand Up @@ -102,8 +127,10 @@ def test_groups_empty(api_mock, mocker, constants):
# These are constants in conftest.py. Mock input "awdeorio", which selects
# a group.
mocker.patch("pick.pick", side_effect=[
(constants["COURSE_109"], 1), # First call to pick() selects course
(constants["PROJECT_1005"], 0), # Second call selects project
# First call to pick() selects course
(Option(constants["COURSE_109"], constants["COURSE_109"]), 1),
# Second call selects project
(Option(constants["PROJECT_1005"], constants["PROJECT_1005"]), 0),
])
mocker.patch("builtins.input", return_value="awdeorio")

Expand Down
29 changes: 29 additions & 0 deletions tests/test_matching.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,24 @@ def test_project_match_pattern(search, expected_project_pk):
assert project["pk"] == expected_project_pk


@pytest.mark.parametrize(
"search, expected_project_pk",
[
("p1", 1527),
("p2", 1525),
("p3", 1524),
("p4", 1526),
("p5", 1523),
]
)
def test_project_match_pattern_include_invalid(search, expected_project_pk):
"""Many supported input patterns."""
matches = utils.project_match(search, PROJECTS_INCLUDING_INVALID)
assert len(matches) == 1
project = matches[0]
assert project["pk"] == expected_project_pk


@pytest.mark.parametrize(
"search",
[
Expand Down Expand Up @@ -327,3 +345,14 @@ def test_project_match_bad_num(search):
{"pk": 434, "name": "Project 5 - Machine Learning"},
{"pk": 426, "name": "Lab 06 - Container ADTs"},
]

# These projects are from EECS 485 Fall 2022
PROJECTS_INCLUDING_INVALID = [
{"pk": 1527, "name": "Project 1 - Templated Static Site Generator"},
{"pk": 1525, "name": "Project 2 - Server-side Dynamic Pages"},
{"pk": 1524, "name": "Project 3 - Client-side Dynamic Pages"},
{"pk": 1526, "name": "Project 4 - MapReduce"},
{"pk": 1523, "name": "Project 5 - Search Engine"},
{"pk": 1749, "name": "Testing JVM errors 20.04"},
{"pk": 1748, "name": "Testing JVM errors 22.04"},
]
10 changes: 7 additions & 3 deletions tests/test_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import click
import click.testing
import utils
from pick import Option
from agiocli.__main__ import main


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

# Run agio
runner = click.testing.CliRunner()
Expand All @@ -140,8 +142,10 @@ def test_projects_empty(api_mock, mocker, constants):
# Mock user-selection menu, users selects course 109, then project 1005.
# These constants are defined in conftest.py
mocker.patch("pick.pick", side_effect=[
(constants["COURSE_109"], 1), # First call to pick() selects course
(constants["PROJECT_1005"], 0), # Second call selects project
# First call to pick() selects course
(Option(constants["COURSE_109"], constants["COURSE_109"]), 1),
# Second call selects project
(Option(constants["PROJECT_1005"], constants["PROJECT_1005"]), 0),
])

# Run agio
Expand Down
11 changes: 8 additions & 3 deletions tests/test_submissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import json
import click
import click.testing
from pick import Option
from agiocli.__main__ import main


Expand Down Expand Up @@ -126,9 +127,13 @@ def test_submissions_empty(api_mock, mocker, constants):
# then submission 1128572. These are constants in conftest.py. Mock input
# "awdeorio", which selects a group.
mocker.patch("pick.pick", side_effect=[
(constants["COURSE_109"], 1), # First call to pick() selects course
(constants["PROJECT_1005"], 0), # Second call selects project
(constants["SUBMISSION_1128572"], 0), # Third call selects submission
# First call to pick() selects course
(Option(constants["COURSE_109"], constants["COURSE_109"]), 1),
# Second call selects project
(Option(constants["PROJECT_1005"], constants["PROJECT_1005"]), 0),
# Third call selects submission
(Option(constants["SUBMISSION_1128572"],
constants["SUBMISSION_1128572"]), 0),
])
mocker.patch("builtins.input", return_value="awdeorio")

Expand Down
3 changes: 1 addition & 2 deletions tox.ini
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
# Local host configuration with one Python 3 version
[tox]
envlist = py36, py37, py38, py39, py310
envlist = py37, py38, py39, py310

# GitHub Actions configuration with multiple Python versions
# https://github.com/ymyzk/tox-gh-actions#tox-gh-actions-configuration
[gh-actions]
python =
3.6: py36
3.7: py37
3.8: py38
3.9: py39
Expand Down

0 comments on commit 24ee3ee

Please sign in to comment.