From d9ddc3f0549b6eda040d91431479e748308c4110 Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Tue, 9 Jun 2026 09:17:30 -0400 Subject: [PATCH 1/4] Add agentic coding config --- AGENTS.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 77 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..185f726 --- /dev/null +++ b/AGENTS.md @@ -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,test] +``` + +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 setup.py +$ pydocstyle agiocli tests setup.py +$ pylint agiocli tests setup.py +$ 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/* setup.py"`. 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__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 `setup.py` (`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). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8b7cbf4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +See [AGENTS.md](AGENTS.md). From 5d50903ec2cb50a04c057233e9e38a48dfa9bf5c Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Tue, 9 Jun 2026 09:23:02 -0400 Subject: [PATCH 2/4] Replace setup.py with pyproject.toml --- AGENTS.md | 12 +++++----- CONTRIBUTING.md | 18 +++++++-------- MANIFEST.in | 4 +--- pyproject.toml | 51 +++++++++++++++++++++++++++++++++++++++++++ setup.py | 58 ------------------------------------------------- tox.ini | 8 +++---- 6 files changed, 71 insertions(+), 80 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.py diff --git a/AGENTS.md b/AGENTS.md index 185f726..d4a90ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ Install for development: ```console $ python3 -m venv .venv $ source .venv/bin/activate -$ pip install --editable .[dev,test] +$ pip install --editable .[dev] ``` Run tests, coverage, and a single test: @@ -25,9 +25,9 @@ $ pytest tests/test_courses.py::test_courses_pk Lint (all four must pass; this is what CI runs): ```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 ``` @@ -36,7 +36,7 @@ Run the full lint + test suite in a clean throwaway virtualenv (mirrors CI exact $ tox -e py3 ``` -Note: pydocstyle cannot glob `tests/`, so tox invokes it as `sh -c "pydocstyle agiocli tests/* setup.py"`. When running pydocstyle manually against tests, match that pattern. +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 @@ -69,7 +69,7 @@ Tests are system tests driven through Click's `CliRunner` (`runner.invoke(main, - 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 `setup.py` (`version=`); bumping it is a manual step in the release procedure. +- The version string lives in `pyproject.toml` (`version =`); bumping it is a manual step in the release procedure. ## Release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4015d06..805fb55 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. @@ -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 ``` @@ -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 ``` @@ -72,7 +72,7 @@ $ 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 ``` @@ -80,8 +80,8 @@ 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 diff --git a/MANIFEST.in b/MANIFEST.in index 689df6e..304e715 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0693054 --- /dev/null +++ b/pyproject.toml @@ -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.7" +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*"] diff --git a/setup.py b/setup.py deleted file mode 100644 index daf65ce..0000000 --- a/setup.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Autograder.io CLI build and install configuration.""" -import os -import io -import setuptools - - -# Read the contents of README file -PROJECT_DIR = os.path.abspath(os.path.dirname(__file__)) -with io.open(os.path.join(PROJECT_DIR, "README.md"), encoding="utf-8") as f: - LONG_DESCRIPTION = f.read() - - -setuptools.setup( - name="agiocli", - description="A command line interface to autograder.io", - long_description=LONG_DESCRIPTION, - long_description_content_type="text/markdown", - version="0.5.1", - author="Andrew DeOrio", - author_email="awdeorio@umich.edu", - url="https://github.com/eecs485staff/agio-cli/", - license="MIT", - packages=["agiocli"], - keywords=[ - "autograder", "autograder.io", "auto grader", - "agcli", "agio", "ag-cli", "agio-cli", - ], - install_requires=[ - "click", - "pick>=2.0.0", - "python-dateutil", - "requests", - ], - extras_require={ - "dev": [ - "pdbpp", - "twine", - "tox", - ], - "test": [ - "check-manifest", - "freezegun", - "pycodestyle", - "pydocstyle", - "pylint", - "pytest", - "pytest-cov", - "pytest-mock", - "requests-mock", - ], - }, - python_requires='>=3.7', - entry_points={ - "console_scripts": [ - "agio = agiocli.__main__:main", - ] - }, -) diff --git a/tox.ini b/tox.ini index 43ea57c..f625857 100644 --- a/tox.ini +++ b/tox.ini @@ -17,10 +17,10 @@ python = 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 From 1aa855fed3150b895d69e4845802162c2704e276 Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Tue, 9 Jun 2026 09:26:00 -0400 Subject: [PATCH 3/4] lint --- agiocli/__main__.py | 6 +++--- agiocli/api_client.py | 3 +-- tests/test_cli.py | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/agiocli/__main__.py b/agiocli/__main__.py index 33fd2a3..d7025b5 100644 --- a/agiocli/__main__.py +++ b/agiocli/__main__.py @@ -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: @@ -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"]) @@ -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"]) diff --git a/agiocli/api_client.py b/agiocli/api_client.py index beb9cb1..b296ed7 100644 --- a/agiocli/api_client.py +++ b/agiocli/api_client.py @@ -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): diff --git a/tests/test_cli.py b/tests/test_cli.py index 18b6673..704ed38 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 From 09c04894aa1a758fa9da431b49e8e58f53e3de5c Mon Sep 17 00:00:00 2001 From: Andrew DeOrio Date: Tue, 9 Jun 2026 09:54:39 -0400 Subject: [PATCH 4/4] Fix python version and bump GH actions --- .github/workflows/continuous_integration.yml | 20 ++++++++++---------- pyproject.toml | 2 +- tox.ini | 14 +++----------- 3 files changed, 14 insertions(+), 22 deletions(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 476b233..c8a9eb5 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -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 @@ -21,7 +21,7 @@ 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 @@ -29,32 +29,32 @@ jobs: # 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 diff --git a/pyproject.toml b/pyproject.toml index 0693054..812fc2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ keywords = [ "autograder", "autograder.io", "auto grader", "agcli", "agio", "ag-cli", "agio-cli", ] -requires-python = ">=3.7" +requires-python = ">=3.10" dependencies = [ "click", "pick>=2.0.0", diff --git a/tox.ini b/tox.ini index f625857..8044e5a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,15 +1,7 @@ -# 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.