Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions .github/workflows/continuous_integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ name: CI
# Define conditions for when to run this action
on:
pull_request: # Run on all pull requests
push: # Run on all pushes to master
push: # Run on all pushes to main or develop
branches:
- main
- develop
Expand All @@ -21,40 +21,40 @@ jobs:
strategy:
# Define OS and Python versions to use. 3.x is the latest minor version.
matrix:
python-version: ["3.7", "3.x"] # 3.x is the latest minor version
python-version: ["3.x"] # 3.x is the latest minor version
os: [ubuntu-latest]

# Sequence of tasks for this job
steps:
# Check out latest code
# Docs: https://github.com/actions/checkout
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v4

# Set up Python
# Docs: https://github.com/actions/setup-python
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

# Install dependencies
# https://github.com/ymyzk/tox-gh-actions#workflow-configuration
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install coverage tox tox-gh-actions
pip install coverage tox

# Run tests
# https://github.com/ymyzk/tox-gh-actions#workflow-configuration
# Run linters and tests against the latest Python (matrix "3.x")
- name: Run tests
run: tox
run: tox -e py3
- name: Combine coverage
run: coverage xml

# Upload coverage report
# https://github.com/codecov/codecov-action
- name: Upload coverage report
uses: codecov/codecov-action@v1
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: eecs485staff/agio-cli
fail_ci_if_error: true
76 changes: 76 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# AGENTS.md

This file provides guidance to coding agents when working with code in this repository.

## Overview

`agio` is a command line interface to [autograder.io](https://autograder.io), distributed on PyPI as the `agiocli` package. It wraps the autograder.io REST API and adds "smart" selection: instructors can refer to courses, projects, groups, and submissions by human shorthand (`eecs485sp21`, `p1`, a uniqname) instead of database primary keys.

## Commands

Install for development:
```console
$ python3 -m venv .venv
$ source .venv/bin/activate
$ pip install --editable .[dev]
```

Run tests, coverage, and a single test:
```console
$ pytest
$ pytest -vv --log-cli-level=DEBUG # verbose
$ pytest --cov ./agiocli --cov-report term-missing
$ pytest tests/test_courses.py::test_courses_pk
```

Lint (all four must pass; this is what CI runs):
```console
$ pycodestyle agiocli tests
$ pydocstyle agiocli tests
$ pylint agiocli tests
$ check-manifest
```

Run the full lint + test suite in a clean throwaway virtualenv (mirrors CI exactly):
```console
$ tox -e py3
```

Note: pydocstyle cannot glob `tests/`, so tox invokes it as `sh -c "pydocstyle agiocli tests/*"`. When running pydocstyle manually against tests, match that pattern.

## Architecture

Three modules under `agiocli/`, layered:

- `__main__.py` - Click CLI. A top-level `main` group with five subcommands: `login`, `courses`, `projects`, `groups`, `submissions`. Each subcommand is thin: it builds an `APIClient`, then delegates all logic to `utils`. The `--debug` flag is stored on the Click context object and threaded into the client.

- `api_client.py` - `APIClient`, a thin authenticated wrapper over `requests` (get/post/put/patch/delete plus `get_paginated`). `APIClient.make_default()` is the intended constructor. It finds the API token by walking up from the current directory to `$HOME` looking for `.agtoken` (see `get_api_token`/`walk_up_to_home_dir`). `do_request` injects the `Authorization: Token ...` header, checks status, and decodes JSON or octet-stream (file downloads) responses. On any HTTP error it calls `sys.exit` with a message rather than raising.

- `utils.py` - All the real logic. Two categories of function:
- **Parsing**: `parse_course_string` (e.g. `eecs485sp21` -> `(2021, 'Spring', 'EECS 485')`) and `parse_project_string` (e.g. `p4mapreduce` -> `('Project', 4, 'mapreduce')`). Both use verbose regexes with abbreviation lookup tables. Defaulting to the current semester and to the `EECS` department happens here.
- **Smart selection**: `get_<thing>_smart()` for course/project/group/submission. These share a consistent resolution strategy: if the arg is numeric, treat it as a primary key and fetch directly; otherwise resolve the parent entity (a project needs a course, a group needs a project, etc.) by recursively calling the parent's `get_*_smart`, fetch the list, then either match the user's string or, if no arg was given, prompt interactively. Matching that is ambiguous or empty exits with an error listing the candidates.

The selection chain is the key design idea: `submission -> group -> project -> course`. Each level can be supplied explicitly via a flag (`-c`, `-p`, `-g`) or left to interactive selection.

Interactive prompts use `pick` (arrow-key menus) for courses/projects/submissions and `readline` tab-completion for group member uniqnames.

## Testing

Tests are system tests driven through Click's `CliRunner` (`runner.invoke(main, [...])`), asserting on `exit_code` and `output`. Key fixtures in `tests/conftest.py`:

- `api_mock` - mocks all autograder.io REST endpoints with `requests-mock`, returning canned JSON. Most tests just request this fixture.
- `mocker.patch("pick.pick", ...)` - simulates a user's interactive menu choice.
- `freezegun.freeze_time(...)` - pins "today" so current-semester defaulting is deterministic.

`tests/conftest.py` imports `utils` directly, so `tox` sets `PYTHONPATH={toxinidir}`. Canned constants and the config fixture live in `conftest.py` and `tests/testdata/`.

## Conventions

- Errors surfaced to the user are reported via `sys.exit("Error: ...")`, not exceptions, except `TokenFileNotFound` and `UnsupportedAssignmentError` which are caught by callers.
- Click docstrings use `\b` to prevent paragraph rewrapping; lines with `\b` carry a `# noqa: D301`.
- Subcommands with many params carry `# pylint: disable=too-many-arguments` because each CLI option needs a function parameter.
- The version string lives in `pyproject.toml` (`version =`); bumping it is a manual step in the release procedure.

## Release

`develop` is the default working branch; releases are merged to `main` and tagged. Full procedure (version bump, tox, tag, Test PyPI, PyPI, GitHub release) is in [CONTRIBUTING.md](CONTRIBUTING.md).
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
See [AGENTS.md](AGENTS.md).
18 changes: 9 additions & 9 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Set up a development virtual environment.
```console
$ python3 -m venv .venv
$ source env/bin/activate
$ pip install --editable .[dev,test]
$ pip install --editable .[dev]
```

A `agio` entry point script is installed in your virtual environment.
Expand All @@ -29,9 +29,9 @@ $ pytest --cov ./agiocli --cov-report term-missing

Test code style
```console
$ pycodestyle agiocli tests setup.py
$ pydocstyle agiocli tests setup.py
$ pylint agiocli tests setup.py
$ pycodestyle agiocli tests
$ pydocstyle agiocli tests
$ pylint agiocli tests
$ check-manifest
```

Expand All @@ -56,8 +56,8 @@ $ tox -e py3

Update version
```console
$ $EDITOR setup.py
$ git commit -m "version bump" setup.py
$ $EDITOR pyproject.toml
$ git commit -m "version bump" pyproject.toml
$ git push origin develop
```

Expand All @@ -72,16 +72,16 @@ $ git merge --no-ff origin/develop
Build distribution binary and source tarball locally
```console
$ rm -rf dist
$ python3 setup.py sdist bdist_wheel
$ python3 -m build
$ ls dist
agiocli-0.1.0-py3-none-any.whl agiocli-0.1.0.tar.gz
```

Tag a release
```console
$ git tag -a X.Y.Z
$ grep version= setup.py
version="X.Y.Z",
$ grep version pyproject.toml
version = "X.Y.Z"
$ git describe
X.Y.Z
$ git push --tags origin main
Expand Down
4 changes: 1 addition & 3 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
include LICENSE
include MANIFEST.in
include README.md
include CONTRIBUTING.md
include *.md
include .pylintrc
graft tests
include test

# Avoid dev and and binary files
exclude tox.ini
Expand Down
6 changes: 3 additions & 3 deletions agiocli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def projects(ctx, project_arg, course_arg, show_list, web, config): # noqa: D30
agio projects --course eecs485sp21 p1 --config

"""
# pylint: disable=too-many-arguments
# pylint: disable=too-many-arguments,too-many-positional-arguments
try:
client = APIClient.make_default(debug=ctx.obj["DEBUG"])
except TokenFileNotFound as err:
Expand Down Expand Up @@ -169,7 +169,7 @@ def groups(ctx, group_arg, project_arg, course_arg, show_list, list_json, web):

"""
# We must have an function argument for each CLI argument or option
# pylint: disable=too-many-arguments
# pylint: disable=too-many-arguments,too-many-positional-arguments

try:
client = APIClient.make_default(debug=ctx.obj["DEBUG"])
Expand Down Expand Up @@ -238,7 +238,7 @@ def submissions(ctx, submission_arg, group_arg,
agio submissions [...] --download
"""
# We must have an function argument for each CLI argument or option
# pylint: disable=too-many-arguments
# pylint: disable=too-many-arguments,too-many-positional-arguments

try:
client = APIClient.make_default(debug=ctx.obj["DEBUG"])
Expand Down
3 changes: 1 addition & 2 deletions agiocli/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,7 @@ def get_paginated(self, path, *args, **kwargs):
response.raise_for_status()
assert "results" in response.json()
assert "next" in response.json()
for item in response.json()["results"]:
yield item
yield from response.json()["results"]
page_url = response.json()['next']

def post(self, path, *args, **kwargs):
Expand Down
51 changes: 51 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
[build-system]
requires = ["setuptools>=64.0.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "agiocli"
version = "0.5.1"
description = "A command line interface to autograder.io"
license = {file = "LICENSE"}
authors = [
{name = "Andrew DeOrio", email = "awdeorio@umich.edu"}
]
readme = "README.md"
keywords = [
"autograder", "autograder.io", "auto grader",
"agcli", "agio", "ag-cli", "agio-cli",
]
requires-python = ">=3.10"
dependencies = [
"click",
"pick>=2.0.0",
"python-dateutil",
"requests",
]

[project.urls]
repository = "https://github.com/eecs485staff/agio-cli/"

[project.scripts]
agio = "agiocli.__main__:main"

[project.optional-dependencies]
dev = [
"build",
"check-manifest",
"freezegun",
"pdbpp",
"pycodestyle",
"pydocstyle",
"pylint",
"pytest",
"pytest-cov",
"pytest-mock",
"requests-mock",
"tox",
"twine",
]

[tool.setuptools.packages.find]
where = ["."]
include = ["agiocli*"]
58 changes: 0 additions & 58 deletions setup.py

This file was deleted.

2 changes: 1 addition & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

def test_example():
"""Dummy example test."""
runner = click.testing.CliRunner(mix_stderr=False)
runner = click.testing.CliRunner()
result = runner.invoke(main, ["--version"], catch_exceptions=False)
assert result.exit_code == 0, result.output
assert "version" in result.output
22 changes: 7 additions & 15 deletions tox.ini
Original file line number Diff line number Diff line change
@@ -1,26 +1,18 @@
# Local host configuration with one Python 3 version
# Run linters and unit tests on the available Python 3 interpreter. CI runs
# the latest Python via 'tox -e py3', so there's no version list to maintain.
[tox]
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.7: py37
3.8: py38
3.9: py39
3.10: py310
envlist = py3

# Run unit tests
# HACK: Pydocstyle fails to find tests. Invoke a shell to use a glob.
[testenv]
setenv =
PYTHONPATH = {toxinidir}
allowlist_externals = sh
extras = test
extras = dev
commands =
pycodestyle agiocli tests setup.py
sh -c "pydocstyle agiocli tests/* setup.py"
pylint agiocli tests setup.py
pycodestyle agiocli tests
sh -c "pydocstyle agiocli tests/*"
pylint agiocli tests
check-manifest
pytest -vvs --cov agiocli
Loading