logify360 1.0.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.
@@ -0,0 +1,65 @@
1
+ """Tiny ANSI color helpers, a stdlib-only stand-in for picocolors.
2
+
3
+ Colors are disabled automatically when:
4
+
5
+ * ``NO_COLOR`` is set (https://no-color.org/), or
6
+ * ``stdout`` is not a TTY (piped / redirected / CI without a terminal).
7
+
8
+ Keeping this dependency-free matters: installing logify360 should not
9
+ pull a color library just so the SDK is importable.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import sys
16
+
17
+ _RESET = "\033[0m"
18
+
19
+
20
+ def _enabled() -> bool:
21
+ if os.environ.get("NO_COLOR") is not None:
22
+ return False
23
+ if os.environ.get("FORCE_COLOR") is not None:
24
+ return True
25
+ try:
26
+ return sys.stdout.isatty()
27
+ except Exception:
28
+ return False
29
+
30
+
31
+ def _wrap(code: str, text: str) -> str:
32
+ if not _enabled():
33
+ return text
34
+ return f"\033[{code}m{text}{_RESET}"
35
+
36
+
37
+ def bold(text: str) -> str:
38
+ return _wrap("1", text)
39
+
40
+
41
+ def dim(text: str) -> str:
42
+ return _wrap("2", text)
43
+
44
+
45
+ def underline(text: str) -> str:
46
+ return _wrap("4", text)
47
+
48
+
49
+ def red(text: str) -> str:
50
+ return _wrap("31", text)
51
+
52
+
53
+ def green(text: str) -> str:
54
+ return _wrap("32", text)
55
+
56
+
57
+ def yellow(text: str) -> str:
58
+ return _wrap("33", text)
59
+
60
+
61
+ def cyan(text: str) -> str:
62
+ return _wrap("36", text)
63
+
64
+
65
+ __all__ = ["bold", "cyan", "dim", "green", "red", "underline", "yellow"]
@@ -0,0 +1,100 @@
1
+ """Idempotently add or update a ``KEY=VALUE`` line in a ``.env`` file.
2
+
3
+ Mirrors ``packages/cli/src/env-writer.ts`` so the Python and Node wizards
4
+ behave identically:
5
+
6
+ * Existing lines and comments are preserved.
7
+ * If ``KEY`` already exists, its value is replaced in place.
8
+ * If the file does not exist, it is created.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ from dataclasses import dataclass
16
+ from typing import Optional
17
+
18
+ #: Owner read/write only - env files carry the customer's API key (CLI-02).
19
+ _ENV_FILE_MODE = 0o600
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class UpsertResult:
24
+ changed: bool
25
+ created: bool
26
+ existed: bool
27
+
28
+
29
+ def read_env_var(cwd: str, filename: str, key: str) -> Optional[str]:
30
+ r"""Return the current value of ``key`` in the env file, or ``None``.
31
+
32
+ ``None`` also covers an empty or blank value. The whitespace around ``=`` is
33
+ ``[ \t]``, never ``\s``: ``\s`` also matches the newline, so ``KEY=`` with nothing
34
+ after it used to return the NEXT line (``OTHER=1``) as if it were the key.
35
+ """
36
+ path = os.path.join(cwd, filename)
37
+ if not os.path.isfile(path):
38
+ return None
39
+ try:
40
+ with open(path, encoding="utf-8") as fh:
41
+ text = fh.read()
42
+ except OSError:
43
+ return None
44
+ match = re.search(rf"^{re.escape(key)}[ \t]*=[ \t]*(.+)$", text, re.MULTILINE)
45
+ return (match.group(1).strip() or None) if match else None
46
+
47
+
48
+ def upsert_env_var(cwd: str, filename: str, key: str, value: str) -> UpsertResult:
49
+ path = os.path.join(cwd, filename)
50
+ existed = os.path.isfile(path)
51
+ existing = ""
52
+ if existed:
53
+ try:
54
+ with open(path, encoding="utf-8") as fh:
55
+ existing = fh.read()
56
+ except OSError:
57
+ existing = ""
58
+
59
+ lines = existing.split("\n") if existing else []
60
+ pattern = re.compile(rf"^{re.escape(key)}\s*=")
61
+ found = False
62
+ value_changed = False
63
+ new_line = f"{key}={value}"
64
+
65
+ updated = []
66
+ for line in lines:
67
+ if pattern.match(line):
68
+ found = True
69
+ if line != new_line:
70
+ value_changed = True
71
+ updated.append(new_line)
72
+ else:
73
+ updated.append(line)
74
+
75
+ if not found:
76
+ if updated and updated[-1] != "":
77
+ updated.append("")
78
+ updated.append(new_line)
79
+ updated.append("") # trailing newline
80
+
81
+ next_text = "\n".join(updated)
82
+ if next_text == existing:
83
+ return UpsertResult(changed=False, created=False, existed=existed)
84
+
85
+ # CLI-02: the file holds the customer's sl_live_* key - owner read/write
86
+ # only, on create and on update alike (an existing 0644 file is tightened).
87
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, _ENV_FILE_MODE)
88
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
89
+ fh.write(next_text)
90
+ if existed and os.name != "nt":
91
+ os.chmod(path, _ENV_FILE_MODE)
92
+
93
+ return UpsertResult(
94
+ changed=value_changed if found else True,
95
+ created=not existed,
96
+ existed=existed,
97
+ )
98
+
99
+
100
+ __all__ = ["UpsertResult", "read_env_var", "upsert_env_var"]
@@ -0,0 +1,183 @@
1
+ """Detect the Python project layout and which LLM libraries it already uses.
2
+
3
+ Unlike the Node CLI (Next.js-centric), Python projects vary widely in their
4
+ package manager and manifest. We sniff the common ones and, crucially, figure
5
+ out which provider SDKs (``openai`` / ``anthropic`` / ``google-generativeai``)
6
+ are declared so the wizard can suggest the right integrations even before the
7
+ Logify360 key tells us which provider keys are registered.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import re
14
+ from dataclasses import dataclass, field
15
+ from typing import List, Optional
16
+
17
+ try: # Python 3.11+ ships tomllib; older versions degrade to a regex sniff.
18
+ import tomllib # type: ignore[import-not-found]
19
+ except ModuleNotFoundError: # pragma: no cover - exercised on <3.11 only
20
+ tomllib = None
21
+
22
+
23
+ # Maps the Logify360 provider name to the PyPI distribution names that imply it.
24
+ PROVIDER_LIBRARIES = {
25
+ "openai": ("openai",),
26
+ "anthropic": ("anthropic",),
27
+ "gemini": ("google-generativeai", "google-genai"),
28
+ }
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class ProjectInfo:
33
+ """Everything the wizard learned by looking at the filesystem."""
34
+
35
+ package_manager: str # 'poetry' | 'uv' | 'pipenv' | 'pip' | 'unknown'
36
+ manifest: Optional[str] # the file we read deps from, if any
37
+ env_file: str # preferred env file to write to (always '.env' for Python)
38
+ detected_providers: List[str] = field(default_factory=list)
39
+
40
+
41
+ def detect_project(cwd: Optional[str] = None) -> ProjectInfo:
42
+ root = cwd or os.getcwd()
43
+
44
+ pm, manifest = _detect_package_manager(root)
45
+ providers = _detect_providers(root)
46
+
47
+ return ProjectInfo(
48
+ package_manager=pm,
49
+ manifest=manifest,
50
+ env_file=".env",
51
+ detected_providers=providers,
52
+ )
53
+
54
+
55
+ def _exists(root: str, name: str) -> bool:
56
+ return os.path.isfile(os.path.join(root, name))
57
+
58
+
59
+ def _detect_package_manager(root: str) -> tuple[str, Optional[str]]:
60
+ """Lockfiles are the most reliable signal; fall back to manifests."""
61
+ if _exists(root, "poetry.lock"):
62
+ return "poetry", "pyproject.toml"
63
+ if _exists(root, "uv.lock"):
64
+ return "uv", "pyproject.toml"
65
+ if _exists(root, "Pipfile.lock") or _exists(root, "Pipfile"):
66
+ return "pipenv", "Pipfile"
67
+
68
+ if _exists(root, "pyproject.toml"):
69
+ # A pyproject with [tool.poetry] means poetry even without a lockfile.
70
+ text = _read(os.path.join(root, "pyproject.toml"))
71
+ if text and "[tool.poetry]" in text:
72
+ return "poetry", "pyproject.toml"
73
+ return "pip", "pyproject.toml"
74
+
75
+ if _exists(root, "requirements.txt"):
76
+ return "pip", "requirements.txt"
77
+
78
+ return "unknown", None
79
+
80
+
81
+ def _read(path: str) -> Optional[str]:
82
+ try:
83
+ with open(path, encoding="utf-8") as fh:
84
+ return fh.read()
85
+ except OSError:
86
+ return None
87
+
88
+
89
+ def _detect_providers(root: str) -> List[str]:
90
+ """Collect declared dependencies across every manifest we can find, then
91
+ map distribution names back to Logify360 provider names."""
92
+ declared = set()
93
+
94
+ declared |= _deps_from_pyproject(os.path.join(root, "pyproject.toml"))
95
+ declared |= _deps_from_requirements(os.path.join(root, "requirements.txt"))
96
+ declared |= _deps_from_pipfile(os.path.join(root, "Pipfile"))
97
+
98
+ found: List[str] = []
99
+ for provider, dists in PROVIDER_LIBRARIES.items():
100
+ if any(_normalize(d) in declared for d in dists):
101
+ found.append(provider)
102
+ return found
103
+
104
+
105
+ def _normalize(name: str) -> str:
106
+ # PEP 503 normalization so 'Google-GenerativeAI' == 'google-generativeai'.
107
+ return re.sub(r"[-_.]+", "-", name).strip().lower()
108
+
109
+
110
+ def _deps_from_pyproject(path: str) -> set[str]:
111
+ text = _read(path)
112
+ if text is None:
113
+ return set()
114
+
115
+ names: set[str] = set()
116
+ if tomllib is not None:
117
+ try:
118
+ data = tomllib.loads(text)
119
+ except Exception:
120
+ data = {}
121
+ # PEP 621 [project].dependencies + optional-dependencies
122
+ project = data.get("project", {}) if isinstance(data, dict) else {}
123
+ for spec in project.get("dependencies", []) or []:
124
+ names.add(_dist_from_spec(spec))
125
+ for group in (project.get("optional-dependencies", {}) or {}).values():
126
+ for spec in group or []:
127
+ names.add(_dist_from_spec(spec))
128
+ # Poetry [tool.poetry.dependencies] is a table keyed by dist name.
129
+ poetry = (
130
+ data.get("tool", {}).get("poetry", {}) if isinstance(data, dict) else {}
131
+ )
132
+ for key in (poetry.get("dependencies", {}) or {}):
133
+ names.add(_normalize(key))
134
+ for group in (poetry.get("group", {}) or {}).values():
135
+ for key in (group.get("dependencies", {}) or {}):
136
+ names.add(_normalize(key))
137
+ else: # pragma: no cover - <3.11 fallback
138
+ for spec in re.findall(r'"([A-Za-z0-9._-]+(?:\[[^\]]*\])?[^"]*)"', text):
139
+ names.add(_dist_from_spec(spec))
140
+
141
+ names.discard("")
142
+ return names
143
+
144
+
145
+ def _deps_from_requirements(path: str) -> set[str]:
146
+ text = _read(path)
147
+ if text is None:
148
+ return set()
149
+ names: set[str] = set()
150
+ for line in text.splitlines():
151
+ line = line.strip()
152
+ if not line or line.startswith("#") or line.startswith("-"):
153
+ continue
154
+ names.add(_dist_from_spec(line))
155
+ names.discard("")
156
+ return names
157
+
158
+
159
+ def _deps_from_pipfile(path: str) -> set[str]:
160
+ text = _read(path)
161
+ if text is None:
162
+ return set()
163
+ # Pipfile is TOML but commonly read loosely; grab the bare package names
164
+ # under [packages] / [dev-packages] via a simple key sniff.
165
+ names: set[str] = set()
166
+ for match in re.finditer(r'^\s*"?([A-Za-z0-9._-]+)"?\s*=', text, re.MULTILINE):
167
+ names.add(_normalize(match.group(1)))
168
+ names.discard("")
169
+ return names
170
+
171
+
172
+ def _dist_from_spec(spec: str) -> str:
173
+ """Extract the distribution name from a PEP 508 requirement string.
174
+
175
+ ``openai>=1.0.0`` → ``openai``; ``logify360[openai]`` → ``logify360``.
176
+ """
177
+ spec = spec.strip()
178
+ # Cut at the first version / marker / extras delimiter.
179
+ name = re.split(r"[\s<>=!~\[;@(]", spec, maxsplit=1)[0]
180
+ return _normalize(name)
181
+
182
+
183
+ __all__ = ["PROVIDER_LIBRARIES", "ProjectInfo", "detect_project"]
@@ -0,0 +1,143 @@
1
+ """Install the ``logify360`` provider extras into the user's project.
2
+
3
+ The ``logify360`` distribution is the SDK *and* this CLI, so the user already
4
+ has it on their machine to run ``logify360 init``. What this step does is make
5
+ it a declared dependency of their project and pull the right provider extras
6
+ (``logify360[openai]`` etc.) using whichever package manager they use.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
13
+ import shlex
14
+ import subprocess # noqa: S404 - we build argv lists ourselves, never shell strings
15
+ import sys
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import List, Optional, Sequence
19
+
20
+ # Logify360 provider name → extras key in pyproject [project.optional-dependencies].
21
+ PROVIDER_EXTRAS = {
22
+ "openai": "openai",
23
+ "anthropic": "anthropic",
24
+ "gemini": "gemini",
25
+ }
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class InstallResult:
30
+ ok: bool
31
+ command: str
32
+ error: Optional[str] = None
33
+
34
+
35
+ def resolve_sdk_artifact(cwd: str, artifact: Optional[str]) -> Optional[str]:
36
+ """Local dashboard wheel, or None to install ``logify360`` from PyPI."""
37
+ if not artifact:
38
+ return None
39
+ path = (Path(cwd) / artifact).resolve()
40
+ if path.is_file() and re.fullmatch(r"logify360-[0-9][^-]*-py3-none-any\.whl", path.name):
41
+ return str(path)
42
+ raise ValueError(
43
+ "Expected a local SDK wheel named logify360-<version>-py3-none-any.whl, "
44
+ "or omit --sdk-artifact to install logify360 from PyPI."
45
+ )
46
+
47
+
48
+ def install_target(providers: Sequence[str], artifact: Optional[str] = None) -> str:
49
+ """Append provider extras to a wheel path or the PyPI package name."""
50
+ base = artifact or "logify360"
51
+ extras = sorted({PROVIDER_EXTRAS[p] for p in providers if p in PROVIDER_EXTRAS})
52
+ if not extras:
53
+ return base
54
+ return f"{base}[{','.join(extras)}]"
55
+
56
+
57
+ def _install_argv(package_manager: str, target: str) -> List[str]:
58
+ if package_manager == "poetry":
59
+ return ["poetry", "add", target]
60
+ if package_manager == "uv":
61
+ return ["uv", "add", target]
62
+ if package_manager == "pipenv":
63
+ return ["pipenv", "install", target]
64
+ # pip (and 'unknown') uses the active interpreter so we install into the
65
+ # same environment the CLI is running from.
66
+ return [sys.executable, "-m", "pip", "install", target]
67
+
68
+
69
+ def install_logify360(
70
+ cwd: str,
71
+ package_manager: str,
72
+ providers: Sequence[str],
73
+ *,
74
+ sdk_artifact: Optional[str] = None,
75
+ dry_run: bool = False,
76
+ timeout: int = 300,
77
+ ) -> InstallResult:
78
+ try:
79
+ artifact = resolve_sdk_artifact(cwd, sdk_artifact)
80
+ except ValueError as exc:
81
+ return InstallResult(ok=False, command="", error=str(exc))
82
+ target = install_target(providers, artifact)
83
+ argv = _install_argv(package_manager, target)
84
+ command = shlex.join(
85
+ argv if argv[0] != sys.executable else ["python", "-m", "pip", "install", target]
86
+ )
87
+
88
+ if dry_run:
89
+ return InstallResult(ok=True, command=command)
90
+
91
+ try:
92
+ proc = subprocess.run( # noqa: S603 - argv is a fixed list, no shell
93
+ argv,
94
+ cwd=cwd,
95
+ capture_output=True,
96
+ text=True,
97
+ timeout=timeout,
98
+ check=False,
99
+ )
100
+ except FileNotFoundError:
101
+ return InstallResult(
102
+ ok=False,
103
+ command=command,
104
+ error=f"{package_manager} not found on PATH",
105
+ )
106
+ except subprocess.TimeoutExpired:
107
+ return InstallResult(ok=False, command=command, error="install timed out")
108
+
109
+ if proc.returncode == 0:
110
+ return InstallResult(ok=True, command=command)
111
+
112
+ detail = (proc.stderr or proc.stdout or "").strip()
113
+ return InstallResult(
114
+ ok=False,
115
+ command=command,
116
+ error=f"exited with code {proc.returncode}{f': {detail[:200]}' if detail else ''}",
117
+ )
118
+
119
+
120
+ def is_logify360_declared(cwd: str) -> bool:
121
+ """Best-effort check for an existing ``logify360`` dependency in a manifest."""
122
+ for manifest in ("pyproject.toml", "requirements.txt", "Pipfile"):
123
+ path = os.path.join(cwd, manifest)
124
+ if not os.path.isfile(path):
125
+ continue
126
+ try:
127
+ with open(path, encoding="utf-8") as fh:
128
+ text = fh.read()
129
+ except OSError:
130
+ continue
131
+ # Match the package name as a token, not a substring of another word.
132
+ if re.search(r"(?<![\w-])logify360(?![\w-])", text):
133
+ return True
134
+ return False
135
+
136
+
137
+ __all__ = [
138
+ "PROVIDER_EXTRAS",
139
+ "InstallResult",
140
+ "install_logify360",
141
+ "install_target",
142
+ "is_logify360_declared",
143
+ ]
@@ -0,0 +1,83 @@
1
+ """Validate a Logify360 key against the API and introspect its providers.
2
+
3
+ Hits ``GET /api/v1/me/key-info`` exactly like ``packages/cli`` does. The
4
+ endpoint authenticates with the ``sl_live_*`` key in the ``Authorization``
5
+ header (no Supabase session) and returns which provider keys are registered
6
+ under that Logify360 key, so the wizard knows which integrations to patch.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import List, Optional
13
+
14
+ import httpx
15
+
16
+ from .._base_url import _api_key_problem
17
+
18
+
19
+ class KeyInfoError(Exception):
20
+ """A user-actionable failure validating the key (network or API)."""
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class KeyInfo:
25
+ project_id: Optional[str]
26
+ project_name: Optional[str]
27
+ providers: List[str] = field(default_factory=list)
28
+ scope: str = "full"
29
+
30
+
31
+ def fetch_key_info(api_key: str, api_base: str, *, timeout: float = 15.0) -> KeyInfo:
32
+ # The key goes into a header, and the error httpx raises for an illegal header value
33
+ # quotes it. Refuse a malformed key here, before any request, whoever the caller is.
34
+ problem = _api_key_problem(api_key, "The key")
35
+ if problem:
36
+ raise KeyInfoError(f"{problem}. Copy the key again from the dashboard.")
37
+
38
+ url = f"{api_base.rstrip('/')}/api/v1/me/key-info"
39
+ try:
40
+ response = httpx.get(
41
+ url,
42
+ headers={"Authorization": f"Bearer {api_key}"},
43
+ timeout=timeout,
44
+ )
45
+ except httpx.HTTPError as exc:
46
+ # The exception CLASS, never its text: for a bad header that text is the header,
47
+ # and the header is the key. The class says what happened (ConnectError,
48
+ # ReadTimeout, ...), the base URL says where.
49
+ raise KeyInfoError(
50
+ f"Network error contacting {api_base}. Check your connection. ({type(exc).__name__})"
51
+ ) from exc
52
+
53
+ if response.status_code == 401:
54
+ raise KeyInfoError(
55
+ "Logify360 rejected this key (401). Re-copy it from the dashboard."
56
+ )
57
+ if response.status_code >= 400:
58
+ raise KeyInfoError(
59
+ f"Logify360 returned {response.status_code} from /me/key-info. Try again in a moment."
60
+ )
61
+
62
+ try:
63
+ payload = response.json()
64
+ except ValueError as exc:
65
+ raise KeyInfoError("Unexpected (non-JSON) response from /me/key-info.") from exc
66
+
67
+ data = payload.get("data") if isinstance(payload, dict) else None
68
+ if not isinstance(data, dict):
69
+ raise KeyInfoError("Unexpected response shape from /me/key-info.")
70
+
71
+ providers = data.get("providers") or []
72
+ if not isinstance(providers, list):
73
+ providers = []
74
+
75
+ return KeyInfo(
76
+ project_id=data.get("projectId"),
77
+ project_name=data.get("projectName"),
78
+ providers=[str(p) for p in providers],
79
+ scope=str(data.get("scope") or "full"),
80
+ )
81
+
82
+
83
+ __all__ = ["KeyInfo", "KeyInfoError", "fetch_key_info"]