iris-security-sdk 0.2.12__tar.gz → 0.2.16__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iris-security-sdk
3
- Version: 0.2.12
3
+ Version: 0.2.16
4
4
  Summary: IRIS — AI Agent Governance SDK. Govern AI agents locally.
5
5
  Author-email: Gilbert Martin <gilbert@iris-security.io>
6
6
  License: Apache-2.0
@@ -47,7 +47,11 @@ from iris_core.evidence.vault import EvidenceVault
47
47
  from iris_core.cost.tracker import CostSummary, CostTracker, CostEntry
48
48
  from iris_core.cost.pricing import PricingRegistry
49
49
 
50
- __version__ = "0.2.12"
50
+ __version__ = "0.2.16"
51
+
52
+ AARM_CONFORMANCE = "Core" # R1-R6 satisfied
53
+ AARM_VERSION = "1.0"
54
+ AIUC1_ALIGNMENT = "Q1-2026"
51
55
  __all__ = [
52
56
  # Main classes
53
57
  "IrisAgent",
@@ -74,6 +78,10 @@ __all__ = [
74
78
  "CostSummary",
75
79
  "CostEntry",
76
80
  "PricingRegistry",
81
+ "AARM_CONFORMANCE",
82
+ "AARM_VERSION",
83
+ "AIUC1_ALIGNMENT",
84
+ "__version__",
77
85
  "IrisViolationError",
78
86
  "IrisCrisisDetectedError",
79
87
  "IrisHITLRequiredError",
@@ -1,10 +1,12 @@
1
1
  """First-run telemetry for IRIS SDK installs. Opt-out via IRIS_TELEMETRY_OPT_OUT=1.
2
2
 
3
3
  Each event includes an ISO 8601 UTC timestamp for server-side DoD/WoW/MoM aggregation.
4
+ Daily CLI usage is aggregated locally and sent once per UTC day (previous day's totals).
4
5
  """
5
6
 
6
7
  from __future__ import annotations
7
8
 
9
+ import json
8
10
  import os
9
11
  import shutil
10
12
  import sys
@@ -13,6 +15,7 @@ import uuid
13
15
  from datetime import datetime, timezone
14
16
  from importlib.metadata import PackageNotFoundError, version
15
17
  from pathlib import Path
18
+ from typing import Any
16
19
 
17
20
  from iris._telemetry_config import TELEMETRY_ENABLED, TELEMETRY_ENDPOINT
18
21
 
@@ -20,6 +23,8 @@ _IRIS_DIR = Path.home() / ".iris"
20
23
  _INSTALL_ID_FILE = _IRIS_DIR / "install_id"
21
24
  _FIRST_RUN_SENTINEL = _IRIS_DIR / ".telemetry_sent"
22
25
  _FIRST_POLICY_SENTINEL = _IRIS_DIR / ".first_policy_sent"
26
+ _RUN_STATS_FILE = _IRIS_DIR / "run_stats.json"
27
+ _SKIPPED_COMMANDS = frozenset({"ping"})
23
28
 
24
29
 
25
30
  def detect_country() -> str:
@@ -127,6 +132,10 @@ def _get_iris_version() -> str:
127
132
  return "unknown"
128
133
 
129
134
 
135
+ def _utc_today() -> str:
136
+ return datetime.now(timezone.utc).date().isoformat()
137
+
138
+
130
139
  def _build_payload(event: str) -> dict[str, str]:
131
140
  return {
132
141
  "event": event,
@@ -142,15 +151,56 @@ def _build_payload(event: str) -> dict[str, str]:
142
151
  }
143
152
 
144
153
 
145
- def _send_event(event: str) -> None:
154
+ def _build_daily_payload(stats: dict[str, Any]) -> dict[str, str]:
155
+ payload = _build_payload("daily_usage")
156
+ payload["usage_date"] = str(stats["date"])
157
+ payload["run_count"] = str(stats["run_count"])
158
+ payload["commands"] = json.dumps(stats.get("commands", {}), sort_keys=True)
159
+ return payload
160
+
161
+
162
+ def _empty_day_stats(day: str) -> dict[str, Any]:
163
+ return {"date": day, "run_count": 0, "commands": {}}
164
+
165
+
166
+ def _load_run_stats() -> dict[str, Any]:
167
+ if not _RUN_STATS_FILE.exists():
168
+ return _empty_day_stats(_utc_today())
169
+ try:
170
+ data = json.loads(_RUN_STATS_FILE.read_text(encoding="utf-8"))
171
+ if not isinstance(data, dict):
172
+ return _empty_day_stats(_utc_today())
173
+ data.setdefault("commands", {})
174
+ return data
175
+ except (OSError, json.JSONDecodeError):
176
+ return _empty_day_stats(_utc_today())
177
+
178
+
179
+ def _save_run_stats(stats: dict[str, Any]) -> None:
180
+ _RUN_STATS_FILE.write_text(json.dumps(stats, indent=2, sort_keys=True), encoding="utf-8")
181
+
182
+
183
+ def _send_payload(payload: dict[str, str]) -> None:
146
184
  try:
147
185
  import httpx
148
186
 
149
- httpx.post(TELEMETRY_ENDPOINT, json=_build_payload(event), timeout=2.0)
187
+ httpx.post(TELEMETRY_ENDPOINT, json=payload, timeout=2.0)
150
188
  except Exception:
151
189
  pass
152
190
 
153
191
 
192
+ def _send_event(event: str) -> None:
193
+ _send_payload(_build_payload(event))
194
+
195
+
196
+ def _flush_daily_usage(stats: dict[str, Any]) -> None:
197
+ if int(stats.get("run_count", 0)) <= 0:
198
+ return
199
+ payload = _build_daily_payload(stats)
200
+ thread = threading.Thread(target=_send_payload, args=(payload,), daemon=True)
201
+ thread.start()
202
+
203
+
154
204
  def _maybe_fire_once(event: str, sentinel: Path) -> None:
155
205
  if not telemetry_enabled():
156
206
  return
@@ -178,6 +228,34 @@ def maybe_fire_first_policy_run() -> None:
178
228
  _maybe_fire_once("first_policy_run", _FIRST_POLICY_SENTINEL)
179
229
 
180
230
 
231
+ def maybe_record_cli_usage(command: str) -> None:
232
+ """Increment local run counters; send one daily_usage event per UTC day on rollover."""
233
+ if not telemetry_enabled() or not command or command in _SKIPPED_COMMANDS:
234
+ return
235
+
236
+ today = _utc_today()
237
+ try:
238
+ _IRIS_DIR.mkdir(parents=True, exist_ok=True)
239
+ stats = _load_run_stats()
240
+ except OSError:
241
+ return
242
+
243
+ stored_date = stats.get("date")
244
+ if stored_date != today:
245
+ if stored_date and int(stats.get("run_count", 0)) > 0:
246
+ _flush_daily_usage(stats)
247
+ stats = _empty_day_stats(today)
248
+
249
+ commands = stats.setdefault("commands", {})
250
+ commands[command] = int(commands.get(command, 0)) + 1
251
+ stats["run_count"] = int(stats.get("run_count", 0)) + 1
252
+
253
+ try:
254
+ _save_run_stats(stats)
255
+ except OSError:
256
+ pass
257
+
258
+
181
259
  def send_ping() -> None:
182
260
  """Fire a test telemetry ping event (internal verification)."""
183
261
  if not telemetry_enabled():
@@ -0,0 +1,227 @@
1
+ """Offline workload detection for compliance intelligence scans."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ _PROVIDER_IMPORTS = {
11
+ "openai": "openai",
12
+ "anthropic": "anthropic",
13
+ "google": "google",
14
+ "generativeai": "google",
15
+ "vertexai": "google",
16
+ "azure": "azure",
17
+ "bedrock": "aws",
18
+ "boto3": "aws",
19
+ "cohere": "cohere",
20
+ "mistralai": "mistral",
21
+ }
22
+
23
+ _FRAMEWORK_IMPORTS = {
24
+ "langchain": "langchain",
25
+ "crewai": "crewai",
26
+ "llama_index": "llama_index",
27
+ "semantic_kernel": "semantic_kernel",
28
+ "autogen": "autogen",
29
+ "haystack": "haystack",
30
+ }
31
+
32
+ _MODEL_PATTERNS = [
33
+ re.compile(r"gpt-4[a-z0-9.-]*", re.I),
34
+ re.compile(r"gpt-3\.5[a-z0-9.-]*", re.I),
35
+ re.compile(r"claude-[a-z0-9.-]+", re.I),
36
+ re.compile(r"gemini-[a-z0-9.-]+", re.I),
37
+ re.compile(r"text-embedding-[a-z0-9.-]+", re.I),
38
+ ]
39
+
40
+ _MODEL_PROVIDER_PREFIXES: list[tuple[re.Pattern[str], str]] = [
41
+ (re.compile(r"^gpt-", re.I), "openai"),
42
+ (re.compile(r"^o[134]-", re.I), "openai"),
43
+ (re.compile(r"^text-embedding-", re.I), "openai"),
44
+ (re.compile(r"^claude-", re.I), "anthropic"),
45
+ (re.compile(r"^gemini-", re.I), "google"),
46
+ (re.compile(r"^gemini/", re.I), "google"),
47
+ (re.compile(r"^text-bison", re.I), "google"),
48
+ (re.compile(r"^command", re.I), "cohere"),
49
+ (re.compile(r"^mistral", re.I), "mistral"),
50
+ (re.compile(r"^llama", re.I), "meta"),
51
+ (re.compile(r"^amazon\.", re.I), "aws"),
52
+ (re.compile(r"^anthropic\.", re.I), "anthropic"),
53
+ (re.compile(r"^azure/", re.I), "azure"),
54
+ (re.compile(r"^vertex_ai/", re.I), "google"),
55
+ (re.compile(r"^openai/", re.I), "openai"),
56
+ (re.compile(r"^bedrock/", re.I), "aws"),
57
+ ]
58
+
59
+ _DATA_CATEGORY_PATTERNS = {
60
+ "pii": re.compile(
61
+ r"\b(ssn|social_security|date_of_birth|dob|passport|email_address|phone_number|"
62
+ r"first_name|last_name|address_line|national_id)\b",
63
+ re.I,
64
+ ),
65
+ "phi": re.compile(
66
+ r"\b(patient_id|medical_record|diagnosis|icd_?10|hipaa|health_record|"
67
+ r"prescription|mrn|protected_health)\b",
68
+ re.I,
69
+ ),
70
+ "financial": re.compile(
71
+ r"\b(credit_card|card_number|bank_account|routing_number|iban|pci|"
72
+ r"account_balance|transaction_amount)\b",
73
+ re.I,
74
+ ),
75
+ "biometric": re.compile(
76
+ r"\b(fingerprint|face_id|retina_scan|voice_print|biometric)\b",
77
+ re.I,
78
+ ),
79
+ }
80
+
81
+ _REQUIREMENTS_FILES = (
82
+ "requirements.txt",
83
+ "requirements-dev.txt",
84
+ "pyproject.toml",
85
+ "Pipfile",
86
+ "setup.py",
87
+ )
88
+
89
+
90
+ def _iter_source_files(root: Path) -> list[Path]:
91
+ files: list[Path] = []
92
+ skip = {".git", ".venv", "venv", "node_modules", "__pycache__", ".iris", "dist", "build"}
93
+ for path in root.rglob("*"):
94
+ if any(part in skip for part in path.parts):
95
+ continue
96
+ if path.suffix in {".py", ".pyi", ".ts", ".tsx", ".js", ".jsx", ".json", ".yaml", ".yml"}:
97
+ if path.stat().st_size > 512_000:
98
+ continue
99
+ files.append(path)
100
+ return files
101
+
102
+
103
+ def _scan_imports(text: str) -> tuple[set[str], set[str]]:
104
+ providers: set[str] = set()
105
+ frameworks: set[str] = set()
106
+ for line in text.splitlines():
107
+ stripped = line.strip()
108
+ if stripped.startswith("import ") or stripped.startswith("from "):
109
+ lowered = stripped.lower()
110
+ for needle, provider in _PROVIDER_IMPORTS.items():
111
+ if needle in lowered:
112
+ providers.add(provider)
113
+ for needle, fw in _FRAMEWORK_IMPORTS.items():
114
+ if needle in lowered:
115
+ frameworks.add(fw)
116
+ return providers, frameworks
117
+
118
+
119
+ def _scan_models(text: str) -> set[str]:
120
+ models: set[str] = set()
121
+ for pattern in _MODEL_PATTERNS:
122
+ for match in pattern.findall(text):
123
+ models.add(match.lower())
124
+ return models
125
+
126
+
127
+ def _scan_data_categories(text: str) -> set[str]:
128
+ categories: set[str] = set()
129
+ for category, pattern in _DATA_CATEGORY_PATTERNS.items():
130
+ if pattern.search(text):
131
+ categories.add(category)
132
+ return categories
133
+
134
+
135
+ def infer_provider_from_model(model: str) -> str | None:
136
+ """Infer provider slug from a model identifier (shared by observability adapters)."""
137
+ if not model:
138
+ return None
139
+ normalized = model.strip()
140
+ for pattern, provider in _MODEL_PROVIDER_PREFIXES:
141
+ if pattern.search(normalized):
142
+ return provider
143
+ return None
144
+
145
+
146
+ def infer_providers_from_models(models: list[str]) -> list[str]:
147
+ """Return sorted unique providers inferred from model names."""
148
+ providers: set[str] = set()
149
+ for model in models:
150
+ provider = infer_provider_from_model(model)
151
+ if provider:
152
+ providers.add(provider)
153
+ return sorted(providers)
154
+
155
+
156
+ def scan_data_categories_from_text(text: str) -> list[str]:
157
+ """Scan arbitrary metadata/tags text for sensitive data category hints."""
158
+ return sorted(_scan_data_categories(text))
159
+
160
+
161
+ def _scan_requirements(root: Path) -> tuple[set[str], set[str]]:
162
+ providers: set[str] = set()
163
+ frameworks: set[str] = set()
164
+ for name in _REQUIREMENTS_FILES:
165
+ req_path = root / name
166
+ if not req_path.exists():
167
+ continue
168
+ text = req_path.read_text(encoding="utf-8", errors="ignore").lower()
169
+ for needle, provider in _PROVIDER_IMPORTS.items():
170
+ if needle in text:
171
+ providers.add(provider)
172
+ for needle, fw in _FRAMEWORK_IMPORTS.items():
173
+ if needle in text:
174
+ frameworks.add(fw)
175
+ return providers, frameworks
176
+
177
+
178
+ def detect_workload(path: str = ".") -> dict[str, Any]:
179
+ """Static offline detection of workload profile attributes."""
180
+ root = Path(path).resolve()
181
+ providers: set[str] = set()
182
+ frameworks: set[str] = set()
183
+ models: set[str] = set()
184
+ data_categories: set[str] = set()
185
+
186
+ req_providers, req_frameworks = _scan_requirements(root)
187
+ providers |= req_providers
188
+ frameworks |= req_frameworks
189
+
190
+ for file_path in _iter_source_files(root):
191
+ try:
192
+ text = file_path.read_text(encoding="utf-8", errors="ignore")
193
+ except OSError:
194
+ continue
195
+ p, f = _scan_imports(text)
196
+ providers |= p
197
+ frameworks |= f
198
+ models |= _scan_models(text)
199
+ data_categories |= _scan_data_categories(text)
200
+
201
+ agent_hints = sum(1 for _ in root.rglob("passport.yaml"))
202
+ autonomy = "assistive"
203
+ if agent_hints >= 3:
204
+ autonomy = "supervised"
205
+ if agent_hints >= 6:
206
+ autonomy = "autonomous"
207
+
208
+ return {
209
+ "source": "sdk_scan",
210
+ "models": sorted(models),
211
+ "providers": sorted(providers),
212
+ "frameworks": sorted(frameworks),
213
+ "data_categories": sorted(data_categories),
214
+ "deployment_regions": ["us"],
215
+ "agent_count": max(agent_hints, 1 if models or providers else 0),
216
+ "autonomy_level": autonomy,
217
+ "customer_facing": any(
218
+ part in {"pages", "app", "frontend", "ui", "web"} for part in root.parts
219
+ ),
220
+ }
221
+
222
+
223
+ def profile_payload_hash(profile: dict[str, Any]) -> str:
224
+ import hashlib
225
+
226
+ canonical = json.dumps(profile, sort_keys=True, separators=(",", ":"))
227
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
@@ -0,0 +1 @@
1
+ """iris_sdk namespace package for scan helpers."""
@@ -0,0 +1,17 @@
1
+ """Re-export scan helpers for iris_sdk import path."""
2
+
3
+ from iris.scan import (
4
+ detect_workload,
5
+ infer_provider_from_model,
6
+ infer_providers_from_models,
7
+ profile_payload_hash,
8
+ scan_data_categories_from_text,
9
+ )
10
+
11
+ __all__ = [
12
+ "detect_workload",
13
+ "infer_provider_from_model",
14
+ "infer_providers_from_models",
15
+ "profile_payload_hash",
16
+ "scan_data_categories_from_text",
17
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iris-security-sdk
3
- Version: 0.2.12
3
+ Version: 0.2.16
4
4
  Summary: IRIS — AI Agent Governance SDK. Govern AI agents locally.
5
5
  Author-email: Gilbert Martin <gilbert@iris-security.io>
6
6
  License: Apache-2.0
@@ -4,9 +4,13 @@ iris/__init__.py
4
4
  iris/_telemetry.py
5
5
  iris/_telemetry_config.py
6
6
  iris/hitl.py
7
+ iris/scan.py
8
+ iris_sdk/__init__.py
9
+ iris_sdk/scan.py
7
10
  iris_security_sdk.egg-info/PKG-INFO
8
11
  iris_security_sdk.egg-info/SOURCES.txt
9
12
  iris_security_sdk.egg-info/dependency_links.txt
10
13
  iris_security_sdk.egg-info/requires.txt
11
14
  iris_security_sdk.egg-info/top_level.txt
15
+ tests/test_scan.py
12
16
  tests/test_telemetry.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "iris-security-sdk"
7
- version = "0.2.12"
7
+ version = "0.2.16"
8
8
  description = "IRIS — AI Agent Governance SDK. Govern AI agents locally."
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Gilbert Martin", email = "gilbert@iris-security.io" }]
@@ -0,0 +1,51 @@
1
+ # Copyright 2024-2025 Gilbert Martin / IRIS Security, Inc.
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from unittest.mock import patch
7
+
8
+ import pytest
9
+
10
+ from iris.scan import detect_workload
11
+
12
+
13
+ @pytest.fixture
14
+ def sample_project(tmp_path: Path) -> Path:
15
+ (tmp_path / "requirements.txt").write_text(
16
+ "openai>=1.0\nlangchain>=0.2\n",
17
+ encoding="utf-8",
18
+ )
19
+ app = tmp_path / "app.py"
20
+ app.write_text(
21
+ """
22
+ import openai
23
+ from langchain_openai import ChatOpenAI
24
+
25
+ model = ChatOpenAI(model="gpt-4o")
26
+ client = openai.OpenAI()
27
+
28
+ class UserSchema:
29
+ ssn = "social_security"
30
+ email_address = "test@example.com"
31
+ """,
32
+ encoding="utf-8",
33
+ )
34
+ return tmp_path
35
+
36
+
37
+ def test_detect_workload_finds_openai_and_langchain(sample_project: Path):
38
+ profile = detect_workload(str(sample_project))
39
+ assert "openai" in profile["providers"]
40
+ assert "langchain" in profile["frameworks"]
41
+ assert any("gpt" in m for m in profile["models"])
42
+ assert "pii" in profile["data_categories"]
43
+
44
+
45
+ def test_detect_workload_offline_no_network(sample_project: Path):
46
+ def fail_socket(*args, **kwargs):
47
+ raise AssertionError("Network call attempted during offline scan")
48
+
49
+ with patch("socket.socket", side_effect=fail_socket):
50
+ profile = detect_workload(str(sample_project))
51
+ assert profile["source"] == "sdk_scan"
@@ -0,0 +1,105 @@
1
+ """Tests for IRIS telemetry opt-out, internal/CI exclusion, and daily usage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from unittest.mock import patch
9
+
10
+ import pytest
11
+
12
+ from iris import _telemetry
13
+
14
+
15
+ @pytest.fixture(autouse=True)
16
+ def _reset_env(monkeypatch: pytest.MonkeyPatch) -> None:
17
+ monkeypatch.delenv("IRIS_TELEMETRY_OPT_OUT", raising=False)
18
+ monkeypatch.delenv("IRIS_TELEMETRY_INTERNAL", raising=False)
19
+ monkeypatch.delenv("CI", raising=False)
20
+ monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
21
+
22
+
23
+ @pytest.fixture
24
+ def iris_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
25
+ iris_dir = tmp_path / ".iris"
26
+ iris_dir.mkdir()
27
+ monkeypatch.setattr(_telemetry, "_IRIS_DIR", iris_dir)
28
+ monkeypatch.setattr(_telemetry, "_INSTALL_ID_FILE", iris_dir / "install_id")
29
+ monkeypatch.setattr(_telemetry, "_FIRST_RUN_SENTINEL", iris_dir / ".telemetry_sent")
30
+ monkeypatch.setattr(_telemetry, "_FIRST_POLICY_SENTINEL", iris_dir / ".first_policy_sent")
31
+ monkeypatch.setattr(_telemetry, "_RUN_STATS_FILE", iris_dir / "run_stats.json")
32
+ return iris_dir
33
+
34
+
35
+ def test_telemetry_enabled_by_default() -> None:
36
+ with patch.object(_telemetry, "TELEMETRY_ENABLED", True):
37
+ assert _telemetry.telemetry_enabled() is True
38
+
39
+
40
+ def test_telemetry_opt_out() -> None:
41
+ with patch.dict(os.environ, {"IRIS_TELEMETRY_OPT_OUT": "1"}):
42
+ assert _telemetry.telemetry_enabled() is False
43
+
44
+
45
+ def test_telemetry_skips_ci() -> None:
46
+ with patch.object(_telemetry, "TELEMETRY_ENABLED", True):
47
+ with patch.dict(os.environ, {"GITHUB_ACTIONS": "true"}):
48
+ assert _telemetry.telemetry_enabled() is False
49
+
50
+
51
+ def test_telemetry_skips_internal_flag() -> None:
52
+ with patch.object(_telemetry, "TELEMETRY_ENABLED", True):
53
+ with patch.dict(os.environ, {"IRIS_TELEMETRY_INTERNAL": "1"}):
54
+ assert _telemetry.telemetry_enabled() is False
55
+
56
+
57
+ def test_telemetry_skips_iris_sdk_path() -> None:
58
+ with patch.object(_telemetry, "TELEMETRY_ENABLED", True):
59
+ with patch.object(_telemetry.shutil, "which", return_value="/Users/dev/iris-sdk/.venv/bin/iris"):
60
+ assert _telemetry.telemetry_enabled() is False
61
+
62
+
63
+ def test_maybe_record_cli_usage_increments_local_stats(iris_home: Path) -> None:
64
+ with patch.object(_telemetry, "TELEMETRY_ENABLED", True):
65
+ _telemetry.maybe_record_cli_usage("scan")
66
+ _telemetry.maybe_record_cli_usage("scan")
67
+ _telemetry.maybe_record_cli_usage("compile")
68
+
69
+ stats = json.loads((iris_home / "run_stats.json").read_text())
70
+ assert stats["run_count"] == 3
71
+ assert stats["commands"] == {"scan": 2, "compile": 1}
72
+
73
+
74
+ def test_maybe_record_cli_usage_skips_ping(iris_home: Path) -> None:
75
+ with patch.object(_telemetry, "TELEMETRY_ENABLED", True):
76
+ _telemetry.maybe_record_cli_usage("ping")
77
+ assert not (iris_home / "run_stats.json").exists()
78
+
79
+
80
+ def test_day_rollover_flushes_previous_day(iris_home: Path) -> None:
81
+ sent: list[dict[str, str]] = []
82
+
83
+ def capture(payload: dict[str, str]) -> None:
84
+ sent.append(payload)
85
+
86
+ (iris_home / "run_stats.json").write_text(
87
+ json.dumps({"date": "2026-06-14", "run_count": 5, "commands": {"scan": 5}}),
88
+ encoding="utf-8",
89
+ )
90
+
91
+ with patch.object(_telemetry, "TELEMETRY_ENABLED", True):
92
+ with patch.object(_telemetry, "_utc_today", return_value="2026-06-15"):
93
+ with patch.object(_telemetry, "_send_payload", side_effect=capture):
94
+ _telemetry.maybe_record_cli_usage("scan")
95
+
96
+ assert len(sent) == 1
97
+ assert sent[0]["event"] == "daily_usage"
98
+ assert sent[0]["usage_date"] == "2026-06-14"
99
+ assert sent[0]["run_count"] == "5"
100
+ assert json.loads(sent[0]["commands"]) == {"scan": 5}
101
+
102
+ stats = json.loads((iris_home / "run_stats.json").read_text())
103
+ assert stats["date"] == "2026-06-15"
104
+ assert stats["run_count"] == 1
105
+ assert stats["commands"] == {"scan": 1}
@@ -1,46 +0,0 @@
1
- """Tests for IRIS telemetry opt-out and internal/CI exclusion."""
2
-
3
- from __future__ import annotations
4
-
5
- import os
6
- from unittest.mock import patch
7
-
8
- import pytest
9
-
10
- from iris import _telemetry
11
-
12
-
13
- @pytest.fixture(autouse=True)
14
- def _reset_env(monkeypatch: pytest.MonkeyPatch) -> None:
15
- monkeypatch.delenv("IRIS_TELEMETRY_OPT_OUT", raising=False)
16
- monkeypatch.delenv("IRIS_TELEMETRY_INTERNAL", raising=False)
17
- monkeypatch.delenv("CI", raising=False)
18
- monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
19
-
20
-
21
- def test_telemetry_enabled_by_default() -> None:
22
- with patch.object(_telemetry, "TELEMETRY_ENABLED", True):
23
- assert _telemetry.telemetry_enabled() is True
24
-
25
-
26
- def test_telemetry_opt_out() -> None:
27
- with patch.dict(os.environ, {"IRIS_TELEMETRY_OPT_OUT": "1"}):
28
- assert _telemetry.telemetry_enabled() is False
29
-
30
-
31
- def test_telemetry_skips_ci() -> None:
32
- with patch.object(_telemetry, "TELEMETRY_ENABLED", True):
33
- with patch.dict(os.environ, {"GITHUB_ACTIONS": "true"}):
34
- assert _telemetry.telemetry_enabled() is False
35
-
36
-
37
- def test_telemetry_skips_internal_flag() -> None:
38
- with patch.object(_telemetry, "TELEMETRY_ENABLED", True):
39
- with patch.dict(os.environ, {"IRIS_TELEMETRY_INTERNAL": "1"}):
40
- assert _telemetry.telemetry_enabled() is False
41
-
42
-
43
- def test_telemetry_skips_iris_sdk_path() -> None:
44
- with patch.object(_telemetry, "TELEMETRY_ENABLED", True):
45
- with patch.object(_telemetry.shutil, "which", return_value="/Users/dev/iris-sdk/.venv/bin/iris"):
46
- assert _telemetry.telemetry_enabled() is False