semanticembed 0.7.0__tar.gz → 0.7.1__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.
- {semanticembed-0.7.0 → semanticembed-0.7.1}/CHANGELOG.md +10 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/PKG-INFO +1 -1
- {semanticembed-0.7.0 → semanticembed-0.7.1}/pyproject.toml +1 -1
- {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/__init__.py +9 -2
- {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/client.py +167 -0
- semanticembed-0.7.1/tests/test_aclient.py +170 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/.claude/skills/analyze/SKILL.md +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/.github/workflows/publish.yml +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/.gitignore +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/LICENSE +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/README.md +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/docs/api_reference.md +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/docs/dimensions.md +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/docs/getting_started.md +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/docs/input_format.md +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/docs/license_keys.md +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/docs/output_format.md +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/examples/ai_agent_pipeline.json +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/examples/cicd_pipeline.json +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/examples/google_online_boutique.json +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/examples/react/README.md +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/examples/react/RadarChart.tsx +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/examples/react/RiskTable.tsx +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/examples/react/TopologySummary.tsx +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/examples/react/useSemanticEmbed.ts +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/examples/sample_pipeline.json +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/examples/weaveworks_sock_shop.json +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/notebooks/01_quickstart.ipynb +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/notebooks/02_dimensions.ipynb +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/notebooks/03_drift_detection.ipynb +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/notebooks/04_bring_your_own.ipynb +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/notebooks/05_ai_agent_pipelines.ipynb +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/notebooks/06_cicd_pipelines.ipynb +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/notebooks/07_opentelemetry.ipynb +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/notebooks/08_qwen_compression.ipynb +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/notebooks/qwen_compression_cookbook.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/skill/SETUP.md +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/skill/SKILL.md +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/skill/analyze.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/agent/README.md +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/agent/__init__.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/agent/__main__.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/agent/agent.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/agent/gemini_agent.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/dedupe.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/exceptions.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/explain.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/extract.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/find_edges.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/live.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/models.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/__init__.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/fixtures/autogen_app.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/fixtures/cdk_app.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/fixtures/crewai_app.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/fixtures/docker-compose.yml +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/fixtures/langgraph_app.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/fixtures/pulumi_app.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/fixtures/stack.yaml +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/fixtures/trace_jaeger.json +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/fixtures/trace_otlp.json +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/fixtures/trace_zipkin.json +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/test_client.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/test_dedupe.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/test_extract.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/test_extract_ai.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/test_extract_iac.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/test_extract_otel.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/test_live.py +0 -0
- {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/test_skill.py +0 -0
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the SemanticEmbed SDK.
|
|
4
4
|
|
|
5
|
+
## 0.7.1 — Async surface
|
|
6
|
+
|
|
7
|
+
- New: `aencode`, `aencode_file`, `aencode_diff` — async siblings of the
|
|
8
|
+
sync versions. Same preflight node-count guard, retry-once-on-5xx, and
|
|
9
|
+
optional in-process cache.
|
|
10
|
+
- Cache is **shared** across sync + async: a sync `encode(edges, cache=True)`
|
|
11
|
+
followed by `await aencode(edges, cache=True)` hits the same entry.
|
|
12
|
+
- `aencode_diff` issues both encodes in parallel via `asyncio.gather`.
|
|
13
|
+
- Useful for FastAPI / Celery / event-loop integrations.
|
|
14
|
+
|
|
5
15
|
## 0.7.0 — Honeycomb + Datadog live connectors
|
|
6
16
|
|
|
7
17
|
- New: `semanticembed.live.from_honeycomb(dataset, api_key)` — runs a
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: semanticembed
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.1
|
|
4
4
|
Summary: 6D structural intelligence for directed graphs. Six numbers per node. Sub-millisecond.
|
|
5
5
|
Project-URL: Homepage, https://github.com/jmurray10/semanticembed-sdk
|
|
6
6
|
Project-URL: Documentation, https://github.com/jmurray10/semanticembed-sdk
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "semanticembed"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.1"
|
|
8
8
|
description = "6D structural intelligence for directed graphs. Six numbers per node. Sub-millisecond."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "Proprietary"}
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from .client import
|
|
5
|
+
from .client import (
|
|
6
|
+
encode, encode_file, report, drift, encode_diff,
|
|
7
|
+
aencode, aencode_file, aencode_diff,
|
|
8
|
+
clear_encode_cache,
|
|
9
|
+
)
|
|
6
10
|
from .models import SemanticResult, RiskReport, RiskEntry, DIMENSION_NAMES
|
|
7
11
|
from .exceptions import (
|
|
8
12
|
SemanticEmbedError,
|
|
@@ -17,7 +21,7 @@ from .dedupe import dedupe_edges
|
|
|
17
21
|
from .explain import explain, ask
|
|
18
22
|
from .find_edges import find_edges
|
|
19
23
|
|
|
20
|
-
__version__ = "0.7.
|
|
24
|
+
__version__ = "0.7.1"
|
|
21
25
|
|
|
22
26
|
# Set this to your license key to unlock unlimited nodes:
|
|
23
27
|
# import semanticembed
|
|
@@ -28,6 +32,9 @@ __all__ = [
|
|
|
28
32
|
"encode",
|
|
29
33
|
"encode_file",
|
|
30
34
|
"encode_diff",
|
|
35
|
+
"aencode",
|
|
36
|
+
"aencode_file",
|
|
37
|
+
"aencode_diff",
|
|
31
38
|
"report",
|
|
32
39
|
"drift",
|
|
33
40
|
"explain",
|
|
@@ -373,3 +373,170 @@ def encode_diff(
|
|
|
373
373
|
result_after = encode(edges_after, license_key=license_key,
|
|
374
374
|
api_url=api_url, timeout=timeout)
|
|
375
375
|
return drift(result_before, result_after, detail=detail)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
# Async surface
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
#
|
|
382
|
+
# `aencode` / `aencode_file` / `aencode_diff` mirror the sync versions:
|
|
383
|
+
# same preflight node-count guard, same retry-once-on-5xx logic, same
|
|
384
|
+
# in-process LRU cache (shared with the sync side -- a sync encode and a
|
|
385
|
+
# subsequent async aencode of the same edges hit the same cache entry).
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
async def _apost_with_retry(
|
|
389
|
+
url: str,
|
|
390
|
+
headers: dict[str, str],
|
|
391
|
+
payload: dict,
|
|
392
|
+
timeout: float,
|
|
393
|
+
) -> httpx.Response:
|
|
394
|
+
"""Async sibling of _post_with_retry. One retry on 502/503/504/ConnectError/ReadTimeout."""
|
|
395
|
+
import asyncio
|
|
396
|
+
|
|
397
|
+
last_exc: Exception | None = None
|
|
398
|
+
for attempt in range(2):
|
|
399
|
+
try:
|
|
400
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
401
|
+
resp = await client.post(
|
|
402
|
+
f"{url}/api/v1/encode", headers=headers, json=payload
|
|
403
|
+
)
|
|
404
|
+
if resp.status_code in _RETRYABLE_STATUS and attempt == 0:
|
|
405
|
+
await asyncio.sleep(_RETRY_BACKOFF_SECONDS)
|
|
406
|
+
continue
|
|
407
|
+
return resp
|
|
408
|
+
except (httpx.ConnectError, httpx.ReadTimeout) as e:
|
|
409
|
+
last_exc = e
|
|
410
|
+
if attempt == 0:
|
|
411
|
+
await asyncio.sleep(_RETRY_BACKOFF_SECONDS)
|
|
412
|
+
continue
|
|
413
|
+
raise SemanticConnectionError(
|
|
414
|
+
f"Could not connect to SemanticEmbed API at {url} after retry: {e}"
|
|
415
|
+
) from e
|
|
416
|
+
if last_exc is not None:
|
|
417
|
+
raise SemanticConnectionError(
|
|
418
|
+
f"Could not connect to SemanticEmbed API at {url}: {last_exc}"
|
|
419
|
+
) from last_exc
|
|
420
|
+
return resp # last 5xx response
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
async def aencode(
|
|
424
|
+
edges: list,
|
|
425
|
+
*,
|
|
426
|
+
license_key: str | None = None,
|
|
427
|
+
api_url: str | None = None,
|
|
428
|
+
timeout: float = 60.0,
|
|
429
|
+
cache: bool = False,
|
|
430
|
+
) -> SemanticResult:
|
|
431
|
+
"""Async version of :func:`encode`.
|
|
432
|
+
|
|
433
|
+
Identical semantics — preflight node guard, retry-on-5xx, optional
|
|
434
|
+
cache (shared with the sync ``encode``). Useful inside FastAPI
|
|
435
|
+
handlers, asyncio task groups, or any event-loop context.
|
|
436
|
+
|
|
437
|
+
Example::
|
|
438
|
+
|
|
439
|
+
import asyncio, semanticembed as se
|
|
440
|
+
|
|
441
|
+
async def main():
|
|
442
|
+
result = await se.aencode([("a", "b"), ("b", "c")], cache=True)
|
|
443
|
+
print(result.table)
|
|
444
|
+
|
|
445
|
+
asyncio.run(main())
|
|
446
|
+
"""
|
|
447
|
+
normalized = _normalize_edges(edges)
|
|
448
|
+
if len(normalized) < 2:
|
|
449
|
+
raise ValueError("Graph must have at least 2 edges.")
|
|
450
|
+
|
|
451
|
+
if cache:
|
|
452
|
+
cache_key = _encode_cache_key(normalized)
|
|
453
|
+
hit = _cache_get(cache_key)
|
|
454
|
+
if hit is not None:
|
|
455
|
+
return hit
|
|
456
|
+
|
|
457
|
+
key = _resolve_key(license_key)
|
|
458
|
+
url = (api_url or os.environ.get("SEMANTICEMBED_API_URL") or DEFAULT_API_URL).rstrip("/")
|
|
459
|
+
|
|
460
|
+
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
461
|
+
if key:
|
|
462
|
+
headers["X-API-Key"] = key
|
|
463
|
+
|
|
464
|
+
node_set: set[str] = set()
|
|
465
|
+
for e in normalized:
|
|
466
|
+
node_set.add(e[0])
|
|
467
|
+
node_set.add(e[1])
|
|
468
|
+
n_nodes = len(node_set)
|
|
469
|
+
if not key and n_nodes > FREE_TIER_LIMIT:
|
|
470
|
+
raise NodeLimitError(n_nodes, FREE_TIER_LIMIT)
|
|
471
|
+
if not key and n_nodes > FREE_TIER_LIMIT * 0.8:
|
|
472
|
+
import warnings
|
|
473
|
+
warnings.warn(
|
|
474
|
+
f"Graph has {n_nodes}/{FREE_TIER_LIMIT} free tier nodes. "
|
|
475
|
+
f"Set a license key to avoid hitting the limit.",
|
|
476
|
+
stacklevel=2,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
payload = {"edges": normalized}
|
|
480
|
+
start = time.perf_counter()
|
|
481
|
+
resp = await _apost_with_retry(url, headers, payload, timeout)
|
|
482
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
483
|
+
|
|
484
|
+
if resp.status_code == 401:
|
|
485
|
+
raise AuthenticationError(
|
|
486
|
+
"Invalid API key. Check your license key or contact jeffmurr@seas.upenn.edu"
|
|
487
|
+
)
|
|
488
|
+
if resp.status_code == 403:
|
|
489
|
+
import re as _re
|
|
490
|
+
detail = resp.text[:300]
|
|
491
|
+
match = _re.search(r"(\d+) nodes.*limit.*?(\d+)", detail)
|
|
492
|
+
if match:
|
|
493
|
+
raise NodeLimitError(int(match.group(1)), int(match.group(2)))
|
|
494
|
+
raise NodeLimitError(n_nodes, FREE_TIER_LIMIT)
|
|
495
|
+
if resp.status_code >= 400:
|
|
496
|
+
raise APIError(resp.status_code, resp.text[:200])
|
|
497
|
+
|
|
498
|
+
result = _parse_response(resp.json(), elapsed_ms)
|
|
499
|
+
if cache:
|
|
500
|
+
_cache_put(cache_key, result)
|
|
501
|
+
return result
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
async def aencode_file(
|
|
505
|
+
path: str,
|
|
506
|
+
*,
|
|
507
|
+
license_key: str | None = None,
|
|
508
|
+
api_url: str | None = None,
|
|
509
|
+
timeout: float = 60.0,
|
|
510
|
+
cache: bool = False,
|
|
511
|
+
) -> SemanticResult:
|
|
512
|
+
"""Async version of :func:`encode_file`."""
|
|
513
|
+
import json
|
|
514
|
+
|
|
515
|
+
with open(path) as f:
|
|
516
|
+
data = json.load(f)
|
|
517
|
+
edges = data.get("edges", data) if isinstance(data, dict) else data
|
|
518
|
+
return await aencode(
|
|
519
|
+
edges, license_key=license_key, api_url=api_url, timeout=timeout, cache=cache
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
async def aencode_diff(
|
|
524
|
+
edges_before: list,
|
|
525
|
+
edges_after: list,
|
|
526
|
+
*,
|
|
527
|
+
detail: bool = True,
|
|
528
|
+
license_key: str | None = None,
|
|
529
|
+
api_url: str | None = None,
|
|
530
|
+
timeout: float = 60.0,
|
|
531
|
+
cache: bool = False,
|
|
532
|
+
) -> dict[str, dict[str, dict[str, float]]]:
|
|
533
|
+
"""Async version of :func:`encode_diff`. Issues both encodes in parallel."""
|
|
534
|
+
import asyncio
|
|
535
|
+
|
|
536
|
+
before_t, after_t = await asyncio.gather(
|
|
537
|
+
aencode(edges_before, license_key=license_key, api_url=api_url,
|
|
538
|
+
timeout=timeout, cache=cache),
|
|
539
|
+
aencode(edges_after, license_key=license_key, api_url=api_url,
|
|
540
|
+
timeout=timeout, cache=cache),
|
|
541
|
+
)
|
|
542
|
+
return drift(before_t, after_t, detail=detail)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Tests for the async surface: aencode, aencode_file, aencode_diff.
|
|
2
|
+
|
|
3
|
+
Mirrors the sync test cases — preflight, retry-once, cache, error paths.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
import pytest
|
|
13
|
+
import respx
|
|
14
|
+
|
|
15
|
+
import semanticembed as se
|
|
16
|
+
from semanticembed.client import DEFAULT_API_URL, FREE_TIER_LIMIT
|
|
17
|
+
from semanticembed.exceptions import (
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
NodeLimitError,
|
|
20
|
+
SemanticConnectionError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
VALID_BODY = {
|
|
25
|
+
"embeddings": {
|
|
26
|
+
"a": {"depth": 0.0, "independence": 0.5, "hierarchy": 0.5,
|
|
27
|
+
"throughput": 0.5, "criticality": 0.5, "fanout": 1.0},
|
|
28
|
+
"b": {"depth": 0.5, "independence": 0.5, "hierarchy": 0.5,
|
|
29
|
+
"throughput": 0.5, "criticality": 0.5, "fanout": 0.5},
|
|
30
|
+
"c": {"depth": 1.0, "independence": 0.5, "hierarchy": 0.5,
|
|
31
|
+
"throughput": 0.5, "criticality": 0.5, "fanout": 0.0},
|
|
32
|
+
},
|
|
33
|
+
"risks": [],
|
|
34
|
+
"fingerprint": {},
|
|
35
|
+
"metadata": {"n_nodes": 3, "n_edges": 2, "max_depth": 2},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
SAMPLE_EDGES = [["a", "b"], ["b", "c"]]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.fixture(autouse=True)
|
|
42
|
+
def _reset(monkeypatch):
|
|
43
|
+
se.license_key = None
|
|
44
|
+
se.clear_encode_cache()
|
|
45
|
+
monkeypatch.delenv("SEMANTICEMBED_LICENSE_KEY", raising=False)
|
|
46
|
+
monkeypatch.delenv("SEMANTICEMBED_API_KEY", raising=False)
|
|
47
|
+
monkeypatch.delenv("SEMANTICEMBED_API_URL", raising=False)
|
|
48
|
+
yield
|
|
49
|
+
se.license_key = None
|
|
50
|
+
se.clear_encode_cache()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _run(coro):
|
|
54
|
+
return asyncio.run(coro)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestAencodeHappyPath:
|
|
58
|
+
@respx.mock
|
|
59
|
+
def test_returns_result(self):
|
|
60
|
+
respx.post(f"{DEFAULT_API_URL}/api/v1/encode").mock(
|
|
61
|
+
return_value=httpx.Response(200, json=VALID_BODY)
|
|
62
|
+
)
|
|
63
|
+
result = _run(se.aencode(SAMPLE_EDGES))
|
|
64
|
+
assert set(result.vectors.keys()) == {"a", "b", "c"}
|
|
65
|
+
assert result.graph_info["nodes"] == 3
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestAencodePreflight:
|
|
69
|
+
def test_free_tier_preflight_skips_http(self):
|
|
70
|
+
edges = [[f"n{i}", f"n{i+1}"] for i in range(59)] # 60 nodes
|
|
71
|
+
with respx.mock:
|
|
72
|
+
route = respx.post(f"{DEFAULT_API_URL}/api/v1/encode").mock(
|
|
73
|
+
return_value=httpx.Response(200, json=VALID_BODY)
|
|
74
|
+
)
|
|
75
|
+
with pytest.raises(NodeLimitError) as ei:
|
|
76
|
+
_run(se.aencode(edges))
|
|
77
|
+
assert route.call_count == 0
|
|
78
|
+
assert ei.value.n_nodes == 60
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TestAencodeRetry:
|
|
82
|
+
@respx.mock
|
|
83
|
+
def test_retries_once_on_503(self):
|
|
84
|
+
route = respx.post(f"{DEFAULT_API_URL}/api/v1/encode").mock(
|
|
85
|
+
side_effect=[
|
|
86
|
+
httpx.Response(503, text="upstream"),
|
|
87
|
+
httpx.Response(200, json=VALID_BODY),
|
|
88
|
+
]
|
|
89
|
+
)
|
|
90
|
+
result = _run(se.aencode(SAMPLE_EDGES))
|
|
91
|
+
assert route.call_count == 2
|
|
92
|
+
assert result is not None
|
|
93
|
+
|
|
94
|
+
@respx.mock
|
|
95
|
+
def test_retries_once_on_connect_error(self):
|
|
96
|
+
route = respx.post(f"{DEFAULT_API_URL}/api/v1/encode").mock(
|
|
97
|
+
side_effect=[
|
|
98
|
+
httpx.ConnectError("dns fail"),
|
|
99
|
+
httpx.Response(200, json=VALID_BODY),
|
|
100
|
+
]
|
|
101
|
+
)
|
|
102
|
+
result = _run(se.aencode(SAMPLE_EDGES))
|
|
103
|
+
assert route.call_count == 2
|
|
104
|
+
assert result is not None
|
|
105
|
+
|
|
106
|
+
@respx.mock
|
|
107
|
+
def test_does_not_retry_on_401(self):
|
|
108
|
+
route = respx.post(f"{DEFAULT_API_URL}/api/v1/encode").mock(
|
|
109
|
+
return_value=httpx.Response(401, text="invalid")
|
|
110
|
+
)
|
|
111
|
+
with pytest.raises(AuthenticationError):
|
|
112
|
+
_run(se.aencode(SAMPLE_EDGES, license_key="bad"))
|
|
113
|
+
assert route.call_count == 1
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TestAencodeCache:
|
|
117
|
+
@respx.mock
|
|
118
|
+
def test_cache_hit_skips_http(self):
|
|
119
|
+
route = respx.post(f"{DEFAULT_API_URL}/api/v1/encode").mock(
|
|
120
|
+
return_value=httpx.Response(200, json=VALID_BODY)
|
|
121
|
+
)
|
|
122
|
+
r1 = _run(se.aencode(SAMPLE_EDGES, cache=True))
|
|
123
|
+
r2 = _run(se.aencode(SAMPLE_EDGES, cache=True))
|
|
124
|
+
assert route.call_count == 1
|
|
125
|
+
assert r1 is r2
|
|
126
|
+
|
|
127
|
+
@respx.mock
|
|
128
|
+
def test_async_and_sync_share_cache(self):
|
|
129
|
+
# Cache hit between sync encode -> async aencode (same edges).
|
|
130
|
+
route = respx.post(f"{DEFAULT_API_URL}/api/v1/encode").mock(
|
|
131
|
+
return_value=httpx.Response(200, json=VALID_BODY)
|
|
132
|
+
)
|
|
133
|
+
r1 = se.encode(SAMPLE_EDGES, cache=True)
|
|
134
|
+
r2 = _run(se.aencode(SAMPLE_EDGES, cache=True))
|
|
135
|
+
assert route.call_count == 1
|
|
136
|
+
assert r1 is r2
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TestAencodeFile:
|
|
140
|
+
@respx.mock
|
|
141
|
+
def test_loads_from_json_file(self, tmp_path):
|
|
142
|
+
f = tmp_path / "g.json"
|
|
143
|
+
f.write_text(json.dumps({"edges": [["a", "b"], ["b", "c"]]}))
|
|
144
|
+
respx.post(f"{DEFAULT_API_URL}/api/v1/encode").mock(
|
|
145
|
+
return_value=httpx.Response(200, json=VALID_BODY)
|
|
146
|
+
)
|
|
147
|
+
result = _run(se.aencode_file(str(f)))
|
|
148
|
+
assert set(result.vectors.keys()) == {"a", "b", "c"}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class TestAencodeDiff:
|
|
152
|
+
@respx.mock
|
|
153
|
+
def test_runs_two_encodes_in_parallel_and_returns_drift(self):
|
|
154
|
+
route = respx.post(f"{DEFAULT_API_URL}/api/v1/encode").mock(
|
|
155
|
+
return_value=httpx.Response(200, json=VALID_BODY)
|
|
156
|
+
)
|
|
157
|
+
result = _run(se.aencode_diff(
|
|
158
|
+
[["a", "b"], ["b", "c"]],
|
|
159
|
+
[["a", "b"], ["b", "c"], ["a", "c"]],
|
|
160
|
+
))
|
|
161
|
+
# Both encodes were issued.
|
|
162
|
+
assert route.call_count == 2
|
|
163
|
+
# Drift returns a dict (may be empty if identical embeddings, that's fine).
|
|
164
|
+
assert isinstance(result, dict)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class TestAencodeRequestValidation:
|
|
168
|
+
def test_too_few_edges_raises(self):
|
|
169
|
+
with pytest.raises(ValueError, match="at least 2 edges"):
|
|
170
|
+
_run(se.aencode([["a", "b"]]))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|