@microsoft/m365-copilot-eval 1.1.0-preview.1 → 1.2.0-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.
- package/README.md +64 -18
- package/package.json +4 -2
- package/schema/CHANGELOG.md +21 -0
- package/schema/v1/eval-document.schema.json +236 -0
- package/schema/v1/examples/invalid/empty-items.json +4 -0
- package/schema/v1/examples/invalid/invalid-semver.json +8 -0
- package/schema/v1/examples/invalid/missing-schema-version.json +7 -0
- package/schema/v1/examples/invalid/wrong-type.json +6 -0
- package/schema/v1/examples/valid/comprehensive.json +92 -0
- package/schema/v1/examples/valid/minimal.json +8 -0
- package/schema/version.json +6 -0
- package/src/clients/cli/custom_evaluators/CitationsEvaluator.py +77 -33
- package/src/clients/cli/main.py +197 -30
- package/src/clients/cli/readme.md +5 -5
- package/src/clients/cli/requirements.txt +2 -0
- package/src/clients/cli/samples/starter.json +13 -10
- package/src/clients/cli/schema_handler.py +349 -0
- package/src/clients/cli/version_check.py +139 -0
- package/src/clients/node-js/bin/runevals.js +34 -103
- package/src/clients/node-js/config/default.js +1 -1
- package/src/clients/node-js/lib/env-loader.js +126 -0
- package/src/clients/node-js/lib/progress.js +36 -36
- package/src/clients/node-js/lib/python-runtime.js +4 -6
- package/src/clients/node-js/lib/venv-manager.js +65 -32
|
@@ -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
|