@microsoft/m365-copilot-eval 1.1.1-preview.1 → 1.2.1-preview.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,349 @@
1
+ """Schema validation, version management, backup, and document upgrade for eval documents.
2
+
3
+ This module provides the core infrastructure for JSON Schema contract versioning:
4
+ - SchemaValidator: Validates eval documents against the JSON Schema
5
+ - SchemaVersionManager: Reads and compares schema versions
6
+ - FileBackupManager: Creates timestamped backups with atomic writes
7
+ - DocumentUpgrader: Orchestrates the full upgrade flow
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import shutil
13
+ import logging
14
+ from dataclasses import dataclass
15
+ from datetime import datetime
16
+ from enum import Enum
17
+ from pathlib import Path
18
+ from typing import Any, Optional
19
+
20
+
21
+ class VersionRelation(Enum):
22
+ """Result of comparing a document's schema version against the current version."""
23
+ CURRENT = "current"
24
+ OLDER = "older"
25
+ NEWER_MAJOR = "newer_major"
26
+ UNSUPPORTED = "unsupported"
27
+
28
+ from jsonschema import Draft202012Validator, ValidationError
29
+ from jsonschema.exceptions import best_match
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # Resolve repo root and schema paths relative to this file
34
+ _REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
35
+ _DEFAULT_VERSION_PATH = _REPO_ROOT / "schema" / "version.json"
36
+
37
+
38
+ def _resolve_schema_path(version_path: Path = _DEFAULT_VERSION_PATH) -> Path:
39
+ """Derive the schema file path from the major version in version.json."""
40
+ try:
41
+ with open(version_path, "r", encoding="utf-8") as f:
42
+ data = json.load(f)
43
+ major = data["version"].split(".")[0]
44
+ except Exception:
45
+ major = "1"
46
+ return _REPO_ROOT / "schema" / f"v{major}" / "eval-document.schema.json"
47
+
48
+
49
+ class SchemaValidator:
50
+ """Validates eval documents against the JSON Schema using Draft 2020-12."""
51
+
52
+ def __init__(self, schema_path: Optional[Path] = None):
53
+ schema_path = schema_path or _resolve_schema_path()
54
+ with open(schema_path, "r", encoding="utf-8") as f:
55
+ self.schema = json.load(f)
56
+
57
+ Draft202012Validator.check_schema(self.schema)
58
+
59
+ self.validator = Draft202012Validator(
60
+ schema=self.schema,
61
+ format_checker=Draft202012Validator.FORMAT_CHECKER,
62
+ )
63
+
64
+ def validate(self, document: dict[str, Any]) -> tuple[bool, list[ValidationError] | None]:
65
+ """Validate a document against the schema.
66
+
67
+ Returns:
68
+ (True, None) if valid, or (False, list_of_errors) if invalid.
69
+ """
70
+ errors = list(self.validator.iter_errors(document))
71
+ if not errors:
72
+ return True, None
73
+ return False, errors
74
+
75
+ @staticmethod
76
+ def format_errors(errors: list[ValidationError]) -> str:
77
+ """Format validation errors into a human-readable string.
78
+
79
+ Uses best_match to surface the most relevant error first, then lists
80
+ remaining errors with JSON paths.
81
+ """
82
+ if not errors:
83
+ return ""
84
+
85
+ best = best_match(errors)
86
+ lines = []
87
+
88
+ if best is not None:
89
+ path_str = " -> ".join(str(p) for p in best.absolute_path) or "(root)"
90
+ lines.append(f"Most relevant error at '{path_str}': {best.message}")
91
+
92
+ for err in errors:
93
+ if err is best:
94
+ continue
95
+ path_str = " -> ".join(str(p) for p in err.absolute_path) or "(root)"
96
+ lines.append(f" - At '{path_str}': {err.message}")
97
+
98
+ lines.append("")
99
+ lines.append("Review the schema changelog for details: schema/CHANGELOG.md")
100
+ return "\n".join(lines)
101
+
102
+
103
+ class SchemaVersionManager:
104
+ """Reads and compares schema versions from version.json."""
105
+
106
+ def __init__(self, version_path: Optional[Path] = None):
107
+ self._version_path = version_path or _DEFAULT_VERSION_PATH
108
+
109
+ def get_current_version(self) -> str:
110
+ """Read the current schema version from version.json."""
111
+ with open(self._version_path, "r", encoding="utf-8") as f:
112
+ data = json.load(f)
113
+ return data["version"]
114
+
115
+ def get_schema_path(self) -> Path:
116
+ """Return the resolved path to the current schema file."""
117
+ return _resolve_schema_path()
118
+
119
+ @staticmethod
120
+ def compare_versions(doc_version: str, current_version: str) -> VersionRelation:
121
+ """Compare document schema version against the current version."""
122
+ try:
123
+ doc_parts = [int(x) for x in doc_version.split(".")]
124
+ cur_parts = [int(x) for x in current_version.split(".")]
125
+ except (ValueError, AttributeError):
126
+ return VersionRelation.UNSUPPORTED
127
+
128
+ if len(doc_parts) != 3 or len(cur_parts) != 3:
129
+ return VersionRelation.UNSUPPORTED
130
+
131
+ doc_major, doc_minor, doc_patch = doc_parts
132
+ cur_major, cur_minor, cur_patch = cur_parts
133
+
134
+ if doc_major > cur_major:
135
+ return VersionRelation.NEWER_MAJOR
136
+
137
+ if doc_parts == cur_parts:
138
+ return VersionRelation.CURRENT
139
+
140
+ if doc_major == cur_major and (doc_minor, doc_patch) < (cur_minor, cur_patch):
141
+ return VersionRelation.OLDER
142
+
143
+ # Same major, doc is newer minor/patch than current — shouldn't happen normally,
144
+ # but treat as current (forward-compatible within major).
145
+ if doc_major == cur_major:
146
+ return VersionRelation.CURRENT
147
+
148
+ # doc_major < cur_major — older major version
149
+ return VersionRelation.OLDER
150
+
151
+
152
+ class FileBackupManager:
153
+ """Creates timestamped backups and performs atomic file writes."""
154
+
155
+ @staticmethod
156
+ def create_backup_path(original_path: Path) -> Path:
157
+ """Generate a timestamped backup path for the given file."""
158
+ timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
159
+ backup_name = f"{original_path.name}.bak.{timestamp}"
160
+ return original_path.parent / backup_name
161
+
162
+ @staticmethod
163
+ def create_backup(file_path: Path) -> Path:
164
+ """Create a backup copy of the file with a timestamped name.
165
+
166
+ Returns the path to the backup file.
167
+ """
168
+ backup_path = FileBackupManager.create_backup_path(file_path)
169
+ shutil.copy2(file_path, backup_path)
170
+ return backup_path
171
+
172
+ @staticmethod
173
+ def atomic_write(file_path: Path, content: str) -> None:
174
+ """Write content to file atomically using temp file + os.replace()."""
175
+ temp_path = file_path.with_suffix(file_path.suffix + ".tmp")
176
+ try:
177
+ with open(temp_path, "w", encoding="utf-8") as f:
178
+ f.write(content)
179
+ os.replace(temp_path, file_path)
180
+ except Exception:
181
+ # Clean up temp file on failure
182
+ if temp_path.exists():
183
+ temp_path.unlink()
184
+ raise
185
+
186
+
187
+ @dataclass
188
+ class UpgradeResult:
189
+ """Result of a document upgrade operation."""
190
+ upgraded: bool
191
+ old_version: str
192
+ new_version: str
193
+ document: Optional[dict] = None
194
+ backup_path: Optional[Path] = None
195
+ message: str = ""
196
+ error: Optional[str] = None
197
+
198
+
199
+ class DocumentUpgrader:
200
+ """Orchestrates document validation, backup, and schema version upgrade.
201
+
202
+ Upgrade flow:
203
+ 1. Load JSON document
204
+ 2. Detect schemaVersion (assume "1.0.0" if missing per FR-030)
205
+ 3. Reject unknown future versions
206
+ 4. Validate against current schema
207
+ 5. If valid + legacy (no schemaVersion): backup + upgrade
208
+ 6. If valid + older (same major): upgrade without backup (ADR-007)
209
+ 7. If invalid: return error without modifying file (FR-034)
210
+ 8. Write updated document atomically
211
+ """
212
+
213
+ def __init__(
214
+ self,
215
+ validator: Optional[SchemaValidator] = None,
216
+ version_manager: Optional[SchemaVersionManager] = None,
217
+ ):
218
+ self._validator = validator or SchemaValidator()
219
+ self._version_manager = version_manager or SchemaVersionManager()
220
+
221
+ def upgrade(self, file_path: Path) -> UpgradeResult:
222
+ """Validate and upgrade an eval document file.
223
+
224
+ Args:
225
+ file_path: Path to the eval document JSON file.
226
+
227
+ Returns:
228
+ UpgradeResult with details of what happened.
229
+ """
230
+ current_version = self._version_manager.get_current_version()
231
+
232
+ # 1. Load document
233
+ with open(file_path, "r", encoding="utf-8") as f:
234
+ document = json.load(f)
235
+
236
+ # 2. Detect document type and schemaVersion
237
+ is_legacy = False
238
+ if isinstance(document, list):
239
+ # Bare array format — wrap into eval document format
240
+ document = {"items": document}
241
+ is_legacy = True
242
+ elif isinstance(document, dict) and "items" not in document:
243
+ if "prompts" in document:
244
+ # Dict format with prompts/expected_responses — convert
245
+ prompts = document.get("prompts", [])
246
+ expected_responses = document.get("expected_responses", [])
247
+ items = []
248
+ for i, prompt in enumerate(prompts):
249
+ item: dict[str, Any] = {"prompt": prompt}
250
+ if i < len(expected_responses) and expected_responses[i]:
251
+ item["expected_response"] = expected_responses[i]
252
+ items.append(item)
253
+ document = {"items": items}
254
+ is_legacy = True
255
+
256
+ doc_version = document.get("schemaVersion")
257
+ if doc_version is None:
258
+ doc_version = "1.0.0"
259
+ is_legacy = True
260
+
261
+ # 3. Reject unsupported or newer major versions
262
+ comparison = SchemaVersionManager.compare_versions(doc_version, current_version)
263
+ if comparison == VersionRelation.NEWER_MAJOR:
264
+ return UpgradeResult(
265
+ upgraded=False,
266
+ old_version=doc_version,
267
+ new_version=current_version,
268
+ error=(
269
+ f"Document uses schema v{doc_version}, but this CLI only supports "
270
+ f"v{current_version}. Please update the CLI to a version that "
271
+ f"supports schema v{doc_version.split('.')[0]}.x."
272
+ ),
273
+ )
274
+ if comparison == VersionRelation.UNSUPPORTED:
275
+ return UpgradeResult(
276
+ upgraded=False,
277
+ old_version=doc_version,
278
+ new_version=current_version,
279
+ error=(
280
+ f"Document has an invalid schema version '{doc_version}'. "
281
+ "Expected a valid SemVer string (e.g., '1.0.0'). "
282
+ "Please check the document source."
283
+ ),
284
+ )
285
+
286
+ # 4. Validate against current schema — inject schemaVersion temporarily for validation
287
+ validation_doc = dict(document)
288
+ if "schemaVersion" not in validation_doc:
289
+ validation_doc["schemaVersion"] = current_version
290
+
291
+ is_valid, errors = self._validator.validate(validation_doc)
292
+
293
+ if not is_valid:
294
+ error_msg = SchemaValidator.format_errors(errors)
295
+ return UpgradeResult(
296
+ upgraded=False,
297
+ old_version=doc_version,
298
+ new_version=current_version,
299
+ error=f"Document validation failed:\n{error_msg}",
300
+ )
301
+
302
+ # 5/6. Document is valid — decide upgrade strategy
303
+ if comparison == VersionRelation.CURRENT and not is_legacy:
304
+ # Already at current version and not legacy — no-op
305
+ return UpgradeResult(
306
+ upgraded=False,
307
+ old_version=doc_version,
308
+ new_version=current_version,
309
+ document=document,
310
+ message="Document is already at current schema version.",
311
+ )
312
+
313
+ # Upgrade needed — three scenarios reach here:
314
+ # 1. is_legacy=True, comparison=CURRENT → legacy format (array/dict/no schemaVersion)
315
+ # at current version; rewrite as eval document with backup
316
+ # 2. is_legacy=True, comparison=OLDER → legacy format at an older version;
317
+ # rewrite as eval document with backup
318
+ # 3. is_legacy=False, comparison=OLDER → proper eval document at an older version;
319
+ # bump schemaVersion in-place, no backup needed (ADR-007)
320
+ backup_path = None
321
+ if is_legacy:
322
+ # Legacy document — create backup before structural conversion (FR-032)
323
+ backup_path = FileBackupManager.create_backup(file_path)
324
+
325
+ # Update schemaVersion
326
+ document["schemaVersion"] = current_version
327
+
328
+ # Write atomically
329
+ updated_content = json.dumps(document, indent=2, ensure_ascii=False) + "\n"
330
+ FileBackupManager.atomic_write(file_path, updated_content)
331
+
332
+ if backup_path:
333
+ message = (
334
+ f"Document upgraded from {doc_version} to {current_version}. "
335
+ f"Original backed up to {backup_path}"
336
+ )
337
+ else:
338
+ message = f"Document upgraded from {doc_version} to {current_version}."
339
+
340
+ logger.info(message)
341
+
342
+ return UpgradeResult(
343
+ upgraded=True,
344
+ old_version=doc_version,
345
+ new_version=current_version,
346
+ document=document,
347
+ backup_path=backup_path,
348
+ message=message,
349
+ )
@@ -0,0 +1,139 @@
1
+ """
2
+ Minimum version check for the M365 Copilot Agent Evaluation CLI.
3
+
4
+ Strategy:
5
+ The published package.json contains a custom "minCliVersion" field.
6
+ At runtime the CLI fetches the latest published package metadata via
7
+ an aka.ms redirect that points to the npm registry API:
8
+
9
+ https://aka.ms/m365-evals-min-version
10
+ -> https://registry.npmjs.org/@microsoft/m365-copilot-eval/latest
11
+
12
+ The response is the full published package.json as JSON. We simply
13
+ read the "minCliVersion" field and compare it against the local
14
+ version.
15
+
16
+ Using aka.ms as an intermediary lets us change the target endpoint
17
+ without shipping a new CLI version.
18
+ """
19
+
20
+ import json
21
+ import os
22
+ import urllib.request
23
+ import urllib.error
24
+ from typing import Optional
25
+
26
+ from packaging.version import Version, InvalidVersion
27
+
28
+ # The public npm package name
29
+ NPM_PACKAGE_NAME = "@microsoft/m365-copilot-eval"
30
+
31
+ # npm registry API URL via aka.ms redirect
32
+ MIN_VERSION_URL = "https://aka.ms/m365-evals-min-version"
33
+
34
+ # Timeout for fetching from the registry (seconds) — kept short to fail fast
35
+ FETCH_TIMEOUT = 3
36
+
37
+
38
+ def get_cli_version(quiet: bool = False) -> Optional[Version]:
39
+ """
40
+ Read the current CLI version from package.json at the repo root.
41
+
42
+ release-please keeps package.json's "version" field up to date,
43
+ so there is no need for a hardcoded version constant.
44
+
45
+ Args:
46
+ quiet: If True, suppress warning output on failure.
47
+
48
+ Returns:
49
+ A parsed Version object (e.g. Version('1.1.1-preview.1')),
50
+ or None if package.json cannot be read or the version is
51
+ unparseable.
52
+ """
53
+ try:
54
+ # Walk up from this file (src/clients/cli/) to the repo root
55
+ here = os.path.dirname(os.path.abspath(__file__))
56
+ package_json_path = os.path.join(here, "..", "..", "..", "package.json")
57
+ package_json_path = os.path.normpath(package_json_path)
58
+ with open(package_json_path, "r", encoding="utf-8") as f:
59
+ data = json.load(f)
60
+ return Version(data.get("version"))
61
+ except Exception as e:
62
+ if not quiet:
63
+ print(
64
+ f"\033[33mWarning: Could not determine current CLI version: {e}\033[0m"
65
+ )
66
+ return None
67
+
68
+
69
+ def fetch_min_version(quiet: bool = False) -> Optional[Version]:
70
+ """
71
+ Fetch the minimum required CLI version from the npm registry.
72
+
73
+ How it works:
74
+ 1. GET https://registry.npmjs.org/@microsoft/m365-copilot-eval/latest
75
+ 2. The response is the published package.json as plain JSON.
76
+ 3. Read the "minCliVersion" field.
77
+
78
+ Args:
79
+ quiet: If True, suppress warning output on failure.
80
+
81
+ Returns:
82
+ A parsed Version object (e.g. Version('1.3.0')),
83
+ or None on any failure.
84
+ """
85
+ try:
86
+ req = urllib.request.Request(
87
+ MIN_VERSION_URL,
88
+ headers={
89
+ "Accept": "application/json",
90
+ },
91
+ )
92
+ with urllib.request.urlopen(req, timeout=FETCH_TIMEOUT) as resp:
93
+ data = json.loads(resp.read().decode("utf-8"))
94
+
95
+ return Version(data.get("minCliVersion"))
96
+
97
+ except Exception as e:
98
+ if not quiet:
99
+ print(
100
+ f"\033[33mWarning: Could not fetch minimum version from registry: {e}\033[0m"
101
+ )
102
+ return None
103
+
104
+
105
+ def check_min_version(current_version: Optional[Version], quiet: bool = False) -> bool:
106
+ """
107
+ Check whether the current CLI version meets the minimum required version
108
+ published in the npm registry.
109
+
110
+ Args:
111
+ current_version: The parsed Version this CLI is running
112
+ (from get_cli_version()), or None if version
113
+ cannot be determined.
114
+ quiet: If True, suppress informational output.
115
+
116
+ Returns:
117
+ True if the version is acceptable (>= minVersion or check failed
118
+ gracefully), False if the current version is below the minimum.
119
+ """
120
+ # Fail open if current version unknown (package.json missing/unreadable)
121
+ if current_version is None:
122
+ return True
123
+
124
+ min_version = fetch_min_version(quiet=quiet)
125
+
126
+ if min_version is None:
127
+ return True
128
+
129
+ if current_version < min_version:
130
+ print(
131
+ f"\033[91m"
132
+ f"This version, {current_version}, of the M365 Evals CLI is no longer functional, "
133
+ f"you must update your tool to continue using it.\n"
134
+ f"Update by running: npm install -g {NPM_PACKAGE_NAME}@latest"
135
+ f"\033[0m"
136
+ )
137
+ return False
138
+
139
+ return True