loxo-cli 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/CHANGELOG.md +22 -0
  2. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/PKG-INFO +1 -1
  3. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/pyproject.toml +1 -1
  4. loxo_cli-0.2.2/src/loxo_cli/__init__.py +10 -0
  5. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/commands/ref.py +20 -8
  6. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/pagination.py +15 -2
  7. loxo_cli-0.2.2/tests/test_cmd_ref.py +94 -0
  8. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/tests/test_pagination.py +17 -0
  9. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/uv.lock +1 -1
  10. loxo_cli-0.2.0/src/loxo_cli/__init__.py +0 -1
  11. loxo_cli-0.2.0/tests/test_cmd_ref.py +0 -43
  12. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/.github/workflows/ci.yml +0 -0
  13. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/.github/workflows/publish.yml +0 -0
  14. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/.gitignore +0 -0
  15. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/LICENSE +0 -0
  16. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/README.md +0 -0
  17. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/__main__.py +0 -0
  18. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/client.py +0 -0
  19. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/commands/__init__.py +0 -0
  20. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/commands/_app.py +0 -0
  21. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/commands/_helpers.py +0 -0
  22. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/commands/activities.py +0 -0
  23. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/commands/api.py +0 -0
  24. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/commands/candidates.py +0 -0
  25. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/commands/companies.py +0 -0
  26. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/commands/configure.py +0 -0
  27. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/commands/deals.py +0 -0
  28. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/commands/jobs.py +0 -0
  29. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/commands/people.py +0 -0
  30. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/commands/webhooks.py +0 -0
  31. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/config.py +0 -0
  32. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/errors.py +0 -0
  33. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/models/__init__.py +0 -0
  34. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/models/base.py +0 -0
  35. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/models/candidate.py +0 -0
  36. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/models/company.py +0 -0
  37. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/models/deal.py +0 -0
  38. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/models/job.py +0 -0
  39. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/models/person.py +0 -0
  40. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/models/reference.py +0 -0
  41. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/models/webhook.py +0 -0
  42. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/src/loxo_cli/output.py +0 -0
  43. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/tests/conftest.py +0 -0
  44. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/tests/test_app_state.py +0 -0
  45. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/tests/test_client.py +0 -0
  46. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/tests/test_cmd_activities.py +0 -0
  47. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/tests/test_cmd_api.py +0 -0
  48. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/tests/test_cmd_candidates.py +0 -0
  49. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/tests/test_cmd_companies.py +0 -0
  50. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/tests/test_cmd_configure.py +0 -0
  51. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/tests/test_cmd_deals.py +0 -0
  52. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/tests/test_cmd_jobs.py +0 -0
  53. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/tests/test_cmd_people.py +0 -0
  54. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/tests/test_cmd_webhooks.py +0 -0
  55. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/tests/test_config.py +0 -0
  56. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/tests/test_error_exit_codes.py +0 -0
  57. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/tests/test_errors.py +0 -0
  58. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/tests/test_helpers.py +0 -0
  59. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/tests/test_models.py +0 -0
  60. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/tests/test_output.py +0 -0
  61. {loxo_cli-0.2.0 → loxo_cli-0.2.2}/tests/test_smoke.py +0 -0
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.2]
4
+
5
+ ### Fixed
6
+
7
+ - `loxo ref custom-fields` (and `job-types`, `person-types`) no longer hammer the
8
+ API into rate-limiting (HTTP 429). These reference endpoints return their full
9
+ list in one response and ignore the `after_id` cursor, so the keyset paginator
10
+ looped forever. The paginator now stops when the cursor stops advancing, and
11
+ these endpoints are fetched in a single request.
12
+ - `loxo ref activity-types` no longer fails: the `activity_types` endpoint
13
+ rejects `after_id` with HTTP 422, so it is now fetched without a cursor.
14
+ - `loxo ref lists` now works. It targets the correct `person_lists` endpoint
15
+ (the previous `lists` path returned 404).
16
+
17
+ ## [0.2.1]
18
+
19
+ ### Fixed
20
+
21
+ - `loxo --version` now reports the actual installed version (derived from package
22
+ metadata) instead of a hardcoded string that could drift. (0.2.0 misreported
23
+ itself as 0.1.0.)
24
+
3
25
  ## [0.2.0]
4
26
 
5
27
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loxo-cli
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Unofficial command-line interface for the Loxo recruiting ATS/CRM API
5
5
  Project-URL: Homepage, https://github.com/alphaomegateam/loxo-cli
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "loxo-cli"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Unofficial command-line interface for the Loxo recruiting ATS/CRM API"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -0,0 +1,10 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ # Single source of truth: the installed distribution's metadata (driven by
5
+ # pyproject's version), so `loxo --version` can never drift from the release.
6
+ __version__ = version("loxo-cli")
7
+ except PackageNotFoundError: # running from a source tree without an install
8
+ __version__ = "0.0.0+dev"
9
+
10
+ __all__ = ["__version__"]
@@ -9,47 +9,59 @@ ref_app = typer.Typer(help="Reference data lookups. Unofficial — not affiliate
9
9
  COLUMNS = ["id", "name"]
10
10
 
11
11
 
12
- def _list_reference(ctx: typer.Context, endpoint: str) -> None:
12
+ def _list_reference(ctx: typer.Context, endpoint: str, *, paginated: bool = True) -> None:
13
13
  state = ctx.obj
14
- rows = list(paginate(state.client(), endpoint, scheme="after_id"))
14
+ client = state.client()
15
+ if paginated:
16
+ rows = list(paginate(client, endpoint, scheme="after_id"))
17
+ else:
18
+ # Several reference endpoints return a fixed, complete list in one
19
+ # response and don't support keyset pagination: dynamic_fields/job_types/
20
+ # person_types ignore after_id (driving the paginator loops forever ->
21
+ # 429), and activity_types rejects after_id with HTTP 422. Fetch once.
22
+ rows = extract_items(client.get(endpoint), None)
15
23
  state.emit(rows, columns=COLUMNS)
16
24
 
17
25
 
18
26
  @ref_app.command("job-types")
19
27
  def job_types(ctx: typer.Context) -> None:
20
- _list_reference(ctx, "job_types")
28
+ _list_reference(ctx, "job_types", paginated=False)
21
29
 
22
30
 
23
31
  @ref_app.command("activity-types")
24
32
  def activity_types(ctx: typer.Context) -> None:
25
- _list_reference(ctx, "activity_types")
33
+ _list_reference(ctx, "activity_types", paginated=False)
26
34
 
27
35
 
28
36
  @ref_app.command("source-types")
29
37
  def source_types(ctx: typer.Context) -> None:
38
+ # source_types genuinely keyset-paginates (honors after_id); keep paging.
30
39
  _list_reference(ctx, "source_types")
31
40
 
32
41
 
33
42
  @ref_app.command("person-types")
34
43
  def person_types(ctx: typer.Context) -> None:
35
- _list_reference(ctx, "person_types")
44
+ _list_reference(ctx, "person_types", paginated=False)
36
45
 
37
46
 
38
47
  @ref_app.command("lists")
39
48
  def lists(ctx: typer.Context) -> None:
40
- _list_reference(ctx, "lists")
49
+ """List the agency's people lists."""
50
+ # The endpoint is person_lists (GET /lists is 404). It returns the full list
51
+ # in one response and ignores after_id, so fetch once.
52
+ _list_reference(ctx, "person_lists", paginated=False)
41
53
 
42
54
 
43
55
  @ref_app.command("custom-fields")
44
56
  def custom_fields(ctx: typer.Context) -> None:
45
57
  """List agency custom (dynamic) field definitions."""
46
- _list_reference(ctx, "dynamic_fields")
58
+ _list_reference(ctx, "dynamic_fields", paginated=False)
47
59
 
48
60
 
49
61
  @ref_app.command("dynamic-fields", hidden=True)
50
62
  def dynamic_fields_alias(ctx: typer.Context) -> None:
51
63
  """Alias of custom-fields."""
52
- _list_reference(ctx, "dynamic_fields")
64
+ _list_reference(ctx, "dynamic_fields", paginated=False)
53
65
 
54
66
 
55
67
  @ref_app.command("hierarchies")
@@ -85,9 +85,22 @@ def paginate(
85
85
  items = extract_items(data, items_key)
86
86
  if not items:
87
87
  return
88
+ next_after_id = items[-1].get("id") if isinstance(items[-1], dict) else None
89
+ # If the endpoint ignored after_id and returned the same page again
90
+ # (some reference endpoints, e.g. dynamic_fields, return a fixed list
91
+ # and ignore the cursor), the cursor won't advance. Stop without
92
+ # re-yielding the duplicate page. Without this guard those endpoints
93
+ # loop forever, hammering the API until it rate-limits us (429).
94
+ # Only applies once a cursor has actually been sent: on the first
95
+ # page after_id is None, and a no-id last item there is a legitimate
96
+ # single, complete page that must still be yielded.
97
+ if after_id is not None and next_after_id == after_id:
98
+ return
88
99
  yield from items
89
- after_id = items[-1].get("id") if isinstance(items[-1], dict) else None
90
- if after_id is None:
100
+ # Last item has no id -> can't build a next cursor, so this is the
101
+ # final page.
102
+ if next_after_id is None:
91
103
  return
104
+ after_id = next_after_id
92
105
  else:
93
106
  raise ValueError(f"Unknown pagination scheme: {scheme}")
@@ -0,0 +1,94 @@
1
+ import json
2
+
3
+ import httpx
4
+ import respx
5
+ from typer.testing import CliRunner
6
+
7
+ from loxo_cli.__main__ import app
8
+
9
+ runner = CliRunner()
10
+ ENV = {"LOXO_API_KEY": "k", "LOXO_API_SLUG": "acme"}
11
+
12
+
13
+ @respx.mock
14
+ def test_ref_job_types_single_fetch():
15
+ # job_types ignores after_id (returns its full list every call), so the
16
+ # command fetches once instead of driving the keyset paginator.
17
+ route = respx.get("https://app.loxo.co/api/acme/job_types").mock(
18
+ return_value=httpx.Response(
19
+ 200, json=[{"id": 1, "name": "Perm"}, {"id": 2, "name": "Temp"}]
20
+ )
21
+ )
22
+ result = runner.invoke(app, ["--json", "ref", "job-types"], env=ENV)
23
+ assert [x["name"] for x in json.loads(result.stdout)] == ["Perm", "Temp"]
24
+ assert route.call_count == 1
25
+
26
+
27
+ @respx.mock
28
+ def test_ref_activity_types_single_fetch_no_after_id():
29
+ # activity_types REJECTS after_id with HTTP 422. The command must not send
30
+ # it: a single fetch with no after_id param, and never a second request.
31
+ def handler(request):
32
+ assert "after_id" not in request.url.params, "must not send after_id to activity_types"
33
+ return httpx.Response(200, json=[{"id": 9, "name": "Call"}])
34
+
35
+ route = respx.get("https://app.loxo.co/api/acme/activity_types").mock(side_effect=handler)
36
+ result = runner.invoke(app, ["--json", "ref", "activity-types"], env=ENV)
37
+ assert result.exit_code == 0
38
+ assert [x["name"] for x in json.loads(result.stdout)] == ["Call"]
39
+ assert route.call_count == 1
40
+
41
+
42
+ @respx.mock
43
+ def test_ref_source_types_still_paginates():
44
+ # source_types genuinely honors after_id; the command must keep paging.
45
+ route = respx.get("https://app.loxo.co/api/acme/source_types").mock(
46
+ side_effect=[
47
+ httpx.Response(200, json=[{"id": 10, "name": "A"}, {"id": 11, "name": "B"}]),
48
+ httpx.Response(200, json=[{"id": 12, "name": "C"}]),
49
+ httpx.Response(200, json=[]),
50
+ ]
51
+ )
52
+ result = runner.invoke(app, ["--json", "ref", "source-types"], env=ENV)
53
+ assert [x["name"] for x in json.loads(result.stdout)] == ["A", "B", "C"]
54
+ assert route.call_count == 3
55
+
56
+
57
+ @respx.mock
58
+ def test_ref_custom_fields_single_fetch():
59
+ # dynamic_fields is a flat config list that ignores after_id, so the command
60
+ # fetches it exactly once rather than driving the keyset paginator (which the
61
+ # endpoint would loop forever, causing 429s). A single non-empty response is
62
+ # all the endpoint ever returns.
63
+ route = respx.get("https://app.loxo.co/api/acme/dynamic_fields").mock(
64
+ return_value=httpx.Response(200, json=[{"id": 1, "name": "custom_text_3"}])
65
+ )
66
+ result = runner.invoke(app, ["--json", "ref", "custom-fields"], env=ENV)
67
+ assert json.loads(result.stdout)[0]["name"] == "custom_text_3"
68
+ assert route.call_count == 1
69
+
70
+
71
+ @respx.mock
72
+ def test_ref_lists_uses_person_lists_endpoint():
73
+ # `ref lists` maps to the person_lists endpoint (GET /lists 404s). It returns
74
+ # the full list in one response and ignores after_id -> single fetch.
75
+ lists_route = respx.get("https://app.loxo.co/api/acme/lists").mock(
76
+ return_value=httpx.Response(404, json={"status": 404, "error": "Not Found"})
77
+ )
78
+ person_lists_route = respx.get("https://app.loxo.co/api/acme/person_lists").mock(
79
+ return_value=httpx.Response(200, json=[{"id": 5, "name": "Hot Leads"}])
80
+ )
81
+ result = runner.invoke(app, ["--json", "ref", "lists"], env=ENV)
82
+ assert result.exit_code == 0
83
+ assert [x["name"] for x in json.loads(result.stdout)] == ["Hot Leads"]
84
+ assert person_lists_route.call_count == 1
85
+ assert lists_route.call_count == 0 # must not hit the 404 path
86
+
87
+
88
+ @respx.mock
89
+ def test_ref_hierarchies():
90
+ respx.get("https://app.loxo.co/api/acme/dynamic_fields/7/hierarchies").mock(
91
+ return_value=httpx.Response(200, json={"hierarchies": [{"id": 1, "name": "L1"}]})
92
+ )
93
+ result = runner.invoke(app, ["--json", "ref", "hierarchies", "7"], env=ENV)
94
+ assert json.loads(result.stdout)[0]["name"] == "L1"
@@ -102,3 +102,20 @@ def test_after_id_pagination():
102
102
  with LoxoClient(SETTINGS) as client:
103
103
  items = list(paginate(client, "source_types", scheme="after_id"))
104
104
  assert [i["id"] for i in items] == [10, 11, 12]
105
+
106
+
107
+ @respx.mock
108
+ def test_after_id_stops_when_cursor_does_not_advance():
109
+ # Some reference endpoints (e.g. dynamic_fields) return a fixed, complete
110
+ # list and IGNORE the after_id query param — so they hand back the same
111
+ # non-empty page on every request. The after_id paginator must detect the
112
+ # non-advancing cursor and stop, instead of looping forever and getting
113
+ # rate-limited (429). It must also not re-yield the duplicate page.
114
+ base = "https://app.loxo.co/api/acme/dynamic_fields"
115
+ same = [{"id": 1, "name": "custom_text_3"}, {"id": 2, "name": "custom_hierarchy_5"}]
116
+ route = respx.get(base).mock(return_value=httpx.Response(200, json=same))
117
+ with LoxoClient(SETTINGS) as client:
118
+ items = list(paginate(client, "dynamic_fields", scheme="after_id"))
119
+ assert [i["id"] for i in items] == [1, 2]
120
+ # One real page + one probe that proves the cursor didn't advance, then stop.
121
+ assert route.call_count == 2
@@ -274,7 +274,7 @@ wheels = [
274
274
 
275
275
  [[package]]
276
276
  name = "loxo-cli"
277
- version = "0.2.0"
277
+ version = "0.2.2"
278
278
  source = { editable = "." }
279
279
  dependencies = [
280
280
  { name = "click" },
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"
@@ -1,43 +0,0 @@
1
- import json
2
-
3
- import httpx
4
- import respx
5
- from typer.testing import CliRunner
6
-
7
- from loxo_cli.__main__ import app
8
-
9
- runner = CliRunner()
10
- ENV = {"LOXO_API_KEY": "k", "LOXO_API_SLUG": "acme"}
11
-
12
-
13
- @respx.mock
14
- def test_ref_job_types_after_id():
15
- respx.get("https://app.loxo.co/api/acme/job_types").mock(
16
- side_effect=[
17
- httpx.Response(200, json=[{"id": 1, "name": "Perm"}, {"id": 2, "name": "Temp"}]),
18
- httpx.Response(200, json=[]),
19
- ]
20
- )
21
- result = runner.invoke(app, ["--json", "ref", "job-types"], env=ENV)
22
- assert [x["name"] for x in json.loads(result.stdout)] == ["Perm", "Temp"]
23
-
24
-
25
- @respx.mock
26
- def test_ref_custom_fields():
27
- respx.get("https://app.loxo.co/api/acme/dynamic_fields").mock(
28
- side_effect=[
29
- httpx.Response(200, json=[{"id": 1, "name": "custom_text_3"}]),
30
- httpx.Response(200, json=[]),
31
- ]
32
- )
33
- result = runner.invoke(app, ["--json", "ref", "custom-fields"], env=ENV)
34
- assert json.loads(result.stdout)[0]["name"] == "custom_text_3"
35
-
36
-
37
- @respx.mock
38
- def test_ref_hierarchies():
39
- respx.get("https://app.loxo.co/api/acme/dynamic_fields/7/hierarchies").mock(
40
- return_value=httpx.Response(200, json={"hierarchies": [{"id": 1, "name": "L1"}]})
41
- )
42
- result = runner.invoke(app, ["--json", "ref", "hierarchies", "7"], env=ENV)
43
- assert json.loads(result.stdout)[0]["name"] == "L1"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes