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.
- logify360/__init__.py +99 -0
- logify360/_base_url.py +86 -0
- logify360/cli/__init__.py +33 -0
- logify360/cli/__main__.py +10 -0
- logify360/cli/code_patcher.py +449 -0
- logify360/cli/colors.py +65 -0
- logify360/cli/env_writer.py +100 -0
- logify360/cli/framework_detect.py +183 -0
- logify360/cli/installer.py +143 -0
- logify360/cli/key_info.py +83 -0
- logify360/cli/main.py +557 -0
- logify360/cli/prompts.py +146 -0
- logify360/client.py +205 -0
- logify360/evals.py +186 -0
- logify360/headers.py +95 -0
- logify360/integrations/__init__.py +60 -0
- logify360/integrations/_proxy_client.py +133 -0
- logify360/integrations/anthropic.py +171 -0
- logify360/integrations/gemini.py +180 -0
- logify360/integrations/langchain.py +450 -0
- logify360/integrations/llama_index.py +486 -0
- logify360/integrations/openai.py +158 -0
- logify360/middleware.py +262 -0
- logify360/observe.py +430 -0
- logify360/parsers.py +218 -0
- logify360/resilience.py +530 -0
- logify360/sampler.py +243 -0
- logify360/span.py +306 -0
- logify360/trace.py +222 -0
- logify360/transport.py +455 -0
- logify360/trust.py +310 -0
- logify360/types.py +66 -0
- logify360-1.0.0.dist-info/METADATA +548 -0
- logify360-1.0.0.dist-info/RECORD +36 -0
- logify360-1.0.0.dist-info/WHEEL +4 -0
- logify360-1.0.0.dist-info/entry_points.txt +2 -0
logify360/cli/colors.py
ADDED
|
@@ -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"]
|