From 3fc5e69e67a72fd128f4d4e8d826108cff268cf8 Mon Sep 17 00:00:00 2001 From: rabesss <26330469+rabesss@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:13:50 +0530 Subject: [PATCH 1/4] feat: add a read-only submit --dry-run 'lighthouse submit COURSE FOLDER --file F --dry-run [--json]' resolves the course and dropbox folder with a read-only client and prints the destination (course, folder, file name and size). It never reads the file body or uploads, needs no --yes, and flags a folder name it could not verify. Ported from the MAHE-useful part of #28 without its trial --site option. Refs #28 #25 --- lighthouse_cli/cli.py | 8 +++- lighthouse_cli/submit.py | 45 +++++++++++++++++++- tests/test_submit.py | 91 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 3 deletions(-) diff --git a/lighthouse_cli/cli.py b/lighthouse_cli/cli.py index 2d45f32..2b80805 100644 --- a/lighthouse_cli/cli.py +++ b/lighthouse_cli/cli.py @@ -506,8 +506,10 @@ def assignments(course_id: str | None, json_output: bool) -> None: @click.argument("folder_id") @click.option("-f", "--file", "file_path", required=True, help="Path to the file to submit.") @click.option("--yes", "yes", is_flag=True, default=False, help="Skip confirmation prompt and submit immediately.") +@click.option("--dry-run", "dry_run", is_flag=True, default=False, + help="Resolve and print the destination without reading or uploading the file.") @click.option("--json", "json_output", is_flag=True, help="Output this command's JSON result.") -def submit(course_id: str, folder_id: str, file_path: str, yes: bool, json_output: bool) -> None: +def submit(course_id: str, folder_id: str, file_path: str, yes: bool, dry_run: bool, json_output: bool) -> None: """REMOTE WRITE: submit a file to a D2L dropbox folder. COURSE_ID is the course identifier (numeric OrgUnitId or name substring). @@ -518,10 +520,13 @@ def submit(course_id: str, folder_id: str, file_path: str, yes: bool, json_outpu Example: lighthouse submit "signals" "Assignment 1" --file solution.pdf lighthouse submit signals "Assignment 1" --file solution.pdf --yes + lighthouse submit signals "Assignment 1" --file solution.pdf --dry-run --json This command changes remote LMS state. The command prompts for confirmation before submitting (course name, folder name, file path). Use --yes to skip the prompt (required for agent/automation use). + --dry-run only resolves the course and folder (read-only) and prints the + destination; it needs no --yes and never uploads. On success, prints a JSON object with submission_id, folder_id, folder_name, course_id, course_name, file info, and submitted_at timestamp. @@ -532,4 +537,5 @@ def submit(course_id: str, folder_id: str, file_path: str, yes: bool, json_outpu file_path=file_path, yes=yes, json_output=json_output, + dry_run=dry_run, )) diff --git a/lighthouse_cli/submit.py b/lighthouse_cli/submit.py index 9d93f16..d14f6fc 100644 --- a/lighthouse_cli/submit.py +++ b/lighthouse_cli/submit.py @@ -23,6 +23,9 @@ _DEFAULT_FOLDER_NAME = "Unknown folder" _DEFAULT_FILE_NAME = "Unknown file" _CLIENT_INIT_ERROR = "Could not initialize Lighthouse client." +_DRY_RUN_UNVERIFIED_WARNING = ( + "The folder name could not be verified; check the folder ID. No submission was sent." +) def cmd_submit( @@ -31,6 +34,7 @@ def cmd_submit( file_path: str, yes: bool = False, json_output: bool = False, + dry_run: bool = False, ) -> int: """Submit a file to a dropbox folder. @@ -45,6 +49,9 @@ def cmd_submit( folder_name, course_id, course_name, file, submitted_at). Non-interactive / agent-friendly: --yes + --json = only JSON on stdout. + + ``dry_run`` resolves the same destination with a read-only client and + prints the plan without reading the file body or uploading anything. """ # Validate the local input before constructing a client or resolving any # remote identifiers. A declined submission should not read the file body, @@ -64,14 +71,14 @@ def cmd_submit( # Keep the explicit confirmation requirement for non-interactive callers. # This check happens after local validation, but before any API work. - if not yes and not sys.stdin.isatty(): + if not dry_run and not yes and not sys.stdin.isatty(): return _submit_error( "Refusing to submit without --yes in non-interactive mode. Use --yes flag to confirm.", json_output, ) try: - client = LighthouseClient() + client = LighthouseClient(read_only_auth=True) if dry_run else LighthouseClient() except Exception: return _submit_error(_CLIENT_INIT_ERROR, json_output) @@ -83,6 +90,10 @@ def cmd_submit( return _submit_error(e, json_output) folder_name = _get_folder_name(client, org_id, folder_id_int) + if dry_run: + return _submit_dry_run( + org_id, course_name, folder_id_int, folder_name, file_path_obj, display_filename, json_output, + ) # Confirmation prompt (skip with --yes). JSON-mode prompts must not pollute # stdout; ``input`` is called without a prompt because input() writes its @@ -157,6 +168,36 @@ def cmd_submit( return 0 +def _submit_dry_run( + org_id: int, course_name: str, folder_id: int, folder_name: str, + file_path: Path, display_filename: str, json_output: bool, +) -> int: + """Report the resolved destination; reads only the file's size.""" + try: + file_size = file_path.stat().st_size + except OSError: + return _submit_error("Could not read file.", json_output) + verified = folder_name != _DEFAULT_FOLDER_NAME + if json_output: + payload: dict[str, object] = { + "dry_run": True, + "course_id": org_id, + "course_name": course_name, + "folder_id": folder_id, + "folder_name": folder_name, + "folder_verified": verified, + "file": {"name": display_filename, "size_bytes": file_size}, + } + if not verified: + payload["warning"] = _DRY_RUN_UNVERIFIED_WARNING + _output_json(payload) + else: + print(f"Would submit to '{folder_name}' in '{course_name}'.\n File: {display_filename} ({file_size} bytes)") + if not verified: + print(f"Warning: {_DRY_RUN_UNVERIFIED_WARNING}") + return 0 + + def _submit_error(message: BaseException | str, json_output: bool) -> int: """Emit a safe submit failure without double-formatting its diagnostic. diff --git a/tests/test_submit.py b/tests/test_submit.py index 99ff0dc..a298368 100644 --- a/tests/test_submit.py +++ b/tests/test_submit.py @@ -1890,3 +1890,94 @@ def test_multipart_boundary_is_unique(self, sample_submission_response: dict) -> assert len(captured) == 2 boundaries = [request["headers"]["Content-Type"] for request in captured] assert boundaries[0] != boundaries[1] + + +class TestSubmitDryRun: + """`submit --dry-run` resolves the destination read-only and never uploads.""" + + @staticmethod + def _client(detail: object = None) -> MagicMock: + client = MagicMock() + client.get_courses.return_value = [{"OrgUnitId": 44347, "Name": "Signals & Systems"}] + client.get_dropbox_folders.return_value = [{"Id": 789, "Name": "Assignment 1 - Signals"}] + client.get_dropbox_folder_detail.return_value = ( + {"Name": "Assignment 1 - Signals"} if detail is None else detail + ) + return client + + def test_dry_run_reports_destination_without_reading_or_uploading( + self, cli_runner: CliRunner, temp_pdf_file: Path, + ) -> None: + from lighthouse_cli.cli import cli + + client = self._client() + with patch("lighthouse_cli.submit.LighthouseClient", return_value=client) as client_cls, \ + patch.object(Path, "read_bytes", side_effect=AssertionError("must not read the file")): + result = cli_runner.invoke( + cli, ["submit", "44347", "789", "--file", str(temp_pdf_file), "--dry-run", "--json"], + ) + assert result.exit_code == 0, result.output + data = json_module.loads(result.stdout) + assert data == { + "dry_run": True, "course_id": 44347, "course_name": "Signals & Systems", + "folder_id": 789, "folder_name": "Assignment 1 - Signals", "folder_verified": True, + "file": {"name": "test.pdf", "size_bytes": temp_pdf_file.stat().st_size}, + } + client_cls.assert_called_once_with(read_only_auth=True) + client.submit_file.assert_not_called() + + def test_dry_run_needs_no_yes_in_non_interactive_mode( + self, cli_runner: CliRunner, temp_pdf_file: Path, + ) -> None: + from lighthouse_cli.cli import cli + + client = self._client() + with patch("lighthouse_cli.submit.LighthouseClient", return_value=client): + result = cli_runner.invoke( + cli, ["submit", "44347", "789", "--file", str(temp_pdf_file), "--dry-run"], + ) + assert result.exit_code == 0, result.output + assert "Would submit to 'Assignment 1 - Signals' in 'Signals & Systems'" in result.output + client.submit_file.assert_not_called() + + def test_dry_run_flags_an_unverified_folder_name( + self, cli_runner: CliRunner, temp_pdf_file: Path, + ) -> None: + from lighthouse_cli.cli import cli + + client = self._client(detail={"Name": ""}) + with patch("lighthouse_cli.submit.LighthouseClient", return_value=client): + result = cli_runner.invoke( + cli, ["submit", "44347", "789", "--file", str(temp_pdf_file), "--dry-run", "--json"], + ) + assert result.exit_code == 0 + data = json_module.loads(result.stdout) + assert data["folder_verified"] is False + assert "No submission was sent" in data["warning"] + client.submit_file.assert_not_called() + + def test_dry_run_still_reports_resolution_errors( + self, cli_runner: CliRunner, temp_pdf_file: Path, + ) -> None: + from lighthouse_cli.cli import cli + + client = self._client() + client.get_courses.return_value = [] + with patch("lighthouse_cli.submit.LighthouseClient", return_value=client): + result = cli_runner.invoke( + cli, ["submit", "nope", "789", "--file", str(temp_pdf_file), "--dry-run", "--json"], + ) + assert result.exit_code == 1 + assert json_module.loads(result.stdout)["error"] + client.submit_file.assert_not_called() + + def test_real_submit_still_requires_yes_when_non_interactive( + self, cli_runner: CliRunner, temp_pdf_file: Path, + ) -> None: + from lighthouse_cli.cli import cli + + with patch("lighthouse_cli.submit.LighthouseClient") as client_cls: + result = cli_runner.invoke(cli, ["submit", "44347", "789", "--file", str(temp_pdf_file)]) + assert result.exit_code == 1 + assert "--yes" in result.output + client_cls.assert_not_called() From 44a96022a77427172932a66f55527d6028e533d4 Mon Sep 17 00:00:00 2001 From: rabesss <26330469+rabesss@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:26:45 +0530 Subject: [PATCH 2/4] fix: report dry-run folder verification explicitly From pullfrog and Kilo reviews of 1471802: - folder_verified now means the folder's details were read, not that its name differs from the display fallback, so a folder literally named "Unknown folder" is no longer flagged. - Build the client as LighthouseClient(read_only_auth=dry_run), like download --dry-run. - submit --help documents the dry-run JSON shape. --- lighthouse_cli/cli.py | 5 ++++- lighthouse_cli/submit.py | 22 +++++++++++----------- tests/test_submit.py | 22 +++++++++++++++++++--- 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/lighthouse_cli/cli.py b/lighthouse_cli/cli.py index 2b80805..6cc7dbe 100644 --- a/lighthouse_cli/cli.py +++ b/lighthouse_cli/cli.py @@ -529,7 +529,10 @@ def submit(course_id: str, folder_id: str, file_path: str, yes: bool, dry_run: b destination; it needs no --yes and never uploads. On success, prints a JSON object with submission_id, folder_id, folder_name, - course_id, course_name, file info, and submitted_at timestamp. + course_id, course_name, file info, and submitted_at timestamp. A --dry-run + instead prints dry_run, course_id, course_name, folder_id, folder_name, + folder_verified and file info (plus a warning when the folder details + could not be read). """ raise SystemExit(cmd_submit( course_id=course_id, diff --git a/lighthouse_cli/submit.py b/lighthouse_cli/submit.py index d14f6fc..54ced9d 100644 --- a/lighthouse_cli/submit.py +++ b/lighthouse_cli/submit.py @@ -24,7 +24,7 @@ _DEFAULT_FILE_NAME = "Unknown file" _CLIENT_INIT_ERROR = "Could not initialize Lighthouse client." _DRY_RUN_UNVERIFIED_WARNING = ( - "The folder name could not be verified; check the folder ID. No submission was sent." + "The folder details could not be read; check the folder ID. No submission was sent." ) @@ -78,7 +78,7 @@ def cmd_submit( ) try: - client = LighthouseClient(read_only_auth=True) if dry_run else LighthouseClient() + client = LighthouseClient(read_only_auth=dry_run) except Exception: return _submit_error(_CLIENT_INIT_ERROR, json_output) @@ -89,10 +89,11 @@ def cmd_submit( except Exception as e: return _submit_error(e, json_output) - folder_name = _get_folder_name(client, org_id, folder_id_int) + folder_name, folder_verified = _get_folder_name(client, org_id, folder_id_int) if dry_run: return _submit_dry_run( - org_id, course_name, folder_id_int, folder_name, file_path_obj, display_filename, json_output, + org_id, course_name, folder_id_int, folder_name, folder_verified, + file_path_obj, display_filename, json_output, ) # Confirmation prompt (skip with --yes). JSON-mode prompts must not pollute @@ -169,7 +170,7 @@ def cmd_submit( def _submit_dry_run( - org_id: int, course_name: str, folder_id: int, folder_name: str, + org_id: int, course_name: str, folder_id: int, folder_name: str, verified: bool, file_path: Path, display_filename: str, json_output: bool, ) -> int: """Report the resolved destination; reads only the file's size.""" @@ -177,7 +178,6 @@ def _submit_dry_run( file_size = file_path.stat().st_size except OSError: return _submit_error("Could not read file.", json_output) - verified = folder_name != _DEFAULT_FOLDER_NAME if json_output: payload: dict[str, object] = { "dry_run": True, @@ -350,15 +350,15 @@ def _positive_folder_id(value: object) -> int | None: return None -def _get_folder_name(client: LighthouseClient, org_id: int, folder_id: int) -> str: - """Get the name of a dropbox folder by ID.""" +def _get_folder_name(client: LighthouseClient, org_id: int, folder_id: int) -> tuple[str, bool]: + """Get a dropbox folder's display name and whether its detail was read.""" try: detail = client.get_dropbox_folder_detail(org_id, folder_id) except Exception: - return _DEFAULT_FOLDER_NAME + return _DEFAULT_FOLDER_NAME, False if not isinstance(detail, dict): - return _DEFAULT_FOLDER_NAME - return _safe_display_name(detail.get("Name"), _DEFAULT_FOLDER_NAME) + return _DEFAULT_FOLDER_NAME, False + return _safe_display_name(detail.get("Name"), _DEFAULT_FOLDER_NAME), True def _safe_display_name(value: object, fallback: str) -> str: diff --git a/tests/test_submit.py b/tests/test_submit.py index a298368..4b5e876 100644 --- a/tests/test_submit.py +++ b/tests/test_submit.py @@ -537,7 +537,7 @@ def test_submit_client_constructor_failure_is_safe_json( } assert result.stdout.count('"error"') == 1 assert "CLIENT_SECRET_SENTINEL" not in result.output - mock_client_cls.assert_called_once_with() + mock_client_cls.assert_called_once_with(read_only_auth=False) read_bytes_mock.assert_not_called() def test_submit_success_with_yes_flag_json_output( @@ -1940,12 +1940,13 @@ def test_dry_run_needs_no_yes_in_non_interactive_mode( assert "Would submit to 'Assignment 1 - Signals' in 'Signals & Systems'" in result.output client.submit_file.assert_not_called() - def test_dry_run_flags_an_unverified_folder_name( + def test_dry_run_flags_a_folder_whose_details_could_not_be_read( self, cli_runner: CliRunner, temp_pdf_file: Path, ) -> None: from lighthouse_cli.cli import cli - client = self._client(detail={"Name": ""}) + client = self._client() + client.get_dropbox_folder_detail.side_effect = RuntimeError("lookup failed") with patch("lighthouse_cli.submit.LighthouseClient", return_value=client): result = cli_runner.invoke( cli, ["submit", "44347", "789", "--file", str(temp_pdf_file), "--dry-run", "--json"], @@ -1956,6 +1957,21 @@ def test_dry_run_flags_an_unverified_folder_name( assert "No submission was sent" in data["warning"] client.submit_file.assert_not_called() + def test_dry_run_verifies_a_folder_literally_named_like_the_fallback( + self, cli_runner: CliRunner, temp_pdf_file: Path, + ) -> None: + from lighthouse_cli.cli import cli + + client = self._client(detail={"Name": "Unknown folder"}) + with patch("lighthouse_cli.submit.LighthouseClient", return_value=client): + result = cli_runner.invoke( + cli, ["submit", "44347", "789", "--file", str(temp_pdf_file), "--dry-run", "--json"], + ) + assert result.exit_code == 0, result.output + data = json_module.loads(result.stdout) + assert data["folder_verified"] is True + assert "warning" not in data + def test_dry_run_still_reports_resolution_errors( self, cli_runner: CliRunner, temp_pdf_file: Path, ) -> None: From f18d089ac8911e13abe71e79ca23c65f0c29cc61 Mon Sep 17 00:00:00 2001 From: rabesss <26330469+rabesss@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:34:18 +0530 Subject: [PATCH 3/4] docs: document submit --dry-run in the README --- README.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 95ea721..920ac6a 100644 --- a/README.md +++ b/README.md @@ -887,7 +887,7 @@ For a single course with no grade items, human output says --- -### `lighthouse submit -f FILE COURSE_ID FOLDER_ID [--yes] [--json]` +### `lighthouse submit -f FILE COURSE_ID FOLDER_ID [--yes] [--dry-run] [--json]` Submit a file to a D2L dropbox folder. @@ -909,6 +909,7 @@ courses` affect local state only. |------|-------------| | `-f`, `--file` | Path to the file to submit (required) | | `--yes` | Skip confirmation prompt; required in non-TTY mode | +| `--dry-run` | Resolve the course and folder read-only and print the destination; never reads the file body or uploads, and needs no `--yes` | | `--json` | Output structured JSON result | **API call:** `POST /d2l/api/le/1.93/{orgId}/dropbox/folders/{folderId}/submissions/mysubmissions/` @@ -952,6 +953,22 @@ Submitted successfully. Submission ID: 5001 } ``` +**JSON output (`--json`, `--dry-run`):** +```json +{ + "dry_run": true, + "course_id": 1001, + "course_name": "Introduction to CS", + "folder_id": 101, + "folder_name": "Homework 1", + "folder_verified": true, + "file": {"name": "homework.pdf", "size_bytes": 24576} +} +``` + +`folder_verified` is `false`, with a `warning`, when the folder's details +could not be read; check the folder ID before submitting. + --- ### `lighthouse announcements [COURSE_ID] [--json]` From 40b827f66b2b6a9d51a440d9189931d3dd245d0d Mon Sep 17 00:00:00 2001 From: rabesss <26330469+rabesss@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:36:25 +0530 Subject: [PATCH 4/4] fix: verify a dry-run folder only when its name is usable From Kilo's review of 44a9602: a readable folder whose Name is missing, empty or rejected for display came back as "Unknown folder" with folder_verified true and no warning. folder_verified now also requires a usable name. --- README.md | 2 +- lighthouse_cli/cli.py | 2 +- lighthouse_cli/submit.py | 7 ++++--- tests/test_submit.py | 11 ++++++++--- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 920ac6a..66777bf 100644 --- a/README.md +++ b/README.md @@ -966,7 +966,7 @@ Submitted successfully. Submission ID: 5001 } ``` -`folder_verified` is `false`, with a `warning`, when the folder's details +`folder_verified` is `false`, with a `warning`, when the folder's name could not be read; check the folder ID before submitting. --- diff --git a/lighthouse_cli/cli.py b/lighthouse_cli/cli.py index 6cc7dbe..84d2b6c 100644 --- a/lighthouse_cli/cli.py +++ b/lighthouse_cli/cli.py @@ -531,7 +531,7 @@ def submit(course_id: str, folder_id: str, file_path: str, yes: bool, dry_run: b On success, prints a JSON object with submission_id, folder_id, folder_name, course_id, course_name, file info, and submitted_at timestamp. A --dry-run instead prints dry_run, course_id, course_name, folder_id, folder_name, - folder_verified and file info (plus a warning when the folder details + folder_verified and file info (plus a warning when the folder name could not be read). """ raise SystemExit(cmd_submit( diff --git a/lighthouse_cli/submit.py b/lighthouse_cli/submit.py index 54ced9d..7fdeb51 100644 --- a/lighthouse_cli/submit.py +++ b/lighthouse_cli/submit.py @@ -24,7 +24,7 @@ _DEFAULT_FILE_NAME = "Unknown file" _CLIENT_INIT_ERROR = "Could not initialize Lighthouse client." _DRY_RUN_UNVERIFIED_WARNING = ( - "The folder details could not be read; check the folder ID. No submission was sent." + "The folder name could not be read; check the folder ID. No submission was sent." ) @@ -351,14 +351,15 @@ def _positive_folder_id(value: object) -> int | None: def _get_folder_name(client: LighthouseClient, org_id: int, folder_id: int) -> tuple[str, bool]: - """Get a dropbox folder's display name and whether its detail was read.""" + """Get a dropbox folder's display name and whether it was read and usable.""" try: detail = client.get_dropbox_folder_detail(org_id, folder_id) except Exception: return _DEFAULT_FOLDER_NAME, False if not isinstance(detail, dict): return _DEFAULT_FOLDER_NAME, False - return _safe_display_name(detail.get("Name"), _DEFAULT_FOLDER_NAME), True + name = _safe_display_name(detail.get("Name"), "") + return (name, True) if name else (_DEFAULT_FOLDER_NAME, False) def _safe_display_name(value: object, fallback: str) -> str: diff --git a/tests/test_submit.py b/tests/test_submit.py index 4b5e876..9722e88 100644 --- a/tests/test_submit.py +++ b/tests/test_submit.py @@ -1940,13 +1940,17 @@ def test_dry_run_needs_no_yes_in_non_interactive_mode( assert "Would submit to 'Assignment 1 - Signals' in 'Signals & Systems'" in result.output client.submit_file.assert_not_called() - def test_dry_run_flags_a_folder_whose_details_could_not_be_read( - self, cli_runner: CliRunner, temp_pdf_file: Path, + @pytest.mark.parametrize("detail", [RuntimeError("lookup failed"), {"Name": ""}, {}, {"Name": "x" * 300}]) + def test_dry_run_flags_a_folder_whose_name_could_not_be_read( + self, cli_runner: CliRunner, temp_pdf_file: Path, detail: object, ) -> None: from lighthouse_cli.cli import cli client = self._client() - client.get_dropbox_folder_detail.side_effect = RuntimeError("lookup failed") + if isinstance(detail, Exception): + client.get_dropbox_folder_detail.side_effect = detail + else: + client.get_dropbox_folder_detail.return_value = detail with patch("lighthouse_cli.submit.LighthouseClient", return_value=client): result = cli_runner.invoke( cli, ["submit", "44347", "789", "--file", str(temp_pdf_file), "--dry-run", "--json"], @@ -1954,6 +1958,7 @@ def test_dry_run_flags_a_folder_whose_details_could_not_be_read( assert result.exit_code == 0 data = json_module.loads(result.stdout) assert data["folder_verified"] is False + assert data["folder_name"] == "Unknown folder" assert "No submission was sent" in data["warning"] client.submit_file.assert_not_called()