diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..d993ce0 --- /dev/null +++ b/.flake8 @@ -0,0 +1,8 @@ +[flake8] +max-line-length = 100 +extend-ignore = E203, E501, E704 +exclude = + .git, + .venv, + build, + *.egg-info diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..74829cd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: "/" + schedule: + interval: "monthly" + labels: + - dependencies + commit-message: + prefix: "[Dependency] " + open-pull-requests-limit: 100 + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + labels: + - dependencies + commit-message: + prefix: "[DevDependency] " + open-pull-requests-limit: 100 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e79eefc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + PYTHON_VERSION: "3.10" + +jobs: + python-lint: + name: Python Lint + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: ${{ env.PYTHON_VERSION }} + - name: Cache Pip + uses: actions/cache@v5 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-${{ github.job }}-pip-${{ hashFiles('pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-${{ github.job }}-pip- + - name: Install python libraries + run: python3 -m pip install -e ".[dev]" + - name: Run flake8 + run: python3 -m flake8 + - name: Run pylint + run: python3 -m pylint --recursive=y src tests + + python-unit: + name: Python Unit + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: ${{ env.PYTHON_VERSION }} + - name: Cache Pip + uses: actions/cache@v5 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-${{ github.job }}-pip-${{ hashFiles('pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-${{ github.job }}-pip- + - name: Install python libraries + run: python3 -m pip install -e ".[dev]" + - name: Run tests + run: pytest --cov=submitty_cli --cov-report=xml -v + - name: Upload Coverage + uses: codecov/codecov-action@v5 + with: + files: coverage.xml + flags: submitty_cli + disable_search: true + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..98d293c --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ + +# Build +build/ +dist/ +uv.lock + +# Virtual environments +.venv/ +venv/ + +# Test / coverage +.pytest_cache/ +.coverage +coverage.xml +htmlcov/ + +# Editors +.vscode/ +.idea/ diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..5c2a590 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,16 @@ +[MASTER] +load-plugins=pylint_pytest + +[FORMAT] +max-line-length=100 + +[MESSAGES CONTROL] +disable= + too-many-arguments, + too-many-locals, + too-few-public-methods, + missing-module-docstring, + unspecified-encoding, + missing-function-docstring, + wrong-import-order, + too-many-branches, diff --git a/README.md b/README.md index bd8cc6c..25b2ea8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,264 @@ -# SubmittyCLI -A command line interface to Submitty to streamline work by system administrators and instructors. +# submitty-cli + +A unified command-line interface for Submitty system administration. This tool is the long-term replacement for the collection of individual scripts in [`sbin/`](https://github.com/Submitty/Submitty/tree/main/sbin), consolidating them into a single, fully-tested CLI that operates through the [Submitty REST API](https://submitty.org/developer/api). + +## Motivation + +The `sbin/` directory contains dozens of standalone Python and shell scripts (`adduser.py`, `create_course.sh`, `generate_grade_summaries.py`, etc.) that each solve one problem in isolation. Over time this creates: + +- **No shared test coverage** — individual scripts are difficult to unit test without a live server +- **Duplicated config loading** — every script re-reads `/submitty/config/*.json` in its own way +- **Inconsistent interfaces** — flags, output formats, and error codes vary between scripts +- **Fragmented documentation** — behavior lives only in the script itself + +`submitty` replaces these scripts one at a time with subcommands that share a common config loader, HTTP client, output formatter, and test harness. Existing `sbin/` scripts are not removed until their replacement command is stable. + +## Installation + +### With uv (recommended) + +[uv](https://docs.astral.sh/uv/) is the fastest way to get started — no manual venv management needed. + +```bash +# Install uv (once, system-wide) +curl -LsSf https://astral.sh/uv/install.sh | sh + +# From the repo root: + +# Run tests or commands without activating a venv +uv run pytest +uv run submitty auth status + +# Install as a persistent global tool (like pipx) +uv tool install . +submitty --help +``` + +### With pip + +```bash +pip install -e ".[dev]" +``` + +## Configuration + +The CLI requires no config files. All connection details are stored in `~/.config/submitty/` after the first login: + +| File | Contents | Set by | +|---|---|---| +| `~/.config/submitty/server` | Server URL | `submitty auth login --server ` | +| `~/.config/submitty/token` | API token | `submitty auth login` | +| `~/.config/submitty/user` | Logged-in user ID | `submitty auth login` | + +Environment variables override the saved files: + +| Variable | Overrides | +|---|---| +| `SUBMITTY_SERVER` | Saved server URL | +| `SUBMITTY_TOKEN` | Saved token | + +If no server has been saved and `SUBMITTY_SERVER` is not set, the server defaults to `http://localhost` (useful when running directly on the Submitty host). + +## Usage + +``` +submitty [--format {table,json}] [args] +``` + +Every command supports `--format json` for scripting: + +```bash +submitty --format json course list | jq '.[].key' +``` + +## Command reference + +Commands marked ✅ are implemented. Commands marked 🔲 are planned. + +### auth — Authentication + +```bash +submitty auth login --server # ✅ Log in; saves server URL, token, and user +submitty auth login # ✅ Log in using the saved or default server +submitty auth token # ✅ Print the active API token +submitty auth status # ✅ Validate the token against the server +submitty auth logout # ✅ Invalidate the token and remove cached files +``` + +### course — Course management + +```bash +submitty course list # ✅ List all courses +submitty course create --instructor [--group ] # ✅ Create a course +submitty course config get # ✅ Show course config (JSON) +submitty course config set # ✅ Update one config value +``` + +### term — Term management + +```bash +submitty term list # 🔲 List all terms +submitty term create # 🔲 Create a new term (replaces create_term.sh) +``` + +### user — User management + +```bash +submitty user list # 🔲 List all Submitty users +submitty user add [--email] [--name] # 🔲 Create a new user (replaces adduser.py) +submitty user enroll # 🔲 Enroll a user in a course (replaces adduser_course.py) +submitty user unenroll # 🔲 Remove a user from a course +``` + +### report — Grade reporting + +```bash +submitty report summary # 🔲 Generate grade summaries (replaces generate_grade_summaries.py) +submitty report rainbow build # 🔲 Run rainbow grades (replaces auto_rainbow_grades.py) +submitty report rainbow schedule # 🔲 Manage rainbow grade schedule (replaces auto_rainbow_scheduler.py) +``` + +### worker — Autograding worker management + +```bash +submitty worker list # 🔲 List worker machines and status +submitty worker restart # 🔲 Restart shipper and all workers (replaces restart_shipper_and_all_workers.py) +submitty worker stop # 🔲 Stop shipper and all workers (replaces killall_shippers_and_workers.sh) +submitty worker sysinfo # 🔲 Push updated system info for all workers (replaces update_worker_sysinfo.sh) +submitty worker repair # 🔲 Restart any inactive core services (replaces repair_services.sh) +``` + +### docker — Docker image management + +```bash +submitty docker cleanup # 🔲 Remove unused Docker images (replaces docker_cleanup.sh) +``` + +### notification — Notifications and email + +```bash +submitty notification send # 🔲 Send a notification (replaces send_notification.py) +submitty email cleanup # 🔲 Remove old email records (replaces cleanup_old_email.py) +``` + +### admin — System administration + +```bash +submitty admin check # 🔲 Verify installation and course data (replaces check_everything.py) +submitty admin session cleanup # 🔲 Delete expired sessions (replaces delete_expired_sessions.py) +submitty admin anonymize # 🔲 Assign anonymous IDs for a gradeable (replaces anonymize.py) +submitty admin version # 🔲 Show Submitty version details (replaces get_version_details.py) +``` + +## Development + +### Project layout + +``` +src/submitty_cli/ + config.py Loads server URL and token from ~/.config/submitty/ → SubmittyConfig + client.py httpx-based API client; raises AuthError / NotFoundError / APIError + state.py AppState — lazy config + client, injected via Typer context + output.py OutputFormat enum, print_table / print_json / print_error + cli.py Root Typer app; registers command groups + commands/ + auth.py auth token / status / login / logout + course.py course list / create / config get / config set + +tests/submitty_cli/ mirrors src/submitty_cli/ (no __init__.py) + conftest.py Shared fixtures: sample_config, mock_client, mock_state, runner + test_cli.py + test_config.py + test_client.py + commands/ + test_auth.py + test_course.py +``` + +### Running tests + +```bash +# With uv (no venv activation needed) +uv run pytest +uv run pytest -v +uv run pytest --cov +uv run pytest tests/submitty_cli/commands/test_auth.py + +# With an activated venv +pytest # all tests +pytest -v # verbose +pytest --cov # with coverage report +pytest tests/submitty_cli/commands/test_auth.py # single file +``` + +### Adding a new command + +1. Create `src/submitty_cli/commands/.py` with a `_app = typer.Typer()`. +2. Register it in `src/submitty_cli/cli.py`: + ```python + from submitty_cli.commands. import _app + app.add_typer(_app, name="", help="...") + ``` +3. Create the matching test file at `tests/submitty_cli/commands/test_.py`. +4. Access config and the HTTP client through `ctx.obj` (an `AppState`): + ```python + @_app.command("list") + def noun_list(ctx: typer.Context) -> None: + state: AppState = ctx.obj + result = state.client.get("/api/...") + ``` + +### Testing commands in isolation + +Commands are tested without a live server by injecting a pre-built `AppState` with a `MagicMock` client: + +```python +def test_my_command(runner, mock_state): + mock_state.client.get.return_value = {"status": "success", "data": [...]} + result = runner.invoke(app, ["noun", "list"], obj=mock_state) + assert result.exit_code == 0 +``` + +The `runner`, `mock_state`, `mock_client`, and `sample_config` fixtures are defined in `tests/submitty_cli/conftest.py` and available to all test files automatically. + +## sbin migration status + +| sbin script | Replacement command | Status | +|---|---|---| +| `api_token_generate.php` | `submitty auth token` | ✅ Done | +| `create_course.sh` | `submitty course create` | ✅ Done | +| `create_term.sh` | `submitty term create` | 🔲 Planned | +| `adduser.py` | `submitty user add` | 🔲 Planned | +| `adduser_course.py` | `submitty user enroll` | 🔲 Planned | +| `generate_grade_summaries.py` | `submitty report summary` | 🔲 Planned | +| `auto_rainbow_grades.py` | `submitty report rainbow build` | 🔲 Planned | +| `auto_rainbow_scheduler.py` | `submitty report rainbow schedule` | 🔲 Planned | +| `restart_shipper_and_all_workers.py` | `submitty worker restart` | 🔲 Planned | +| `killall_shippers_and_workers.sh` | `submitty worker stop` | 🔲 Planned | +| `update_worker_sysinfo.sh` | `submitty worker sysinfo` | 🔲 Planned | +| `repair_services.sh` | `submitty worker repair` | 🔲 Planned | +| `docker_cleanup.sh` | `submitty docker cleanup` | 🔲 Planned | +| `send_notification.py` | `submitty notification send` | 🔲 Planned | +| `cleanup_old_email.py` | `submitty email cleanup` | 🔲 Planned | +| `delete_expired_sessions.py` | `submitty admin session cleanup` | 🔲 Planned | +| `check_everything.py` | `submitty admin check` | 🔲 Planned | +| `anonymize.py` | `submitty admin anonymize` | 🔲 Planned | +| `anonymize_autograding_logs.py` | `submitty admin anonymize` | 🔲 Planned | +| `get_version_details.py` | `submitty admin version` | 🔲 Planned | +| `authentication.py` | internal library (no CLI replacement) | — | +| `database_queries.py` | internal library (no CLI replacement) | — | +| `send_email.py` | internal daemon (no CLI replacement) | — | +| `build_config_upload.py` | internal daemon (no CLI replacement) | — | +| `replay_experiment_script.sh` | experimental (no CLI replacement) | — | + +### Local testing + +```sh +uv tool install --reinstall . +read -rs PASSWORD +export SUBMITTY_USER="carmpr" +export SUBMITTY_SERVER="https://submitty.cs.wallawalla.edu" +export SUBMITTY_TOKEN=$(curl -s -X POST ${SUBMITTY_SERVER}/api/token -d "user_id=${SUBMITTY_USER}&password=$PASSWORD" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['token'])") +submitty auth token +submitty auth status +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b09c101 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "submitty-cli" +version = "0.1.0" +description = "Submitty command-line interface" +readme = "README.md" +requires-python = ">=3.9" +dependencies = [ + "typer>=0.12", + "httpx>=0.27", + "rich>=13", + "typing-extensions>=4.0", +] + +[project.scripts] +submitty = "submitty_cli.cli:main" + +[project.optional-dependencies] +dev = [ + "pytest>=8", + "pytest-cov>=5", + "respx>=0.21", + "flake8>=7", + "flake8-bugbear>=24.12.12", + "pylint>=3.3.9", + "pylint-pytest>=1.1", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] + +[tool.coverage.run] +source = ["src/submitty_cli"] diff --git a/src/submitty_cli/__init__.py b/src/submitty_cli/__init__.py new file mode 100644 index 0000000..1f385bb --- /dev/null +++ b/src/submitty_cli/__init__.py @@ -0,0 +1,2 @@ +"""Submitty command-line interface.""" +__version__ = "0.1.0" diff --git a/src/submitty_cli/cli.py b/src/submitty_cli/cli.py new file mode 100644 index 0000000..d4457b4 --- /dev/null +++ b/src/submitty_cli/cli.py @@ -0,0 +1,47 @@ +"""Root Typer application: registers command groups and global options.""" +from __future__ import annotations + +import typer +from typing_extensions import Annotated + +from submitty_cli.client import APIError +from submitty_cli.commands.auth import auth_app +from submitty_cli.commands.course import course_app +from submitty_cli.config import ConfigError +from submitty_cli.output import OutputFormat, print_error +from submitty_cli.state import AppState + + +app = typer.Typer( + name="submitty", + help="Submitty command-line interface", + no_args_is_help=True, +) +app.add_typer(auth_app, name="auth", help="Authentication commands") +app.add_typer(course_app, name="course", help="Course management commands") + + +@app.callback() +def callback( + ctx: typer.Context, + fmt: Annotated[ + OutputFormat, + typer.Option("--format", "-f", help="Output format (table or json)"), + ] = OutputFormat.TABLE, +) -> None: + """Configure global options and build the shared AppState for subcommands.""" + if not isinstance(ctx.obj, AppState): + ctx.obj = AppState(fmt=fmt) + ctx.obj.fmt = fmt + + +def main() -> None: + """Entry point that wraps app() with clean error handling for config/API errors.""" + try: + app() + except ConfigError as exc: + print_error(str(exc)) + raise SystemExit(1) from exc + except APIError as exc: + print_error(str(exc)) + raise SystemExit(1) from exc diff --git a/src/submitty_cli/client.py b/src/submitty_cli/client.py new file mode 100644 index 0000000..11faa95 --- /dev/null +++ b/src/submitty_cli/client.py @@ -0,0 +1,80 @@ +"""HTTP client wrapper for the Submitty REST API.""" +from __future__ import annotations + +import httpx + + +class APIError(Exception): + """Raised when the API returns an unexpected error response.""" + + def __init__(self, message: str, status_code: int = 0) -> None: + super().__init__(message, status_code) + self.status_code = status_code + + +class AuthError(APIError): + """Raised on HTTP 401 — token is missing, invalid, or expired.""" + + +class NotFoundError(APIError): + """Raised on HTTP 404 — the requested resource does not exist.""" + + +class SubmittyClient: + """Thin httpx wrapper that injects Bearer auth and maps HTTP errors to exceptions.""" + + def __init__(self, base_url: str, token: str) -> None: + if not token: + raise APIError( + "API token is empty — run 'submitty auth login ' or set SUBMITTY_TOKEN" + ) + self._http = httpx.Client( + base_url=base_url, + headers={"Authorization": token}, + timeout=30.0, + ) + + def _raise_for_status(self, response: httpx.Response) -> dict: + """Translate HTTP error codes and application-level failures into typed exceptions. + + Returns the parsed JSON body on success so callers don't need to parse it again. + """ + if response.status_code == 401: + raise AuthError("Authentication failed — check your token", 401) + if response.status_code == 404: + raise NotFoundError("Resource not found", 404) + if response.is_error: + try: + message = response.json().get("message", response.text) + except ValueError: + message = response.text + raise APIError(f"API error {response.status_code}: {message}", response.status_code) + body = response.json() + if isinstance(body, dict) and body.get("status") == "fail": + raise APIError(body.get("message", "Request failed"), response.status_code) + return body + + def get(self, path: str, **kwargs: object) -> dict: + """Send a GET request and return the parsed JSON body.""" + response = self._http.get(path, **kwargs) + return self._raise_for_status(response) + + def post(self, path: str, **kwargs: object) -> dict: + """Send a POST request and return the parsed JSON body.""" + response = self._http.post(path, **kwargs) + return self._raise_for_status(response) + + def put(self, path: str, **kwargs: object) -> dict: + """Send a PUT request and return the parsed JSON body.""" + response = self._http.put(path, **kwargs) + return self._raise_for_status(response) + + def close(self) -> None: + """Close the underlying HTTP connection pool.""" + self._http.close() + + def __enter__(self) -> "SubmittyClient": + return self + + def __exit__(self, *args: object) -> None: + self.close() diff --git a/src/submitty_cli/commands/__init__.py b/src/submitty_cli/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/submitty_cli/commands/auth.py b/src/submitty_cli/commands/auth.py new file mode 100644 index 0000000..a741321 --- /dev/null +++ b/src/submitty_cli/commands/auth.py @@ -0,0 +1,94 @@ +"""Authentication commands: token, status, login, logout.""" +from __future__ import annotations + +import httpx +import typer +from typing_extensions import Annotated + +from submitty_cli.client import AuthError +from submitty_cli.config import ( + DEFAULT_SERVER, TOKEN_FILE, delete_token, load_user, save_server, save_token, save_user, +) +from submitty_cli.output import print_error, print_success +from submitty_cli.state import AppState + +auth_app = typer.Typer(no_args_is_help=True) + + +@auth_app.command("token") +def token(ctx: typer.Context) -> None: + """Print the active API token (env var, cached file, or config).""" + state: AppState = ctx.obj + typer.echo(state.config.token) + + +@auth_app.command("status") +def status(ctx: typer.Context) -> None: + """Validate the current token against the server.""" + state: AppState = ctx.obj + try: + state.client.get("/api/courses") + user = load_user() or "unknown" + print_success(f"Authenticated as {user} at {state.config.server_url}") + except AuthError as exc: + print_error("Token is invalid or expired") + raise typer.Exit(1) from exc + + +@auth_app.command("login") +def login( + user_id: Annotated[str, typer.Argument(help="Submitty user ID")], + server: Annotated[ + str, + typer.Option("--server", help="Submitty server URL (saved for future commands)"), + ] = DEFAULT_SERVER, +) -> None: + """Authenticate with username and password; cache the server URL and token.""" + server_url = server.rstrip("/") + password = typer.prompt("Password", hide_input=True) + try: + response = httpx.post( + f"{server_url}/api/token", + data={"user_id": user_id, "password": password}, + timeout=30.0, + ) + except httpx.RequestError as exc: + print_error(f"Could not reach server: {exc}") + raise typer.Exit(1) from exc + + if response.status_code == 401: + print_error("Invalid credentials") + raise typer.Exit(1) + + if response.is_error: + print_error(f"Login failed ({response.status_code})") + raise typer.Exit(1) + + try: + body = response.json() + except Exception as exc: + print_error("Login failed: server returned non-JSON response") + raise typer.Exit(1) from exc + + if body.get("status") == "fail": + print_error(body.get("message", "Invalid credentials")) + raise typer.Exit(1) + + new_token = body.get("data", {}).get("token", "") + if not new_token: + print_error("Login succeeded but no token in response") + raise typer.Exit(1) + + save_server(server_url) + save_token(new_token) + save_user(user_id) + print_success(f"Logged in as {user_id} at {server_url}. Token saved to {TOKEN_FILE}") + + +@auth_app.command("logout") +def logout(ctx: typer.Context) -> None: + """Invalidate the current token on the server and remove the cached token file.""" + state: AppState = ctx.obj + state.client.post("/api/token/invalidate") + delete_token() + print_success("Logged out and token removed") diff --git a/src/submitty_cli/commands/course.py b/src/submitty_cli/commands/course.py new file mode 100644 index 0000000..f26e8d4 --- /dev/null +++ b/src/submitty_cli/commands/course.py @@ -0,0 +1,99 @@ +"""Course management commands: list, create, config get/set.""" +from __future__ import annotations + +import typer +from typing_extensions import Annotated + +from submitty_cli.client import APIError +from submitty_cli.output import OutputFormat, print_error, print_json, print_table +from submitty_cli.state import AppState + +course_app = typer.Typer(no_args_is_help=True) +course_config_app = typer.Typer(no_args_is_help=True) +course_app.add_typer(course_config_app, name="config", help="Course configuration") + + +@course_app.command("list") +def course_list(ctx: typer.Context) -> None: + """List all courses.""" + state: AppState = ctx.obj + result = state.client.get("/api/courses") + data = result.get("data", {}) + courses = data.get("unarchived_courses", []) + data.get("archived_courses", []) + + if state.fmt == OutputFormat.JSON: + print_json(courses) + else: + rows = [ + { + "term": c.get("display_semester") or c.get("semester", ""), + "key": c.get("semester", ""), + "course": c.get("title", ""), + "name": c.get("display_name", ""), + } + for c in courses + ] + print_table(rows, columns=["term", "key", "course", "name"]) + + +@course_app.command("create") +def course_create( + ctx: typer.Context, + term: Annotated[str, typer.Argument(help="Term key (e.g. winter26)")], + course: Annotated[str, typer.Argument(help="Course identifier (e.g. cptr142)")], + instructor: Annotated[str, typer.Option("--instructor", help="Head instructor user ID")], + group: Annotated[str, typer.Option("--group", help="Unix group for the course")] = "", +) -> None: + """Create a new course.""" + state: AppState = ctx.obj + try: + state.client.post( + "/api/courses", + data={ + "course_semester": term, + "course_title": course, + "head_instructor": instructor, + "group_name": group or f"{term}_{course}", + }, + ) + typer.echo(f"Course {term}/{course} created") + except APIError as e: + print_error(str(e)) + raise typer.Exit(1) + + +@course_config_app.command("get") +def config_get( + ctx: typer.Context, + semester: Annotated[str, typer.Argument(help="Semester identifier")], + course: Annotated[str, typer.Argument(help="Course identifier")], +) -> None: + """Get course configuration.""" + state: AppState = ctx.obj + try: + result = state.client.get(f"/api/courses/{semester}/{course}/config") + print_json(result.get("data", {})) + except APIError as e: + print_error(str(e)) + raise typer.Exit(1) + + +@course_config_app.command("set") +def config_set( + ctx: typer.Context, + semester: Annotated[str, typer.Argument(help="Term key (e.g. winter26)")], + course: Annotated[str, typer.Argument(help="Course identifier (e.g. cptr142)")], + name: Annotated[str, typer.Argument(help="Config key to update (e.g. course_name)")], + value: Annotated[str, typer.Argument(help="New value")], +) -> None: + """Update a single course configuration value.""" + state: AppState = ctx.obj + try: + state.client.post( + f"/api/courses/{semester}/{course}/config", + data={"name": name, "entry": value}, + ) + typer.echo(f"Set {name} for {semester}/{course}") + except APIError as e: + print_error(str(e)) + raise typer.Exit(1) diff --git a/src/submitty_cli/config.py b/src/submitty_cli/config.py new file mode 100644 index 0000000..3f81994 --- /dev/null +++ b/src/submitty_cli/config.py @@ -0,0 +1,90 @@ +"""Config loader: token and server URL from user-level files or environment variables.""" +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +_CONFIG_DIR = Path.home() / ".config" / "submitty" +TOKEN_FILE = _CONFIG_DIR / "token" +USER_FILE = _CONFIG_DIR / "user" +SERVER_FILE = _CONFIG_DIR / "server" + +DEFAULT_SERVER = "http://localhost" + + +class ConfigError(Exception): + """Raised when a required config value is missing.""" + + +@dataclass +class SubmittyConfig: + """Server connection details for the Submitty API.""" + + server_url: str + token: str + + +def load_server() -> str: + """Return the server URL from the first available source. + + Resolution order: + 1. SUBMITTY_SERVER environment variable + 2. ~/.config/submitty/server (written by 'submitty auth login') + 3. http://localhost (default for server-local use) + """ + if env_server := os.environ.get("SUBMITTY_SERVER"): + return env_server.rstrip("/") + if SERVER_FILE.exists(): + if server := SERVER_FILE.read_text(encoding="utf-8").strip(): + return server.rstrip("/") + return DEFAULT_SERVER + + +def save_server(url: str) -> None: + """Write the server URL to the user-level server file.""" + SERVER_FILE.parent.mkdir(parents=True, exist_ok=True) + SERVER_FILE.write_text(url.rstrip("/"), encoding="utf-8") + + +def resolve_token() -> str: + """Return an API token from the first available source. + + Resolution order: + 1. SUBMITTY_TOKEN environment variable + 2. ~/.config/submitty/token (written by 'submitty auth login') + """ + if env_token := os.environ.get("SUBMITTY_TOKEN"): + return env_token + if TOKEN_FILE.exists(): + if token_content := TOKEN_FILE.read_text(encoding="utf-8").strip(): + return token_content + raise ConfigError( + "No API token found. Run 'submitty auth login ' or set SUBMITTY_TOKEN." + ) + + +def save_token(token: str) -> None: + """Write a token to the user-level token file.""" + TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True) + TOKEN_FILE.write_text(token, encoding="utf-8") + + +def save_user(user_id: str) -> None: + """Write the authenticated user ID alongside the token.""" + USER_FILE.parent.mkdir(parents=True, exist_ok=True) + USER_FILE.write_text(user_id, encoding="utf-8") + + +def load_user() -> str: + """Return the cached user ID, or empty string if not logged in.""" + if USER_FILE.exists(): + return USER_FILE.read_text(encoding="utf-8").strip() + return "" + + +def delete_token() -> None: + """Remove the cached token and user files, if present.""" + TOKEN_FILE.unlink(missing_ok=True) + USER_FILE.unlink(missing_ok=True) diff --git a/src/submitty_cli/output.py b/src/submitty_cli/output.py new file mode 100644 index 0000000..40a8eb2 --- /dev/null +++ b/src/submitty_cli/output.py @@ -0,0 +1,44 @@ +"""Output helpers: table and JSON formatters, error/success printers.""" +from __future__ import annotations + +import json +from enum import Enum +from typing import Any, List + +import typer +from rich.console import Console +from rich.table import Table + + +console = Console() +err_console = Console(stderr=True, style="bold red") + + +class OutputFormat(str, Enum): + """Supported CLI output formats.""" + + TABLE = "table" + JSON = "json" + + +def print_json(data: Any) -> None: + """Print data as indented JSON.""" + typer.echo(json.dumps(data, indent=2)) + + +def print_table(rows: List[dict], columns: List[str]) -> None: + """Print rows as a rich table with the given column headers.""" + table = Table(*columns, show_header=True, header_style="bold cyan") + for row in rows: + table.add_row(*[str(row.get(col, "")) for col in columns]) + console.print(table) + + +def print_error(message: str) -> None: + """Print an error message to stderr in red.""" + err_console.print(f"Error: {message}") + + +def print_success(message: str) -> None: + """Print a success message in green.""" + console.print(f"[green]{message}[/green]") diff --git a/src/submitty_cli/state.py b/src/submitty_cli/state.py new file mode 100644 index 0000000..a000c9e --- /dev/null +++ b/src/submitty_cli/state.py @@ -0,0 +1,49 @@ +"""AppState: lazily loaded config and HTTP client, threaded through Typer's ctx.obj.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional + +from submitty_cli.client import SubmittyClient +from submitty_cli.config import SubmittyConfig, load_server, resolve_token +from submitty_cli.output import OutputFormat + + +@dataclass +class AppState: + """Shared runtime state passed through Typer's context object to every command.""" + fmt: OutputFormat = OutputFormat.TABLE + _config: Optional[SubmittyConfig] = field(default=None, repr=False) + _client: Optional[SubmittyClient] = field(default=None, repr=False) + + @classmethod + def for_testing(cls, config: SubmittyConfig, client: SubmittyClient) -> "AppState": + """Create an AppState with pre-built config and client for use in tests.""" + state = cls() + state._config = config # pylint: disable=protected-access + state._client = client # pylint: disable=protected-access + return state + + @property + def server_url(self) -> str: + """Return the server URL without requiring a token.""" + if self._config is not None: + return self._config.server_url + return load_server() + + @property + def config(self) -> SubmittyConfig: + """Load config on first access.""" + if self._config is None: + self._config = SubmittyConfig( + server_url=load_server(), + token=resolve_token(), + ) + return self._config + + @property + def client(self) -> SubmittyClient: + """Build the API client on first access.""" + if self._client is None: + self._client = SubmittyClient(self.config.server_url, self.config.token) + return self._client diff --git a/tests/submitty_cli/commands/__init__.py b/tests/submitty_cli/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/submitty_cli/commands/test_auth.py b/tests/submitty_cli/commands/test_auth.py new file mode 100644 index 0000000..5a9f820 --- /dev/null +++ b/tests/submitty_cli/commands/test_auth.py @@ -0,0 +1,129 @@ +from unittest.mock import patch + +import httpx +import respx + +from submitty_cli.cli import app +from submitty_cli.client import AuthError + +LOGIN_URL = "https://submitty.example.com/api/token" + + +def test_token_prints_token(runner, mock_state): + result = runner.invoke(app, ["auth", "token"], obj=mock_state) + assert result.exit_code == 0 + assert mock_state.config.token in result.output + + +def test_status_success(runner, mock_state): + mock_state.client.get.return_value = {"status": "success", "data": {}} + with patch("submitty_cli.commands.auth.load_user", return_value="instructor01"): + result = runner.invoke(app, ["auth", "status"], obj=mock_state) + assert result.exit_code == 0 + assert "instructor01" in result.output + assert mock_state.config.server_url in result.output + + +def test_status_unknown_user_when_not_logged_in(runner, mock_state): + mock_state.client.get.return_value = {"status": "success", "data": {}} + with patch("submitty_cli.commands.auth.load_user", return_value=""): + result = runner.invoke(app, ["auth", "status"], obj=mock_state) + assert result.exit_code == 0 + assert "unknown" in result.output + + +def test_status_auth_failure_exits_nonzero(runner, mock_state): + mock_state.client.get.side_effect = AuthError("Authentication failed", 401) + result = runner.invoke(app, ["auth", "status"], obj=mock_state) + assert result.exit_code == 1 + + +def test_logout_invalidates_and_removes_token(runner, mock_state): + mock_state.client.post.return_value = {"status": "success"} + with patch("submitty_cli.commands.auth.delete_token") as mock_delete: + result = runner.invoke(app, ["auth", "logout"], obj=mock_state) + assert result.exit_code == 0 + mock_state.client.post.assert_called_once_with("/api/token/invalidate") + mock_delete.assert_called_once() + + +@respx.mock +def test_login_saves_token_and_user(runner, mock_state): + """login uses an unauthenticated httpx call — mock at the httpx level.""" + respx.post(LOGIN_URL).mock( + return_value=httpx.Response(200, json={"data": {"token": "new-token-xyz"}}) + ) + with ( + patch("submitty_cli.commands.auth.save_token") as mock_save_token, + patch("submitty_cli.commands.auth.save_user") as mock_save_user, + patch("submitty_cli.commands.auth.save_server"), + ): + result = runner.invoke( + app, + ["auth", "login", "--server", "https://submitty.example.com", "instructor01"], + input="secret\n", + obj=mock_state, + ) + assert result.exit_code == 0 + mock_save_token.assert_called_once_with("new-token-xyz") + mock_save_user.assert_called_once_with("instructor01") + + +@respx.mock +def test_login_saves_server_url(runner, mock_state): + respx.post(LOGIN_URL).mock( + return_value=httpx.Response(200, json={"data": {"token": "new-token-xyz"}}) + ) + with ( + patch("submitty_cli.commands.auth.save_token"), + patch("submitty_cli.commands.auth.save_user"), + patch("submitty_cli.commands.auth.save_server") as mock_save_server, + ): + runner.invoke( + app, + ["auth", "login", "--server", "https://submitty.example.com/", "instructor01"], + input="secret\n", + obj=mock_state, + ) + mock_save_server.assert_called_once_with("https://submitty.example.com") + + +@respx.mock +def test_login_bad_credentials_exits_nonzero(runner, mock_state): + respx.post(LOGIN_URL).mock(return_value=httpx.Response(401)) + result = runner.invoke( + app, + ["auth", "login", "--server", "https://submitty.example.com", "instructor01"], + input="wrong\n", + obj=mock_state, + ) + assert result.exit_code == 1 + + +@respx.mock +def test_login_status_fail_exits_nonzero(runner, mock_state): + """Submitty returns HTTP 200 with status=fail for bad credentials.""" + respx.post(LOGIN_URL).mock( + return_value=httpx.Response( + 200, json={"status": "fail", "message": "Invalid credentials"} + ) + ) + result = runner.invoke( + app, + ["auth", "login", "--server", "https://submitty.example.com", "instructor01"], + input="wrong\n", + obj=mock_state, + ) + assert result.exit_code == 1 + + +@respx.mock +def test_login_server_error_exits_nonzero(runner, mock_state): + respx.post(LOGIN_URL).mock(return_value=httpx.Response(500)) + result = runner.invoke( + app, + ["auth", "login", "--server", "https://submitty.example.com", "instructor01"], + input="secret\n", + obj=mock_state, + ) + assert result.exit_code == 1 diff --git a/tests/submitty_cli/commands/test_course.py b/tests/submitty_cli/commands/test_course.py new file mode 100644 index 0000000..25a312b --- /dev/null +++ b/tests/submitty_cli/commands/test_course.py @@ -0,0 +1,161 @@ +import json + +from submitty_cli.cli import app +from submitty_cli.client import APIError + + +def test_course_list_table(runner, mock_state): + mock_state.client.get.return_value = { + "status": "success", + "data": { + "unarchived_courses": [ + { + "semester": "s25", + "title": "csci1200", + "display_name": "Data Structures", + "display_semester": "Spring 2025", + }, + { + "semester": "s25", + "title": "csci2200", + "display_name": "Foundations of CS", + "display_semester": "Spring 2025", + }, + ], + "archived_courses": [], + }, + } + result = runner.invoke(app, ["course", "list"], obj=mock_state) + assert result.exit_code == 0 + assert "csci1200" in result.output + assert "csci2200" in result.output + assert "Spring 2025" in result.output + assert "s25" in result.output + + +def test_course_list_includes_archived(runner, mock_state): + mock_state.client.get.return_value = { + "status": "success", + "data": { + "unarchived_courses": [ + {"semester": "s25", "title": "csci1200", "display_name": "", + "display_semester": "Spring 2025"}, + ], + "archived_courses": [ + {"semester": "f24", "title": "csci1200", "display_name": "", + "display_semester": "Fall 2024"}, + ], + }, + } + result = runner.invoke(app, ["course", "list"], obj=mock_state) + assert result.exit_code == 0 + assert "Spring 2025" in result.output + assert "s25" in result.output + assert "Fall 2024" in result.output + assert "f24" in result.output + + +def test_course_list_json_format(runner, mock_state): + mock_state.client.get.return_value = { + "status": "success", + "data": { + "unarchived_courses": [ + {"semester": "s25", "title": "csci1200", "display_name": "", + "display_semester": "Spring 2025"}, + ], + "archived_courses": [], + }, + } + result = runner.invoke(app, ["--format", "json", "course", "list"], obj=mock_state) + assert result.exit_code == 0 + parsed = json.loads(result.output) + assert parsed[0]["title"] == "csci1200" + + +def test_course_list_empty(runner, mock_state): + mock_state.client.get.return_value = { + "status": "success", + "data": {"unarchived_courses": [], "archived_courses": []}, + } + result = runner.invoke(app, ["course", "list"], obj=mock_state) + assert result.exit_code == 0 + + +def test_course_create_success(runner, mock_state): + mock_state.client.post.return_value = {"status": "success"} + result = runner.invoke( + app, ["course", "create", "s25", "csci1200", "--instructor", "prof01"], obj=mock_state + ) + assert result.exit_code == 0 + assert "created" in result.output + + +def test_course_create_passes_correct_payload(runner, mock_state): + mock_state.client.post.return_value = {"status": "success"} + runner.invoke( + app, + ["course", "create", "s25", "csci1200", "--instructor", "prof01", "--group", "mygroup"], + obj=mock_state, + ) + _, kwargs = mock_state.client.post.call_args + assert kwargs["data"]["course_semester"] == "s25" + assert kwargs["data"]["course_title"] == "csci1200" + assert kwargs["data"]["head_instructor"] == "prof01" + assert kwargs["data"]["group_name"] == "mygroup" + + +def test_course_create_api_error_exits_nonzero(runner, mock_state): + mock_state.client.post.side_effect = APIError("Course already exists", 409) + result = runner.invoke( + app, ["course", "create", "s25", "csci1200", "--instructor", "prof01"], obj=mock_state + ) + assert result.exit_code == 1 + + +def test_config_get_prints_json(runner, mock_state): + mock_state.client.get.return_value = { + "status": "success", + "data": {"course_name": "Data Structures", "enabled": True}, + } + result = runner.invoke(app, ["course", "config", "get", "s25", "csci1200"], obj=mock_state) + assert result.exit_code == 0 + parsed = json.loads(result.output) + assert parsed["course_name"] == "Data Structures" + + +def test_config_get_calls_correct_endpoint(runner, mock_state): + mock_state.client.get.return_value = {"status": "success", "data": {}} + runner.invoke(app, ["course", "config", "get", "s25", "csci1200"], obj=mock_state) + mock_state.client.get.assert_called_once_with("/api/courses/s25/csci1200/config") + + +def test_config_set_success(runner, mock_state): + mock_state.client.post.return_value = {"status": "success"} + result = runner.invoke( + app, + ["course", "config", "set", "s25", "csci1200", "course_name", "Updated Name"], + obj=mock_state, + ) + assert result.exit_code == 0 + assert "course_name" in result.output + + +def test_config_set_sends_form_encoded(runner, mock_state): + mock_state.client.post.return_value = {"status": "success"} + runner.invoke( + app, + ["course", "config", "set", "s25", "csci1200", "course_name", "Updated Name"], + obj=mock_state, + ) + _, kwargs = mock_state.client.post.call_args + assert kwargs["data"] == {"name": "course_name", "entry": "Updated Name"} + + +def test_config_set_api_error_exits_nonzero(runner, mock_state): + mock_state.client.post.side_effect = APIError("Forbidden", 403) + result = runner.invoke( + app, + ["course", "config", "set", "s25", "csci1200", "course_name", "x"], + obj=mock_state, + ) + assert result.exit_code == 1 diff --git a/tests/submitty_cli/conftest.py b/tests/submitty_cli/conftest.py new file mode 100644 index 0000000..431f75c --- /dev/null +++ b/tests/submitty_cli/conftest.py @@ -0,0 +1,29 @@ +import pytest +from typer.testing import CliRunner +from unittest.mock import MagicMock + +from submitty_cli.config import SubmittyConfig +from submitty_cli.state import AppState + + +@pytest.fixture +def sample_config() -> SubmittyConfig: + return SubmittyConfig( + server_url="https://submitty.example.com", + token="test-token-abc123", + ) + + +@pytest.fixture +def mock_client() -> MagicMock: + return MagicMock() + + +@pytest.fixture +def mock_state(sample_config: SubmittyConfig, mock_client: MagicMock) -> AppState: + return AppState.for_testing(sample_config, mock_client) + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() diff --git a/tests/submitty_cli/test_cli.py b/tests/submitty_cli/test_cli.py new file mode 100644 index 0000000..fe02877 --- /dev/null +++ b/tests/submitty_cli/test_cli.py @@ -0,0 +1,31 @@ +from typer.testing import CliRunner + +from submitty_cli.cli import app + +runner = CliRunner() + + +def test_app_help(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "submitty" in result.output.lower() + + +def test_no_args_shows_help(): + result = runner.invoke(app, []) + assert "auth" in result.output + assert "course" in result.output + + +def test_auth_help(): + result = runner.invoke(app, ["auth", "--help"]) + assert result.exit_code == 0 + assert "token" in result.output + assert "status" in result.output + + +def test_course_help(): + result = runner.invoke(app, ["course", "--help"]) + assert result.exit_code == 0 + assert "list" in result.output + assert "create" in result.output diff --git a/tests/submitty_cli/test_client.py b/tests/submitty_cli/test_client.py new file mode 100644 index 0000000..ff2635d --- /dev/null +++ b/tests/submitty_cli/test_client.py @@ -0,0 +1,99 @@ +import httpx +import pytest +import respx + +from submitty_cli.client import APIError, AuthError, NotFoundError, SubmittyClient + +BASE_URL = "https://submitty.example.com" + + +@pytest.fixture +def api_client() -> SubmittyClient: + return SubmittyClient(BASE_URL, "test-token") + + +@respx.mock +def test_get_success(api_client): + respx.get(f"{BASE_URL}/api/courses").mock( + return_value=httpx.Response(200, json={"status": "success", "data": []}) + ) + result = api_client.get("/api/courses") + assert result["status"] == "success" + + +@respx.mock +def test_get_sends_auth_header(api_client): + route = respx.get(f"{BASE_URL}/api/courses").mock( + return_value=httpx.Response(200, json={}) + ) + api_client.get("/api/courses") + assert route.calls[0].request.headers["Authorization"] == "test-token" + + +@respx.mock +def test_get_401_raises_auth_error(api_client): + respx.get(f"{BASE_URL}/api/token").mock( + return_value=httpx.Response(401, json={"message": "Unauthorized"}) + ) + with pytest.raises(AuthError): + api_client.get("/api/token") + + +@respx.mock +def test_get_404_raises_not_found(api_client): + respx.get(f"{BASE_URL}/api/missing").mock( + return_value=httpx.Response(404) + ) + with pytest.raises(NotFoundError): + api_client.get("/api/missing") + + +@respx.mock +def test_post_success(api_client): + respx.post(f"{BASE_URL}/api/courses").mock( + return_value=httpx.Response(200, json={"status": "success"}) + ) + result = api_client.post("/api/courses", json={"semester": "s25"}) + assert result["status"] == "success" + + +@respx.mock +def test_server_error_raises_api_error(api_client): + respx.get(f"{BASE_URL}/api/something").mock( + return_value=httpx.Response(500, json={"message": "Internal error"}) + ) + with pytest.raises(APIError) as exc_info: + api_client.get("/api/something") + assert exc_info.value.status_code == 500 + + +@respx.mock +def test_auth_error_has_correct_status_code(api_client): + respx.get(f"{BASE_URL}/api/token").mock( + return_value=httpx.Response(401) + ) + with pytest.raises(AuthError) as exc_info: + api_client.get("/api/token") + assert exc_info.value.status_code == 401 + + +@respx.mock +def test_application_fail_status_raises_api_error(api_client): + """HTTP 200 with status=fail in body should raise APIError.""" + respx.post(f"{BASE_URL}/api/courses").mock( + return_value=httpx.Response( + 200, json={"status": "fail", "message": "You don't have access to this endpoint."} + ) + ) + with pytest.raises(APIError) as exc_info: + api_client.post("/api/courses", data={}) + assert "You don't have access" in str(exc_info.value) + + +@respx.mock +def test_application_fail_without_message_raises_api_error(api_client): + respx.get(f"{BASE_URL}/api/something").mock( + return_value=httpx.Response(200, json={"status": "fail"}) + ) + with pytest.raises(APIError): + api_client.get("/api/something") diff --git a/tests/submitty_cli/test_config.py b/tests/submitty_cli/test_config.py new file mode 100644 index 0000000..23ad6fa --- /dev/null +++ b/tests/submitty_cli/test_config.py @@ -0,0 +1,104 @@ +import pytest + +from submitty_cli.config import ( + ConfigError, + DEFAULT_SERVER, + delete_token, + load_server, + resolve_token, + save_server, + save_token, +) + + +# --------------------------------------------------------------------------- +# resolve_token +# --------------------------------------------------------------------------- + +def test_resolve_token_from_env(monkeypatch): + monkeypatch.setenv("SUBMITTY_TOKEN", "env-token") + assert resolve_token() == "env-token" + + +def test_resolve_token_from_token_file(monkeypatch, tmp_path): + monkeypatch.delenv("SUBMITTY_TOKEN", raising=False) + token_file = tmp_path / "token" + token_file.write_text("file-token", encoding="utf-8") + monkeypatch.setattr("submitty_cli.config.TOKEN_FILE", token_file) + assert resolve_token() == "file-token" + + +def test_resolve_token_no_source_raises(monkeypatch, tmp_path): + monkeypatch.delenv("SUBMITTY_TOKEN", raising=False) + monkeypatch.setattr("submitty_cli.config.TOKEN_FILE", tmp_path / "no-token") + with pytest.raises(ConfigError, match="submitty auth login"): + resolve_token() + + +def test_resolve_token_env_wins_over_file(monkeypatch, tmp_path): + monkeypatch.setenv("SUBMITTY_TOKEN", "env-token") + token_file = tmp_path / "token" + token_file.write_text("file-token", encoding="utf-8") + monkeypatch.setattr("submitty_cli.config.TOKEN_FILE", token_file) + assert resolve_token() == "env-token" + + +# --------------------------------------------------------------------------- +# load_server +# --------------------------------------------------------------------------- + +def test_load_server_from_env(monkeypatch): + monkeypatch.setenv("SUBMITTY_SERVER", "https://env.example.com") + assert load_server() == "https://env.example.com" + + +def test_load_server_strips_trailing_slash(monkeypatch): + monkeypatch.setenv("SUBMITTY_SERVER", "https://env.example.com/") + assert load_server() == "https://env.example.com" + + +def test_load_server_from_file(monkeypatch, tmp_path): + monkeypatch.delenv("SUBMITTY_SERVER", raising=False) + server_file = tmp_path / "server" + server_file.write_text("https://file.example.com", encoding="utf-8") + monkeypatch.setattr("submitty_cli.config.SERVER_FILE", server_file) + assert load_server() == "https://file.example.com" + + +def test_load_server_defaults_to_localhost(monkeypatch, tmp_path): + monkeypatch.delenv("SUBMITTY_SERVER", raising=False) + monkeypatch.setattr("submitty_cli.config.SERVER_FILE", tmp_path / "no-server") + assert load_server() == DEFAULT_SERVER + + +def test_load_server_env_wins_over_file(monkeypatch, tmp_path): + monkeypatch.setenv("SUBMITTY_SERVER", "https://env.example.com") + server_file = tmp_path / "server" + server_file.write_text("https://file.example.com", encoding="utf-8") + monkeypatch.setattr("submitty_cli.config.SERVER_FILE", server_file) + assert load_server() == "https://env.example.com" + + +# --------------------------------------------------------------------------- +# save_token / delete_token / save_server +# --------------------------------------------------------------------------- + +def test_save_and_delete_token(monkeypatch, tmp_path): + token_file = tmp_path / "submitty" / "token" + monkeypatch.setattr("submitty_cli.config.TOKEN_FILE", token_file) + save_token("my-token") + assert token_file.read_text(encoding="utf-8") == "my-token" + delete_token() + assert not token_file.exists() + + +def test_delete_token_missing_file_is_safe(monkeypatch, tmp_path): + monkeypatch.setattr("submitty_cli.config.TOKEN_FILE", tmp_path / "nonexistent") + delete_token() + + +def test_save_server_writes_file(monkeypatch, tmp_path): + server_file = tmp_path / "server" + monkeypatch.setattr("submitty_cli.config.SERVER_FILE", server_file) + save_server("https://submitty.example.com/") + assert server_file.read_text(encoding="utf-8") == "https://submitty.example.com"