From b906c3430209a6391dc553fb05a0aabd2c5fd37f Mon Sep 17 00:00:00 2001 From: Patrick Young Date: Thu, 9 Oct 2025 15:55:40 -0400 Subject: [PATCH 1/7] feat: add cli base parser --- codecov.yaml | 19 +++++++++++++ usajobsapi/cli.py | 70 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 codecov.yaml diff --git a/codecov.yaml b/codecov.yaml new file mode 100644 index 0000000..9daf869 --- /dev/null +++ b/codecov.yaml @@ -0,0 +1,19 @@ +codecov: + notify: + after_n_builds: 3 + require_ci_to_pass: yes + +coverage: + range: "70...100" + round: down + precision: 2 + + default: + threshold: 5 + +comment: + layout: "condensed_header, condensed_files, condensed_footer" + hide_project_coverage: true + +github_checks: + annotations: true diff --git a/usajobsapi/cli.py b/usajobsapi/cli.py index 047ac14..3b1a648 100644 --- a/usajobsapi/cli.py +++ b/usajobsapi/cli.py @@ -1,2 +1,70 @@ +""" +Command line interface for the python-usajobsapi package. + +This module builds the argument parser that powers the `usajobsapi` +executable, handling global configuration common to every subcommand. +""" + +from __future__ import annotations + +import argparse +import sys + +from usajobsapi._version import __title__ as pkg_title +from usajobsapi._version import __version__ as pkg_version +from usajobsapi.client import USAJobsClient + + +def build_parser() -> argparse.ArgumentParser: + """Create the top-level argument parser for the CLI.""" + client_defaults = USAJobsClient() + parser = argparse.ArgumentParser( + prog=pkg_title, + description="USAJOBS REST API Command Line Interface.", + ) + + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {pkg_version}", + help="Display the package version.", + ) + parser.add_argument( + "--timeout", + type=float, + default=client_defaults.timeout, + metavar="SECONDS", + help="Request timeout in seconds. Defaults to the client default.", + ) + parser.add_argument( + "-A", + "--user-agent", + dest="auth_user", + default=client_defaults.headers["User-Agent"], + metavar="EMAIL", + help="Email address associated with the API key (User-Agent header).", + ) + parser.add_argument( + "--auth-key", + dest="auth_key", + default=client_defaults.headers["Authorization-Key"], + metavar="KEY", + help="API key used for authenticated requests.", + ) + parser.add_argument( + "--no-ssl-verify", + dest="ssl_verify", + action="store_false", + default=client_defaults.ssl_verify, + help="Disable TLS certificate verification.", + ) + return parser + + def main() -> None: - pass + if "--version" in sys.argv: + print(pkg_version) + sys.exit(0) + + parser = build_parser() + parser.parse_args(sys.argv) From fd4251294f9eba1cc185d8be655d5a8c7e629de1 Mon Sep 17 00:00:00 2001 From: Patrick Young Date: Thu, 9 Oct 2025 16:13:09 -0400 Subject: [PATCH 2/7] feat: add action and json cli args --- codecov.yaml | 19 ------------------- usajobsapi/cli.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 19 deletions(-) delete mode 100644 codecov.yaml diff --git a/codecov.yaml b/codecov.yaml deleted file mode 100644 index 9daf869..0000000 --- a/codecov.yaml +++ /dev/null @@ -1,19 +0,0 @@ -codecov: - notify: - after_n_builds: 3 - require_ci_to_pass: yes - -coverage: - range: "70...100" - round: down - precision: 2 - - default: - threshold: 5 - -comment: - layout: "condensed_header, condensed_files, condensed_footer" - hide_project_coverage: true - -github_checks: - annotations: true diff --git a/usajobsapi/cli.py b/usajobsapi/cli.py index 3b1a648..e1f03d6 100644 --- a/usajobsapi/cli.py +++ b/usajobsapi/cli.py @@ -8,13 +8,28 @@ from __future__ import annotations import argparse +import json import sys +from typing import Any from usajobsapi._version import __title__ as pkg_title from usajobsapi._version import __version__ as pkg_version from usajobsapi.client import USAJobsClient +def _parse_json(value: str) -> dict[str, Any]: + """Parse JSON-encoded argument data.""" + try: + parsed = json.loads(value) + except json.JSONDecodeError as exc: + raise argparse.ArgumentTypeError(f"Invalid JSON payload: {exc}") from exc + + if not isinstance(parsed, dict): + raise argparse.ArgumentTypeError("JSON payload must decode to an object.") + + return parsed + + def build_parser() -> argparse.ArgumentParser: """Create the top-level argument parser for the CLI.""" client_defaults = USAJobsClient() @@ -23,6 +38,23 @@ def build_parser() -> argparse.ArgumentParser: description="USAJOBS REST API Command Line Interface.", ) + parser.add_argument( + "action", + choices=["announcementtext", "search", "historicjoa"], + help="Endpoint that will be queried.", + ) + parser.add_argument( + "-d", + "--data", + type=_parse_json, + default=None, + metavar="JSON", + help="JSON-encoded parameters to pass to the selected endpoint.", + ) + parser.add_argument( + "--prettify", action="store_true", help="Prettify the JSON output." + ) + parser.add_argument( "--version", action="version", @@ -58,6 +90,7 @@ def build_parser() -> argparse.ArgumentParser: default=client_defaults.ssl_verify, help="Disable TLS certificate verification.", ) + return parser From d9673e3ae91c64cc011c546e31e7b35b51767393 Mon Sep 17 00:00:00 2001 From: Patrick Young Date: Thu, 9 Oct 2025 17:02:27 -0400 Subject: [PATCH 3/7] feat: implement endpoint calls from cli --- usajobsapi/cli.py | 73 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 57 insertions(+), 16 deletions(-) diff --git a/usajobsapi/cli.py b/usajobsapi/cli.py index e1f03d6..fff70b1 100644 --- a/usajobsapi/cli.py +++ b/usajobsapi/cli.py @@ -10,13 +10,22 @@ import argparse import json import sys +from enum import StrEnum from typing import Any +from pydantic import BaseModel + from usajobsapi._version import __title__ as pkg_title from usajobsapi._version import __version__ as pkg_version from usajobsapi.client import USAJobsClient +class ACTIONS(StrEnum): + TEXT = "announcementtext" + SEARCH = "search" + HISTORIC = "historicjoa" + + def _parse_json(value: str) -> dict[str, Any]: """Parse JSON-encoded argument data.""" try: @@ -25,7 +34,9 @@ def _parse_json(value: str) -> dict[str, Any]: raise argparse.ArgumentTypeError(f"Invalid JSON payload: {exc}") from exc if not isinstance(parsed, dict): - raise argparse.ArgumentTypeError("JSON payload must decode to an object.") + raise argparse.ArgumentTypeError( + "JSON payload must decode to an object (dict)." + ) return parsed @@ -38,16 +49,24 @@ def build_parser() -> argparse.ArgumentParser: description="USAJOBS REST API Command Line Interface.", ) + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {pkg_version}", + help="Display the package version.", + ) + parser.add_argument( "action", - choices=["announcementtext", "search", "historicjoa"], - help="Endpoint that will be queried.", + type=ACTIONS, + choices=list(ACTIONS), + help="Endpoint to query.", ) parser.add_argument( "-d", "--data", type=_parse_json, - default=None, + default={}, metavar="JSON", help="JSON-encoded parameters to pass to the selected endpoint.", ) @@ -56,10 +75,11 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument( - "--version", - action="version", - version=f"%(prog)s {pkg_version}", - help="Display the package version.", + "--no-ssl-verify", + dest="ssl_verify", + action="store_false", + default=client_defaults.ssl_verify, + help="Disable TLS certificate verification.", ) parser.add_argument( "--timeout", @@ -68,6 +88,7 @@ def build_parser() -> argparse.ArgumentParser: metavar="SECONDS", help="Request timeout in seconds. Defaults to the client default.", ) + parser.add_argument( "-A", "--user-agent", @@ -83,13 +104,6 @@ def build_parser() -> argparse.ArgumentParser: metavar="KEY", help="API key used for authenticated requests.", ) - parser.add_argument( - "--no-ssl-verify", - dest="ssl_verify", - action="store_false", - default=client_defaults.ssl_verify, - help="Disable TLS certificate verification.", - ) return parser @@ -100,4 +114,31 @@ def main() -> None: sys.exit(0) parser = build_parser() - parser.parse_args(sys.argv) + args = parser.parse_args() + + client = USAJobsClient( + ssl_verify=args.ssl_verify, + timeout=args.timeout, + auth_user=args.auth_user, + auth_key=args.auth_key, + ) + + action = args.action + if not action: + sys.exit(0) + + resp: BaseModel | None = None + if action == ACTIONS.TEXT: + resp = client.announcement_text(**args.data) + elif action == ACTIONS.SEARCH: + resp = client.search_jobs(**args.data) + elif action == ACTIONS.HISTORIC: + resp = client.historic_joa(**args.data) + + if not resp: + sys.exit(1) + + if args.prettify: + print(resp.model_dump_json(indent=2)) + else: + print(resp.model_dump_json()) From ccf6714272a7b6f8c0268620023b5fafe558174b Mon Sep 17 00:00:00 2001 From: Patrick Young Date: Thu, 9 Oct 2025 17:17:47 -0400 Subject: [PATCH 4/7] feat: add cli unit tests --- tests/unit/test_cli.py | 228 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 224 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 877c54c..fc916f8 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1,6 +1,226 @@ -"""Placeholder tests for USAJobs API package.""" +"""Unit tests for the CLI entry points.""" +from __future__ import annotations -def test_placeholder() -> None: - """A trivial test to ensure the test suite runs.""" - assert True +import argparse +import json +import sys +from typing import Any, ClassVar + +import pytest +from pydantic import BaseModel + +from usajobsapi import cli + + +class DummyResponse(BaseModel): + """Lightweight response model used to emulate client responses.""" + + payload: dict[str, Any] + + +class FakeClient: + """Test double for ``USAJobsClient`` used to capture CLI interactions.""" + + instances: ClassVar[list["FakeClient"]] = [] + + def __init__( + self, + url: str | None = "https://data.usajobs.gov", + ssl_verify: bool = True, + timeout: float | None = 60, + auth_user: str | None = None, + auth_key: str | None = None, + session: Any = None, + ) -> None: + self.url = url + self.ssl_verify = ssl_verify + self.timeout = timeout + self.auth_user = auth_user + self.auth_key = auth_key + self.session = session + self.headers = { + "Host": "data.usajobs.gov", + "User-Agent": auth_user, + "Authorization-Key": auth_key, + } + self.calls: list[tuple[str, dict[str, Any]]] = [] + FakeClient.instances.append(self) + + def _respond(self, method_name: str, params: dict[str, Any]) -> DummyResponse: + recorded = dict(params) + self.calls.append((method_name, recorded)) + return DummyResponse(payload={"method": method_name, "params": recorded}) + + def announcement_text(self, **kwargs: Any) -> DummyResponse: + return self._respond("announcement_text", kwargs) + + def search_jobs(self, **kwargs: Any) -> DummyResponse: + return self._respond("search_jobs", kwargs) + + def historic_joa(self, **kwargs: Any) -> DummyResponse: + return self._respond("historic_joa", kwargs) + + +@pytest.fixture() +def fake_client(monkeypatch: pytest.MonkeyPatch) -> type[FakeClient]: + """Patch ``USAJobsClient`` with the fake client for a single test.""" + FakeClient.instances.clear() + monkeypatch.setattr(cli, "USAJobsClient", FakeClient) + return FakeClient + + +def test_parse_json_valid_object() -> None: + """Ensure JSON parsing returns a dictionary.""" + payload = cli._parse_json('{"foo": "bar"}') + assert payload == {"foo": "bar"} + + +def test_parse_json_invalid_json() -> None: + """Invalid JSON strings raise argparse errors.""" + with pytest.raises(argparse.ArgumentTypeError): + cli._parse_json("{invalid") + + +def test_parse_json_requires_object() -> None: + """Only JSON objects are accepted as CLI payloads.""" + with pytest.raises(argparse.ArgumentTypeError): + cli._parse_json('["not", "an", "object"]') + + +def test_build_parser_defaults() -> None: + """The parser should expose the expected default values.""" + parser = cli.build_parser() + args = parser.parse_args([cli.ACTIONS.TEXT.value]) + + assert args.action == cli.ACTIONS.TEXT + assert args.data == {} + assert args.prettify is False + assert args.ssl_verify is True + assert args.timeout == cli.USAJobsClient().timeout + assert args.auth_user is None + assert args.auth_key is None + + +def test_build_parser_custom_overrides() -> None: + """Custom overrides should populate their respective fields.""" + parser = cli.build_parser() + payload = {"keyword": "python"} + args = parser.parse_args( + [ + cli.ACTIONS.SEARCH.value, + "-d", + json.dumps(payload), + "--prettify", + "--no-ssl-verify", + "--timeout", + "10", + "-A", + "user@example.com", + "--auth-key", + "secret", + ] + ) + + assert args.action == cli.ACTIONS.SEARCH + assert args.data == payload + assert args.prettify is True + assert args.ssl_verify is False + assert args.timeout == 10.0 + assert args.auth_user == "user@example.com" + assert args.auth_key == "secret" + + +def test_main_version_short_circuit( + monkeypatch: pytest.MonkeyPatch, capfd: pytest.CaptureFixture[str] +) -> None: + """--version should print the package version and exit.""" + monkeypatch.setattr(sys, "argv", ["prog", "--version"], raising=False) + + with pytest.raises(SystemExit) as exc: + cli.main() + + assert exc.value.code == 0 + out = capfd.readouterr().out.strip() + assert out == cli.pkg_version + + +@pytest.mark.parametrize( + ("action", "expected_method"), + [ + (cli.ACTIONS.TEXT, "announcement_text"), + (cli.ACTIONS.SEARCH, "search_jobs"), + (cli.ACTIONS.HISTORIC, "historic_joa"), + ], +) +def test_main_dispatches_to_expected_client_method( + monkeypatch: pytest.MonkeyPatch, + capfd: pytest.CaptureFixture[str], + fake_client: type[FakeClient], + action: cli.ACTIONS, + expected_method: str, +) -> None: + """Each CLI action should invoke the corresponding client method.""" + payload = {"query": action.value} + monkeypatch.setattr( + sys, + "argv", + ["prog", action.value, "-d", json.dumps(payload)], + raising=False, + ) + + cli.main() + + out = capfd.readouterr().out.strip() + assert json.loads(out) == { + "payload": {"method": expected_method, "params": payload} + } + + instance = fake_client.instances[-1] + assert instance.calls[-1] == (expected_method, payload) + + +def test_main_prettify_outputs_indented_json( + monkeypatch: pytest.MonkeyPatch, + capfd: pytest.CaptureFixture[str], + fake_client: type[FakeClient], +) -> None: + """--prettify should request indented JSON output.""" + payload = {"foo": "bar"} + monkeypatch.setattr( + sys, + "argv", + ["prog", cli.ACTIONS.SEARCH.value, "-d", json.dumps(payload), "--prettify"], + raising=False, + ) + + cli.main() + out = capfd.readouterr().out + + assert out.startswith("{\n") + assert '"payload"' in out + + instance = fake_client.instances[-1] + assert instance.calls[-1] == ("search_jobs", payload) + + +def test_main_defaults_to_empty_payload( + monkeypatch: pytest.MonkeyPatch, + capfd: pytest.CaptureFixture[str], + fake_client: type[FakeClient], +) -> None: + """When no JSON payload is supplied the client receives an empty dict.""" + monkeypatch.setattr( + sys, + "argv", + ["prog", cli.ACTIONS.SEARCH.value], + raising=False, + ) + + cli.main() + + out = capfd.readouterr().out.strip() + assert json.loads(out) == {"payload": {"method": "search_jobs", "params": {}}} + + instance = fake_client.instances[-1] + assert instance.calls[-1] == ("search_jobs", {}) From a12afbc9cc458648122c15e983110ea140d19b70 Mon Sep 17 00:00:00 2001 From: Patrick Young Date: Thu, 9 Oct 2025 17:30:54 -0400 Subject: [PATCH 5/7] feat: define package executabl command --- pyproject.toml | 3 +++ usajobsapi/cli.py | 7 +++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cde4e60..7466f7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,9 @@ dynamic = ["version"] [project.optional-dependencies] build = ["uv ~= 0.8.17"] +[project.scripts] +usajobsapi = "usajobsapi.cli:main" + [tool.uv.sources] mkdocs_external_files = { path = "mkdocs/mkdocs_external_files" } diff --git a/usajobsapi/cli.py b/usajobsapi/cli.py index fff70b1..302fefc 100644 --- a/usajobsapi/cli.py +++ b/usajobsapi/cli.py @@ -1,8 +1,7 @@ """ Command line interface for the python-usajobsapi package. -This module builds the argument parser that powers the `usajobsapi` -executable, handling global configuration common to every subcommand. +This module powers the `usajobsapi` executable, so that a user can query the exposed endpoints from the command line. """ from __future__ import annotations @@ -41,7 +40,7 @@ def _parse_json(value: str) -> dict[str, Any]: return parsed -def build_parser() -> argparse.ArgumentParser: +def _build_parser() -> argparse.ArgumentParser: """Create the top-level argument parser for the CLI.""" client_defaults = USAJobsClient() parser = argparse.ArgumentParser( @@ -113,7 +112,7 @@ def main() -> None: print(pkg_version) sys.exit(0) - parser = build_parser() + parser = _build_parser() args = parser.parse_args() client = USAJobsClient( From 86a5c06305091f98498f13dc0be64502f3759e33 Mon Sep 17 00:00:00 2001 From: Patrick Young Date: Thu, 9 Oct 2025 17:41:37 -0400 Subject: [PATCH 6/7] fix: invalid function name --- tests/unit/test_cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index fc916f8..8e95c8c 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -90,7 +90,7 @@ def test_parse_json_requires_object() -> None: def test_build_parser_defaults() -> None: """The parser should expose the expected default values.""" - parser = cli.build_parser() + parser = cli._build_parser() args = parser.parse_args([cli.ACTIONS.TEXT.value]) assert args.action == cli.ACTIONS.TEXT @@ -104,7 +104,7 @@ def test_build_parser_defaults() -> None: def test_build_parser_custom_overrides() -> None: """Custom overrides should populate their respective fields.""" - parser = cli.build_parser() + parser = cli._build_parser() payload = {"keyword": "python"} args = parser.parse_args( [ From ea5b5310ad027e8ba1e154fea5b5f480922fa7ca Mon Sep 17 00:00:00 2001 From: Patrick Young Date: Thu, 9 Oct 2025 17:49:15 -0400 Subject: [PATCH 7/7] test(refactor): consolidate _parse_json unit tests --- tests/unit/test_cli.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 8e95c8c..affdeb2 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -76,16 +76,17 @@ def test_parse_json_valid_object() -> None: assert payload == {"foo": "bar"} -def test_parse_json_invalid_json() -> None: +@pytest.mark.parametrize( + "raw", + [ + "{invalid", + '["not", "an", "object"]', + ], +) +def test_parse_json_invalid_inputs(raw: str) -> None: """Invalid JSON strings raise argparse errors.""" with pytest.raises(argparse.ArgumentTypeError): - cli._parse_json("{invalid") - - -def test_parse_json_requires_object() -> None: - """Only JSON objects are accepted as CLI payloads.""" - with pytest.raises(argparse.ArgumentTypeError): - cli._parse_json('["not", "an", "object"]') + cli._parse_json(raw) def test_build_parser_defaults() -> None: