cortex-training 0.0.3__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.
@@ -0,0 +1,112 @@
1
+ # Copyright 2025 Snowflake Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """Canonical validation for LoRA configs used by Cortex Training weight sync."""
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any
21
+
22
+ PEFT_LORA_DEFAULT_R = 8
23
+ PEFT_LORA_DEFAULT_ALPHA = 8
24
+ PEFT_LORA_DEFAULT_DROPOUT = 0.0
25
+ PEFT_LORA_DEFAULT_BIAS = "none"
26
+ SUPPORTED_LORA_PEFT_CONFIG_KEYS = frozenset(
27
+ {
28
+ "peft_type",
29
+ "task_type",
30
+ "r",
31
+ "lora_alpha",
32
+ "lora_dropout",
33
+ "bias",
34
+ "target_modules",
35
+ "target_parameters",
36
+ }
37
+ )
38
+
39
+
40
+ def normalize_lora_peft_config(
41
+ peft_config: dict[str, Any],
42
+ *,
43
+ location: str = "peft_config",
44
+ ) -> dict[str, Any]:
45
+ """Validate and normalize the LoRA subset supported by weight sync."""
46
+ if not isinstance(peft_config, dict):
47
+ raise ValueError(f"{location} must be an object, got {peft_config!r}")
48
+
49
+ unsupported = sorted(set(peft_config) - SUPPORTED_LORA_PEFT_CONFIG_KEYS)
50
+ if unsupported:
51
+ raise ValueError(
52
+ f"{location} contains unsupported field(s): {unsupported}. "
53
+ f"Supported fields: {sorted(SUPPORTED_LORA_PEFT_CONFIG_KEYS)}"
54
+ )
55
+
56
+ peft_type = peft_config.get("peft_type")
57
+ if peft_type != "Lora":
58
+ raise ValueError(f"{location}.peft_type must be 'Lora', got {peft_type!r}")
59
+
60
+ task_type = peft_config.get("task_type", "CAUSAL_LM")
61
+ if task_type != "CAUSAL_LM":
62
+ raise ValueError(f"{location}.task_type must be 'CAUSAL_LM', got {task_type!r}")
63
+
64
+ rank = peft_config.get("r", PEFT_LORA_DEFAULT_R)
65
+ if not isinstance(rank, int) or isinstance(rank, bool) or rank <= 0:
66
+ raise ValueError(f"{location}.r must be a positive integer, got {rank!r}")
67
+
68
+ lora_alpha = peft_config.get("lora_alpha", PEFT_LORA_DEFAULT_ALPHA)
69
+ if not isinstance(lora_alpha, int) or isinstance(lora_alpha, bool) or lora_alpha < 0:
70
+ raise ValueError(f"{location}.lora_alpha must be a non-negative integer, got {lora_alpha!r}")
71
+
72
+ lora_dropout = peft_config.get("lora_dropout", PEFT_LORA_DEFAULT_DROPOUT)
73
+ if not isinstance(lora_dropout, (int, float)) or isinstance(lora_dropout, bool) or not 0 <= lora_dropout <= 1:
74
+ raise ValueError(f"{location}.lora_dropout must be a number in [0, 1], got {lora_dropout!r}")
75
+
76
+ bias = peft_config.get("bias", PEFT_LORA_DEFAULT_BIAS)
77
+ if bias not in {"none", "all", "lora_only"}:
78
+ raise ValueError(f"{location}.bias must be one of 'none', 'all', or 'lora_only', got {bias!r}")
79
+ if bias != "none":
80
+ raise ValueError(f"{location}.bias={bias!r} is not supported by vLLM 0.26 LoRA. Use bias='none'.")
81
+
82
+ target_modules = peft_config.get("target_modules", [])
83
+ if isinstance(target_modules, str):
84
+ if not target_modules:
85
+ raise ValueError(f"{location}.target_modules cannot be empty")
86
+ elif isinstance(target_modules, list):
87
+ if any(not isinstance(value, str) or not value for value in target_modules):
88
+ raise ValueError(f"{location}.target_modules must contain only non-empty strings")
89
+ target_modules = list(target_modules)
90
+ else:
91
+ raise ValueError(f"{location}.target_modules must be a string or list of strings, got {target_modules!r}")
92
+
93
+ target_parameters = peft_config.get("target_parameters", [])
94
+ if not isinstance(target_parameters, list) or any(
95
+ not isinstance(value, str) or not value for value in target_parameters
96
+ ):
97
+ raise ValueError(f"{location}.target_parameters must be a list of non-empty strings")
98
+ target_parameters = list(target_parameters)
99
+
100
+ if not target_modules and not target_parameters:
101
+ raise ValueError(f"{location} must specify target_modules, target_parameters, or both")
102
+
103
+ return {
104
+ "peft_type": "Lora",
105
+ "task_type": "CAUSAL_LM",
106
+ "r": rank,
107
+ "lora_alpha": lora_alpha,
108
+ "lora_dropout": float(lora_dropout),
109
+ "bias": bias,
110
+ "target_modules": target_modules,
111
+ "target_parameters": target_parameters,
112
+ }
@@ -0,0 +1,526 @@
1
+ # Copyright 2025 Snowflake Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """Best-effort client telemetry over Snowflake's OTLP endpoint.
17
+
18
+ Telemetry is deliberately isolated from the Cortex Training request session
19
+ because the two paths use different authentication schemes:
20
+
21
+ * Cortex Training APIs accept the PAT directly.
22
+ * Observability APIs require a short-lived Snowflake session token.
23
+
24
+ All metric emission failures are swallowed. Telemetry must never change the
25
+ outcome of a caller's training or inference operation.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import atexit
31
+ import logging
32
+ import math
33
+ import os
34
+ import queue
35
+ import threading
36
+ import time
37
+ import weakref
38
+ from collections.abc import Mapping
39
+ from typing import Any
40
+ from urllib.parse import urlparse
41
+
42
+ import requests
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ _PAT_TOKEN_TYPE = "PROGRAMMATIC_ACCESS_TOKEN"
47
+ _DEFAULT_SESSION_VALIDITY_SECONDS = 3600.0
48
+ _MIN_SESSION_VALIDITY_SECONDS = 60.0
49
+ _DEFAULT_TIMEOUT_SECONDS = 3.0
50
+ _FAILURE_COOLDOWN_SECONDS = 300.0
51
+ _MAX_CONSECUTIVE_FAILURES = 3
52
+ _SNOWFLAKE_HOST_SUFFIX = ".snowflakecomputing.com"
53
+ _AUTH_REJECTED_STATUSES = {401, 403}
54
+ _DEFAULT_QUEUE_SIZE = 1024
55
+ _WORKER_IDLE_SECONDS = 30.0
56
+ _STOP = object()
57
+ _EMITTERS: weakref.WeakSet["OtlpMetricEmitter"] = weakref.WeakSet()
58
+
59
+
60
+ def _flush_emitters_at_exit() -> None:
61
+ deadline = time.monotonic() + 1.0
62
+ for emitter in list(_EMITTERS):
63
+ remaining = max(deadline - time.monotonic(), 0.0)
64
+ emitter.close(timeout=remaining)
65
+
66
+
67
+ atexit.register(_flush_emitters_at_exit)
68
+
69
+
70
+ def _snapshot(value: Any) -> Any:
71
+ """Copy JSON-like values before a background worker observes them."""
72
+ if isinstance(value, Mapping):
73
+ return {str(key): _snapshot(item) for key, item in value.items()}
74
+ if isinstance(value, (list, tuple)):
75
+ return [_snapshot(item) for item in value]
76
+ return value
77
+
78
+
79
+ def _otlp_value(value: Any) -> dict[str, Any]:
80
+ """Convert a JSON-like Python value to an OTLP ``AnyValue``."""
81
+ if isinstance(value, bool):
82
+ return {"boolValue": value}
83
+ if isinstance(value, int):
84
+ return {"intValue": str(value)}
85
+ if isinstance(value, float):
86
+ if math.isnan(value) or math.isinf(value):
87
+ return {"stringValue": str(value)}
88
+ return {"doubleValue": value}
89
+ if isinstance(value, Mapping):
90
+ return {
91
+ "kvlistValue": {
92
+ "values": [
93
+ {"key": str(key), "value": _otlp_value(item)}
94
+ for key, item in value.items()
95
+ if item is not None
96
+ ]
97
+ }
98
+ }
99
+ if isinstance(value, (list, tuple)):
100
+ return {
101
+ "arrayValue": {
102
+ "values": [_otlp_value(item) for item in value if item is not None]
103
+ }
104
+ }
105
+ return {"stringValue": str(value)}
106
+
107
+
108
+ def _telemetry_base_url(hostname: str) -> str:
109
+ """Build an OTLP base URL from the observability hostname response.
110
+
111
+ GS returns a host, optionally with a ``/system`` path. Posts go to
112
+ ``https://{hostname}/v1/logs`` without stripping that path, so a value of
113
+ ``telemetry.example.snowflakecomputing.com/system`` becomes
114
+ ``https://telemetry.example.snowflakecomputing.com/system``.
115
+ """
116
+ hostname = hostname.strip().rstrip("/")
117
+ if not hostname:
118
+ raise ValueError("observability response did not contain a hostname")
119
+ if not hostname.startswith(("http://", "https://")):
120
+ hostname = f"https://{hostname}"
121
+ parsed = urlparse(hostname)
122
+ host = parsed.hostname or ""
123
+ if not host.endswith(_SNOWFLAKE_HOST_SUFFIX):
124
+ raise ValueError("observability hostname is not a Snowflake collector")
125
+ return hostname.rstrip("/")
126
+
127
+
128
+ class CachedSessionTokenProvider:
129
+ """Exchange a PAT for a Snowflake session token and cache it until expiry."""
130
+
131
+ def __init__(
132
+ self,
133
+ base_url: str,
134
+ pat: str,
135
+ *,
136
+ verify_ssl: bool = True,
137
+ timeout: float = _DEFAULT_TIMEOUT_SECONDS,
138
+ refresh_buffer_seconds: float = 60.0,
139
+ ):
140
+ self.base_url = base_url.rstrip("/")
141
+ self.timeout = timeout
142
+ self.refresh_buffer_seconds = refresh_buffer_seconds
143
+ self._pat = pat
144
+ self._verify_ssl = verify_ssl
145
+ self._pid = os.getpid()
146
+ self._session = self._new_session()
147
+ self._token: str | None = None
148
+ self._expires_at = 0.0
149
+ self._lock = threading.Lock()
150
+
151
+ def _new_session(self) -> requests.Session:
152
+ session = requests.Session()
153
+ session.verify = self._verify_ssl
154
+ session.headers.update(
155
+ {
156
+ "Authorization": f"Bearer {self._pat}",
157
+ "X-Snowflake-Authorization-Token-Type": _PAT_TOKEN_TYPE,
158
+ "Accept": "application/json",
159
+ }
160
+ )
161
+ return session
162
+
163
+ def _ensure_process(self) -> None:
164
+ if self._pid == os.getpid():
165
+ return
166
+ self._pid = os.getpid()
167
+ self._session = self._new_session()
168
+ self._token = None
169
+ self._expires_at = 0.0
170
+ self._lock = threading.Lock()
171
+
172
+ def invalidate(self) -> None:
173
+ """Drop the cached token so the next call re-exchanges the PAT."""
174
+ self._ensure_process()
175
+ with self._lock:
176
+ self._token = None
177
+ self._expires_at = 0.0
178
+
179
+ def get_token(self) -> str:
180
+ """Return a cached session token, refreshing it shortly before expiry."""
181
+ self._ensure_process()
182
+ now = time.monotonic()
183
+ if self._token is not None and now < self._expires_at:
184
+ return self._token
185
+
186
+ with self._lock:
187
+ now = time.monotonic()
188
+ if self._token is not None and now < self._expires_at:
189
+ return self._token
190
+
191
+ response = self._session.post(
192
+ f"{self.base_url}/api/v2/sessions",
193
+ json={},
194
+ timeout=self.timeout,
195
+ )
196
+ response.raise_for_status()
197
+ body = response.json()
198
+ token = body["token"]
199
+ if not isinstance(token, str) or not token:
200
+ raise ValueError("session response did not contain a token")
201
+
202
+ validity = body.get("validityInSeconds", _DEFAULT_SESSION_VALIDITY_SECONDS)
203
+ try:
204
+ validity_seconds = float(validity)
205
+ except (TypeError, ValueError):
206
+ validity_seconds = _DEFAULT_SESSION_VALIDITY_SECONDS
207
+ validity_seconds = max(validity_seconds, _MIN_SESSION_VALIDITY_SECONDS)
208
+ refresh_buffer = min(
209
+ self.refresh_buffer_seconds,
210
+ validity_seconds * 0.1,
211
+ )
212
+ self._token = token
213
+ self._expires_at = now + validity_seconds - refresh_buffer
214
+ return token
215
+
216
+ def close(self) -> None:
217
+ self._session.close()
218
+
219
+
220
+ class OtlpMetricEmitter:
221
+ """Emit OTLP log records as best-effort client-side metrics."""
222
+
223
+ def __init__(
224
+ self,
225
+ base_url: str,
226
+ token_provider: CachedSessionTokenProvider,
227
+ *,
228
+ service_name: str = "cortex-training",
229
+ service_version: str | None = None,
230
+ verify_ssl: bool = True,
231
+ timeout: float = _DEFAULT_TIMEOUT_SECONDS,
232
+ max_consecutive_failures: int = _MAX_CONSECUTIVE_FAILURES,
233
+ failure_cooldown_seconds: float = _FAILURE_COOLDOWN_SECONDS,
234
+ background: bool = True,
235
+ queue_size: int = _DEFAULT_QUEUE_SIZE,
236
+ ):
237
+ self.base_url = base_url.rstrip("/")
238
+ self.account_host = urlparse(self.base_url).hostname
239
+ if not self.account_host:
240
+ raise ValueError("base_url must contain an account hostname")
241
+ self.token_provider = token_provider
242
+ self.service_name = service_name
243
+ self.service_version = service_version
244
+ self.timeout = timeout
245
+ self._max_consecutive_failures = max_consecutive_failures
246
+ self._failure_cooldown_seconds = failure_cooldown_seconds
247
+ self._verify_ssl = verify_ssl
248
+ self._queue_size = queue_size
249
+ self._pid = os.getpid()
250
+ self._session = self._new_session()
251
+ self._telemetry_base_url: str | None = None
252
+ self._hostname_lock = threading.Lock()
253
+ self._consecutive_failures = 0
254
+ self._disabled_until = 0.0
255
+ self._background = background
256
+ self._queue: queue.Queue[Any] = queue.Queue(maxsize=queue_size)
257
+ self._worker: threading.Thread | None = None
258
+ self._worker_lock = threading.Lock()
259
+ self._pending_condition = threading.Condition()
260
+ self._pending = 0
261
+ self._closed = False
262
+ _EMITTERS.add(self)
263
+
264
+ def _new_session(self) -> requests.Session:
265
+ session = requests.Session()
266
+ session.verify = self._verify_ssl
267
+ return session
268
+
269
+ def _ensure_process(self) -> None:
270
+ if self._pid == os.getpid():
271
+ return
272
+ self._pid = os.getpid()
273
+ self._session = self._new_session()
274
+ self._telemetry_base_url = None
275
+ self._hostname_lock = threading.Lock()
276
+ self._consecutive_failures = 0
277
+ self._disabled_until = 0.0
278
+ self._queue = queue.Queue(maxsize=self._queue_size)
279
+ self._worker = None
280
+ self._worker_lock = threading.Lock()
281
+ self._pending_condition = threading.Condition()
282
+ self._pending = 0
283
+ self.token_provider._ensure_process()
284
+
285
+ def _is_disabled(self) -> bool:
286
+ return time.monotonic() < self._disabled_until
287
+
288
+ def _note_success(self) -> None:
289
+ self._consecutive_failures = 0
290
+
291
+ def _note_failure(self) -> None:
292
+ self._consecutive_failures += 1
293
+ if self._consecutive_failures >= self._max_consecutive_failures:
294
+ self._disabled_until = time.monotonic() + self._failure_cooldown_seconds
295
+
296
+ def _invalidate_auth(self) -> None:
297
+ self.token_provider.invalidate()
298
+ with self._hostname_lock:
299
+ self._telemetry_base_url = None
300
+
301
+ def _get_telemetry_base_url(self, token: str) -> str:
302
+ if self._telemetry_base_url is not None:
303
+ return self._telemetry_base_url
304
+
305
+ with self._hostname_lock:
306
+ if self._telemetry_base_url is not None:
307
+ return self._telemetry_base_url
308
+ response = self._session.get(
309
+ f"{self.base_url}/observability/system/hostname",
310
+ headers={"Authorization": f'Snowflake Token="{token}"'},
311
+ timeout=self.timeout,
312
+ )
313
+ response.raise_for_status()
314
+ hostname = response.json()["hostname"]
315
+ if not isinstance(hostname, str) or not hostname:
316
+ raise ValueError("observability response did not contain a hostname")
317
+ self._telemetry_base_url = _telemetry_base_url(hostname)
318
+ return self._telemetry_base_url
319
+
320
+ def _ensure_worker(self) -> None:
321
+ worker = self._worker
322
+ if worker is not None and worker.is_alive():
323
+ return
324
+ with self._worker_lock:
325
+ worker = self._worker
326
+ if worker is not None and worker.is_alive():
327
+ return
328
+ self._worker = None
329
+ worker = threading.Thread(
330
+ target=self._run,
331
+ name="cortex-training-telemetry",
332
+ daemon=True,
333
+ )
334
+ worker.start()
335
+ self._worker = worker
336
+
337
+ def _run(self) -> None:
338
+ try:
339
+ while True:
340
+ try:
341
+ item = self._queue.get(timeout=_WORKER_IDLE_SECONDS)
342
+ except queue.Empty:
343
+ return
344
+ if item is _STOP:
345
+ self._queue.task_done()
346
+ return
347
+ operation, value, attributes, time_unix_nano = item
348
+ try:
349
+ self._emit_sync(
350
+ operation,
351
+ value,
352
+ attributes=attributes,
353
+ time_unix_nano=time_unix_nano,
354
+ )
355
+ finally:
356
+ self._queue.task_done()
357
+ with self._pending_condition:
358
+ self._pending -= 1
359
+ self._pending_condition.notify_all()
360
+ finally:
361
+ with self._worker_lock:
362
+ if self._worker is threading.current_thread():
363
+ self._worker = None
364
+ if not self._closed and not self._queue.empty():
365
+ self._ensure_worker()
366
+
367
+ def flush(self, timeout: float = 1.0) -> bool:
368
+ """Wait briefly for queued metrics, primarily during interpreter exit."""
369
+ deadline = time.monotonic() + timeout
370
+ with self._pending_condition:
371
+ while self._pending:
372
+ remaining = deadline - time.monotonic()
373
+ if remaining <= 0:
374
+ return False
375
+ self._pending_condition.wait(remaining)
376
+ return True
377
+
378
+ def close(self, timeout: float = 1.0) -> None:
379
+ """Flush queued events and release this emitter's worker and sessions."""
380
+ self._ensure_process()
381
+ worker = self._worker
382
+ if self._closed and (worker is None or not worker.is_alive()):
383
+ self._session.close()
384
+ self.token_provider.close()
385
+ _EMITTERS.discard(self)
386
+ return
387
+ self._closed = True
388
+ deadline = time.monotonic() + timeout
389
+ self.flush(timeout=max(deadline - time.monotonic(), 0.0))
390
+ worker = self._worker
391
+ if worker is not None and worker.is_alive():
392
+ try:
393
+ self._queue.put_nowait(_STOP)
394
+ except queue.Full:
395
+ pass
396
+ worker.join(timeout=max(deadline - time.monotonic(), 0.0))
397
+ if worker is None or not worker.is_alive():
398
+ self._session.close()
399
+ self.token_provider.close()
400
+ _EMITTERS.discard(self)
401
+
402
+ def emit(
403
+ self,
404
+ operation: str,
405
+ value: Any = 1,
406
+ *,
407
+ attributes: Mapping[str, Any] | None = None,
408
+ ) -> None:
409
+ """Queue one operation metric without blocking or raising into the caller."""
410
+ try:
411
+ self._ensure_process()
412
+ if self._closed:
413
+ return
414
+ time_unix_nano = time.time_ns()
415
+ if not self._background:
416
+ self._emit_sync(
417
+ operation,
418
+ value,
419
+ attributes=attributes,
420
+ time_unix_nano=time_unix_nano,
421
+ )
422
+ return
423
+ item = (
424
+ operation,
425
+ _snapshot(value),
426
+ _snapshot(attributes) if attributes is not None else None,
427
+ time_unix_nano,
428
+ )
429
+ with self._pending_condition:
430
+ self._pending += 1
431
+ try:
432
+ self._queue.put_nowait(item)
433
+ except Exception:
434
+ with self._pending_condition:
435
+ self._pending -= 1
436
+ self._pending_condition.notify_all()
437
+ raise
438
+ self._ensure_worker()
439
+ except Exception:
440
+ logger.debug("client telemetry enqueue failed", exc_info=True)
441
+
442
+ def _emit_sync(
443
+ self,
444
+ operation: str,
445
+ value: Any = 1,
446
+ *,
447
+ attributes: Mapping[str, Any] | None = None,
448
+ time_unix_nano: int | None = None,
449
+ ) -> None:
450
+ """Emit one operation metric, swallowing setup and export failures."""
451
+ self._ensure_process()
452
+ if self._is_disabled():
453
+ return
454
+ try:
455
+ token = self.token_provider.get_token()
456
+ telemetry_base_url = self._get_telemetry_base_url(token)
457
+ record_attributes = {
458
+ **dict(attributes or {}),
459
+ "event.name": operation,
460
+ }
461
+ resource_attributes = [
462
+ {
463
+ "key": "service.name",
464
+ "value": {"stringValue": self.service_name},
465
+ },
466
+ {
467
+ "key": "snowflake.account_host",
468
+ "value": {"stringValue": self.account_host},
469
+ },
470
+ ]
471
+ if self.service_version:
472
+ resource_attributes.append(
473
+ {
474
+ "key": "service.version",
475
+ "value": {"stringValue": self.service_version},
476
+ }
477
+ )
478
+ response = self._session.post(
479
+ f"{telemetry_base_url}/v1/logs",
480
+ headers={
481
+ "Authorization": f'Snowflake Token="{token}"',
482
+ "Content-Type": "application/json",
483
+ "Accept": "application/json",
484
+ },
485
+ json={
486
+ "resourceLogs": [
487
+ {
488
+ "resource": {"attributes": resource_attributes},
489
+ "scopeLogs": [
490
+ {
491
+ "scope": {"name": self.service_name},
492
+ "logRecords": [
493
+ {
494
+ "timeUnixNano": str(
495
+ time.time_ns()
496
+ if time_unix_nano is None
497
+ else time_unix_nano
498
+ ),
499
+ "severityText": "INFO",
500
+ "body": _otlp_value(value),
501
+ "attributes": [
502
+ {
503
+ "key": str(key),
504
+ "value": _otlp_value(item),
505
+ }
506
+ for key, item in record_attributes.items()
507
+ if item is not None
508
+ ],
509
+ }
510
+ ],
511
+ }
512
+ ],
513
+ }
514
+ ]
515
+ },
516
+ timeout=self.timeout,
517
+ )
518
+ response.raise_for_status()
519
+ self._note_success()
520
+ except Exception as exc:
521
+ logger.debug("client telemetry emit failed", exc_info=True)
522
+ response = getattr(exc, "response", None)
523
+ status = getattr(response, "status_code", None)
524
+ if status in _AUTH_REJECTED_STATUSES:
525
+ self._invalidate_auth()
526
+ self._note_failure()
@@ -0,0 +1,23 @@
1
+ # Copyright 2025 Snowflake Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """Read-only terminal UI for Cortex Training job logs and scheduling events.
17
+
18
+ The formatting helpers import cleanly without ``textual``; the app and entry
19
+ point use the package's standard TUI dependencies.
20
+ """
21
+
22
+ from cortex_training.tui.format import format_event # noqa: F401
23
+ from cortex_training.tui.format import format_log_entry