agentops-accelerator 0.3.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.
- agentops/__init__.py +10 -0
- agentops/__main__.py +6 -0
- agentops/agent/__init__.py +12 -0
- agentops/agent/_legacy_ids.py +92 -0
- agentops/agent/analyzer.py +207 -0
- agentops/agent/checks/__init__.py +1 -0
- agentops/agent/checks/catalog.py +880 -0
- agentops/agent/checks/errors.py +279 -0
- agentops/agent/checks/foundry_config.py +75 -0
- agentops/agent/checks/latency.py +84 -0
- agentops/agent/checks/opex.py +157 -0
- agentops/agent/checks/opex_workspace.py +874 -0
- agentops/agent/checks/posture.py +36 -0
- agentops/agent/checks/posture_rules/__init__.py +53 -0
- agentops/agent/checks/posture_rules/content_filter.py +59 -0
- agentops/agent/checks/posture_rules/diagnostics.py +74 -0
- agentops/agent/checks/posture_rules/local_auth.py +55 -0
- agentops/agent/checks/posture_rules/managed_identity.py +59 -0
- agentops/agent/checks/posture_rules/network.py +68 -0
- agentops/agent/checks/regression.py +78 -0
- agentops/agent/checks/release_readiness.py +182 -0
- agentops/agent/checks/safety.py +247 -0
- agentops/agent/checks/spec_conformance.py +375 -0
- agentops/agent/cockpit.py +5159 -0
- agentops/agent/config.py +240 -0
- agentops/agent/findings.py +113 -0
- agentops/agent/history.py +142 -0
- agentops/agent/knowledge/__init__.py +182 -0
- agentops/agent/knowledge/waf-checklist.csv +39 -0
- agentops/agent/llm_assist/__init__.py +16 -0
- agentops/agent/llm_assist/_base.py +124 -0
- agentops/agent/llm_assist/_bundle_rule.py +154 -0
- agentops/agent/llm_assist/_client.py +347 -0
- agentops/agent/llm_assist/_dataset_rules.py +191 -0
- agentops/agent/llm_assist/_engine.py +106 -0
- agentops/agent/llm_assist/_prompt_rules.py +291 -0
- agentops/agent/llm_assist/_spec_rules.py +235 -0
- agentops/agent/production_telemetry.py +430 -0
- agentops/agent/report.py +207 -0
- agentops/agent/server/__init__.py +1 -0
- agentops/agent/server/app.py +84 -0
- agentops/agent/server/auth.py +94 -0
- agentops/agent/server/chat.py +44 -0
- agentops/agent/server/protocol.py +72 -0
- agentops/agent/sources/__init__.py +1 -0
- agentops/agent/sources/azure_monitor.py +523 -0
- agentops/agent/sources/azure_resources.py +602 -0
- agentops/agent/sources/foundry_control.py +174 -0
- agentops/agent/sources/results_history.py +494 -0
- agentops/agent/sources/spec_detectors/__init__.py +42 -0
- agentops/agent/sources/spec_detectors/_base.py +58 -0
- agentops/agent/sources/spec_detectors/agents_md.py +75 -0
- agentops/agent/sources/spec_detectors/spec_kit.py +172 -0
- agentops/agent/time_range.py +117 -0
- agentops/cli/__init__.py +1 -0
- agentops/cli/app.py +4823 -0
- agentops/core/__init__.py +1 -0
- agentops/core/agentops_config.py +592 -0
- agentops/core/config_loader.py +22 -0
- agentops/core/evaluators.py +480 -0
- agentops/core/release_evidence.py +56 -0
- agentops/core/results.py +117 -0
- agentops/mcp/__init__.py +10 -0
- agentops/mcp/server.py +232 -0
- agentops/pipeline/__init__.py +8 -0
- agentops/pipeline/cloud_results.py +189 -0
- agentops/pipeline/cloud_runner.py +901 -0
- agentops/pipeline/comparison.py +108 -0
- agentops/pipeline/diagnostics.py +51 -0
- agentops/pipeline/invocations.py +535 -0
- agentops/pipeline/official_eval.py +414 -0
- agentops/pipeline/orchestrator.py +775 -0
- agentops/pipeline/prompt_deploy.py +377 -0
- agentops/pipeline/publisher.py +121 -0
- agentops/pipeline/reporter.py +202 -0
- agentops/pipeline/runtime.py +409 -0
- agentops/pipeline/thresholds.py +84 -0
- agentops/services/__init__.py +1 -0
- agentops/services/cicd.py +720 -0
- agentops/services/eval_analysis.py +848 -0
- agentops/services/evidence_pack.py +757 -0
- agentops/services/initializer.py +86 -0
- agentops/services/preflight.py +470 -0
- agentops/services/setup_wizard.py +709 -0
- agentops/services/skills.py +643 -0
- agentops/services/trace_promotion.py +300 -0
- agentops/services/workflow_analysis.py +1129 -0
- agentops/templates/.gitignore +15 -0
- agentops/templates/__init__.py +1 -0
- agentops/templates/agent-server/Dockerfile +23 -0
- agentops/templates/agent-server/README.md +61 -0
- agentops/templates/agent-server/main.bicep +94 -0
- agentops/templates/agent.yaml +87 -0
- agentops/templates/agentops.yaml +58 -0
- agentops/templates/foundry.svg +71 -0
- agentops/templates/icon.png +0 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-dev-azd.yml +118 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-dev.yml +73 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-prod-azd.yml +141 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-prod.yml +94 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-prompt-agent.yml +167 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-qa-azd.yml +118 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-qa.yml +68 -0
- agentops/templates/pipelines/azuredevops/agentops-pr-prompt-agent.yml +210 -0
- agentops/templates/pipelines/azuredevops/agentops-pr.yml +155 -0
- agentops/templates/pipelines/azuredevops/agentops-watchdog.yml +106 -0
- agentops/templates/project.gitignore +36 -0
- agentops/templates/sample-traces.jsonl +3 -0
- agentops/templates/skills/agentops-agent/SKILL.md +137 -0
- agentops/templates/skills/agentops-config/SKILL.md +113 -0
- agentops/templates/skills/agentops-dataset/SKILL.md +84 -0
- agentops/templates/skills/agentops-eval/SKILL.md +189 -0
- agentops/templates/skills/agentops-report/SKILL.md +71 -0
- agentops/templates/skills/agentops-workflow/SKILL.md +471 -0
- agentops/templates/smoke.jsonl +3 -0
- agentops/templates/waf-checklist.README.md +84 -0
- agentops/templates/waf-checklist.csv +22 -0
- agentops/templates/workflows/agentops-deploy-dev-azd.yml +166 -0
- agentops/templates/workflows/agentops-deploy-dev.yml +187 -0
- agentops/templates/workflows/agentops-deploy-prod-azd.yml +183 -0
- agentops/templates/workflows/agentops-deploy-prod.yml +171 -0
- agentops/templates/workflows/agentops-deploy-prompt-agent.yml +197 -0
- agentops/templates/workflows/agentops-deploy-qa-azd.yml +156 -0
- agentops/templates/workflows/agentops-deploy-qa.yml +145 -0
- agentops/templates/workflows/agentops-pr-prompt-agent.yml +210 -0
- agentops/templates/workflows/agentops-pr.yml +148 -0
- agentops/templates/workflows/agentops-watchdog.yml +122 -0
- agentops/utils/__init__.py +1 -0
- agentops/utils/azd_env.py +435 -0
- agentops/utils/azure_endpoints.py +62 -0
- agentops/utils/colors.py +47 -0
- agentops/utils/dotenv_loader.py +105 -0
- agentops/utils/foundry_discovery.py +229 -0
- agentops/utils/logging.py +59 -0
- agentops/utils/telemetry.py +554 -0
- agentops/utils/yaml.py +36 -0
- agentops_accelerator-0.3.0.dist-info/METADATA +278 -0
- agentops_accelerator-0.3.0.dist-info/RECORD +142 -0
- agentops_accelerator-0.3.0.dist-info/WHEEL +5 -0
- agentops_accelerator-0.3.0.dist-info/entry_points.txt +2 -0
- agentops_accelerator-0.3.0.dist-info/licenses/LICENSE +21 -0
- agentops_accelerator-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""azd environment integration for AgentOps.
|
|
2
|
+
|
|
3
|
+
AgentOps can share wizard-collected configuration with ``azd`` when a workspace
|
|
4
|
+
already has, or explicitly requests, an azd environment. Fresh AgentOps-only
|
|
5
|
+
workspaces use ``.agentops/.env`` instead. This module is the single source of
|
|
6
|
+
truth for:
|
|
7
|
+
|
|
8
|
+
* **Discovery** — find the active ``azd`` environment using the same rules
|
|
9
|
+
Doctor already uses (``AZURE_ENV_NAME`` env var first, then
|
|
10
|
+
``.azure/config.json``'s ``defaultEnvironment``, then the single
|
|
11
|
+
environment folder when only one exists).
|
|
12
|
+
* **Reading** — parse the ``.env`` file with the same lenient
|
|
13
|
+
``KEY=VALUE`` rules as our :mod:`agentops.utils.dotenv_loader`.
|
|
14
|
+
* **Writing** — line-preserving updates that keep comments, ordering, and
|
|
15
|
+
untouched keys intact. Newly added keys go to the end of the file.
|
|
16
|
+
* **Bootstrap** — when the caller deliberately opts into azd, create the
|
|
17
|
+
minimal layout (``.azure/<name>/.env`` + ``.azure/config.json`` +
|
|
18
|
+
``.azure/.gitignore``) so AgentOps never writes secrets to a git-tracked
|
|
19
|
+
file.
|
|
20
|
+
|
|
21
|
+
Notes for reviewers:
|
|
22
|
+
|
|
23
|
+
* **No new dependencies.** The parser/writer here use the same micro-format
|
|
24
|
+
rules as ``dotenv_loader.parse_env_file``.
|
|
25
|
+
* **azd-compatible, not azd-required.** If ``azd`` is installed we can call its
|
|
26
|
+
CLI to mutate envs; if not, we fall back to direct line-preserving edits.
|
|
27
|
+
* **Secret safety.** Whenever we create the ``.azure/`` directory we also
|
|
28
|
+
drop a ``.gitignore`` that excludes ``*/.env`` so the wizard cannot leak
|
|
29
|
+
``APPLICATIONINSIGHTS_CONNECTION_STRING`` into source control even if
|
|
30
|
+
the user has not run ``azd init`` themselves.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import shutil
|
|
38
|
+
import subprocess
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Dict, List, Optional, Tuple
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Discovery
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class AzdEnvLocation:
|
|
51
|
+
"""Where (and how) an azd environment was discovered."""
|
|
52
|
+
|
|
53
|
+
name: Optional[str]
|
|
54
|
+
env_path: Optional[Path]
|
|
55
|
+
azure_dir: Path
|
|
56
|
+
status: str # "ok" | "missing_env_file" | "ambiguous" | "not_found"
|
|
57
|
+
reason: Optional[str] = None
|
|
58
|
+
candidates: List[str] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def found(self) -> bool:
|
|
62
|
+
return self.status == "ok" and self.env_path is not None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def discover_azd_env(workspace: Path) -> AzdEnvLocation:
|
|
66
|
+
"""Resolve which azd environment AgentOps should target.
|
|
67
|
+
|
|
68
|
+
Order of precedence:
|
|
69
|
+
|
|
70
|
+
1. ``AZURE_ENV_NAME`` from the process environment.
|
|
71
|
+
2. ``defaultEnvironment`` from ``.azure/config.json``.
|
|
72
|
+
3. The only sub-directory of ``.azure/`` that contains a ``.env`` file
|
|
73
|
+
(when exactly one exists).
|
|
74
|
+
|
|
75
|
+
Returns an :class:`AzdEnvLocation` describing what was found. The
|
|
76
|
+
caller is expected to look at ``found`` / ``status`` to decide
|
|
77
|
+
whether to prompt for bootstrap.
|
|
78
|
+
"""
|
|
79
|
+
workspace = workspace.resolve()
|
|
80
|
+
azure_dir = workspace / ".azure"
|
|
81
|
+
|
|
82
|
+
if not azure_dir.is_dir():
|
|
83
|
+
return AzdEnvLocation(
|
|
84
|
+
name=None,
|
|
85
|
+
env_path=None,
|
|
86
|
+
azure_dir=azure_dir,
|
|
87
|
+
status="not_found",
|
|
88
|
+
reason="workspace has no .azure directory",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
env_name: Optional[str] = os.environ.get("AZURE_ENV_NAME") or None
|
|
92
|
+
config_path = azure_dir / "config.json"
|
|
93
|
+
if not env_name and config_path.is_file():
|
|
94
|
+
try:
|
|
95
|
+
raw = json.loads(config_path.read_text(encoding="utf-8"))
|
|
96
|
+
if isinstance(raw, dict):
|
|
97
|
+
candidate = raw.get("defaultEnvironment")
|
|
98
|
+
if isinstance(candidate, str) and candidate.strip():
|
|
99
|
+
env_name = candidate.strip()
|
|
100
|
+
except (json.JSONDecodeError, OSError):
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
if not env_name:
|
|
104
|
+
candidates = sorted(
|
|
105
|
+
p.name for p in azure_dir.iterdir()
|
|
106
|
+
if p.is_dir() and (p / ".env").is_file()
|
|
107
|
+
)
|
|
108
|
+
if len(candidates) == 1:
|
|
109
|
+
env_name = candidates[0]
|
|
110
|
+
elif candidates:
|
|
111
|
+
return AzdEnvLocation(
|
|
112
|
+
name=None,
|
|
113
|
+
env_path=None,
|
|
114
|
+
azure_dir=azure_dir,
|
|
115
|
+
status="ambiguous",
|
|
116
|
+
reason=(
|
|
117
|
+
"multiple azd environments found; set AZURE_ENV_NAME "
|
|
118
|
+
"or .azure/config.json defaultEnvironment"
|
|
119
|
+
),
|
|
120
|
+
candidates=candidates,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if not env_name:
|
|
124
|
+
return AzdEnvLocation(
|
|
125
|
+
name=None,
|
|
126
|
+
env_path=None,
|
|
127
|
+
azure_dir=azure_dir,
|
|
128
|
+
status="not_found",
|
|
129
|
+
reason="no azd environment selected",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
env_path = azure_dir / env_name / ".env"
|
|
133
|
+
if not env_path.is_file():
|
|
134
|
+
return AzdEnvLocation(
|
|
135
|
+
name=env_name,
|
|
136
|
+
env_path=env_path,
|
|
137
|
+
azure_dir=azure_dir,
|
|
138
|
+
status="missing_env_file",
|
|
139
|
+
reason=f"{env_path} does not exist",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return AzdEnvLocation(
|
|
143
|
+
name=env_name,
|
|
144
|
+
env_path=env_path,
|
|
145
|
+
azure_dir=azure_dir,
|
|
146
|
+
status="ok",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# Reading
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def parse_env_file(path: Path) -> Dict[str, str]:
|
|
156
|
+
"""Parse a ``KEY=VALUE`` file. Returns ``{}`` on any error.
|
|
157
|
+
|
|
158
|
+
Matches the lenient rules of ``dotenv_loader.parse_env_file``:
|
|
159
|
+
skips blank lines and ``#`` comments, tolerates ``export`` prefixes,
|
|
160
|
+
strips matching single or double quotes from values.
|
|
161
|
+
"""
|
|
162
|
+
if not path.exists() or not path.is_file():
|
|
163
|
+
return {}
|
|
164
|
+
try:
|
|
165
|
+
text = path.read_text(encoding="utf-8")
|
|
166
|
+
except OSError:
|
|
167
|
+
return {}
|
|
168
|
+
out: Dict[str, str] = {}
|
|
169
|
+
for raw_line in text.splitlines():
|
|
170
|
+
line = raw_line.strip()
|
|
171
|
+
if not line or line.startswith("#"):
|
|
172
|
+
continue
|
|
173
|
+
if line.startswith("export "):
|
|
174
|
+
line = line[len("export "):].lstrip()
|
|
175
|
+
if "=" not in line:
|
|
176
|
+
continue
|
|
177
|
+
key, _, value = line.partition("=")
|
|
178
|
+
key = key.strip()
|
|
179
|
+
if not key or not key.replace("_", "").isalnum():
|
|
180
|
+
continue
|
|
181
|
+
value = value.strip()
|
|
182
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
183
|
+
value = value[1:-1]
|
|
184
|
+
out[key] = value
|
|
185
|
+
return out
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# Bootstrap
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
_GITIGNORE_LINES = (
|
|
194
|
+
"# Created by AgentOps to keep azd environment secrets out of git.",
|
|
195
|
+
"# azd creates the same rule by default; this file is harmless when",
|
|
196
|
+
"# both tools are used together.",
|
|
197
|
+
"*/.env",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def ensure_azure_gitignore(azure_dir: Path) -> bool:
|
|
202
|
+
"""Write ``.azure/.gitignore`` so per-env ``.env`` files are excluded.
|
|
203
|
+
|
|
204
|
+
Returns ``True`` when the file was created or augmented. Idempotent —
|
|
205
|
+
if a usable ignore pattern is already present, returns ``False``.
|
|
206
|
+
"""
|
|
207
|
+
azure_dir.mkdir(parents=True, exist_ok=True)
|
|
208
|
+
gitignore = azure_dir / ".gitignore"
|
|
209
|
+
needed = "*/.env"
|
|
210
|
+
if gitignore.is_file():
|
|
211
|
+
try:
|
|
212
|
+
existing = gitignore.read_text(encoding="utf-8")
|
|
213
|
+
except OSError:
|
|
214
|
+
existing = ""
|
|
215
|
+
stripped = {
|
|
216
|
+
ln.strip() for ln in existing.splitlines() if ln.strip() and not ln.startswith("#")
|
|
217
|
+
}
|
|
218
|
+
if needed in stripped or ".env" in stripped or "**/.env" in stripped:
|
|
219
|
+
return False
|
|
220
|
+
with gitignore.open("a", encoding="utf-8") as fh:
|
|
221
|
+
if not existing.endswith("\n"):
|
|
222
|
+
fh.write("\n")
|
|
223
|
+
fh.write(needed + "\n")
|
|
224
|
+
return True
|
|
225
|
+
gitignore.write_text("\n".join(_GITIGNORE_LINES) + "\n", encoding="utf-8")
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def ensure_azd_env(
|
|
230
|
+
workspace: Path,
|
|
231
|
+
env_name: str,
|
|
232
|
+
*,
|
|
233
|
+
set_default: bool = True,
|
|
234
|
+
) -> AzdEnvLocation:
|
|
235
|
+
"""Create ``.azure/<env_name>/.env`` if missing and protect it from git.
|
|
236
|
+
|
|
237
|
+
Idempotent. If the environment already exists, returns its current
|
|
238
|
+
location. Otherwise it:
|
|
239
|
+
|
|
240
|
+
1. Creates ``.azure/`` and ``.azure/<env_name>/``.
|
|
241
|
+
2. Writes ``.azure/.gitignore`` so ``*/.env`` is excluded from git.
|
|
242
|
+
3. Creates an empty ``.env`` with a single-line header.
|
|
243
|
+
4. When ``set_default`` is ``True`` and ``.azure/config.json`` is
|
|
244
|
+
absent or missing a ``defaultEnvironment``, writes that field.
|
|
245
|
+
|
|
246
|
+
Returns the resulting :class:`AzdEnvLocation`.
|
|
247
|
+
"""
|
|
248
|
+
workspace = workspace.resolve()
|
|
249
|
+
azure_dir = workspace / ".azure"
|
|
250
|
+
env_dir = azure_dir / env_name
|
|
251
|
+
env_path = env_dir / ".env"
|
|
252
|
+
|
|
253
|
+
env_dir.mkdir(parents=True, exist_ok=True)
|
|
254
|
+
ensure_azure_gitignore(azure_dir)
|
|
255
|
+
|
|
256
|
+
if not env_path.exists():
|
|
257
|
+
env_path.write_text(
|
|
258
|
+
"# Managed alongside azd. Run `agentops init` or `azd env set`\n"
|
|
259
|
+
"# to update values here. Secrets in this file are git-ignored\n"
|
|
260
|
+
"# via .azure/.gitignore.\n",
|
|
261
|
+
encoding="utf-8",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if set_default:
|
|
265
|
+
config_path = azure_dir / "config.json"
|
|
266
|
+
config: Dict[str, object] = {}
|
|
267
|
+
if config_path.is_file():
|
|
268
|
+
try:
|
|
269
|
+
data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
270
|
+
if isinstance(data, dict):
|
|
271
|
+
config = data
|
|
272
|
+
except (json.JSONDecodeError, OSError):
|
|
273
|
+
config = {}
|
|
274
|
+
if not isinstance(config.get("defaultEnvironment"), str) or not config["defaultEnvironment"]:
|
|
275
|
+
config.setdefault("version", 1)
|
|
276
|
+
config["defaultEnvironment"] = env_name
|
|
277
|
+
config_path.write_text(
|
|
278
|
+
json.dumps(config, indent=2) + "\n",
|
|
279
|
+
encoding="utf-8",
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
return AzdEnvLocation(
|
|
283
|
+
name=env_name,
|
|
284
|
+
env_path=env_path,
|
|
285
|
+
azure_dir=azure_dir,
|
|
286
|
+
status="ok",
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def set_default_azd_env(workspace: Path, env_name: str) -> Path:
|
|
291
|
+
"""Force ``.azure/config.json``'s ``defaultEnvironment`` to ``env_name``.
|
|
292
|
+
|
|
293
|
+
Unlike :func:`ensure_azd_env`, this *always* writes the field even if
|
|
294
|
+
a different default was already configured. Use it when the user has
|
|
295
|
+
explicitly named an azd environment (for example, ``--azd-env qa``)
|
|
296
|
+
and that intent should override any previous default.
|
|
297
|
+
|
|
298
|
+
Returns the path to ``.azure/config.json``.
|
|
299
|
+
"""
|
|
300
|
+
workspace = workspace.resolve()
|
|
301
|
+
azure_dir = workspace / ".azure"
|
|
302
|
+
azure_dir.mkdir(parents=True, exist_ok=True)
|
|
303
|
+
config_path = azure_dir / "config.json"
|
|
304
|
+
config: Dict[str, object] = {}
|
|
305
|
+
if config_path.is_file():
|
|
306
|
+
try:
|
|
307
|
+
data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
308
|
+
if isinstance(data, dict):
|
|
309
|
+
config = data
|
|
310
|
+
except (json.JSONDecodeError, OSError):
|
|
311
|
+
config = {}
|
|
312
|
+
config.setdefault("version", 1)
|
|
313
|
+
config["defaultEnvironment"] = env_name
|
|
314
|
+
config_path.write_text(
|
|
315
|
+
json.dumps(config, indent=2) + "\n",
|
|
316
|
+
encoding="utf-8",
|
|
317
|
+
)
|
|
318
|
+
return config_path
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
# Writing — line-preserving
|
|
323
|
+
# ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def set_env_values(env_path: Path, updates: Dict[str, str]) -> List[str]:
|
|
327
|
+
"""Update the ``.env`` file with ``updates`` (line-preserving).
|
|
328
|
+
|
|
329
|
+
For each key in ``updates``:
|
|
330
|
+
|
|
331
|
+
* If the file already contains a non-commented assignment for that
|
|
332
|
+
key, rewrite the matching line in place — preserving comments and
|
|
333
|
+
ordering for every other line.
|
|
334
|
+
* Otherwise, append ``KEY=VALUE`` to the end of the file.
|
|
335
|
+
|
|
336
|
+
Returns the list of keys that were actually written (i.e. the value
|
|
337
|
+
changed compared to what was already on disk).
|
|
338
|
+
"""
|
|
339
|
+
env_path.parent.mkdir(parents=True, exist_ok=True)
|
|
340
|
+
|
|
341
|
+
if env_path.exists():
|
|
342
|
+
try:
|
|
343
|
+
text = env_path.read_text(encoding="utf-8")
|
|
344
|
+
except OSError:
|
|
345
|
+
text = ""
|
|
346
|
+
else:
|
|
347
|
+
text = ""
|
|
348
|
+
lines = text.splitlines()
|
|
349
|
+
trailing_newline = text.endswith("\n") if text else True
|
|
350
|
+
|
|
351
|
+
current = parse_env_file(env_path) if env_path.exists() else {}
|
|
352
|
+
|
|
353
|
+
changed: List[str] = []
|
|
354
|
+
remaining_updates = dict(updates)
|
|
355
|
+
|
|
356
|
+
for idx, raw_line in enumerate(lines):
|
|
357
|
+
stripped = raw_line.strip()
|
|
358
|
+
if not stripped or stripped.startswith("#"):
|
|
359
|
+
continue
|
|
360
|
+
key_part = stripped[len("export "):] if stripped.startswith("export ") else stripped
|
|
361
|
+
if "=" not in key_part:
|
|
362
|
+
continue
|
|
363
|
+
key = key_part.split("=", 1)[0].strip()
|
|
364
|
+
if key in remaining_updates:
|
|
365
|
+
new_value = remaining_updates.pop(key)
|
|
366
|
+
if current.get(key) != new_value:
|
|
367
|
+
lines[idx] = f"{key}={_format_value(new_value)}"
|
|
368
|
+
changed.append(key)
|
|
369
|
+
# If unchanged we still want to skip the append-fallback below,
|
|
370
|
+
# so we leave the line untouched and remove the key.
|
|
371
|
+
|
|
372
|
+
for key, new_value in remaining_updates.items():
|
|
373
|
+
# Key was not present in the file — append it.
|
|
374
|
+
if current.get(key) == new_value:
|
|
375
|
+
# Nothing to do; ``current`` reflects what parse_env_file
|
|
376
|
+
# would see, so this means the value really matches.
|
|
377
|
+
continue
|
|
378
|
+
lines.append(f"{key}={_format_value(new_value)}")
|
|
379
|
+
changed.append(key)
|
|
380
|
+
|
|
381
|
+
if changed or not env_path.exists():
|
|
382
|
+
new_text = "\n".join(lines)
|
|
383
|
+
if trailing_newline or not new_text.endswith("\n"):
|
|
384
|
+
new_text += "\n"
|
|
385
|
+
env_path.write_text(new_text, encoding="utf-8")
|
|
386
|
+
|
|
387
|
+
return changed
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _format_value(value: str) -> str:
|
|
391
|
+
if value == "":
|
|
392
|
+
return ""
|
|
393
|
+
if any(ch in value for ch in (" ", "#", "=", "\t")):
|
|
394
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
395
|
+
return value
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# ---------------------------------------------------------------------------
|
|
399
|
+
# azd CLI integration (optional preferred path)
|
|
400
|
+
# ---------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def azd_cli_available() -> bool:
|
|
404
|
+
"""Return ``True`` when the ``azd`` CLI is on ``PATH``.
|
|
405
|
+
|
|
406
|
+
On Windows the binary may be ``azd.cmd``; ``shutil.which`` handles
|
|
407
|
+
both via ``PATHEXT``.
|
|
408
|
+
"""
|
|
409
|
+
return shutil.which("azd") is not None
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def azd_env_set(workspace: Path, env_name: str, key: str, value: str) -> Tuple[bool, str]:
|
|
413
|
+
"""Call ``azd env set KEY VALUE`` for the given workspace.
|
|
414
|
+
|
|
415
|
+
Returns ``(success, message)``. The wizard prefers this path when the
|
|
416
|
+
azd CLI is installed because it gives azd a chance to apply its own
|
|
417
|
+
formatting and triggers any update hooks. We fall back to direct file
|
|
418
|
+
edits via :func:`set_env_values` when azd is not available or fails.
|
|
419
|
+
"""
|
|
420
|
+
if not azd_cli_available():
|
|
421
|
+
return False, "azd CLI not found on PATH"
|
|
422
|
+
try:
|
|
423
|
+
result = subprocess.run( # noqa: S603,S607
|
|
424
|
+
["azd", "env", "set", key, value, "-e", env_name],
|
|
425
|
+
cwd=str(workspace),
|
|
426
|
+
capture_output=True,
|
|
427
|
+
text=True,
|
|
428
|
+
timeout=30,
|
|
429
|
+
check=False,
|
|
430
|
+
)
|
|
431
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
432
|
+
return False, f"azd env set failed to launch: {exc}"
|
|
433
|
+
if result.returncode != 0:
|
|
434
|
+
return False, (result.stderr or result.stdout or "azd env set failed").strip()
|
|
435
|
+
return True, ""
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Helpers for normalizing Azure OpenAI / AI Services endpoint URLs.
|
|
2
|
+
|
|
3
|
+
Users commonly copy the Azure OpenAI endpoint straight from the Foundry
|
|
4
|
+
portal, which displays it with the inference-path suffix appended
|
|
5
|
+
(``https://<resource>.openai.azure.com/openai/v1``). The
|
|
6
|
+
``azure-ai-evaluation`` SDK and the ``openai`` SDK both want the
|
|
7
|
+
*base* endpoint without that suffix, and would otherwise produce a
|
|
8
|
+
confusing ``404`` or ``ResourceNotFound`` error.
|
|
9
|
+
|
|
10
|
+
This module strips the well-known inference-path suffixes so the user
|
|
11
|
+
can paste whichever URL the portal showed and have AgentOps work
|
|
12
|
+
transparently.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
# Ordered from most- to least-specific so the longest match wins.
|
|
21
|
+
# The match is case-insensitive and tolerant of trailing slashes.
|
|
22
|
+
_KNOWN_SUFFIXES = (
|
|
23
|
+
"/openai/v1",
|
|
24
|
+
"/openai/deployments",
|
|
25
|
+
"/openai",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_SUFFIX_RE = re.compile(
|
|
30
|
+
r"(" + "|".join(re.escape(s) for s in _KNOWN_SUFFIXES) + r")/*\Z",
|
|
31
|
+
re.IGNORECASE,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def normalize_azure_openai_endpoint(value: Optional[str]) -> Optional[str]:
|
|
36
|
+
"""Return ``value`` with well-known inference-path suffixes removed.
|
|
37
|
+
|
|
38
|
+
Examples
|
|
39
|
+
--------
|
|
40
|
+
``https://x.openai.azure.com/openai/v1`` -> ``https://x.openai.azure.com``
|
|
41
|
+
``https://x.openai.azure.com/openai/`` -> ``https://x.openai.azure.com``
|
|
42
|
+
``https://x.openai.azure.com`` -> ``https://x.openai.azure.com``
|
|
43
|
+
``None`` / ``""`` -> returned unchanged
|
|
44
|
+
|
|
45
|
+
The helper is deliberately conservative:
|
|
46
|
+
|
|
47
|
+
* Only the exact suffixes in :data:`_KNOWN_SUFFIXES` are stripped.
|
|
48
|
+
* The scheme, host, port, and any earlier path segments are
|
|
49
|
+
preserved exactly.
|
|
50
|
+
* The result has no trailing slash so downstream SDKs build
|
|
51
|
+
consistent URLs.
|
|
52
|
+
"""
|
|
53
|
+
if value is None:
|
|
54
|
+
return None
|
|
55
|
+
stripped = value.strip()
|
|
56
|
+
if not stripped:
|
|
57
|
+
return stripped
|
|
58
|
+
# Drop the known suffix when it appears at the very end of the URL.
|
|
59
|
+
rewritten = _SUFFIX_RE.sub("", stripped)
|
|
60
|
+
# Trim any straggling trailing slash so callers building paths
|
|
61
|
+
# never get a doubled ``//``.
|
|
62
|
+
return rewritten.rstrip("/")
|
agentops/utils/colors.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Tiny ANSI color helpers for CLI progress output.
|
|
2
|
+
|
|
3
|
+
Colors are automatically disabled when stdout is not a TTY, when the
|
|
4
|
+
``NO_COLOR`` environment variable is set (https://no-color.org/), or when
|
|
5
|
+
``AGENTOPS_NO_COLOR`` is set. No emojis, no extended unicode - only plain
|
|
6
|
+
ASCII text wrapped in standard ANSI SGR escape codes that all modern
|
|
7
|
+
terminals (Windows Terminal, ConEmu, VS Code, macOS, Linux) understand.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
_RESET = "\x1b[0m"
|
|
16
|
+
_CODES = {
|
|
17
|
+
"dim": "\x1b[2m",
|
|
18
|
+
"bold": "\x1b[1m",
|
|
19
|
+
"red": "\x1b[31m",
|
|
20
|
+
"green": "\x1b[32m",
|
|
21
|
+
"yellow": "\x1b[33m",
|
|
22
|
+
"blue": "\x1b[34m",
|
|
23
|
+
"magenta": "\x1b[35m",
|
|
24
|
+
"cyan": "\x1b[36m",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _enabled() -> bool:
|
|
29
|
+
if os.environ.get("NO_COLOR"):
|
|
30
|
+
return False
|
|
31
|
+
if os.environ.get("AGENTOPS_NO_COLOR"):
|
|
32
|
+
return False
|
|
33
|
+
stream = sys.stdout
|
|
34
|
+
try:
|
|
35
|
+
return bool(stream.isatty())
|
|
36
|
+
except Exception: # noqa: BLE001
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def style(text: str, *names: str) -> str:
|
|
41
|
+
"""Wrap ``text`` in the given ANSI styles (e.g. ``"green"``, ``"bold"``)."""
|
|
42
|
+
if not _enabled() or not names:
|
|
43
|
+
return text
|
|
44
|
+
prefix = "".join(_CODES.get(name, "") for name in names)
|
|
45
|
+
if not prefix:
|
|
46
|
+
return text
|
|
47
|
+
return f"{prefix}{text}{_RESET}"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Lightweight ``.env`` loader for the AgentOps workspace.
|
|
2
|
+
|
|
3
|
+
AgentOps auto-loads the workspace env file at startup so contributors do not
|
|
4
|
+
have to ``export`` env vars in every shell session. New AgentOps-only
|
|
5
|
+
workspaces use ``.agentops/.env``; repositories that already have an active
|
|
6
|
+
``azd`` environment keep using ``.azure/<active-env>/.env``.
|
|
7
|
+
|
|
8
|
+
Lookup order (first file that contributes at least one new variable wins):
|
|
9
|
+
|
|
10
|
+
1. ``.azure/<active-env>/.env`` — when an azd environment is already active.
|
|
11
|
+
2. ``.agentops/.env`` — AgentOps-owned local env file.
|
|
12
|
+
3. ``./.env`` — project-root fallback for hand-managed setups.
|
|
13
|
+
|
|
14
|
+
Design choices:
|
|
15
|
+
|
|
16
|
+
* **No new dependency.** We parse the file ourselves — only
|
|
17
|
+
``KEY=VALUE`` lines, blank lines, and ``#`` comments. No shell escapes
|
|
18
|
+
or variable interpolation. This keeps the loader tiny and predictable.
|
|
19
|
+
* **Never override.** Variables already present in ``os.environ`` win.
|
|
20
|
+
The ``.env`` files are a fallback, not an override — that matches how
|
|
21
|
+
azd, dotenv, and direnv all behave.
|
|
22
|
+
* **Silent on failure.** Missing or malformed files do not crash
|
|
23
|
+
AgentOps; startup must remain fast and robust.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Iterable, Tuple
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _candidate_paths(workspace: Path) -> Iterable[Path]:
|
|
34
|
+
"""Return the ordered list of ``.env`` files AgentOps will look at."""
|
|
35
|
+
workspace = workspace.resolve()
|
|
36
|
+
# Prefer the active azd environment when one is configured.
|
|
37
|
+
try:
|
|
38
|
+
from agentops.utils.azd_env import discover_azd_env # noqa: PLC0415
|
|
39
|
+
|
|
40
|
+
location = discover_azd_env(workspace)
|
|
41
|
+
if location.found and location.env_path is not None:
|
|
42
|
+
yield location.env_path
|
|
43
|
+
except Exception: # noqa: BLE001
|
|
44
|
+
# Discovery is best-effort; never crash startup on a malformed
|
|
45
|
+
# ``.azure/`` layout.
|
|
46
|
+
pass
|
|
47
|
+
# Legacy fallback: pre-azd-refactor workspaces.
|
|
48
|
+
yield workspace / ".agentops" / ".env"
|
|
49
|
+
# Also support a project-root .env so users who already keep secrets
|
|
50
|
+
# there benefit from the same auto-load (without overwriting OS env).
|
|
51
|
+
yield workspace / ".env"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_env_file(path: Path) -> dict[str, str]:
|
|
55
|
+
"""Parse a ``KEY=VALUE`` file. Returns an empty dict on any error."""
|
|
56
|
+
if not path.exists() or not path.is_file():
|
|
57
|
+
return {}
|
|
58
|
+
try:
|
|
59
|
+
text = path.read_text(encoding="utf-8")
|
|
60
|
+
except OSError:
|
|
61
|
+
return {}
|
|
62
|
+
out: dict[str, str] = {}
|
|
63
|
+
for raw_line in text.splitlines():
|
|
64
|
+
line = raw_line.strip()
|
|
65
|
+
if not line or line.startswith("#"):
|
|
66
|
+
continue
|
|
67
|
+
if line.startswith("export "):
|
|
68
|
+
line = line[len("export ") :].lstrip()
|
|
69
|
+
if "=" not in line:
|
|
70
|
+
continue
|
|
71
|
+
key, _, value = line.partition("=")
|
|
72
|
+
key = key.strip()
|
|
73
|
+
if not key or not key.replace("_", "").isalnum():
|
|
74
|
+
continue
|
|
75
|
+
value = value.strip()
|
|
76
|
+
# Strip surrounding matching quotes (single or double).
|
|
77
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
78
|
+
value = value[1:-1]
|
|
79
|
+
out[key] = value
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_workspace_dotenv(workspace: Path | None = None) -> Tuple[Path, int] | None:
|
|
84
|
+
"""Load the active workspace ``.env`` file into ``os.environ``.
|
|
85
|
+
|
|
86
|
+
Tries the active azd environment file first, then AgentOps' local
|
|
87
|
+
``.agentops/.env``, then project-root ``.env``. Values already set in
|
|
88
|
+
the process environment win — this loader only *adds* missing keys.
|
|
89
|
+
Returns ``(path, count)`` for the file that contributed at least one new
|
|
90
|
+
variable, or ``None`` when nothing was loaded.
|
|
91
|
+
"""
|
|
92
|
+
base = (workspace or Path.cwd()).resolve()
|
|
93
|
+
for path in _candidate_paths(base):
|
|
94
|
+
parsed = parse_env_file(path)
|
|
95
|
+
if not parsed:
|
|
96
|
+
continue
|
|
97
|
+
added = 0
|
|
98
|
+
for key, value in parsed.items():
|
|
99
|
+
if key in os.environ:
|
|
100
|
+
continue
|
|
101
|
+
os.environ[key] = value
|
|
102
|
+
added += 1
|
|
103
|
+
if added:
|
|
104
|
+
return path, added
|
|
105
|
+
return None
|