Metadata-Version: 2.4
Name: gscope-cli
Version: 0.2.0
Summary: Unofficial instructor-side Gradescope client: read outlines, push rubrics, from Python or the command line.
Author-email: higger <matt.higger@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/matthigger/gscope
Project-URL: Issues, https://github.com/matthigger/gscope/issues
Keywords: gradescope,rubric,grading,teaching,education
Classifier: Environment :: Console
Classifier: Intended Audience :: Education
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Education
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.25
Provides-Extra: pset
Requires-Dist: pset>=0.6; extra == "pset"
Provides-Extra: test
Requires-Dist: pytest>=7; extra == "test"
Dynamic: license-file

# gscope

An unofficial, instructor-side Gradescope client for Python and the command
line.  It reads courses, assignments, outlines and rubrics, and pushes rubric
items from a spec file, so a rubric written once in a document goes up to every
version of a quiz without retyping.

    pip install gscope-cli

The package installs the `gscope` command and the `gscope` Python module.

> **Unofficial.**  Gradescope has no public API.  gscope makes the same requests
> Gradescope's own web pages make, using your logged-in session.  Gradescope
> can change those routes without notice, so every write is a dry run until
> you pass `--apply`, and is read back and checked afterwards.  Use it on
> courses you teach, at a human pace, and read Gradescope's terms of service.

## Logging in

gscope uses the session cookie from a browser where you are already logged
in.  That works with single sign-on (school) accounts, which have no
Gradescope password.

1. Open any gradescope.com page while logged in, and open DevTools (F12).
2. Go to the **Network** tab and reload the page.
3. Click the first `www.gradescope.com` request.  Under **Request Headers**,
   copy the whole value of **Cookie**.
4. Save it to `~/.config/gscope/cookie`, then `chmod 600` that file.

gscope looks for the cookie in, in order:
- `--cookie FILE`
- `$GSCOPE_COOKIE`, holding the header itself
- `$GSCOPE_COOKIE_FILE`
- `~/.config/gscope/cookie`

Logging out of Gradescope in the browser ends the session, and the cookie
stops working.

Accounts with a Gradescope password can also use
`gscope.Session.login(email, password)` from Python.

## Command line

    gscope courses                         # id and name of every course
    gscope assignments COURSE              # points, submissions, % graded
    gscope questions COURSE ASSIGNMENT     # question tree, points, scoring type
    gscope rubric show COURSE ASSIGNMENT   # rubric items, as signed points
    gscope rubric push SPEC [--map MAP] [--apply]
    gscope rubric to-json RUBRIC.md --map MAP [-o SPEC.json]
    gscope assignment create COURSE TITLE TEMPLATE.pdf [--apply]
    gscope assignment delete COURSE ASSIGNMENT --confirm TITLE --apply
    gscope outline show COURSE ASSIGNMENT
    gscope outline guess TEMPLATE.pdf --parts "i:7,ii:7,iii:6" [-o OUTLINE.json]
    gscope outline push COURSE ASSIGNMENT OUTLINE.json [--replace] [--apply]
    gscope scans list COURSE ASSIGNMENT
    gscope scans upload COURSE ASSIGNMENT SCAN.pdf... [--apply]
    gscope submissions COURSE ASSIGNMENT   # who is matched, who is not
    gscope setup SETUP.json [--steps create,outline,scoring,rubric,scans] [--apply]
    gscope roster COURSE [-o roster.csv]
    gscope scores COURSE ASSIGNMENT [-o scores.csv]

The ids are the numbers in Gradescope URLs: `/courses/COURSE/assignments/ASSIGNMENT`.
`gscope questions` lists the question ids.

### Pushing a rubric

`rubric push` creates the items on each question, in order.  It refuses any
question that already has rubric items, or that is a group of parts, so it
cannot make duplicates.  Without `--apply` it only prints what it would do.

A spec is JSON:

```json
{"course_id": 123,
 "questions": [
   {"assignment_id": 456, "question_id": 789, "label": "A Q1",
    "items": [{"points": 0,  "description": "Correct."},
              {"points": -8, "description": "Denominator missing or wrong."}]}]}
```

Or it is a markdown rubric document plus a small map.  In the document, `##`
headings group the questions (a leading "Version " is dropped), and `###`
headings name them by their first word.  Rubric items are list lines with
the points in bold:

```markdown
## Version A
### Q1: Bayes rule (20 pts)
- **0**: Correct.
- **-8**: Denominator missing or wrong.
```

The map says where each section goes, as `[assignment id, question id]`:

```json
{"course_id": 123, "A": {"Q1": [456, 789]}, "B": {"Q1": [457, 790]}}
```

Sections the map doesn't mention, such as TA notes, are ignored.

### Setting up a scanned quiz

`gscope setup` takes one JSON file and does everything a scanned quiz needs
before grading starts: it creates an "Exam / Quiz" assignment per template,
saves each outline, sets the scoring type, pushes the rubric, uploads the scans,
and reports which submissions still need matching to a student.

```json
{"course_id": 123456,
 "scoring": "negative",
 "assignments": [
   {"title": "quiz2a_q1", "template": "templates/quiz2a_q1.pdf",
    "questions": [{"title": "Covariance Matching", "weight": 20}],
    "scans": ["scans/quiz2a_q1_*.pdf"]},
   {"title": "quiz2a_q2", "template": "templates/quiz2a_q2.pdf",
    "questions": [{"title": "Sample Covariance", "parts": [
        {"title": "i", "weight": 7}, {"title": "ii", "weight": 7}, {"title": "iii", "weight": 6}]}]}],
 "rubric": {"doc": "quiz2_rubric.md",
            "map": {"A": {"Q1": ["quiz2a_q1", "1"], "Q2.1": ["quiz2a_q2", "1.1"]}}}}
```

Paths are relative to the setup file.  Rubric map entries name an assignment by
title and a question by its number, so the map can be written before the
assignments exist.  Every step is skipped once it is done, so you can rerun the
same file as the quiz moves along: create the assignments now, push the rubric
once it is written, and upload the scans once they exist.  Without `--apply`, it only
reports what it would do.

Outlines are guessed from the template PDF (this needs `pdftotext` from
poppler).  The name and NUID boxes come from the "Name" and "NUID:" labels,
the question runs from its "Problem" header down, and each part runs from its
label (`i.`, `(a)`, `2.`) to the next one.  The last part also covers page 2, so
graders see work that continued on the back.  Check the guess with
`gscope outline guess ... -o outline.json` and put an explicit `"outline"` in
the setup file when it is wrong.

Gradescope splits uploaded scans by the template's page count, and matches
them to students from the name and SID boxes.  `gscope submissions` lists the
ones it could not match; fix those in Manage Submissions.

New questions start with a 0-point "Correct" item.  `rubric push` replaces it,
and the scoring step removes it from question groups (whose parts hold the
rubric).

### Signs

Specs write points the way a rubric reads: `-8` takes off 8 and `+2` adds 2.
Gradescope stores a weight whose meaning depends on the question's scoring
type.  Under negative scoring, a deduction is a **positive** weight, and a
negative weight adds points.  gscope converts using each question's scoring
type, and `rubric show` converts back.

## Python

```python
from gscope import Session, assignment, rubric

s = Session.from_cookie_file('~/.config/gscope/cookie')
a = assignment.load(s, course_id, assignment_id)
for q in a.walk():
    print(q.id, q.title, q.weight, q.scoring_type, len(q.items))

item = rubric.create_item(s, course_id, question_id, 'Wrong sign', weight=2)
rubric.update_items(s, course_id, question_id, {item.id: {'weight': 3}})
rubric.delete_items(s, course_id, question_id, [item.id])
```

## Status

| Area | State |
|---|---|
| Courses, assignments, roster, scores | done |
| Question tree, outline (read) | done |
| Rubric items: create, update, delete, push spec | done |
| Creating and deleting assignments, outline editing, uploading scans, matching | done |
| Applying grades from a reviewed file | planned |
| `gscope-cli[pset]`: outlines and rubrics from [pset](https://github.com/matthigger/pset) LaTeX problems | reserved, not built |

## Related work

- [nyuoss/gradescope-api](https://github.com/nyuoss/gradescope-api)
  (`gradescopeapi` on PyPI) covers courses, assignments, extensions, due
  dates and submission upload.  gscope's password login follows its form
  fields.
- [Yuanpeng-Li/gradescope-mcp](https://github.com/Yuanpeng-Li/gradescope-mcp)
  is an MCP server for AI assistants, with rubric and grading tools.  Its
  notes on the negative-scoring sign rule match what gscope found.

gscope differs from these by its cookie login (no password needed), the
rubric push with read-back checks, and the planned course-setup features.

## Development

    pip install -e '.[test]'
    pytest

The tests run against an in-memory fake of the Gradescope pages and routes,
in `tests/fake_gs.py`.  They need no network access and contain no real
course data.

MIT licensed.  Not affiliated with Gradescope or Turnitin.
