loxo-cli 0.2.2__tar.gz → 0.3.0__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 (59) hide show
  1. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/CHANGELOG.md +24 -0
  2. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/PKG-INFO +4 -2
  3. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/README.md +3 -1
  4. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/pyproject.toml +1 -1
  5. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/commands/activities.py +6 -3
  6. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/commands/ref.py +48 -4
  7. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/tests/test_cmd_activities.py +25 -0
  8. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/tests/test_cmd_ref.py +85 -3
  9. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/uv.lock +1 -1
  10. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/.github/workflows/ci.yml +0 -0
  11. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/.github/workflows/publish.yml +0 -0
  12. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/.gitignore +0 -0
  13. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/LICENSE +0 -0
  14. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/__init__.py +0 -0
  15. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/__main__.py +0 -0
  16. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/client.py +0 -0
  17. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/commands/__init__.py +0 -0
  18. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/commands/_app.py +0 -0
  19. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/commands/_helpers.py +0 -0
  20. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/commands/api.py +0 -0
  21. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/commands/candidates.py +0 -0
  22. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/commands/companies.py +0 -0
  23. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/commands/configure.py +0 -0
  24. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/commands/deals.py +0 -0
  25. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/commands/jobs.py +0 -0
  26. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/commands/people.py +0 -0
  27. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/commands/webhooks.py +0 -0
  28. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/config.py +0 -0
  29. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/errors.py +0 -0
  30. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/models/__init__.py +0 -0
  31. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/models/base.py +0 -0
  32. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/models/candidate.py +0 -0
  33. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/models/company.py +0 -0
  34. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/models/deal.py +0 -0
  35. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/models/job.py +0 -0
  36. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/models/person.py +0 -0
  37. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/models/reference.py +0 -0
  38. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/models/webhook.py +0 -0
  39. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/output.py +0 -0
  40. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/src/loxo_cli/pagination.py +0 -0
  41. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/tests/conftest.py +0 -0
  42. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/tests/test_app_state.py +0 -0
  43. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/tests/test_client.py +0 -0
  44. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/tests/test_cmd_api.py +0 -0
  45. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/tests/test_cmd_candidates.py +0 -0
  46. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/tests/test_cmd_companies.py +0 -0
  47. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/tests/test_cmd_configure.py +0 -0
  48. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/tests/test_cmd_deals.py +0 -0
  49. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/tests/test_cmd_jobs.py +0 -0
  50. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/tests/test_cmd_people.py +0 -0
  51. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/tests/test_cmd_webhooks.py +0 -0
  52. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/tests/test_config.py +0 -0
  53. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/tests/test_error_exit_codes.py +0 -0
  54. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/tests/test_errors.py +0 -0
  55. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/tests/test_helpers.py +0 -0
  56. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/tests/test_models.py +0 -0
  57. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/tests/test_output.py +0 -0
  58. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/tests/test_pagination.py +0 -0
  59. {loxo_cli-0.2.2 → loxo_cli-0.3.0}/tests/test_smoke.py +0 -0
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0]
4
+
5
+ ### Added
6
+
7
+ - `loxo ref custom-fields` gains `--object/-o` and `--custom-only`. `--object`
8
+ filters to one object's fields by matching `item_type` case-insensitively
9
+ (e.g. `--object deal`); an unknown value errors with the available object
10
+ types. `--custom-only` hides built-in fields, leaving just the agency-defined
11
+ ones. The table now shows `key`, `name`, and `type` (plus `item_type` when
12
+ unfiltered) instead of `id`/`name`, so the opaque keys (`custom_text_3`) map to
13
+ their plain-language names at a glance. `--json` carries the full field record
14
+ plus a derived flat `type`.
15
+
16
+ ## [0.2.3]
17
+
18
+ ### Changed
19
+
20
+ - `loxo activities list` drops the `--job-id` filter and gains `--company-id`.
21
+ Loxo's `person_events` endpoint rejects `job_id` as a query parameter (HTTP
22
+ 422 `Invalid parameters: [:job_id]`), so the flag never worked. `person_id`
23
+ and `company_id` are the only server-side filters the endpoint accepts.
24
+ (`activities add --job-id` is unaffected — there `job_id` is a request-body
25
+ field, not a query parameter.)
26
+
3
27
  ## [0.2.2]
4
28
 
5
29
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loxo-cli
3
- Version: 0.2.2
3
+ Version: 0.3.0
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
@@ -91,7 +91,9 @@ printed by `loxo configure list`, logged, or shown in `--verbose` output.
91
91
 
92
92
  Custom (dynamic) fields are supported on writes via repeatable `--field key=value`
93
93
  (use `key[]=value` to force a list, e.g. hierarchy fields). Discover valid keys with
94
- `loxo ref custom-fields`.
94
+ `loxo ref custom-fields`, which maps each key (`custom_text_3`) to its plain-language
95
+ name and type. Filter to one object with `--object deal` (matches the field's
96
+ `item_type`, case-insensitive) and hide built-ins with `--custom-only`.
95
97
 
96
98
  ## Output
97
99
 
@@ -76,7 +76,9 @@ printed by `loxo configure list`, logged, or shown in `--verbose` output.
76
76
 
77
77
  Custom (dynamic) fields are supported on writes via repeatable `--field key=value`
78
78
  (use `key[]=value` to force a list, e.g. hierarchy fields). Discover valid keys with
79
- `loxo ref custom-fields`.
79
+ `loxo ref custom-fields`, which maps each key (`custom_text_3`) to its plain-language
80
+ name and type. Filter to one object with `--object deal` (matches the field's
81
+ `item_type`, case-insensitive) and hide built-ins with `--custom-only`.
80
82
 
81
83
  ## Output
82
84
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "loxo-cli"
3
- version = "0.2.2"
3
+ version = "0.3.0"
4
4
  description = "Unofficial command-line interface for the Loxo recruiting ATS/CRM API"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -16,16 +16,19 @@ activities_app = typer.Typer(
16
16
  def list_activities(
17
17
  ctx: typer.Context,
18
18
  person_id: Optional[int] = typer.Option(None, "--person-id"),
19
- job_id: Optional[int] = typer.Option(None, "--job-id"),
19
+ company_id: Optional[int] = typer.Option(None, "--company-id"),
20
20
  all_pages: bool = typer.Option(False, "--all"),
21
21
  per_page: int = typer.Option(50, "--per-page"),
22
22
  ) -> None:
23
+ # Loxo's person_events endpoint only accepts person_id / company_id as
24
+ # server-side filters. job_id is not a valid query param (returns 422), so
25
+ # it is intentionally not exposed here — filter by person or company instead.
23
26
  state = ctx.obj
24
27
  params: dict = {}
25
28
  if person_id is not None:
26
29
  params["person_id"] = person_id
27
- if job_id is not None:
28
- params["job_id"] = job_id
30
+ if company_id is not None:
31
+ params["company_id"] = company_id
29
32
  client = state.client()
30
33
  if all_pages:
31
34
  rows = list(
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from typing import Any, Optional
4
+
3
5
  import typer
4
6
 
5
7
  from loxo_cli.pagination import extract_items, paginate
@@ -52,16 +54,58 @@ def lists(ctx: typer.Context) -> None:
52
54
  _list_reference(ctx, "person_lists", paginated=False)
53
55
 
54
56
 
57
+ def _emit_custom_fields(
58
+ ctx: typer.Context, object_filter: Optional[str], custom_only: bool
59
+ ) -> None:
60
+ state = ctx.obj
61
+ # dynamic_fields returns its full config list in one response and ignores
62
+ # after_id, so fetch once (driving the keyset paginator would loop -> 429).
63
+ rows: list[dict[str, Any]] = extract_items(state.client().get("dynamic_fields"), None)
64
+ if object_filter:
65
+ wanted = object_filter.strip().lower()
66
+ available = sorted({r["item_type"] for r in rows if r.get("item_type")})
67
+ if wanted not in {a.lower() for a in available}:
68
+ raise typer.BadParameter(
69
+ f"Unknown object {object_filter!r}. Available: {', '.join(available)}",
70
+ param_hint="--object",
71
+ )
72
+ rows = [r for r in rows if (r.get("item_type") or "").lower() == wanted]
73
+ if custom_only:
74
+ rows = [r for r in rows if not r.get("built_in")]
75
+ for r in rows:
76
+ r["type"] = (r.get("dynamic_field_type") or {}).get("name")
77
+ # item_type is constant once filtered, so drop that column.
78
+ cols = ["key", "name", "type"] if object_filter else ["key", "name", "type", "item_type"]
79
+ state.emit(rows, columns=cols)
80
+
81
+
55
82
  @ref_app.command("custom-fields")
56
- def custom_fields(ctx: typer.Context) -> None:
83
+ def custom_fields(
84
+ ctx: typer.Context,
85
+ object_filter: Optional[str] = typer.Option(
86
+ None,
87
+ "--object",
88
+ "-o",
89
+ help="Filter to one object's fields (matches item_type, case-insensitive).",
90
+ ),
91
+ custom_only: bool = typer.Option(
92
+ False,
93
+ "--custom-only",
94
+ help="Show only agency-defined fields (exclude built-ins).",
95
+ ),
96
+ ) -> None:
57
97
  """List agency custom (dynamic) field definitions."""
58
- _list_reference(ctx, "dynamic_fields", paginated=False)
98
+ _emit_custom_fields(ctx, object_filter, custom_only)
59
99
 
60
100
 
61
101
  @ref_app.command("dynamic-fields", hidden=True)
62
- def dynamic_fields_alias(ctx: typer.Context) -> None:
102
+ def dynamic_fields_alias(
103
+ ctx: typer.Context,
104
+ object_filter: Optional[str] = typer.Option(None, "--object", "-o"),
105
+ custom_only: bool = typer.Option(False, "--custom-only"),
106
+ ) -> None:
63
107
  """Alias of custom-fields."""
64
- _list_reference(ctx, "dynamic_fields", paginated=False)
108
+ _emit_custom_fields(ctx, object_filter, custom_only)
65
109
 
66
110
 
67
111
  @ref_app.command("hierarchies")
@@ -21,6 +21,31 @@ def test_activities_list():
21
21
  assert json.loads(result.stdout)[0]["notes"] == "called"
22
22
 
23
23
 
24
+ @respx.mock
25
+ def test_activities_list_filters_by_person_and_company():
26
+ route = respx.get("https://app.loxo.co/api/acme/person_events").mock(
27
+ return_value=httpx.Response(200, json={"scroll_id": None, "person_events": []})
28
+ )
29
+ result = runner.invoke(
30
+ app,
31
+ ["--json", "activities", "list", "--person-id", "50", "--company-id", "7"],
32
+ env=ENV,
33
+ )
34
+ assert result.exit_code == 0
35
+ assert dict(route.calls.last.request.url.params) == {
36
+ "person_id": "50",
37
+ "company_id": "7",
38
+ "per_page": "50",
39
+ }
40
+
41
+
42
+ def test_activities_list_has_no_job_id_option():
43
+ # job_id is not a valid person_events query param (Loxo returns 422), so the
44
+ # list command must not advertise a --job-id filter.
45
+ result = runner.invoke(app, ["activities", "list", "--help"], env=ENV)
46
+ assert "--job-id" not in result.stdout
47
+
48
+
24
49
  @respx.mock
25
50
  def test_activities_add_wraps_person_event():
26
51
  captured = {}
@@ -54,20 +54,102 @@ def test_ref_source_types_still_paginates():
54
54
  assert route.call_count == 3
55
55
 
56
56
 
57
+ # A representative slice of GET /dynamic_fields: built-in and agency-defined
58
+ # fields spread across more than one object (item_type).
59
+ DYNAMIC_FIELDS = [
60
+ {
61
+ "id": 1,
62
+ "name": "Account manager",
63
+ "key": "owned_by_id",
64
+ "item_type": "Deal",
65
+ "dynamic_field_type": {"id": 3, "name": "Enumeration"},
66
+ "built_in": True,
67
+ },
68
+ {
69
+ "id": 2,
70
+ "name": "UTM Medium",
71
+ "key": "custom_text_3",
72
+ "item_type": "Deal",
73
+ "dynamic_field_type": {"id": 1, "name": "Text"},
74
+ "built_in": False,
75
+ },
76
+ {
77
+ "id": 3,
78
+ "name": "First Name",
79
+ "key": "first_name",
80
+ "item_type": "Person",
81
+ "dynamic_field_type": {"id": 1, "name": "Text"},
82
+ "built_in": True,
83
+ },
84
+ ]
85
+
86
+
57
87
  @respx.mock
58
88
  def test_ref_custom_fields_single_fetch():
59
89
  # dynamic_fields is a flat config list that ignores after_id, so the command
60
90
  # fetches it exactly once rather than driving the keyset paginator (which the
61
91
  # endpoint would loop forever, causing 429s). A single non-empty response is
62
- # all the endpoint ever returns.
92
+ # all the endpoint ever returns. With no filter every field comes back, and
93
+ # each row now exposes its `key`.
63
94
  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"}])
95
+ return_value=httpx.Response(200, json=DYNAMIC_FIELDS)
65
96
  )
66
97
  result = runner.invoke(app, ["--json", "ref", "custom-fields"], env=ENV)
67
- assert json.loads(result.stdout)[0]["name"] == "custom_text_3"
98
+ keys = [x["key"] for x in json.loads(result.stdout)]
99
+ assert keys == ["owned_by_id", "custom_text_3", "first_name"]
68
100
  assert route.call_count == 1
69
101
 
70
102
 
103
+ @respx.mock
104
+ def test_ref_custom_fields_filter_by_object():
105
+ # --object matches item_type case-insensitively and returns only that
106
+ # object's fields.
107
+ respx.get("https://app.loxo.co/api/acme/dynamic_fields").mock(
108
+ return_value=httpx.Response(200, json=DYNAMIC_FIELDS)
109
+ )
110
+ result = runner.invoke(app, ["--json", "ref", "custom-fields", "--object", "deal"], env=ENV)
111
+ rows = json.loads(result.stdout)
112
+ assert {x["item_type"] for x in rows} == {"Deal"}
113
+ assert [x["key"] for x in rows] == ["owned_by_id", "custom_text_3"]
114
+
115
+
116
+ @respx.mock
117
+ def test_ref_custom_fields_unknown_object_errors():
118
+ # An item_type that isn't present is a usage error that names the available
119
+ # object types.
120
+ respx.get("https://app.loxo.co/api/acme/dynamic_fields").mock(
121
+ return_value=httpx.Response(200, json=DYNAMIC_FIELDS)
122
+ )
123
+ result = runner.invoke(app, ["ref", "custom-fields", "--object", "bogus"], env=ENV)
124
+ assert result.exit_code == 2
125
+ combined = result.output + (result.stderr if result.stderr_bytes else "")
126
+ assert "Deal" in combined and "Person" in combined
127
+
128
+
129
+ @respx.mock
130
+ def test_ref_custom_fields_custom_only_drops_builtins():
131
+ # --custom-only keeps only agency-defined (built_in == False) fields.
132
+ respx.get("https://app.loxo.co/api/acme/dynamic_fields").mock(
133
+ return_value=httpx.Response(200, json=DYNAMIC_FIELDS)
134
+ )
135
+ result = runner.invoke(app, ["--json", "ref", "custom-fields", "--custom-only"], env=ENV)
136
+ rows = json.loads(result.stdout)
137
+ assert [x["key"] for x in rows] == ["custom_text_3"]
138
+
139
+
140
+ @respx.mock
141
+ def test_ref_custom_fields_json_carries_key_and_derived_type():
142
+ # JSON output preserves the full record and adds a flat `type` derived from
143
+ # dynamic_field_type.name.
144
+ respx.get("https://app.loxo.co/api/acme/dynamic_fields").mock(
145
+ return_value=httpx.Response(200, json=DYNAMIC_FIELDS)
146
+ )
147
+ result = runner.invoke(app, ["--json", "ref", "custom-fields", "--object", "deal"], env=ENV)
148
+ row = json.loads(result.stdout)[0]
149
+ assert row["key"] == "owned_by_id"
150
+ assert row["type"] == "Enumeration"
151
+
152
+
71
153
  @respx.mock
72
154
  def test_ref_lists_uses_person_lists_endpoint():
73
155
  # `ref lists` maps to the person_lists endpoint (GET /lists 404s). It returns
@@ -274,7 +274,7 @@ wheels = [
274
274
 
275
275
  [[package]]
276
276
  name = "loxo-cli"
277
- version = "0.2.2"
277
+ version = "0.3.0"
278
278
  source = { editable = "." }
279
279
  dependencies = [
280
280
  { name = "click" },
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