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.
Files changed (92) hide show
  1. affinity/__init__.py +139 -0
  2. affinity/cli/__init__.py +7 -0
  3. affinity/cli/click_compat.py +27 -0
  4. affinity/cli/commands/__init__.py +1 -0
  5. affinity/cli/commands/_entity_files_dump.py +219 -0
  6. affinity/cli/commands/_list_entry_fields.py +41 -0
  7. affinity/cli/commands/_v1_parsing.py +77 -0
  8. affinity/cli/commands/company_cmds.py +2139 -0
  9. affinity/cli/commands/completion_cmd.py +33 -0
  10. affinity/cli/commands/config_cmds.py +540 -0
  11. affinity/cli/commands/entry_cmds.py +33 -0
  12. affinity/cli/commands/field_cmds.py +413 -0
  13. affinity/cli/commands/interaction_cmds.py +875 -0
  14. affinity/cli/commands/list_cmds.py +3152 -0
  15. affinity/cli/commands/note_cmds.py +433 -0
  16. affinity/cli/commands/opportunity_cmds.py +1174 -0
  17. affinity/cli/commands/person_cmds.py +1980 -0
  18. affinity/cli/commands/query_cmd.py +444 -0
  19. affinity/cli/commands/relationship_strength_cmds.py +62 -0
  20. affinity/cli/commands/reminder_cmds.py +595 -0
  21. affinity/cli/commands/resolve_url_cmd.py +127 -0
  22. affinity/cli/commands/session_cmds.py +84 -0
  23. affinity/cli/commands/task_cmds.py +110 -0
  24. affinity/cli/commands/version_cmd.py +29 -0
  25. affinity/cli/commands/whoami_cmd.py +36 -0
  26. affinity/cli/config.py +108 -0
  27. affinity/cli/context.py +749 -0
  28. affinity/cli/csv_utils.py +195 -0
  29. affinity/cli/date_utils.py +42 -0
  30. affinity/cli/decorators.py +77 -0
  31. affinity/cli/errors.py +28 -0
  32. affinity/cli/field_utils.py +355 -0
  33. affinity/cli/formatters.py +551 -0
  34. affinity/cli/help_json.py +283 -0
  35. affinity/cli/logging.py +100 -0
  36. affinity/cli/main.py +261 -0
  37. affinity/cli/options.py +53 -0
  38. affinity/cli/paths.py +32 -0
  39. affinity/cli/progress.py +183 -0
  40. affinity/cli/query/__init__.py +163 -0
  41. affinity/cli/query/aggregates.py +357 -0
  42. affinity/cli/query/dates.py +194 -0
  43. affinity/cli/query/exceptions.py +147 -0
  44. affinity/cli/query/executor.py +1236 -0
  45. affinity/cli/query/filters.py +248 -0
  46. affinity/cli/query/models.py +333 -0
  47. affinity/cli/query/output.py +331 -0
  48. affinity/cli/query/parser.py +619 -0
  49. affinity/cli/query/planner.py +430 -0
  50. affinity/cli/query/progress.py +270 -0
  51. affinity/cli/query/schema.py +439 -0
  52. affinity/cli/render.py +1589 -0
  53. affinity/cli/resolve.py +222 -0
  54. affinity/cli/resolvers.py +249 -0
  55. affinity/cli/results.py +308 -0
  56. affinity/cli/runner.py +218 -0
  57. affinity/cli/serialization.py +65 -0
  58. affinity/cli/session_cache.py +276 -0
  59. affinity/cli/types.py +70 -0
  60. affinity/client.py +771 -0
  61. affinity/clients/__init__.py +19 -0
  62. affinity/clients/http.py +3664 -0
  63. affinity/clients/pipeline.py +165 -0
  64. affinity/compare.py +501 -0
  65. affinity/downloads.py +114 -0
  66. affinity/exceptions.py +615 -0
  67. affinity/filters.py +1128 -0
  68. affinity/hooks.py +198 -0
  69. affinity/inbound_webhooks.py +302 -0
  70. affinity/models/__init__.py +163 -0
  71. affinity/models/entities.py +798 -0
  72. affinity/models/pagination.py +513 -0
  73. affinity/models/rate_limit_snapshot.py +48 -0
  74. affinity/models/secondary.py +413 -0
  75. affinity/models/types.py +663 -0
  76. affinity/policies.py +40 -0
  77. affinity/progress.py +22 -0
  78. affinity/py.typed +0 -0
  79. affinity/services/__init__.py +42 -0
  80. affinity/services/companies.py +1286 -0
  81. affinity/services/lists.py +1892 -0
  82. affinity/services/opportunities.py +1330 -0
  83. affinity/services/persons.py +1348 -0
  84. affinity/services/rate_limits.py +173 -0
  85. affinity/services/tasks.py +193 -0
  86. affinity/services/v1_only.py +2445 -0
  87. affinity/types.py +83 -0
  88. affinity_sdk-0.9.5.dist-info/METADATA +622 -0
  89. affinity_sdk-0.9.5.dist-info/RECORD +92 -0
  90. affinity_sdk-0.9.5.dist-info/WHEEL +4 -0
  91. affinity_sdk-0.9.5.dist-info/entry_points.txt +2 -0
  92. 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
+ """