affinity-sdk 0.9.5__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.
- affinity/__init__.py +139 -0
- affinity/cli/__init__.py +7 -0
- affinity/cli/click_compat.py +27 -0
- affinity/cli/commands/__init__.py +1 -0
- affinity/cli/commands/_entity_files_dump.py +219 -0
- affinity/cli/commands/_list_entry_fields.py +41 -0
- affinity/cli/commands/_v1_parsing.py +77 -0
- affinity/cli/commands/company_cmds.py +2139 -0
- affinity/cli/commands/completion_cmd.py +33 -0
- affinity/cli/commands/config_cmds.py +540 -0
- affinity/cli/commands/entry_cmds.py +33 -0
- affinity/cli/commands/field_cmds.py +413 -0
- affinity/cli/commands/interaction_cmds.py +875 -0
- affinity/cli/commands/list_cmds.py +3152 -0
- affinity/cli/commands/note_cmds.py +433 -0
- affinity/cli/commands/opportunity_cmds.py +1174 -0
- affinity/cli/commands/person_cmds.py +1980 -0
- affinity/cli/commands/query_cmd.py +444 -0
- affinity/cli/commands/relationship_strength_cmds.py +62 -0
- affinity/cli/commands/reminder_cmds.py +595 -0
- affinity/cli/commands/resolve_url_cmd.py +127 -0
- affinity/cli/commands/session_cmds.py +84 -0
- affinity/cli/commands/task_cmds.py +110 -0
- affinity/cli/commands/version_cmd.py +29 -0
- affinity/cli/commands/whoami_cmd.py +36 -0
- affinity/cli/config.py +108 -0
- affinity/cli/context.py +749 -0
- affinity/cli/csv_utils.py +195 -0
- affinity/cli/date_utils.py +42 -0
- affinity/cli/decorators.py +77 -0
- affinity/cli/errors.py +28 -0
- affinity/cli/field_utils.py +355 -0
- affinity/cli/formatters.py +551 -0
- affinity/cli/help_json.py +283 -0
- affinity/cli/logging.py +100 -0
- affinity/cli/main.py +261 -0
- affinity/cli/options.py +53 -0
- affinity/cli/paths.py +32 -0
- affinity/cli/progress.py +183 -0
- affinity/cli/query/__init__.py +163 -0
- affinity/cli/query/aggregates.py +357 -0
- affinity/cli/query/dates.py +194 -0
- affinity/cli/query/exceptions.py +147 -0
- affinity/cli/query/executor.py +1236 -0
- affinity/cli/query/filters.py +248 -0
- affinity/cli/query/models.py +333 -0
- affinity/cli/query/output.py +331 -0
- affinity/cli/query/parser.py +619 -0
- affinity/cli/query/planner.py +430 -0
- affinity/cli/query/progress.py +270 -0
- affinity/cli/query/schema.py +439 -0
- affinity/cli/render.py +1589 -0
- affinity/cli/resolve.py +222 -0
- affinity/cli/resolvers.py +249 -0
- affinity/cli/results.py +308 -0
- affinity/cli/runner.py +218 -0
- affinity/cli/serialization.py +65 -0
- affinity/cli/session_cache.py +276 -0
- affinity/cli/types.py +70 -0
- affinity/client.py +771 -0
- affinity/clients/__init__.py +19 -0
- affinity/clients/http.py +3664 -0
- affinity/clients/pipeline.py +165 -0
- affinity/compare.py +501 -0
- affinity/downloads.py +114 -0
- affinity/exceptions.py +615 -0
- affinity/filters.py +1128 -0
- affinity/hooks.py +198 -0
- affinity/inbound_webhooks.py +302 -0
- affinity/models/__init__.py +163 -0
- affinity/models/entities.py +798 -0
- affinity/models/pagination.py +513 -0
- affinity/models/rate_limit_snapshot.py +48 -0
- affinity/models/secondary.py +413 -0
- affinity/models/types.py +663 -0
- affinity/policies.py +40 -0
- affinity/progress.py +22 -0
- affinity/py.typed +0 -0
- affinity/services/__init__.py +42 -0
- affinity/services/companies.py +1286 -0
- affinity/services/lists.py +1892 -0
- affinity/services/opportunities.py +1330 -0
- affinity/services/persons.py +1348 -0
- affinity/services/rate_limits.py +173 -0
- affinity/services/tasks.py +193 -0
- affinity/services/v1_only.py +2445 -0
- affinity/types.py +83 -0
- affinity_sdk-0.9.5.dist-info/METADATA +622 -0
- affinity_sdk-0.9.5.dist-info/RECORD +92 -0
- affinity_sdk-0.9.5.dist-info/WHEEL +4 -0
- affinity_sdk-0.9.5.dist-info/entry_points.txt +2 -0
- affinity_sdk-0.9.5.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Literal
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
from affinity.types import CompanyId, ListEntryId, ListId, OpportunityId, PersonId
|
|
9
|
+
|
|
10
|
+
from ..click_compat import RichCommand, click
|
|
11
|
+
from ..context import CLIContext
|
|
12
|
+
from ..decorators import category
|
|
13
|
+
from ..errors import CLIError
|
|
14
|
+
from ..options import output_options
|
|
15
|
+
from ..runner import CommandOutput, run_command
|
|
16
|
+
|
|
17
|
+
ResolvedType = Literal["person", "company", "opportunity", "list", "list_entry"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class ResolvedUrl:
|
|
22
|
+
type: ResolvedType
|
|
23
|
+
person_id: int | None = None
|
|
24
|
+
company_id: int | None = None
|
|
25
|
+
opportunity_id: int | None = None
|
|
26
|
+
list_id: int | None = None
|
|
27
|
+
list_entry_id: int | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_ENTITY_RE = re.compile(r"^/(persons|companies|opportunities)/(\d+)$")
|
|
31
|
+
_LIST_RE = re.compile(r"^/lists/(\d+)$")
|
|
32
|
+
_LIST_ENTRY_RE = re.compile(r"^/lists/(\d+)/entries/(\d+)$")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_affinity_url(url: str) -> ResolvedUrl:
|
|
36
|
+
parsed = urlparse(url)
|
|
37
|
+
if parsed.scheme not in {"http", "https"}:
|
|
38
|
+
raise CLIError(
|
|
39
|
+
"URL must start with http:// or https://", exit_code=2, error_type="usage_error"
|
|
40
|
+
)
|
|
41
|
+
host = (parsed.hostname or "").lower()
|
|
42
|
+
if (
|
|
43
|
+
host not in {"app.affinity.co", "app.affinity.com"}
|
|
44
|
+
and not host.endswith(".affinity.co")
|
|
45
|
+
and not host.endswith(".affinity.com")
|
|
46
|
+
):
|
|
47
|
+
raise CLIError(
|
|
48
|
+
"Not an Affinity UI URL (expected *.affinity.co or *.affinity.com)",
|
|
49
|
+
exit_code=2,
|
|
50
|
+
error_type="usage_error",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
path = parsed.path.rstrip("/")
|
|
54
|
+
if m := _ENTITY_RE.match(path):
|
|
55
|
+
kind, raw_id = m.group(1), m.group(2)
|
|
56
|
+
entity_id = int(raw_id)
|
|
57
|
+
if kind == "persons":
|
|
58
|
+
return ResolvedUrl(type="person", person_id=entity_id)
|
|
59
|
+
if kind == "companies":
|
|
60
|
+
return ResolvedUrl(type="company", company_id=entity_id)
|
|
61
|
+
return ResolvedUrl(type="opportunity", opportunity_id=entity_id)
|
|
62
|
+
if m := _LIST_ENTRY_RE.match(path):
|
|
63
|
+
return ResolvedUrl(
|
|
64
|
+
type="list_entry",
|
|
65
|
+
list_id=int(m.group(1)),
|
|
66
|
+
list_entry_id=int(m.group(2)),
|
|
67
|
+
)
|
|
68
|
+
if m := _LIST_RE.match(path):
|
|
69
|
+
return ResolvedUrl(type="list", list_id=int(m.group(1)))
|
|
70
|
+
|
|
71
|
+
raise CLIError("Unrecognized Affinity URL path.", exit_code=2, error_type="usage_error")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@category("read")
|
|
75
|
+
@click.command(name="resolve-url", cls=RichCommand)
|
|
76
|
+
@click.argument("url", type=str)
|
|
77
|
+
@output_options
|
|
78
|
+
@click.pass_obj
|
|
79
|
+
def resolve_url_cmd(ctx: CLIContext, url: str) -> None:
|
|
80
|
+
"""Resolve an Affinity UI URL to entity type and IDs."""
|
|
81
|
+
|
|
82
|
+
def fn(ctx: CLIContext, warnings: list[str]) -> CommandOutput:
|
|
83
|
+
resolved = _parse_affinity_url(url)
|
|
84
|
+
client = ctx.get_client(warnings=warnings)
|
|
85
|
+
|
|
86
|
+
# Validate existence/permissions via SDK.
|
|
87
|
+
if resolved.type == "person":
|
|
88
|
+
_ = client.persons.get(PersonId(resolved.person_id or 0))
|
|
89
|
+
elif resolved.type == "company":
|
|
90
|
+
_ = client.companies.get(CompanyId(resolved.company_id or 0))
|
|
91
|
+
elif resolved.type == "opportunity":
|
|
92
|
+
_ = client.opportunities.get(OpportunityId(resolved.opportunity_id or 0))
|
|
93
|
+
elif resolved.type == "list":
|
|
94
|
+
_ = client.lists.get(ListId(resolved.list_id or 0))
|
|
95
|
+
else:
|
|
96
|
+
_ = client.lists.entries(ListId(resolved.list_id or 0)).get(
|
|
97
|
+
ListEntryId(resolved.list_entry_id or 0)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
data = {
|
|
101
|
+
"type": resolved.type,
|
|
102
|
+
"personId": resolved.person_id,
|
|
103
|
+
"companyId": resolved.company_id,
|
|
104
|
+
"opportunityId": resolved.opportunity_id,
|
|
105
|
+
"listId": resolved.list_id,
|
|
106
|
+
"listEntryId": resolved.list_entry_id,
|
|
107
|
+
"canonicalUrl": _canonical_url(resolved),
|
|
108
|
+
}
|
|
109
|
+
return CommandOutput(
|
|
110
|
+
data={k: v for k, v in data.items() if v is not None},
|
|
111
|
+
warnings=warnings,
|
|
112
|
+
api_called=True,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
run_command(ctx, command="resolve-url", fn=fn)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _canonical_url(resolved: ResolvedUrl) -> str:
|
|
119
|
+
if resolved.type == "person":
|
|
120
|
+
return f"https://app.affinity.co/persons/{resolved.person_id}"
|
|
121
|
+
if resolved.type == "company":
|
|
122
|
+
return f"https://app.affinity.co/companies/{resolved.company_id}"
|
|
123
|
+
if resolved.type == "opportunity":
|
|
124
|
+
return f"https://app.affinity.co/opportunities/{resolved.opportunity_id}"
|
|
125
|
+
if resolved.type == "list":
|
|
126
|
+
return f"https://app.affinity.co/lists/{resolved.list_id}"
|
|
127
|
+
return f"https://app.affinity.co/lists/{resolved.list_id}/entries/{resolved.list_entry_id}"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Session cache management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ..click_compat import RichCommand, RichGroup, click
|
|
12
|
+
from ..decorators import category
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group(name="session", cls=RichGroup)
|
|
16
|
+
def session_group() -> None:
|
|
17
|
+
"""Manage CLI session cache for pipeline optimization."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@category("local")
|
|
21
|
+
@session_group.command(name="start", cls=RichCommand)
|
|
22
|
+
def session_start() -> None:
|
|
23
|
+
"""Create a new session cache directory.
|
|
24
|
+
|
|
25
|
+
Usage: export AFFINITY_SESSION_CACHE=$(affinity session start)
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
cache_dir = tempfile.mkdtemp(prefix="affinity_session_")
|
|
29
|
+
# Output just the path (no newline issues with click.echo)
|
|
30
|
+
click.echo(cache_dir)
|
|
31
|
+
except OSError as e:
|
|
32
|
+
click.echo(f"Error: Cannot create session cache: {e}", err=True)
|
|
33
|
+
raise SystemExit(1) from None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@category("local")
|
|
37
|
+
@session_group.command(name="end", cls=RichCommand)
|
|
38
|
+
def session_end() -> None:
|
|
39
|
+
"""Clean up the current session cache.
|
|
40
|
+
|
|
41
|
+
Reads AFFINITY_SESSION_CACHE env var and removes the directory.
|
|
42
|
+
Safe to call multiple times (idempotent).
|
|
43
|
+
"""
|
|
44
|
+
cache_dir = os.environ.get("AFFINITY_SESSION_CACHE")
|
|
45
|
+
if not cache_dir:
|
|
46
|
+
click.echo("No active session (AFFINITY_SESSION_CACHE not set)", err=True)
|
|
47
|
+
return
|
|
48
|
+
cache_path = Path(cache_dir)
|
|
49
|
+
if cache_path.exists():
|
|
50
|
+
shutil.rmtree(cache_dir, ignore_errors=True)
|
|
51
|
+
click.echo(f"Session ended: {cache_dir}", err=True)
|
|
52
|
+
else:
|
|
53
|
+
click.echo(f"Session directory already removed: {cache_dir}", err=True)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@category("local")
|
|
57
|
+
@session_group.command(name="status", cls=RichCommand)
|
|
58
|
+
def session_status() -> None:
|
|
59
|
+
"""Show current session cache status.
|
|
60
|
+
|
|
61
|
+
Note: Shows stats for ALL cache files in the directory, regardless of
|
|
62
|
+
which API key created them. Filtering by tenant would require API key
|
|
63
|
+
access, which this command intentionally avoids.
|
|
64
|
+
"""
|
|
65
|
+
cache_dir = os.environ.get("AFFINITY_SESSION_CACHE")
|
|
66
|
+
if not cache_dir:
|
|
67
|
+
click.echo("No active session (AFFINITY_SESSION_CACHE not set)")
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
cache_path = Path(cache_dir)
|
|
71
|
+
if not cache_path.exists():
|
|
72
|
+
click.echo(f"Session directory missing: {cache_dir}")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
# Count cache entries and calculate stats
|
|
76
|
+
cache_files = list(cache_path.glob("*.json"))
|
|
77
|
+
total_size = sum(f.stat().st_size for f in cache_files)
|
|
78
|
+
oldest_mtime = min((f.stat().st_mtime for f in cache_files), default=time.time())
|
|
79
|
+
age_seconds = int(time.time() - oldest_mtime)
|
|
80
|
+
|
|
81
|
+
click.echo(f"Session active: {cache_dir}")
|
|
82
|
+
click.echo(f"Cache entries: {len(cache_files)}")
|
|
83
|
+
click.echo(f"Total size: {total_size / 1024:.1f} KB")
|
|
84
|
+
click.echo(f"Oldest entry: {age_seconds // 60}m {age_seconds % 60}s ago")
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from affinity.models.secondary import MergeTask
|
|
4
|
+
|
|
5
|
+
from ..click_compat import RichCommand, RichGroup, click
|
|
6
|
+
from ..context import CLIContext
|
|
7
|
+
from ..decorators import category
|
|
8
|
+
from ..options import output_options
|
|
9
|
+
from ..results import CommandContext
|
|
10
|
+
from ..runner import CommandOutput, run_command
|
|
11
|
+
from ..serialization import serialize_model_for_cli
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.group(name="task", cls=RichGroup)
|
|
15
|
+
def task_group() -> None:
|
|
16
|
+
"""Task commands."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _task_payload(task: MergeTask) -> dict[str, object]:
|
|
20
|
+
return serialize_model_for_cli(task)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@category("read")
|
|
24
|
+
@task_group.command(name="get", cls=RichCommand)
|
|
25
|
+
@click.argument("task_url", type=str)
|
|
26
|
+
@output_options
|
|
27
|
+
@click.pass_obj
|
|
28
|
+
def task_get(ctx: CLIContext, task_url: str) -> None:
|
|
29
|
+
"""Get task status."""
|
|
30
|
+
|
|
31
|
+
def fn(ctx: CLIContext, warnings: list[str]) -> CommandOutput:
|
|
32
|
+
client = ctx.get_client(warnings=warnings)
|
|
33
|
+
task = client.tasks.get(task_url)
|
|
34
|
+
payload = _task_payload(task)
|
|
35
|
+
|
|
36
|
+
cmd_context = CommandContext(
|
|
37
|
+
name="task get",
|
|
38
|
+
inputs={"taskUrl": task_url},
|
|
39
|
+
modifiers={},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return CommandOutput(data={"task": payload}, context=cmd_context, api_called=True)
|
|
43
|
+
|
|
44
|
+
run_command(ctx, command="task get", fn=fn)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@category("read")
|
|
48
|
+
@task_group.command(name="wait", cls=RichCommand)
|
|
49
|
+
@click.argument("task_url", type=str)
|
|
50
|
+
@click.option(
|
|
51
|
+
"--timeout",
|
|
52
|
+
type=float,
|
|
53
|
+
default=300.0,
|
|
54
|
+
show_default=True,
|
|
55
|
+
help="Maximum seconds to wait for task completion.",
|
|
56
|
+
)
|
|
57
|
+
@click.option(
|
|
58
|
+
"--poll-interval",
|
|
59
|
+
type=float,
|
|
60
|
+
default=2.0,
|
|
61
|
+
show_default=True,
|
|
62
|
+
help="Initial polling interval in seconds.",
|
|
63
|
+
)
|
|
64
|
+
@click.option(
|
|
65
|
+
"--max-poll-interval",
|
|
66
|
+
type=float,
|
|
67
|
+
default=30.0,
|
|
68
|
+
show_default=True,
|
|
69
|
+
help="Maximum polling interval in seconds.",
|
|
70
|
+
)
|
|
71
|
+
@output_options
|
|
72
|
+
@click.pass_obj
|
|
73
|
+
def task_wait(
|
|
74
|
+
ctx: CLIContext,
|
|
75
|
+
task_url: str,
|
|
76
|
+
*,
|
|
77
|
+
timeout: float,
|
|
78
|
+
poll_interval: float,
|
|
79
|
+
max_poll_interval: float,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Wait for a task to complete."""
|
|
82
|
+
|
|
83
|
+
def fn(ctx: CLIContext, warnings: list[str]) -> CommandOutput:
|
|
84
|
+
client = ctx.get_client(warnings=warnings)
|
|
85
|
+
task = client.tasks.wait(
|
|
86
|
+
task_url,
|
|
87
|
+
timeout=timeout,
|
|
88
|
+
poll_interval=poll_interval,
|
|
89
|
+
max_poll_interval=max_poll_interval,
|
|
90
|
+
)
|
|
91
|
+
payload = _task_payload(task)
|
|
92
|
+
|
|
93
|
+
# Build CommandContext - only include non-default modifiers
|
|
94
|
+
ctx_modifiers: dict[str, object] = {}
|
|
95
|
+
if timeout != 300.0:
|
|
96
|
+
ctx_modifiers["timeout"] = timeout
|
|
97
|
+
if poll_interval != 2.0:
|
|
98
|
+
ctx_modifiers["pollInterval"] = poll_interval
|
|
99
|
+
if max_poll_interval != 30.0:
|
|
100
|
+
ctx_modifiers["maxPollInterval"] = max_poll_interval
|
|
101
|
+
|
|
102
|
+
cmd_context = CommandContext(
|
|
103
|
+
name="task wait",
|
|
104
|
+
inputs={"taskUrl": task_url},
|
|
105
|
+
modifiers=ctx_modifiers,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return CommandOutput(data={"task": payload}, context=cmd_context, api_called=True)
|
|
109
|
+
|
|
110
|
+
run_command(ctx, command="task wait", fn=fn)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import platform
|
|
4
|
+
|
|
5
|
+
import affinity
|
|
6
|
+
|
|
7
|
+
from ..click_compat import RichCommand, click
|
|
8
|
+
from ..context import CLIContext
|
|
9
|
+
from ..decorators import category
|
|
10
|
+
from ..options import output_options
|
|
11
|
+
from ..runner import CommandOutput, run_command
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@category("local")
|
|
15
|
+
@click.command(name="version", cls=RichCommand)
|
|
16
|
+
@output_options
|
|
17
|
+
@click.pass_obj
|
|
18
|
+
def version_cmd(ctx: CLIContext) -> None:
|
|
19
|
+
"""Show version, Python, and platform information."""
|
|
20
|
+
|
|
21
|
+
def fn(_: CLIContext, _warnings: list[str]) -> CommandOutput:
|
|
22
|
+
data = {
|
|
23
|
+
"version": affinity.__version__,
|
|
24
|
+
"pythonVersion": platform.python_version(),
|
|
25
|
+
"platform": platform.platform(),
|
|
26
|
+
}
|
|
27
|
+
return CommandOutput(data=data, api_called=False)
|
|
28
|
+
|
|
29
|
+
run_command(ctx, command="version", fn=fn)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..click_compat import RichCommand, click
|
|
4
|
+
from ..context import CLIContext
|
|
5
|
+
from ..decorators import category
|
|
6
|
+
from ..options import output_options
|
|
7
|
+
from ..results import CommandContext
|
|
8
|
+
from ..runner import CommandOutput, run_command
|
|
9
|
+
from ..serialization import serialize_model_for_cli
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@category("read")
|
|
13
|
+
@click.command(name="whoami", cls=RichCommand)
|
|
14
|
+
@output_options
|
|
15
|
+
@click.pass_obj
|
|
16
|
+
def whoami_cmd(ctx: CLIContext) -> None:
|
|
17
|
+
"""Show current authenticated user information."""
|
|
18
|
+
|
|
19
|
+
def fn(ctx: CLIContext, warnings: list[str]) -> CommandOutput:
|
|
20
|
+
client = ctx.get_client(warnings=warnings)
|
|
21
|
+
who = client.whoami()
|
|
22
|
+
|
|
23
|
+
cmd_context = CommandContext(
|
|
24
|
+
name="whoami",
|
|
25
|
+
inputs={},
|
|
26
|
+
modifiers={},
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
return CommandOutput(
|
|
30
|
+
data=serialize_model_for_cli(who),
|
|
31
|
+
context=cmd_context,
|
|
32
|
+
warnings=warnings,
|
|
33
|
+
api_called=True,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
run_command(ctx, command="whoami", fn=fn)
|
affinity/cli/config.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
from .errors import CLIError
|
|
12
|
+
|
|
13
|
+
if sys.version_info >= (3, 11): # pragma: no cover
|
|
14
|
+
import tomllib as _tomllib
|
|
15
|
+
else: # pragma: no cover
|
|
16
|
+
import tomli as _tomllib
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class ProfileConfig:
|
|
21
|
+
api_key: str | None = None
|
|
22
|
+
timeout_seconds: float | None = None
|
|
23
|
+
v1_base_url: str | None = None
|
|
24
|
+
v2_base_url: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class LoadedConfig:
|
|
29
|
+
default: ProfileConfig
|
|
30
|
+
profiles: dict[str, ProfileConfig]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _profile_from_mapping(data: dict[str, Any]) -> ProfileConfig:
|
|
34
|
+
timeout = data.get("timeout_seconds")
|
|
35
|
+
return ProfileConfig(
|
|
36
|
+
api_key=data.get("api_key") or None,
|
|
37
|
+
timeout_seconds=float(timeout) if timeout is not None else None,
|
|
38
|
+
v1_base_url=data.get("v1_base_url") or None,
|
|
39
|
+
v2_base_url=data.get("v2_base_url") or None,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_config(path: Path) -> LoadedConfig:
|
|
44
|
+
if not path.exists():
|
|
45
|
+
return LoadedConfig(default=ProfileConfig(), profiles={})
|
|
46
|
+
|
|
47
|
+
if path.suffix.lower() == ".json":
|
|
48
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
49
|
+
else:
|
|
50
|
+
raw = _tomllib.loads(path.read_text(encoding="utf-8"))
|
|
51
|
+
|
|
52
|
+
if not isinstance(raw, dict):
|
|
53
|
+
raise CLIError(
|
|
54
|
+
f"Invalid config file: expected a mapping at top-level: {path}",
|
|
55
|
+
exit_code=2,
|
|
56
|
+
error_type="usage_error",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
raw_dict = cast(dict[str, Any], raw)
|
|
60
|
+
default_raw_any = raw_dict.get("default")
|
|
61
|
+
default_raw = default_raw_any if isinstance(default_raw_any, dict) else {}
|
|
62
|
+
profiles_raw_any = raw_dict.get("profiles")
|
|
63
|
+
profiles_raw = profiles_raw_any if isinstance(profiles_raw_any, dict) else {}
|
|
64
|
+
profiles: dict[str, ProfileConfig] = {}
|
|
65
|
+
for name, value in profiles_raw.items():
|
|
66
|
+
if isinstance(value, dict):
|
|
67
|
+
profiles[str(name)] = _profile_from_mapping(cast(dict[str, Any], value))
|
|
68
|
+
|
|
69
|
+
return LoadedConfig(default=_profile_from_mapping(default_raw), profiles=profiles)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def config_file_permission_warnings(path: Path) -> list[str]:
|
|
73
|
+
if os.name != "posix":
|
|
74
|
+
return []
|
|
75
|
+
try:
|
|
76
|
+
mode = path.stat().st_mode
|
|
77
|
+
except FileNotFoundError:
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
insecure = bool(mode & (stat.S_IRGRP | stat.S_IROTH))
|
|
81
|
+
if insecure:
|
|
82
|
+
return [
|
|
83
|
+
(
|
|
84
|
+
f"Config file is group/world readable: {path} "
|
|
85
|
+
"(consider `chmod 600` to protect secrets)."
|
|
86
|
+
)
|
|
87
|
+
]
|
|
88
|
+
return []
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def config_init_template() -> str:
|
|
92
|
+
return """# Affinity CLI configuration
|
|
93
|
+
#
|
|
94
|
+
# This file is optional. Prefer environment variables or --api-key-file for secrets.
|
|
95
|
+
# On POSIX systems, ensure permissions are restrictive (e.g. chmod 600).
|
|
96
|
+
#
|
|
97
|
+
# Format: TOML
|
|
98
|
+
|
|
99
|
+
[default]
|
|
100
|
+
# api_key = "..."
|
|
101
|
+
# timeout_seconds = 30
|
|
102
|
+
|
|
103
|
+
[profiles.dev]
|
|
104
|
+
# api_key = "..."
|
|
105
|
+
# timeout_seconds = 30
|
|
106
|
+
# v1_base_url = "https://api.affinity.co"
|
|
107
|
+
# v2_base_url = "https://api.affinity.co"
|
|
108
|
+
"""
|