loxo-cli 0.1.0__py3-none-any.whl
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.
- loxo_cli/__init__.py +1 -0
- loxo_cli/__main__.py +132 -0
- loxo_cli/client.py +88 -0
- loxo_cli/commands/__init__.py +0 -0
- loxo_cli/commands/_app.py +28 -0
- loxo_cli/commands/_helpers.py +48 -0
- loxo_cli/commands/activities.py +71 -0
- loxo_cli/commands/api.py +69 -0
- loxo_cli/commands/candidates.py +87 -0
- loxo_cli/commands/companies.py +100 -0
- loxo_cli/commands/configure.py +56 -0
- loxo_cli/commands/deals.py +101 -0
- loxo_cli/commands/jobs.py +76 -0
- loxo_cli/commands/people.py +91 -0
- loxo_cli/commands/ref.py +60 -0
- loxo_cli/commands/webhooks.py +98 -0
- loxo_cli/config.py +140 -0
- loxo_cli/errors.py +65 -0
- loxo_cli/models/__init__.py +20 -0
- loxo_cli/models/base.py +13 -0
- loxo_cli/models/candidate.py +11 -0
- loxo_cli/models/company.py +11 -0
- loxo_cli/models/deal.py +11 -0
- loxo_cli/models/job.py +11 -0
- loxo_cli/models/person.py +14 -0
- loxo_cli/models/reference.py +10 -0
- loxo_cli/models/webhook.py +12 -0
- loxo_cli/output.py +98 -0
- loxo_cli/pagination.py +90 -0
- loxo_cli-0.1.0.dist-info/METADATA +135 -0
- loxo_cli-0.1.0.dist-info/RECORD +34 -0
- loxo_cli-0.1.0.dist-info/WHEEL +4 -0
- loxo_cli-0.1.0.dist-info/entry_points.txt +2 -0
- loxo_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
loxo_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
loxo_cli/__main__.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from loxo_cli import __version__
|
|
11
|
+
from loxo_cli.client import LoxoClient, build_client
|
|
12
|
+
from loxo_cli.commands._app import LoxoGroup
|
|
13
|
+
from loxo_cli.config import LoxoSettings, load_settings
|
|
14
|
+
from loxo_cli.output import render
|
|
15
|
+
|
|
16
|
+
HELP_EPILOG = "Unofficial — not affiliated with Loxo, Inc."
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
cls=LoxoGroup,
|
|
20
|
+
help="loxo — command-line interface for the Loxo recruiting API.",
|
|
21
|
+
epilog=HELP_EPILOG,
|
|
22
|
+
no_args_is_help=True,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class AppState:
|
|
28
|
+
profile: Optional[str]
|
|
29
|
+
api_key: Optional[str]
|
|
30
|
+
slug: Optional[str]
|
|
31
|
+
base_url: Optional[str]
|
|
32
|
+
json_out: bool
|
|
33
|
+
jq: Optional[str]
|
|
34
|
+
verbose: bool
|
|
35
|
+
no_color: bool
|
|
36
|
+
config_path: Optional[Path] = None
|
|
37
|
+
_settings: Optional[LoxoSettings] = field(default=None, repr=False)
|
|
38
|
+
|
|
39
|
+
def settings(self) -> LoxoSettings:
|
|
40
|
+
if self._settings is None:
|
|
41
|
+
self._settings = load_settings(
|
|
42
|
+
profile=self.profile,
|
|
43
|
+
api_key=self.api_key,
|
|
44
|
+
slug=self.slug,
|
|
45
|
+
base_url=self.base_url,
|
|
46
|
+
config_path=self.config_path,
|
|
47
|
+
)
|
|
48
|
+
return self._settings
|
|
49
|
+
|
|
50
|
+
def client(self) -> LoxoClient:
|
|
51
|
+
return build_client(self.settings(), verbose=self.verbose)
|
|
52
|
+
|
|
53
|
+
def console(self) -> Console:
|
|
54
|
+
return Console(no_color=self.no_color)
|
|
55
|
+
|
|
56
|
+
def emit(self, data: Any, *, columns: list[str] | None = None) -> None:
|
|
57
|
+
render(
|
|
58
|
+
data,
|
|
59
|
+
as_json=self.json_out,
|
|
60
|
+
jq=self.jq,
|
|
61
|
+
columns=columns,
|
|
62
|
+
console=self.console(),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _version_callback(value: bool) -> None:
|
|
67
|
+
if value:
|
|
68
|
+
typer.echo(__version__)
|
|
69
|
+
raise typer.Exit()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.callback()
|
|
73
|
+
def main(
|
|
74
|
+
ctx: typer.Context,
|
|
75
|
+
version: bool = typer.Option(
|
|
76
|
+
False, "--version", callback=_version_callback, is_eager=True, help="Show version and exit."
|
|
77
|
+
),
|
|
78
|
+
profile: Optional[str] = typer.Option(None, "--profile", help="Config profile."),
|
|
79
|
+
api_key: Optional[str] = typer.Option(None, "--api-key", help="Loxo API key."),
|
|
80
|
+
slug: Optional[str] = typer.Option(None, "--slug", help="Agency slug."),
|
|
81
|
+
base_url: Optional[str] = typer.Option(None, "--base-url", help="API base URL."),
|
|
82
|
+
json_out: bool = typer.Option(False, "--json", help="Force JSON output."),
|
|
83
|
+
jq: Optional[str] = typer.Option(None, "--jq", help="Filter output (dotted path)."),
|
|
84
|
+
quiet: bool = typer.Option(False, "--quiet", help="Suppress non-error output."),
|
|
85
|
+
verbose: bool = typer.Option(False, "-v", "--verbose", help="Log requests to stderr."),
|
|
86
|
+
no_color: bool = typer.Option(False, "--no-color", help="Disable color."),
|
|
87
|
+
) -> None:
|
|
88
|
+
"""loxo CLI. Unofficial — not affiliated with Loxo, Inc."""
|
|
89
|
+
ctx.obj = AppState(
|
|
90
|
+
profile=profile,
|
|
91
|
+
api_key=api_key,
|
|
92
|
+
slug=slug,
|
|
93
|
+
base_url=base_url,
|
|
94
|
+
json_out=json_out,
|
|
95
|
+
jq=jq,
|
|
96
|
+
verbose=verbose,
|
|
97
|
+
no_color=no_color,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
from loxo_cli.commands import api as _api_cmd # noqa: E402
|
|
102
|
+
from loxo_cli.commands.activities import activities_app # noqa: E402
|
|
103
|
+
from loxo_cli.commands.candidates import candidates_app # noqa: E402
|
|
104
|
+
from loxo_cli.commands.companies import companies_app # noqa: E402
|
|
105
|
+
from loxo_cli.commands.configure import configure_app # noqa: E402
|
|
106
|
+
from loxo_cli.commands.deals import deals_app # noqa: E402
|
|
107
|
+
from loxo_cli.commands.jobs import jobs_app # noqa: E402
|
|
108
|
+
from loxo_cli.commands.people import people_app # noqa: E402
|
|
109
|
+
from loxo_cli.commands.ref import ref_app # noqa: E402
|
|
110
|
+
from loxo_cli.commands.webhooks import webhooks_app # noqa: E402
|
|
111
|
+
|
|
112
|
+
_api_cmd.register(app)
|
|
113
|
+
app.add_typer(configure_app, name="configure")
|
|
114
|
+
app.add_typer(people_app, name="people")
|
|
115
|
+
app.add_typer(jobs_app, name="jobs")
|
|
116
|
+
app.add_typer(companies_app, name="companies")
|
|
117
|
+
app.add_typer(deals_app, name="deals")
|
|
118
|
+
app.add_typer(candidates_app, name="candidates")
|
|
119
|
+
app.add_typer(activities_app, name="activities")
|
|
120
|
+
app.add_typer(webhooks_app, name="webhooks")
|
|
121
|
+
app.add_typer(ref_app, name="ref")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def run() -> None:
|
|
125
|
+
# Exit-code mapping happens in LoxoGroup.invoke (commands/_app.py, set via
|
|
126
|
+
# typer.Typer(cls=LoxoGroup)): Typer does NOT honor a raised ClickException's
|
|
127
|
+
# exit_code, so domain errors become typer.Exit with the mapped code.
|
|
128
|
+
app()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
run()
|
loxo_cli/client.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Any, Mapping
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from loxo_cli.config import LoxoSettings
|
|
9
|
+
from loxo_cli.errors import LoxoError
|
|
10
|
+
|
|
11
|
+
TIMEOUT = 30.0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def url_for(settings: LoxoSettings, endpoint: str) -> str:
|
|
15
|
+
return f"{settings.base_url}/{settings.slug}/{endpoint.lstrip('/')}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LoxoClient:
|
|
19
|
+
def __init__(self, settings: LoxoSettings, *, verbose: bool = False) -> None:
|
|
20
|
+
self._settings = settings
|
|
21
|
+
self._verbose = verbose
|
|
22
|
+
self._http = httpx.Client(
|
|
23
|
+
headers={
|
|
24
|
+
"Authorization": f"Bearer {settings.api_key}",
|
|
25
|
+
"Accept": "application/json",
|
|
26
|
+
},
|
|
27
|
+
follow_redirects=True,
|
|
28
|
+
timeout=TIMEOUT,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def __enter__(self) -> "LoxoClient":
|
|
32
|
+
return self
|
|
33
|
+
|
|
34
|
+
def __exit__(self, *exc: object) -> None:
|
|
35
|
+
self.close()
|
|
36
|
+
|
|
37
|
+
def close(self) -> None:
|
|
38
|
+
self._http.close()
|
|
39
|
+
|
|
40
|
+
def request(
|
|
41
|
+
self,
|
|
42
|
+
method: str,
|
|
43
|
+
endpoint: str,
|
|
44
|
+
*,
|
|
45
|
+
params: Mapping[str, Any] | None = None,
|
|
46
|
+
json: Any | None = None,
|
|
47
|
+
) -> Any:
|
|
48
|
+
target = url_for(self._settings, endpoint)
|
|
49
|
+
if self._verbose:
|
|
50
|
+
# Method + URL only. Never headers (would leak the bearer token).
|
|
51
|
+
print(f"{method.upper()} {target}", file=sys.stderr)
|
|
52
|
+
headers = {"Content-Type": "application/json"} if json is not None else None
|
|
53
|
+
try:
|
|
54
|
+
response = self._http.request(method, target, params=params, json=json, headers=headers)
|
|
55
|
+
response.raise_for_status()
|
|
56
|
+
except httpx.TimeoutException as exc:
|
|
57
|
+
raise LoxoError(
|
|
58
|
+
f"Loxo {method} {endpoint} timed out", status_code=None, is_timeout=True
|
|
59
|
+
) from exc
|
|
60
|
+
except httpx.HTTPStatusError as exc:
|
|
61
|
+
raise LoxoError(
|
|
62
|
+
f"Loxo {method} {endpoint} returned {exc.response.status_code}: "
|
|
63
|
+
f"{exc.response.text[:500]}",
|
|
64
|
+
status_code=exc.response.status_code,
|
|
65
|
+
) from exc
|
|
66
|
+
except httpx.HTTPError as exc:
|
|
67
|
+
raise LoxoError(
|
|
68
|
+
f"Loxo {method} {endpoint} request failed: {exc}", status_code=None
|
|
69
|
+
) from exc
|
|
70
|
+
if not response.content:
|
|
71
|
+
return None
|
|
72
|
+
return response.json()
|
|
73
|
+
|
|
74
|
+
def get(self, endpoint: str, **kw: Any) -> Any:
|
|
75
|
+
return self.request("GET", endpoint, **kw)
|
|
76
|
+
|
|
77
|
+
def post(self, endpoint: str, **kw: Any) -> Any:
|
|
78
|
+
return self.request("POST", endpoint, **kw)
|
|
79
|
+
|
|
80
|
+
def put(self, endpoint: str, **kw: Any) -> Any:
|
|
81
|
+
return self.request("PUT", endpoint, **kw)
|
|
82
|
+
|
|
83
|
+
def delete(self, endpoint: str, **kw: Any) -> Any:
|
|
84
|
+
return self.request("DELETE", endpoint, **kw)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_client(settings: LoxoSettings, *, verbose: bool = False) -> LoxoClient:
|
|
88
|
+
return LoxoClient(settings, verbose=verbose)
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from typer.core import TyperGroup
|
|
7
|
+
|
|
8
|
+
from loxo_cli.errors import ConfigError, LoxoError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LoxoGroup(TyperGroup):
|
|
12
|
+
"""Root command group that maps loxo's domain errors to documented exit codes.
|
|
13
|
+
|
|
14
|
+
Typer's invocation path does NOT honor a raised ``ClickException``'s
|
|
15
|
+
``exit_code`` (it surfaces as a generic exit 1 with no message). Set this as
|
|
16
|
+
the root app's group class via the supported ``typer.Typer(cls=LoxoGroup)``
|
|
17
|
+
hook: its ``invoke`` wraps the entire command tree, so every command — nested
|
|
18
|
+
sub-app commands and root-level commands alike — gets its ``LoxoError`` /
|
|
19
|
+
``ConfigError`` converted into ``typer.Exit`` with the mapped code, with a
|
|
20
|
+
clean message on stderr. Command files stay plain ``typer.Typer``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def invoke(self, ctx) -> Any: # ctx is typer's vendored-click Context
|
|
24
|
+
try:
|
|
25
|
+
return super().invoke(ctx)
|
|
26
|
+
except (LoxoError, ConfigError) as exc:
|
|
27
|
+
typer.echo(f"Error: {exc.format_message()}", err=True)
|
|
28
|
+
raise typer.Exit(code=exc.exit_code) from exc
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, TextIO
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_data(raw: str | None, *, stdin: TextIO | None = None) -> dict:
|
|
12
|
+
if raw is None:
|
|
13
|
+
return {}
|
|
14
|
+
try:
|
|
15
|
+
if raw == "-":
|
|
16
|
+
source = stdin or sys.stdin
|
|
17
|
+
return json.load(source)
|
|
18
|
+
if raw.startswith("@"):
|
|
19
|
+
return json.loads(Path(raw[1:]).read_text())
|
|
20
|
+
return json.loads(raw)
|
|
21
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
22
|
+
raise typer.BadParameter(f"Invalid --data JSON: {exc}") from exc
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_fields(fields: list[str]) -> dict[str, Any]:
|
|
26
|
+
result: dict[str, Any] = {}
|
|
27
|
+
for item in fields:
|
|
28
|
+
if "=" not in item:
|
|
29
|
+
raise typer.BadParameter(f"--field must be key=value, got {item!r}")
|
|
30
|
+
key, value = item.split("=", 1)
|
|
31
|
+
force_list = key.endswith("[]")
|
|
32
|
+
if force_list:
|
|
33
|
+
key = key[:-2]
|
|
34
|
+
if key in result:
|
|
35
|
+
existing = result[key]
|
|
36
|
+
result[key] = existing + [value] if isinstance(existing, list) else [existing, value]
|
|
37
|
+
elif force_list:
|
|
38
|
+
result[key] = [value]
|
|
39
|
+
else:
|
|
40
|
+
result[key] = value
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_payload(resource_key: str, typed: dict, data: dict, fields: dict) -> dict:
|
|
45
|
+
merged: dict[str, Any] = dict(data)
|
|
46
|
+
merged.update({k: v for k, v in typed.items() if v is not None})
|
|
47
|
+
merged.update(fields)
|
|
48
|
+
return {resource_key: merged}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from loxo_cli.commands._helpers import build_payload, load_data, parse_fields
|
|
8
|
+
from loxo_cli.pagination import paginate
|
|
9
|
+
|
|
10
|
+
activities_app = typer.Typer(
|
|
11
|
+
help="Manage person events/activities. Unofficial — not affiliated with Loxo, Inc."
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@activities_app.command("list")
|
|
16
|
+
def list_activities(
|
|
17
|
+
ctx: typer.Context,
|
|
18
|
+
person_id: Optional[int] = typer.Option(None, "--person-id"),
|
|
19
|
+
job_id: Optional[int] = typer.Option(None, "--job-id"),
|
|
20
|
+
all_pages: bool = typer.Option(False, "--all"),
|
|
21
|
+
per_page: int = typer.Option(50, "--per-page"),
|
|
22
|
+
) -> None:
|
|
23
|
+
state = ctx.obj
|
|
24
|
+
params: dict = {}
|
|
25
|
+
if person_id is not None:
|
|
26
|
+
params["person_id"] = person_id
|
|
27
|
+
if job_id is not None:
|
|
28
|
+
params["job_id"] = job_id
|
|
29
|
+
client = state.client()
|
|
30
|
+
if all_pages:
|
|
31
|
+
rows = list(
|
|
32
|
+
paginate(
|
|
33
|
+
client,
|
|
34
|
+
"person_events",
|
|
35
|
+
scheme="scroll_id",
|
|
36
|
+
items_key="person_events",
|
|
37
|
+
params=params,
|
|
38
|
+
per_page=per_page,
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
else:
|
|
42
|
+
params["per_page"] = per_page
|
|
43
|
+
data = client.get("person_events", params=params)
|
|
44
|
+
rows = data.get("person_events", []) if isinstance(data, dict) else data
|
|
45
|
+
state.emit(rows, columns=["id", "activity_type_id", "person_id", "notes"])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@activities_app.command("add")
|
|
49
|
+
def add_activity(
|
|
50
|
+
ctx: typer.Context,
|
|
51
|
+
activity_type_id: int = typer.Option(..., "--activity-type-id"),
|
|
52
|
+
person_id: int = typer.Option(..., "--person-id"),
|
|
53
|
+
job_id: Optional[int] = typer.Option(None, "--job-id"),
|
|
54
|
+
company_id: Optional[int] = typer.Option(None, "--company-id"),
|
|
55
|
+
notes: Optional[str] = typer.Option(None, "--notes"),
|
|
56
|
+
field: list[str] = typer.Option([], "--field"),
|
|
57
|
+
data: Optional[str] = typer.Option(None, "--data", "-d"),
|
|
58
|
+
) -> None:
|
|
59
|
+
state = ctx.obj
|
|
60
|
+
raw = load_data(data)
|
|
61
|
+
inner = raw.get("person_event", raw)
|
|
62
|
+
typed = {
|
|
63
|
+
"activity_type_id": activity_type_id,
|
|
64
|
+
"person_id": person_id,
|
|
65
|
+
"job_id": job_id,
|
|
66
|
+
"company_id": company_id,
|
|
67
|
+
"notes": notes,
|
|
68
|
+
}
|
|
69
|
+
payload = build_payload("person_event", typed, inner, parse_fields(field))
|
|
70
|
+
result = state.client().post("person_events", json=payload)
|
|
71
|
+
state.emit(result)
|
loxo_cli/commands/api.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from loxo_cli.commands._helpers import load_data, parse_fields
|
|
8
|
+
from loxo_cli.pagination import detect_scheme, extract_items, paginate
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register(app: typer.Typer) -> None:
|
|
12
|
+
app.command(
|
|
13
|
+
"api",
|
|
14
|
+
help="Call any Loxo endpoint directly. " "Unofficial — not affiliated with Loxo, Inc.",
|
|
15
|
+
)(api_command)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def api_command(
|
|
19
|
+
ctx: typer.Context,
|
|
20
|
+
method: str = typer.Argument(..., help="HTTP method: GET/POST/PUT/DELETE."),
|
|
21
|
+
path: str = typer.Argument(..., help="Endpoint path, e.g. people or jobs/123."),
|
|
22
|
+
param: list[str] = typer.Option(
|
|
23
|
+
[], "--param", "-p", help="Query param key=value (repeatable)."
|
|
24
|
+
),
|
|
25
|
+
data: Optional[str] = typer.Option(
|
|
26
|
+
None, "--data", "-d", help="JSON body: inline, @file, or - for stdin."
|
|
27
|
+
),
|
|
28
|
+
raw: bool = typer.Option(False, "--raw", help="No-op for the generic command (always raw)."),
|
|
29
|
+
all_pages: bool = typer.Option(False, "--all", help="Auto-paginate all pages."),
|
|
30
|
+
paginate_scheme: Optional[str] = typer.Option(
|
|
31
|
+
None, "--paginate", help="Force scheme: scroll_id|page|after_id."
|
|
32
|
+
),
|
|
33
|
+
) -> None:
|
|
34
|
+
state = ctx.obj
|
|
35
|
+
params = parse_fields(param)
|
|
36
|
+
body = load_data(data) or None
|
|
37
|
+
client = state.client()
|
|
38
|
+
|
|
39
|
+
if all_pages:
|
|
40
|
+
if method.upper() != "GET":
|
|
41
|
+
raise typer.BadParameter("--all only supports GET (pagination is GET-only).")
|
|
42
|
+
scheme = paginate_scheme
|
|
43
|
+
if scheme is None:
|
|
44
|
+
first = client.get(path, params=params)
|
|
45
|
+
scheme = detect_scheme(first)
|
|
46
|
+
# Collect first page items and build continuation params so we
|
|
47
|
+
# don't re-fetch the first page inside paginate().
|
|
48
|
+
first_items = extract_items(first, None)
|
|
49
|
+
cont_params = dict(params or {})
|
|
50
|
+
if scheme == "scroll_id" and isinstance(first, dict):
|
|
51
|
+
next_sid = first.get("scroll_id")
|
|
52
|
+
if next_sid:
|
|
53
|
+
cont_params["scroll_id"] = next_sid
|
|
54
|
+
items = first_items + list(
|
|
55
|
+
paginate(client, path, scheme=scheme, params=cont_params)
|
|
56
|
+
)
|
|
57
|
+
else:
|
|
58
|
+
items = first_items
|
|
59
|
+
else:
|
|
60
|
+
# For page / after_id schemes, fall back to re-paginating from
|
|
61
|
+
# the start; the first-page data is small and the scheme is rare.
|
|
62
|
+
items = list(paginate(client, path, scheme=scheme, params=params))
|
|
63
|
+
else:
|
|
64
|
+
items = list(paginate(client, path, scheme=scheme, params=params))
|
|
65
|
+
state.emit(items)
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
result = client.request(method.upper(), path, params=params, json=body)
|
|
69
|
+
state.emit(result)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from loxo_cli.commands._helpers import load_data, parse_fields
|
|
8
|
+
from loxo_cli.models.candidate import Candidate
|
|
9
|
+
from loxo_cli.pagination import paginate
|
|
10
|
+
|
|
11
|
+
candidates_app = typer.Typer(
|
|
12
|
+
help="Manage job candidates. Unofficial — not affiliated with Loxo, Inc."
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
LIST_COLUMNS = ["id", "person_id", "job_id"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@candidates_app.command("list")
|
|
19
|
+
def list_candidates(
|
|
20
|
+
ctx: typer.Context,
|
|
21
|
+
job: int = typer.Option(..., "--job", "-j"),
|
|
22
|
+
all_pages: bool = typer.Option(False, "--all"),
|
|
23
|
+
per_page: int = typer.Option(50, "--per-page"),
|
|
24
|
+
) -> None:
|
|
25
|
+
state = ctx.obj
|
|
26
|
+
endpoint = f"jobs/{job}/candidates"
|
|
27
|
+
client = state.client()
|
|
28
|
+
if all_pages:
|
|
29
|
+
rows = [
|
|
30
|
+
Candidate.model_validate(i)
|
|
31
|
+
for i in paginate(
|
|
32
|
+
client, endpoint, scheme="scroll_id", items_key="candidates", per_page=per_page
|
|
33
|
+
)
|
|
34
|
+
]
|
|
35
|
+
else:
|
|
36
|
+
data = client.get(endpoint, params={"per_page": per_page})
|
|
37
|
+
rows = [Candidate.model_validate(i) for i in data.get("candidates", [])]
|
|
38
|
+
state.emit(rows, columns=LIST_COLUMNS)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@candidates_app.command("get")
|
|
42
|
+
def get_candidate(
|
|
43
|
+
ctx: typer.Context,
|
|
44
|
+
candidate_id: int = typer.Argument(...),
|
|
45
|
+
job: int = typer.Option(..., "--job", "-j"),
|
|
46
|
+
) -> None:
|
|
47
|
+
state = ctx.obj
|
|
48
|
+
data = state.client().get(f"jobs/{job}/candidates/{candidate_id}")
|
|
49
|
+
state.emit(Candidate.model_validate(data))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@candidates_app.command(
|
|
53
|
+
"add",
|
|
54
|
+
help="Add a person to a job as a candidate. If Loxo rejects the body shape, "
|
|
55
|
+
"use `loxo api POST jobs/<id>/candidates --data @body.json`.",
|
|
56
|
+
)
|
|
57
|
+
def add_candidate(
|
|
58
|
+
ctx: typer.Context,
|
|
59
|
+
job: int = typer.Option(..., "--job", "-j"),
|
|
60
|
+
person: int = typer.Option(..., "--person"),
|
|
61
|
+
field: list[str] = typer.Option([], "--field"),
|
|
62
|
+
data: Optional[str] = typer.Option(None, "--data", "-d"),
|
|
63
|
+
) -> None:
|
|
64
|
+
state = ctx.obj
|
|
65
|
+
body = load_data(data)
|
|
66
|
+
body.setdefault("person_id", person)
|
|
67
|
+
body.update(parse_fields(field))
|
|
68
|
+
result = state.client().post(f"jobs/{job}/candidates", json=body)
|
|
69
|
+
state.emit(Candidate.model_validate(result) if isinstance(result, dict) else result)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@candidates_app.command("update")
|
|
73
|
+
def update_candidate(
|
|
74
|
+
ctx: typer.Context,
|
|
75
|
+
candidate_id: int = typer.Argument(...),
|
|
76
|
+
job: int = typer.Option(..., "--job", "-j"),
|
|
77
|
+
highlights: Optional[str] = typer.Option(None, "--highlights"),
|
|
78
|
+
field: list[str] = typer.Option([], "--field"),
|
|
79
|
+
data: Optional[str] = typer.Option(None, "--data", "-d"),
|
|
80
|
+
) -> None:
|
|
81
|
+
state = ctx.obj
|
|
82
|
+
body = load_data(data)
|
|
83
|
+
if highlights is not None:
|
|
84
|
+
body["highlights"] = highlights
|
|
85
|
+
body.update(parse_fields(field))
|
|
86
|
+
result = state.client().put(f"jobs/{job}/candidates/{candidate_id}", json=body)
|
|
87
|
+
state.emit(result)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from loxo_cli.commands._helpers import build_payload, load_data, parse_fields
|
|
8
|
+
from loxo_cli.models.base import unwrap_envelope
|
|
9
|
+
from loxo_cli.models.company import Company
|
|
10
|
+
from loxo_cli.pagination import paginate
|
|
11
|
+
|
|
12
|
+
companies_app = typer.Typer(help="Manage companies. Unofficial — not affiliated with Loxo, Inc.")
|
|
13
|
+
|
|
14
|
+
LIST_COLUMNS = ["id", "name", "url"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _list(state, query, all_pages, per_page):
|
|
18
|
+
params = {"query": query} if query else {}
|
|
19
|
+
client = state.client()
|
|
20
|
+
if all_pages:
|
|
21
|
+
rows = [
|
|
22
|
+
Company.model_validate(i)
|
|
23
|
+
for i in paginate(
|
|
24
|
+
client,
|
|
25
|
+
"companies",
|
|
26
|
+
scheme="scroll_id",
|
|
27
|
+
items_key="companies",
|
|
28
|
+
params=params,
|
|
29
|
+
per_page=per_page,
|
|
30
|
+
)
|
|
31
|
+
]
|
|
32
|
+
else:
|
|
33
|
+
params["per_page"] = per_page
|
|
34
|
+
data = client.get("companies", params=params)
|
|
35
|
+
rows = [Company.model_validate(i) for i in data.get("companies", [])]
|
|
36
|
+
state.emit(rows, columns=LIST_COLUMNS)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@companies_app.command("list")
|
|
40
|
+
def list_companies(
|
|
41
|
+
ctx: typer.Context,
|
|
42
|
+
query: Optional[str] = typer.Option(None, "--query", "-q"),
|
|
43
|
+
all_pages: bool = typer.Option(False, "--all"),
|
|
44
|
+
per_page: int = typer.Option(50, "--per-page"),
|
|
45
|
+
) -> None:
|
|
46
|
+
_list(ctx.obj, query, all_pages, per_page)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@companies_app.command(
|
|
50
|
+
"search",
|
|
51
|
+
help="Search companies by query. NOTE: the company 'url' field is not a "
|
|
52
|
+
"searchable Lucene field; pass a bare domain as a full-text query.",
|
|
53
|
+
)
|
|
54
|
+
def search_companies(
|
|
55
|
+
ctx: typer.Context,
|
|
56
|
+
query: str = typer.Option(..., "--query", "-q"),
|
|
57
|
+
all_pages: bool = typer.Option(False, "--all"),
|
|
58
|
+
per_page: int = typer.Option(50, "--per-page"),
|
|
59
|
+
) -> None:
|
|
60
|
+
_list(ctx.obj, query, all_pages, per_page)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@companies_app.command("get")
|
|
64
|
+
def get_company(ctx: typer.Context, company_id: int = typer.Argument(...)) -> None:
|
|
65
|
+
state = ctx.obj
|
|
66
|
+
data = state.client().get(f"companies/{company_id}")
|
|
67
|
+
state.emit(Company.model_validate(unwrap_envelope(data, "company")))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@companies_app.command("create")
|
|
71
|
+
def create_company(
|
|
72
|
+
ctx: typer.Context,
|
|
73
|
+
name: Optional[str] = typer.Option(None, "--name"),
|
|
74
|
+
url: Optional[str] = typer.Option(None, "--url"),
|
|
75
|
+
field: list[str] = typer.Option([], "--field"),
|
|
76
|
+
data: Optional[str] = typer.Option(None, "--data", "-d"),
|
|
77
|
+
) -> None:
|
|
78
|
+
state = ctx.obj
|
|
79
|
+
raw = load_data(data)
|
|
80
|
+
inner = raw.get("company", raw)
|
|
81
|
+
payload = build_payload("company", {"name": name, "url": url}, inner, parse_fields(field))
|
|
82
|
+
result = state.client().post("companies", json=payload)
|
|
83
|
+
state.emit(Company.model_validate(unwrap_envelope(result, "company")))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@companies_app.command("update")
|
|
87
|
+
def update_company(
|
|
88
|
+
ctx: typer.Context,
|
|
89
|
+
company_id: int = typer.Argument(...),
|
|
90
|
+
name: Optional[str] = typer.Option(None, "--name"),
|
|
91
|
+
url: Optional[str] = typer.Option(None, "--url"),
|
|
92
|
+
field: list[str] = typer.Option([], "--field"),
|
|
93
|
+
data: Optional[str] = typer.Option(None, "--data", "-d"),
|
|
94
|
+
) -> None:
|
|
95
|
+
state = ctx.obj
|
|
96
|
+
raw = load_data(data)
|
|
97
|
+
inner = raw.get("company", raw)
|
|
98
|
+
payload = build_payload("company", {"name": name, "url": url}, inner, parse_fields(field))
|
|
99
|
+
result = state.client().put(f"companies/{company_id}", json=payload)
|
|
100
|
+
state.emit(Company.model_validate(unwrap_envelope(result, "company")))
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from loxo_cli.config import DEFAULT_BASE_URL, config_file_path, list_profiles, write_profile
|
|
9
|
+
|
|
10
|
+
configure_app = typer.Typer(
|
|
11
|
+
help="Set up credential profiles. Unofficial — not affiliated with Loxo, Inc.",
|
|
12
|
+
invoke_without_command=True,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@configure_app.callback(invoke_without_command=True)
|
|
17
|
+
def configure(
|
|
18
|
+
ctx: typer.Context,
|
|
19
|
+
name: Optional[str] = typer.Option(None, "--name"),
|
|
20
|
+
slug: Optional[str] = typer.Option(None, "--slug"),
|
|
21
|
+
base_url: Optional[str] = typer.Option(None, "--base-url"),
|
|
22
|
+
api_key: Optional[str] = typer.Option(None, "--api-key"),
|
|
23
|
+
config_path: Optional[Path] = typer.Option(None, "--config-path"),
|
|
24
|
+
) -> None:
|
|
25
|
+
if ctx.invoked_subcommand is not None:
|
|
26
|
+
return
|
|
27
|
+
name = name or typer.prompt("Profile name", default="default")
|
|
28
|
+
slug = slug or typer.prompt("Agency slug")
|
|
29
|
+
base_url = base_url or DEFAULT_BASE_URL
|
|
30
|
+
api_key = api_key or typer.prompt("API key", hide_input=True)
|
|
31
|
+
write_profile(
|
|
32
|
+
name,
|
|
33
|
+
api_key=api_key,
|
|
34
|
+
slug=slug,
|
|
35
|
+
base_url=base_url,
|
|
36
|
+
make_default=False,
|
|
37
|
+
config_path=config_path,
|
|
38
|
+
)
|
|
39
|
+
target = config_path or config_file_path()
|
|
40
|
+
typer.echo(f"Saved profile '{name}' to {target}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@configure_app.command("list")
|
|
44
|
+
def list_cmd(
|
|
45
|
+
config_path: Optional[Path] = typer.Option(None, "--config-path"),
|
|
46
|
+
) -> None:
|
|
47
|
+
profiles = list_profiles(config_path=config_path)
|
|
48
|
+
if not profiles:
|
|
49
|
+
typer.echo("No profiles configured. Run `loxo configure`.")
|
|
50
|
+
return
|
|
51
|
+
for pname, info in profiles.items():
|
|
52
|
+
default = " (default)" if info["default"] else ""
|
|
53
|
+
key = "set" if info["has_key"] else "missing"
|
|
54
|
+
typer.echo(
|
|
55
|
+
f"{pname}{default}: slug={info['slug']} " f"base_url={info['base_url']} api_key={key}"
|
|
56
|
+
)
|