qaas-python 0.0.1__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.
- qaas/adapters/__init__.py +19 -0
- qaas/adapters/tracker.py +1783 -0
- qaas/adapters/vcs.py +555 -0
- qaas/cli.py +1757 -0
- qaas/config.py +409 -0
- qaas/defaults/config/agents/api.yaml +18 -0
- qaas/defaults/config/agents/architect.yaml +21 -0
- qaas/defaults/config/agents/auditor.yaml +19 -0
- qaas/defaults/config/agents/browser.yaml +15 -0
- qaas/defaults/config/agents/dba.yaml +20 -0
- qaas/defaults/config/agents/fixer.yaml +55 -0
- qaas/defaults/config/agents/guide.yaml +23 -0
- qaas/defaults/config/agents/load.yaml +26 -0
- qaas/defaults/config/agents/mapper.yaml +19 -0
- qaas/defaults/config/agents/reporter.yaml +19 -0
- qaas/defaults/config/agents/reproducer.yaml +21 -0
- qaas/defaults/config/agents/reviewer.yaml +18 -0
- qaas/defaults/config/agents/socket.yaml +23 -0
- qaas/defaults/config/agents/triage.yaml +20 -0
- qaas/defaults/config/agents/verifier.yaml +20 -0
- qaas/defaults/config/system.yaml +64 -0
- qaas/discover.py +242 -0
- qaas/envelope.py +318 -0
- qaas/envfile.py +100 -0
- qaas/guardrails.py +589 -0
- qaas/mcp/__init__.py +0 -0
- qaas/mcp/context.py +78 -0
- qaas/mcp/contract_diff.py +1011 -0
- qaas/mcp/defect_memory.py +495 -0
- qaas/mcp/env_control.py +925 -0
- qaas/mcp/envelope_server.py +463 -0
- qaas/mcp/test_runner.py +842 -0
- qaas/mcp/tracker.py +420 -0
- qaas/mcp/vcs.py +501 -0
- qaas/paths.py +317 -0
- qaas/plugin/.claude-plugin/plugin.json +9 -0
- qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
- qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
- qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
- qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
- qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
- qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
- qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
- qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
- qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
- qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
- qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
- qaas/plugin/skills/flake-detection/SKILL.md +39 -0
- qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
- qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
- qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
- qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
- qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
- qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
- qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
- qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
- qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
- qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
- qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
- qaas/plugin/skills/routing-rules/SKILL.md +34 -0
- qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
- qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
- qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
- qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
- qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
- qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
- qaas/prompts/API.md +44 -0
- qaas/prompts/ARCHITECT.md +80 -0
- qaas/prompts/AUDITOR.md +62 -0
- qaas/prompts/BROWSER.md +46 -0
- qaas/prompts/DBA.md +59 -0
- qaas/prompts/FIXER.md +55 -0
- qaas/prompts/GUIDE.md +94 -0
- qaas/prompts/LOAD.md +109 -0
- qaas/prompts/MAPPER.md +46 -0
- qaas/prompts/REPORTER.md +61 -0
- qaas/prompts/REPRODUCER.md +43 -0
- qaas/prompts/REVIEWER.md +53 -0
- qaas/prompts/SOCKET.md +100 -0
- qaas/prompts/TRIAGE.md +45 -0
- qaas/prompts/VERIFIER.md +41 -0
- qaas/prompts/_shared.md +45 -0
- qaas/registry.py +496 -0
- qaas/router.py +581 -0
- qaas/runner.py +210 -0
- qaas/scorecard.py +448 -0
- qaas/sdk_compat.py +52 -0
- qaas/store.py +323 -0
- qaas/target.py +287 -0
- qaas/tasks.py +438 -0
- qaas/trace.py +342 -0
- qaas_python-0.0.1.dist-info/METADATA +429 -0
- qaas_python-0.0.1.dist-info/RECORD +96 -0
- qaas_python-0.0.1.dist-info/WHEEL +4 -0
- qaas_python-0.0.1.dist-info/entry_points.txt +2 -0
- qaas_python-0.0.1.dist-info/licenses/LICENSE +21 -0
qaas/envelope.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""The DefectEnvelope — the one contract every agent speaks (architecture §6).
|
|
2
|
+
|
|
3
|
+
Validate on write and on read; reject malformed envelopes rather than repairing
|
|
4
|
+
them. Agents never pass prose to each other, only these.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import re
|
|
11
|
+
import uuid
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from typing import Annotated, Any, Literal
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
17
|
+
|
|
18
|
+
ENVELOPE_VERSION = "1.0"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Domain(StrEnum):
|
|
22
|
+
ARCHITECTURE = "architecture"
|
|
23
|
+
DATABASE = "database"
|
|
24
|
+
API = "api"
|
|
25
|
+
WEBSOCKET = "websocket"
|
|
26
|
+
FRONTEND = "frontend"
|
|
27
|
+
UX = "ux"
|
|
28
|
+
SECURITY = "security"
|
|
29
|
+
PERFORMANCE = "performance"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DefectClass(StrEnum):
|
|
33
|
+
BUG = "bug"
|
|
34
|
+
REGRESSION = "regression"
|
|
35
|
+
UX_FRICTION = "ux-friction"
|
|
36
|
+
TECH_DEBT = "tech-debt"
|
|
37
|
+
VULNERABILITY = "vulnerability"
|
|
38
|
+
PERF_REGRESSION = "perf-regression"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Severity(StrEnum):
|
|
42
|
+
BLOCKER = "blocker"
|
|
43
|
+
CRITICAL = "critical"
|
|
44
|
+
MAJOR = "major"
|
|
45
|
+
MINOR = "minor"
|
|
46
|
+
TRIVIAL = "trivial"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def rank(self) -> int:
|
|
50
|
+
"""0 is most severe. Lets callers compare and sort without a lookup table."""
|
|
51
|
+
return _SEVERITY_ORDER.index(self)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_SEVERITY_ORDER = [
|
|
55
|
+
Severity.BLOCKER,
|
|
56
|
+
Severity.CRITICAL,
|
|
57
|
+
Severity.MAJOR,
|
|
58
|
+
Severity.MINOR,
|
|
59
|
+
Severity.TRIVIAL,
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ReproStatus(StrEnum):
|
|
64
|
+
REPRODUCED = "reproduced"
|
|
65
|
+
FLAKY = "flaky"
|
|
66
|
+
NOT_REPRODUCIBLE = "not_reproducible"
|
|
67
|
+
UNATTEMPTED = "unattempted"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class EvidenceType(StrEnum):
|
|
71
|
+
SCREENSHOT = "screenshot"
|
|
72
|
+
TRACE = "trace"
|
|
73
|
+
QUERY_PLAN = "query_plan"
|
|
74
|
+
LOG = "log"
|
|
75
|
+
FRAME_CAPTURE = "frame_capture"
|
|
76
|
+
TEST_OUTPUT = "test_output"
|
|
77
|
+
HAR = "har"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Strict(BaseModel):
|
|
81
|
+
"""Base for every envelope part: unknown fields are an error, not a shrug."""
|
|
82
|
+
|
|
83
|
+
model_config = ConfigDict(extra="forbid", use_enum_values=False)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Location(Strict):
|
|
87
|
+
service: str | None = None
|
|
88
|
+
paths: list[str] = Field(default_factory=list)
|
|
89
|
+
endpoint: str | None = None
|
|
90
|
+
ui_route: str | None = None
|
|
91
|
+
commit_sha: str | None = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Evidence(Strict):
|
|
95
|
+
type: EvidenceType
|
|
96
|
+
uri: str
|
|
97
|
+
note: str = ""
|
|
98
|
+
|
|
99
|
+
@field_validator("uri")
|
|
100
|
+
@classmethod
|
|
101
|
+
def _known_scheme(cls, v: str) -> str:
|
|
102
|
+
if not re.match(r"^(artifact|file|https?)://", v):
|
|
103
|
+
raise ValueError(
|
|
104
|
+
"evidence uri must start with artifact://, file://, http:// or https://"
|
|
105
|
+
)
|
|
106
|
+
return v
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Environment(Strict):
|
|
110
|
+
branch: str = ""
|
|
111
|
+
fixture: str = ""
|
|
112
|
+
flags: dict[str, Any] = Field(default_factory=dict)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Reproduction(Strict):
|
|
116
|
+
status: ReproStatus = ReproStatus.UNATTEMPTED
|
|
117
|
+
environment: Environment = Field(default_factory=Environment)
|
|
118
|
+
steps: list[str] = Field(default_factory=list)
|
|
119
|
+
failing_test: str | None = None
|
|
120
|
+
flake_rate: float = Field(default=0.0, ge=0.0, le=1.0)
|
|
121
|
+
verified_by: str | None = None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Impact(Strict):
|
|
125
|
+
user_facing: bool = False
|
|
126
|
+
affected_surface: str = ""
|
|
127
|
+
data_loss_risk: bool = False
|
|
128
|
+
security_relevant: bool = False
|
|
129
|
+
frequency_estimate: str = ""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class SuggestedOwner(Strict):
|
|
133
|
+
component: str | None = None
|
|
134
|
+
team: str | None = None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class Dedupe(Strict):
|
|
138
|
+
fingerprint: str | None = None
|
|
139
|
+
similar_to: list[str] = Field(default_factory=list)
|
|
140
|
+
occurrence_count: int = Field(default=1, ge=1)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class TrackerRef(Strict):
|
|
144
|
+
key: str | None = None
|
|
145
|
+
project: str | None = None
|
|
146
|
+
status: str | None = None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _utcnow() -> datetime:
|
|
150
|
+
return datetime.now(timezone.utc)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class DefectEnvelope(Strict):
|
|
154
|
+
"""A single finding, at any stage of its life from draft to filed."""
|
|
155
|
+
|
|
156
|
+
envelope_version: Literal["1.0"] = ENVELOPE_VERSION
|
|
157
|
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
158
|
+
run_id: str
|
|
159
|
+
discovered_by: str
|
|
160
|
+
discovered_at: datetime = Field(default_factory=_utcnow)
|
|
161
|
+
|
|
162
|
+
domain: Domain
|
|
163
|
+
defect_class: DefectClass = Field(alias="class")
|
|
164
|
+
|
|
165
|
+
title: Annotated[str, Field(min_length=1, max_length=90)]
|
|
166
|
+
summary: Annotated[str, Field(min_length=1)]
|
|
167
|
+
|
|
168
|
+
location: Location = Field(default_factory=Location)
|
|
169
|
+
evidence: list[Evidence] = Field(default_factory=list)
|
|
170
|
+
reproduction: Reproduction = Field(default_factory=Reproduction)
|
|
171
|
+
impact: Impact = Field(default_factory=Impact)
|
|
172
|
+
|
|
173
|
+
severity: Severity
|
|
174
|
+
confidence: float = Field(ge=0.0, le=1.0)
|
|
175
|
+
|
|
176
|
+
suggested_owner: SuggestedOwner = Field(default_factory=SuggestedOwner)
|
|
177
|
+
suggested_fix_area: str = ""
|
|
178
|
+
autonomy_eligible: bool = False
|
|
179
|
+
|
|
180
|
+
dedupe: Dedupe = Field(default_factory=Dedupe)
|
|
181
|
+
jira: TrackerRef = Field(default_factory=TrackerRef)
|
|
182
|
+
|
|
183
|
+
model_config = ConfigDict(
|
|
184
|
+
extra="forbid",
|
|
185
|
+
populate_by_name=True,
|
|
186
|
+
ser_json_timedelta="iso8601",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
@field_validator("title")
|
|
190
|
+
@classmethod
|
|
191
|
+
def _single_line_title(cls, v: str) -> str:
|
|
192
|
+
if "\n" in v:
|
|
193
|
+
raise ValueError("title must be a single line")
|
|
194
|
+
return v.strip()
|
|
195
|
+
|
|
196
|
+
@field_validator("discovered_by")
|
|
197
|
+
@classmethod
|
|
198
|
+
def _agent_name(cls, v: str) -> str:
|
|
199
|
+
if not re.fullmatch(r"[A-Z][A-Z_]{2,23}", v):
|
|
200
|
+
raise ValueError("discovered_by must be an agent name in SCREAMING_CASE")
|
|
201
|
+
return v
|
|
202
|
+
|
|
203
|
+
# -- evidence gate ----------------------------------------------------
|
|
204
|
+
# "Evidence or it did not happen" (§2). Enforced here rather than in a
|
|
205
|
+
# prompt so an agent cannot talk its way past it.
|
|
206
|
+
|
|
207
|
+
def has_evidence(self) -> bool:
|
|
208
|
+
# Truthiness, not `is not None`: `failing_test` is an optional string,
|
|
209
|
+
# and an agent filling an optional string with "" is an ordinary model
|
|
210
|
+
# habit. With the identity test, an envelope carrying no evidence at all
|
|
211
|
+
# and `failing_test=""` returned True and sailed through `is_fileable` —
|
|
212
|
+
# turning the one gate a model is not supposed to be able to argue past
|
|
213
|
+
# into one it could satisfy by typing nothing.
|
|
214
|
+
return bool(self.evidence) or bool(self.reproduction.failing_test)
|
|
215
|
+
|
|
216
|
+
def is_fileable(self, min_confidence: float = 0.6) -> tuple[bool, str]:
|
|
217
|
+
"""Whether TRIAGE may file this. Returns (ok, reason-if-not).
|
|
218
|
+
|
|
219
|
+
The confidence gate is §7; the evidence gate is §2. Anything that fails
|
|
220
|
+
goes to the human review queue instead of the tracker.
|
|
221
|
+
"""
|
|
222
|
+
if not self.has_evidence():
|
|
223
|
+
return False, "no evidence: needs an artifact or a failing test"
|
|
224
|
+
if self.confidence < min_confidence:
|
|
225
|
+
return False, (
|
|
226
|
+
f"confidence {self.confidence:.2f} below gate {min_confidence:.2f}"
|
|
227
|
+
)
|
|
228
|
+
if self.reproduction.status == ReproStatus.NOT_REPRODUCIBLE:
|
|
229
|
+
return False, "not reproducible"
|
|
230
|
+
return True, ""
|
|
231
|
+
|
|
232
|
+
# -- dedupe -----------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def fingerprint(self) -> str:
|
|
235
|
+
"""A stable structural identity for this defect.
|
|
236
|
+
|
|
237
|
+
Deliberately excludes prose, line numbers, commit sha, run id and
|
|
238
|
+
timestamps: the same defect reported by two agents in different words,
|
|
239
|
+
or found again after the file moved a few lines, must hash the same.
|
|
240
|
+
"""
|
|
241
|
+
paths = sorted(normalize_path(p) for p in self.location.paths)
|
|
242
|
+
parts = [
|
|
243
|
+
self.domain.value,
|
|
244
|
+
self.defect_class.value,
|
|
245
|
+
self.location.service or "",
|
|
246
|
+
self.location.endpoint or "",
|
|
247
|
+
self.location.ui_route or "",
|
|
248
|
+
"|".join(paths),
|
|
249
|
+
]
|
|
250
|
+
# Everything above comes from `location`, which is entirely optional in
|
|
251
|
+
# EMIT_SCHEMA. With none of it, two unrelated findings that share only a
|
|
252
|
+
# domain and a class hashed identically — and `defect_memory.record`
|
|
253
|
+
# then reported the second as "already tracked as PROJ-N, do not file
|
|
254
|
+
# again", suppressing a real defect and persisting that suppression into
|
|
255
|
+
# the cross-run store, where it repeats every future run.
|
|
256
|
+
#
|
|
257
|
+
# So when there is no structure to hash, fall back to the title. Prose is
|
|
258
|
+
# what this function otherwise excludes on purpose, and it is still the
|
|
259
|
+
# right answer here: a weaker identity beats a wrong one, and the
|
|
260
|
+
# alternative is asserting that two defects are the same on the evidence
|
|
261
|
+
# that neither said where it lived.
|
|
262
|
+
if not any(parts[2:]) or not "".join(parts[2:]):
|
|
263
|
+
parts.append(_normalize_title(self.title))
|
|
264
|
+
digest = hashlib.sha256("\x1f".join(parts).encode()).hexdigest()
|
|
265
|
+
return f"sha256:{digest}"
|
|
266
|
+
|
|
267
|
+
def with_fingerprint(self) -> "DefectEnvelope":
|
|
268
|
+
"""Return a copy carrying its computed fingerprint."""
|
|
269
|
+
return self.model_copy(
|
|
270
|
+
update={"dedupe": self.dedupe.model_copy(update={"fingerprint": self.fingerprint()})}
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def to_json(self) -> str:
|
|
274
|
+
return self.model_dump_json(by_alias=True, indent=2)
|
|
275
|
+
|
|
276
|
+
@classmethod
|
|
277
|
+
def from_json(cls, raw: str | bytes) -> "DefectEnvelope":
|
|
278
|
+
return cls.model_validate_json(raw)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _normalize_title(title: str) -> str:
|
|
282
|
+
"""A title reduced to its words, so wording drift does not fork the hash.
|
|
283
|
+
|
|
284
|
+
Only reached when an envelope carries no location at all — see `fingerprint`.
|
|
285
|
+
"""
|
|
286
|
+
return " ".join(re.findall(r"[a-z0-9]+", title.lower()))
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def normalize_path(path: str) -> str:
|
|
290
|
+
r"""Reduce a cited path to the file it names, so the same file hashes alike.
|
|
291
|
+
|
|
292
|
+
`target-app/api/app/auth.py:112-113` -> `api/app/auth.py`
|
|
293
|
+
|
|
294
|
+
Two things defeated the old version, and both were found in live data rather
|
|
295
|
+
than reasoned about. Its regex was `:\d+(?::\d+)?$`, which matched `:142`
|
|
296
|
+
and `:142:5` but NOT `:112-113` -- and a line *range* is how an agent
|
|
297
|
+
naturally cites a region, so the strip almost never fired. And nothing
|
|
298
|
+
removed the repo-root prefix, so `target-app/api/app/auth.py` and
|
|
299
|
+
`api/app/auth.py` were different files as far as the hash was concerned.
|
|
300
|
+
|
|
301
|
+
The visible damage was that `occurrence_count` never left 1: the same defect
|
|
302
|
+
reported across three runs produced three identities, so `get_occurrences`
|
|
303
|
+
could never say a defect was recurring. Deduplication itself survived only
|
|
304
|
+
because TRIAGE matches on similarity rather than on this hash.
|
|
305
|
+
|
|
306
|
+
`scorecard._norm_path` delegates here. They must not drift: a scorer that
|
|
307
|
+
considers two paths equal while the fingerprint considers them distinct is
|
|
308
|
+
two answers to one question.
|
|
309
|
+
"""
|
|
310
|
+
p = re.sub(r":\d+(?:[-:]\d+)?$", "", path.strip().replace("\\", "/")).lstrip("./")
|
|
311
|
+
for prefix in ("target-app/", "target_app/"):
|
|
312
|
+
if p.startswith(prefix):
|
|
313
|
+
p = p[len(prefix):]
|
|
314
|
+
return p
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
#: Kept as the old name so nothing importing it breaks; it always meant this.
|
|
318
|
+
_strip_line_number = normalize_path
|
qaas/envfile.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Reading credentials out of a `.env` file, without a dependency.
|
|
2
|
+
|
|
3
|
+
Every credential this system needs is an environment variable, deliberately:
|
|
4
|
+
`config/` is committed, and a token in a committed file is a token that leaks.
|
|
5
|
+
That rule is right and it made the tool tedious to use — four exports in every
|
|
6
|
+
new shell, and a run that dies on the fourth because one was forgotten.
|
|
7
|
+
|
|
8
|
+
So: a `.env` next to the project, read once, at CLI start. Two rules keep it
|
|
9
|
+
from becoming a second configuration system:
|
|
10
|
+
|
|
11
|
+
* **The real environment always wins.** A value already exported is never
|
|
12
|
+
overwritten, so `JIRA_PROJECT_KEY=OTHER qaas run` still means what it says,
|
|
13
|
+
and a stale `.env` cannot silently redirect a run.
|
|
14
|
+
* **It is only ever read for credentials.** Nothing in `config/` is looked up
|
|
15
|
+
here. A `.env` that sets `QAAS_TRACKER` works because that is an environment
|
|
16
|
+
override that already existed, not because this file is config.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
#: Where to look, in order. The state directory first, because `.qaas/` is
|
|
25
|
+
#: already gitignored — a credential written there cannot be committed by
|
|
26
|
+
#: accident, which is not true of a `.env` at the root of someone's repository.
|
|
27
|
+
CANDIDATES = (".qaas/.env", ".env")
|
|
28
|
+
|
|
29
|
+
#: Overrides the search. A path names the file to read; an empty value turns
|
|
30
|
+
#: the whole mechanism off. Both are needed by real callers: CI passes real
|
|
31
|
+
#: environment variables and must not have a stray `.env` in a checkout
|
|
32
|
+
#: override them, and the test suite must be hermetic against whatever the
|
|
33
|
+
#: developer happens to have on disk.
|
|
34
|
+
ENV_FILE_VAR = "QAAS_ENV_FILE"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_env(text: str) -> dict[str, str]:
|
|
38
|
+
"""`KEY=value` lines to a dict. Comments, blanks and `export ` tolerated.
|
|
39
|
+
|
|
40
|
+
Quotes are stripped only when they wrap the whole value: a token that
|
|
41
|
+
genuinely contains a quote character is more likely than a caller who meant
|
|
42
|
+
to keep the wrapping ones.
|
|
43
|
+
"""
|
|
44
|
+
values: dict[str, str] = {}
|
|
45
|
+
for raw in text.splitlines():
|
|
46
|
+
line = raw.strip()
|
|
47
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
48
|
+
continue
|
|
49
|
+
if line.startswith("export "):
|
|
50
|
+
line = line[len("export ") :].lstrip()
|
|
51
|
+
key, _, value = line.partition("=")
|
|
52
|
+
key = key.strip()
|
|
53
|
+
if not key:
|
|
54
|
+
continue
|
|
55
|
+
value = value.strip()
|
|
56
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
57
|
+
value = value[1:-1]
|
|
58
|
+
values[key] = value
|
|
59
|
+
return values
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_env_file(start: Path | str | None = None) -> tuple[Path | None, list[str]]:
|
|
63
|
+
"""Load the first `.env` found, without clobbering the real environment.
|
|
64
|
+
|
|
65
|
+
Returns `(path, names_set)` — the file that was used and the variables it
|
|
66
|
+
actually contributed. Names already present in the environment are reported
|
|
67
|
+
as not set by the file, because that is the fact an operator debugging a
|
|
68
|
+
wrong project key needs.
|
|
69
|
+
"""
|
|
70
|
+
override = os.environ.get(ENV_FILE_VAR)
|
|
71
|
+
if override is not None and not override.strip():
|
|
72
|
+
return None, []
|
|
73
|
+
|
|
74
|
+
base = Path(start).expanduser() if start else Path.cwd()
|
|
75
|
+
candidates = (Path(override).expanduser(),) if override else tuple(base / n for n in CANDIDATES)
|
|
76
|
+
for path in candidates:
|
|
77
|
+
if not path.is_file():
|
|
78
|
+
continue
|
|
79
|
+
try:
|
|
80
|
+
values = parse_env(path.read_text(encoding="utf-8", errors="replace"))
|
|
81
|
+
except OSError:
|
|
82
|
+
# An unreadable .env is not worth killing a command over; the
|
|
83
|
+
# missing variable will produce a far clearer error downstream.
|
|
84
|
+
return None, []
|
|
85
|
+
applied = []
|
|
86
|
+
for key, value in values.items():
|
|
87
|
+
# Presence, not truthiness. `os.environ.get(key)` treated an
|
|
88
|
+
# exported-but-empty variable as unset, so the file won — the exact
|
|
89
|
+
# opposite of what this module, CLAUDE.md and
|
|
90
|
+
# `test_the_real_environment_always_wins` all promise. The case that
|
|
91
|
+
# matters is a CI job with `JIRA_API_TOKEN: ${{ secrets.X }}` where
|
|
92
|
+
# the secret is not set: GitHub exports it as "", and a checkout
|
|
93
|
+
# carrying an old `.env` would then file tickets into whatever
|
|
94
|
+
# instance that stale token pointed at.
|
|
95
|
+
if key in os.environ:
|
|
96
|
+
continue
|
|
97
|
+
os.environ[key] = value
|
|
98
|
+
applied.append(key)
|
|
99
|
+
return path, applied
|
|
100
|
+
return None, []
|