Merge branch 'main' of https://github.com/semantica-agi/semantica into fix/docs-explorer-auth-note

This commit is contained in:
Kyou0203
2026-08-18 12:47:01 +08:00
20 changed files with 2001 additions and 240 deletions
+18
View File
@@ -73,6 +73,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **`split`/chunking paths bypassed the centralized spaCy model cache, reloading the model on every call** (#1042, closes #998) by @Accute9, reviewed by @Sameer6305
- `semantica/split/methods.py`'s `split_by_sentences()` and `semantica/split/semantic_chunker.py`'s `SemanticChunker.__init__` each called `spacy.load()` directly instead of reusing the process-level cache added in #889/`semantic_extract/methods.py`'s `load_spacy_model()` — every call/construction re-paid the ~120ms model-load cost independently of `NERExtractor`, which already used the cache
- Both now route through `load_spacy_model()`, sharing one cached `Language` instance per model name across `split_by_sentences()`, `SemanticChunker`, and `NERExtractor`; a missing model still falls back to regex/paragraph chunking without poisoning the cache for a later successful load
- **Fixed during review** (@Sameer6305): `NERExtractor.__init__()` still had a direct `spacy.load()` call site with the same cache-bypass issue, outside the two files named in #998 but sharing the same root cause; routed through the cache alongside stale test patch targets and a strengthened cache-configuration assertion
- **Fixed during review** (@KaifAhmad1): `SemanticChunker.__init__` only caught `OSError` around `load_spacy_model()`, while the sibling fix to `NERExtractor` in this same PR added a broader `except Exception` for a model that is installed but fails at runtime (e.g. a config incompatible with the installed spaCy version). A broken-but-present model crashed `SemanticChunker()` outright instead of degrading to fallback chunking like every other path in this PR. Added the matching `except Exception` branch, leaving `self.nlp` as `None`; new `test_semantic_chunker_falls_back_when_spacy_runtime_is_broken` mirrors the existing `NERExtractor` regression test for the same scenario
- New `tests/split/test_spacy_model_cache.py`: cache reuse across repeated calls/instances, shared cache between `split_by_sentences()`/`SemanticChunker`/`NERExtractor`, distinct model names loading separately, missing-model fallback without poisoning the cache, and the broken-runtime fallback added above
- `pytest tests/split/test_spacy_model_cache.py tests/split/test_splitter.py tests/split/test_chunkers.py`: all passing (3 pre-existing, unrelated `tests/test_ner_configurations.py` failures confirmed present on `main` before this PR)
- **`export_yaml` raised a raw `AttributeError` on list input, silently wrote empty exports for unrecognized dict keys, and graph payloads were reconciled differently by every exporter** (#958, closes #956, #952, #953) by @pravit-amp, reviewed by @Sameer6305
- Graph payloads circulate under two vocabularies, `entities`/`relationships` and `nodes`/`edges`, and each exporter reconciled them locally with a different idiom — `LPGExporter` in particular dropped every entity whenever `nodes` was present but empty, the exact shape `JSONExporter` emits. A new `normalize_graph_payload()` in `utils/helpers.py` centralizes that decision once, adopted by `LPGExporter`, `ArangoAQLExporter`, `Neo4jCSVExporter`, and both YAML exporters; `ContextGraph.to_dict()` now round-trips through YAML correctly as a result
- `export_yaml(records, path)` on a bare list previously failed with `AttributeError` from inside the exporter; it and the other YAML methods now reject non-mapping input with an actionable `ProcessingError` naming the expected keys, since these formats distinguish entities/relationships/triplets and guessing which one a list represents would mislabel the records
@@ -168,6 +176,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Security
- **`Authorization`/`Proxy-Authorization` credentials could leak to a different origin across HTTP redirects, and several ingest paths bypassed the shared SSRF/redirect guard entirely** (#1067, closes #947) by @Sameer6305, reviewed by @KaifAhmad1
- `request_with_ssrf_guard()` previously only stripped sensitive headers from per-request `kwargs["headers"]` on a cross-origin redirect; session-level `Authorization`/`Proxy-Authorization` headers, `session.auth`, and `session.trust_env` (`.netrc` lookup) could all still resurrect credentials on the hop to a foreign origin. All five credential sources are now stripped case-insensitively, kept stripped for the remainder of a multi-hop redirect chain (no resurrection even if a later hop returns to the original host), and unconditionally restored via `finally` — including on exceptions and redirect-limit errors
- `MCPClient._send_request_http()` and `PublicAPIIngestor.detect_public_api()`/`ingest_public_api()` called `httpx.post()`/`requests.post()`/`session.request()` directly, bypassing `request_with_ssrf_guard()` entirely. Both now route through the shared guard, including when `validate_no_auth=False`
- `SeedDataManager.load_from_api()` mutated the caller-supplied `headers` dict in place when adding an API-key `Authorization` header, silently leaking the key back into a dict the caller might reuse elsewhere. Now copies before modifying
- **Fixed during review** (@KaifAhmad1): `allow_private_ips=True` (used to let MCP servers run on localhost/internal networks) was applied to every redirect hop, not just the operator-configured host — a compromised or malicious MCP server could 302-redirect to an internal address (e.g. `169.254.169.254` cloud metadata) and the guard would follow it unchecked, defeating the SSRF protection this PR otherwise adds. Added `allow_private_ips_on_redirect` to `request_with_ssrf_guard()`: a redirect target inherits the original host's private-IP trust only when it matches that host; any other host falls back to strict validation. `MCPClient` now pins `allow_private_ips_on_redirect=False`, so only same-host redirects on a trusted MCP server keep working — a cross-host hop into private address space is blocked
- **Fixed during review** (@KaifAhmad1): `detect_public_api()` only caught `requests.exceptions.RequestException`, but `request_with_ssrf_guard()` raises `ValidationError` (a disjoint hierarchy) for SSRF-blocked hosts, blocked redirect targets, missing `Location`, or exceeded redirect limits — unlike its sibling `ingest_public_api()`, which already caught it. Callers (including `is_public_api()`) got an undocumented raw `ValidationError` instead of `ProcessingError`, and the error-logging call was skipped. Now catches `(ValidationError, ProcessingError)` and re-raises, matching the sibling method
- **Fixed during review** (@KaifAhmad1): `detect_public_api()`/`ingest_public_api()` forwarded `**options` into `request_with_ssrf_guard(..., session=self.session, allow_private_ips=self.allow_private_ips, **request_options)` without stripping `session`/`allow_private_ips` from `request_options` first — a caller passing either through the per-call `**options` (a plausible mistake, since `allow_private_ips` is also a documented constructor-level knob) got a raw `TypeError: got multiple values for keyword argument`. Both are now popped from `request_options` before the call
- New regression coverage added during review: `TestAllowPrivateIpsOnRedirect` (cross-host redirect into private space blocked, same-host redirect trust preserved, default behavior unchanged for existing callers that don't pass the new kwarg) and `TestMCPClientAuthRedirect::test_redirect_to_private_ip_is_blocked`/`test_same_host_redirect_on_private_mcp_server_is_not_blocked` in `tests/ingest/test_auth_header_redirect_security.py`; `test_detect_public_api_propagates_ssrf_validation_error` and duplicate-kwarg regression tests for both methods in `tests/ingest/test_public_api_ingestor.py`
- `pytest tests/ingest/test_auth_header_redirect_security.py tests/ingest/test_public_api_ingestor.py tests/test_seed_manager.py tests/ingest/test_submodules.py tests/ingest/test_cookbook_integration.py`: 111 passed
- **`FeedIngestor`/`FeedMonitor` (RSS/Atom feed ingestion) had no SSRF protection, allowing requests to internal/private network targets** (#928, closes #927) by @ZohaibHassan16
- `FeedIngestor.ingest_feed()`, `discover_feeds()` (link-tag fetch, common-path HEAD probe, and feed-validation GET), and `FeedMonitor.check_updates()` all called `requests.get()`/`requests.head()` directly with default redirect-following and no scheme allowlist or private/loopback/link-local IP validation — despite `semantica/ingest/ssrf.py`'s `request_with_ssrf_guard()` already existing and being used by `web_ingestor.py`/`api_ingestor.py`. `ingest_feed()`'s own URL check only verified `urlparse(url).scheme`/`.netloc` were non-empty, never that the scheme was http/https or that the resolved target IP was safe. Reachable via the public `ingest_feed()`/`ingest()` entry points with any caller-supplied feed URL
- All 5 call sites now route through `request_with_ssrf_guard()`, which validates scheme (http/https only) and resolved IP before the request, and re-validates every redirect `Location` before following it — closing both the direct-IP and redirect-chain SSRF paths. Added an `allow_private_ips` config option to both `FeedIngestor` and `FeedMonitor`, consistent with the other ingestors
+26 -23
View File
@@ -41,6 +41,7 @@ from typing import Any, Dict, List, Optional, Union
from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.logging import get_logger
from .ssrf import request_with_ssrf_guard
@dataclass
@@ -341,36 +342,38 @@ class MCPClient:
raise
def _send_request_http(self, request: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Send request via HTTP."""
try:
import httpx
"""Send request via HTTP, with redirect-safe credential handling.
response = httpx.post(
Uses ``request_with_ssrf_guard`` so that:
* ``Authorization`` / ``Proxy-Authorization`` headers are **not**
forwarded to a different origin if the MCP server issues a redirect
(issue #947).
* The redirect chain is bounded (default 10 hops).
``allow_private_ips=True`` is set because MCP servers are explicitly
configured by the operator and frequently run on localhost or an
internal network — the same trust model as ``allow_private_ips`` opt-in
in the other ingestors. That trust covers only ``self.url`` itself:
``allow_private_ips_on_redirect=False`` keeps redirect targets held to
the normal public-address check, so a compromised or malicious MCP
server cannot use a redirect to route the client into private/
internal address space (e.g. cloud metadata) that the operator never
configured. Scheme validation (http/https only) and the
auth-stripping logic remain active regardless of these flags.
"""
try:
response = request_with_ssrf_guard(
"POST",
self.url,
json=request,
headers=self.headers,
json=request,
timeout=self.config.get("timeout", 30.0),
allow_private_ips=True,
allow_private_ips_on_redirect=False,
)
response.raise_for_status()
return response.json()
except (ImportError, OSError):
# Fallback to requests if httpx not available
try:
import requests
response = requests.post(
self.url,
json=request,
headers=self.headers,
timeout=self.config.get("timeout", 30.0),
)
response.raise_for_status()
return response.json()
except (ImportError, OSError):
raise ProcessingError(
"HTTP transport requires 'httpx' or 'requests' package. "
"Install with: pip install httpx or pip install requests"
)
except Exception as e:
self.logger.error(f"Failed to send HTTP request: {e}")
raise
+32 -6
View File
@@ -45,6 +45,7 @@ except ModuleNotFoundError: # pragma: no cover - fallback for minimal installs
from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.logging import get_logger
from .api_ingestor import APIData, RESTIngestor
from .ssrf import request_with_ssrf_guard
AUTH_HEADER_NAMES = {
"authorization",
@@ -359,18 +360,31 @@ class PublicAPIIngestor(RESTIngestor):
request_options = options.copy()
timeout = request_options.pop("timeout", self.config.get("timeout", 30))
rate_limit_delay = request_options.pop("rate_limit_delay", None)
# session and allow_private_ips are always supplied explicitly below;
# drop any caller-provided copies so request_with_ssrf_guard() does
# not receive duplicate keyword arguments.
request_options.pop("session", None)
request_options.pop("allow_private_ips", None)
request_headers = self._merged_headers(headers)
try:
self._wait_if_needed(rate_limit_delay=rate_limit_delay)
response = self.session.request(
method=method,
url=endpoint,
# Route through the SSRF guard so that:
# * redirects to private/loopback IPs are blocked, and
# * Authorization / Proxy-Authorization are stripped on
# cross-origin redirects (issue #947).
response = request_with_ssrf_guard(
method,
endpoint,
session=self.session,
headers=request_headers,
params=params,
timeout=timeout,
allow_private_ips=self.allow_private_ips,
**request_options,
)
except (ValidationError, ProcessingError):
raise
except requests.exceptions.RequestException as exc:
self.logger.error(f"Failed to detect public API {endpoint}: {exc}")
raise ProcessingError(f"Failed to detect public API: {exc}") from exc
@@ -440,18 +454,30 @@ class PublicAPIIngestor(RESTIngestor):
request_options = options.copy()
timeout = request_options.pop("timeout", self.config.get("timeout", 30))
# session and allow_private_ips are always supplied explicitly below;
# drop any caller-provided copies so request_with_ssrf_guard() does
# not receive duplicate keyword arguments.
request_options.pop("session", None)
request_options.pop("allow_private_ips", None)
request_headers = self._merged_headers(headers)
try:
self._wait_if_needed(rate_limit_delay=rate_limit_delay)
response = self.session.request(
method=method,
url=endpoint,
# Route through the SSRF guard so that:
# * redirects to private/loopback IPs are blocked, and
# * Authorization / Proxy-Authorization are stripped on
# cross-origin redirects even when validate_no_auth=False
# (issue #947).
response = request_with_ssrf_guard(
method,
endpoint,
session=self.session,
headers=request_headers,
params=params,
data=data,
json=json_data,
timeout=timeout,
allow_private_ips=self.allow_private_ips,
**request_options,
)
+184 -43
View File
@@ -268,6 +268,7 @@ def request_with_ssrf_guard(
*,
session: Optional[requests.Session] = None,
allow_private_ips: bool = False,
allow_private_ips_on_redirect: Optional[bool] = None,
max_redirects: int = _DEFAULT_MAX_REDIRECTS,
**kwargs: Any,
) -> requests.Response:
@@ -277,10 +278,65 @@ def request_with_ssrf_guard(
public URL to bounce into private/loopback/link-local space. This helper
disables automatic redirects and re-validates each ``Location`` target
before issuing the next hop.
``allow_private_ips`` trusts the caller's own *url* (e.g. an
operator-configured internal endpoint). That trust follows a redirect
only when the redirect target's host matches the original host (e.g. a
same-host path redirect on a private/localhost server); a redirect to a
*different* host is validated with ``allow_private_ips_on_redirect``
instead, which defaults to ``allow_private_ips`` for backward
compatibility but can be pinned to ``False`` by callers that want to
trust only the original host and never extend private-IP eligibility to
any other host a redirect chain might reach otherwise a private-IP-
eligible endpoint could be tricked into redirecting into arbitrary
internal address space (e.g. cloud metadata) the caller never
configured.
Authorization / credential-header handling (issue #947)
--------------------------------------------------------
Credentials are stripped from **all** sources that ``requests`` can use to
attach an ``Authorization`` header whenever a redirect changes origin:
1. ``kwargs["headers"]`` per-request header dict (already handled).
2. ``session.headers`` session-level headers that ``requests`` merges
automatically; cleared for the hop and restored via ``finally``.
3. ``kwargs["auth"]`` per-request auth tuple/callable; removed from the
local ``kwargs`` copy when stripping is required. This copy never
escapes to the caller, so there is nothing to restore.
4. ``session.auth`` session-level auth handler that ``requests`` merges
via ``merge_setting(auth, self.auth)`` inside ``prepare_request``;
cleared for the hop and restored via ``finally``.
5. ``session.trust_env`` when ``True``, ``requests`` reads ``~/.netrc``
for the *redirect target* host and calls ``prepare_auth()`` with those
credentials even after sources 3 and 4 are cleared; disabled for
cross-origin hops and restored via ``finally``.
Leaving any one of these intact allows ``requests`` to re-attach
credentials on the hop to the foreign origin, defeating the header-level
strip.
Session state that was removed is unconditionally restored in a ``finally``
block so the session is left in its original state after this call returns,
regardless of how it exits (normal return, exception, redirect cap). The
loop is sequential and single-threaded within one call, so the mutation is
safe as long as the caller does not share the session across concurrent
threads (the standard Semantica pattern: one session per ingestor instance).
Once credentials have been stripped for a cross-origin hop they are NOT
re-added for subsequent hops in the same chain, even if a later hop
happens to point back to the original host. This prevents credential
resurrection via crafted multi-hop redirect chains.
"""
kwargs = dict(kwargs)
kwargs.pop("allow_redirects", None)
redirect_allow_private_ips = (
allow_private_ips
if allow_private_ips_on_redirect is None
else allow_private_ips_on_redirect
)
_original_host = (urlparse(url).hostname or "").lower()
validate_url_for_request(url, allow_private_ips=allow_private_ips)
requester = session.request if session is not None else requests.request
@@ -288,56 +344,141 @@ def request_with_ssrf_guard(
current_method = method.upper()
redirects_followed = 0
while True:
response = requester(
current_method,
current_url,
allow_redirects=False,
**kwargs,
)
# -- issue #947: snapshot every session-level credential source so we can
# restore them unconditionally when this call exits.
_SENSITIVE = ("Authorization", "Proxy-Authorization")
_session_auth_backup: dict = {}
_session_auth_handler_backup: Any = None # session.auth backup
_session_trust_env_backup: bool = True # session.trust_env backup
if response.status_code not in _REDIRECT_STATUS_CODES:
return response
if session is not None:
for _h in _SENSITIVE:
# requests stores session headers in a case-insensitive dict;
# .get() matches regardless of the casing used at insertion time.
_val = session.headers.get(_h)
if _val is not None:
_session_auth_backup[_h] = _val
# Snapshot session.auth (HTTPBasicAuth, tuple, callable, or None).
_session_auth_handler_backup = session.auth
# Snapshot session.trust_env (controls .netrc / env proxy lookup).
_session_trust_env_backup = session.trust_env
if redirects_followed >= max_redirects:
response.close()
raise ValidationError(
f"Exceeded maximum redirects ({max_redirects}) while "
f"fetching '{url}'"
# Track whether credentials have been stripped for this redirect chain.
# Once stripped they must not reappear on any subsequent hop.
_auth_stripped = False
try:
while True:
response = requester(
current_method,
current_url,
allow_redirects=False,
**kwargs,
)
location = response.headers.get("Location")
if not location or not str(location).strip():
response.close()
raise ValidationError(
f"Redirect from '{current_url}' is missing a Location header"
if response.status_code not in _REDIRECT_STATUS_CODES:
return response
if redirects_followed >= max_redirects:
response.close()
raise ValidationError(
f"Exceeded maximum redirects ({max_redirects}) while "
f"fetching '{url}'"
)
location = response.headers.get("Location")
if not location or not str(location).strip():
response.close()
raise ValidationError(
f"Redirect from '{current_url}' is missing a Location header"
)
next_url = urljoin(current_url, str(location).strip())
next_host = (urlparse(next_url).hostname or "").lower()
# A redirect back to the original host inherits the caller's
# trust in that host (e.g. a same-host path redirect on a
# private/localhost MCP server). A redirect to a *different*
# host must not inherit that trust, even if the original host
# was private/internal — otherwise a compromised or malicious
# endpoint could redirect into arbitrary private address space
# (e.g. cloud metadata) the caller never configured.
hop_allow_private_ips = (
allow_private_ips
if next_host and next_host == _original_host
else redirect_allow_private_ips
)
validate_url_for_request(next_url, allow_private_ips=hop_allow_private_ips)
next_url = urljoin(current_url, str(location).strip())
validate_url_for_request(next_url, allow_private_ips=allow_private_ips)
# Do not leak sensitive headers or auth handlers to a different
# origin on redirects. All four credential sources are cleared:
# • kwargs["headers"] — per-request header dict
# • session.headers — session-level header dict
# • kwargs["auth"] — per-request auth tuple/callable
# • session.auth — session-level auth handler
#
# Once stripped (_auth_stripped=True), credentials stay absent for
# the remainder of the chain — even if a later hop targets the
# original host — to prevent credential resurrection.
if _auth_stripped or _should_strip_auth(current_url, next_url):
_auth_stripped = True
# Do not leak sensitive headers to a different origin on redirects:
# reuse the caller's headers only while host, port, and scheme keep
# the credential safe, mirroring requests' should_strip_auth.
if _should_strip_auth(current_url, next_url):
kwargs = dict(kwargs)
headers = dict(kwargs.get("headers") or {})
for sensitive in ("Authorization", "Proxy-Authorization"):
headers.pop(sensitive, None)
kwargs["headers"] = headers
# 1. Strip from per-request kwargs headers.
kwargs = dict(kwargs)
headers = dict(kwargs.get("headers") or {})
for sensitive in _SENSITIVE:
headers.pop(sensitive, None)
# Also remove any case variant the caller may have used
# (e.g. "authorization" or "AUTHORIZATION").
for key in list(headers):
if key.lower() == sensitive.lower():
del headers[key]
kwargs["headers"] = headers
# Match requests' historical method rewriting for 301/302/303.
if (
response.status_code in _STRIP_BODY_ON_REDIRECT
and current_method not in {"GET", "HEAD"}
):
current_method = "GET"
for key in ("data", "json", "files"):
kwargs.pop(key, None)
# 2. Strip per-request auth kwarg so requests cannot call
# prepare_auth() with the caller's credential on this hop.
kwargs.pop("auth", None)
# Params apply to the original request URL only; Location is authoritative.
kwargs.pop("params", None)
# 3. Strip session-level headers so requests cannot re-inject
# them when merging session + per-request headers for this hop.
if session is not None:
for sensitive in _SENSITIVE:
# CaseInsensitiveDict.pop(key, None) handles any casing.
session.headers.pop(sensitive, None)
response.close()
current_url = next_url
redirects_followed += 1
# 4. Clear session.auth so prepare_request's merge_setting()
# cannot fall back to the session-level auth handler and
# reattach credentials on the foreign-origin hop.
session.auth = None
# 5. Disable .netrc / environment-proxy credential lookup so
# requests cannot inject credentials from ~/.netrc for the
# redirect target host on this hop.
session.trust_env = False
# Match requests' historical method rewriting for 301/302/303.
if (
response.status_code in _STRIP_BODY_ON_REDIRECT
and current_method not in {"GET", "HEAD"}
):
current_method = "GET"
for key in ("data", "json", "files"):
kwargs.pop(key, None)
# Params apply to the original request URL only; Location is authoritative.
kwargs.pop("params", None)
response.close()
current_url = next_url
redirects_followed += 1
finally:
# Unconditionally restore every session credential source we touched,
# so the session is in its original state after this call returns or raises.
if session is not None:
if _session_auth_backup:
for _h, _v in _session_auth_backup.items():
session.headers[_h] = _v
# Restore session.auth to whatever it was before this call.
session.auth = _session_auth_handler_backup
# Restore session.trust_env (.netrc / env-proxy lookup flag).
session.trust_env = _session_trust_env_backup
+5 -2
View File
@@ -501,8 +501,11 @@ class SeedDataManager:
else:
full_url = api_url
# Prepare headers
request_headers = headers or {}
# Prepare headers — copy the caller's dict so we never mutate it in-place.
# Without the copy, adding "Authorization" here would silently modify the
# caller's original dict and potentially leak the key to subsequent calls
# that reuse the same dict without expecting it to contain credentials.
request_headers = dict(headers) if headers else {}
if api_key:
request_headers["Authorization"] = f"Bearer {api_key}"
+6 -1
View File
@@ -144,7 +144,12 @@ class NERExtractor:
self._ml_runtime_usable = True
if "ml" in self.method and SPACY_AVAILABLE:
try:
self.nlp = spacy.load(self.model_name)
# Deferred import: keeps semantic_extract.methods out of the
# module-level import graph and routes loading through the
# process-level cache so repeated NERExtractor constructions
# never pay the ~120 ms spacy.load() cost more than once.
from .methods import load_spacy_model
self.nlp = load_spacy_model(self.model_name)
except OSError:
self.logger.warning(
f"spaCy model {self.model_name} not found. ML method will fallback."
+3 -2
View File
@@ -97,7 +97,7 @@ from .semantic_chunker import Chunk
logger = get_logger("split_methods")
# Try to import optional dependencies
spacy, SPACY_AVAILABLE = safe_import("spacy")
_, SPACY_AVAILABLE = safe_import("spacy")
nltk, NLTK_AVAILABLE = safe_import("nltk")
tiktoken, TIKTOKEN_AVAILABLE = safe_import("tiktoken")
@@ -336,7 +336,8 @@ def split_by_sentences(
# Try spaCy first
if SPACY_AVAILABLE and kwargs.get("use_spacy", True):
try:
nlp = spacy.load("en_core_web_sm")
from ..semantic_extract.methods import load_spacy_model
nlp = load_spacy_model("en_core_web_sm")
doc = nlp(text)
sentences = [sent.text for sent in doc.sents]
except Exception:
+11 -2
View File
@@ -36,7 +36,8 @@ from ..utils.helpers import safe_import
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
spacy, SPACY_AVAILABLE = safe_import("spacy")
_, SPACY_AVAILABLE = safe_import("spacy")
@dataclass
@@ -79,11 +80,19 @@ class SemanticChunker:
if SPACY_AVAILABLE:
model_name = config.get("model", "en_core_web_sm")
try:
self.nlp = spacy.load(model_name)
from ..semantic_extract.methods import load_spacy_model
self.nlp = load_spacy_model(model_name)
except OSError:
self.logger.warning(
f"spaCy model {model_name} not found. Using fallback chunking."
)
except Exception:
self.logger.warning(
"spaCy model %s failed to initialize and will be disabled "
"for this chunker instance. Using fallback chunking.",
model_name,
exc_info=True,
)
def chunk(self, text: str, **options) -> List[Chunk]:
"""
+20 -8
View File
@@ -398,9 +398,7 @@ def chunk_list(items: List[Any], chunk_size: int) -> List[List[Any]]:
Returns:
List of chunks
"""
return [items[i : i + chunk_size] for i in range(0, len(items), chunk_size)]
return [items[i : i + chunk_size] for i in range(0, len(items), chunk_size)]
def flatten_dict(
d: Dict[str, Any], parent_key: str = "", sep: str = "."
) -> Dict[str, Any]:
@@ -414,18 +412,32 @@ def flatten_dict(
Returns:
Flattened dictionary
Raises:
ValueError: If two input paths produce the same flattened key.
"""
items = []
result = {}
for k, v in d.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
if isinstance(v, dict):
items.extend(flatten_dict(v, new_key, sep=sep).items())
else:
items.append((new_key, v))
nested = flatten_dict(v, new_key, sep=sep)
return dict(items)
for key, value in nested.items():
if key in result:
raise ValueError(
f"Key collision while flattening dictionary: {key}"
)
result[key] = value
else:
if new_key in result:
raise ValueError(
f"Key collision while flattening dictionary: {new_key}"
)
result[new_key] = v
return result
def get_nested_value(
+35
View File
@@ -0,0 +1,35 @@
"""
Shared pytest fixtures for the ingest test suite.
The ``mock_dns`` fixture is applied to *every* test in this directory
(``autouse=True``). It stubs out ``socket.getaddrinfo`` inside the SSRF
guard module so that unit tests that mock ``requests.Session.request`` do not
accidentally hit the network for DNS resolution which would fail in offline
CI environments and cause intermittent timeouts.
Tests that explicitly need to exercise DNS-related behaviour (e.g. checking
that a hostname resolving to a private IP is blocked) override this fixture
by patching ``semantica.ingest.ssrf.socket.getaddrinfo`` with their own
``side_effect`` *inside* the test body; that inner patch wins because
``unittest.mock.patch`` applies patches in innermost-last order.
"""
from __future__ import annotations
import socket
from unittest.mock import patch
import pytest
_PUBLIC_IP = "93.184.216.34" # example.com — a safe, routable public address
@pytest.fixture(autouse=True)
def mock_dns():
"""Map every hostname to a safe public IP for the duration of each test."""
with patch(
"semantica.ingest.ssrf.socket.getaddrinfo",
return_value=[
(socket.AF_INET, socket.SOCK_STREAM, 6, "", (_PUBLIC_IP, 0))
],
):
yield
File diff suppressed because it is too large Load Diff
+20 -20
View File
@@ -10,19 +10,20 @@ class TestCookbookIntegration:
@pytest.fixture
def mock_mcp_server(self):
# We need to patch both httpx and requests because MCPClient tries httpx first
with patch("httpx.post") as mock_httpx_post, \
patch("requests.post") as mock_requests_post:
def side_effect(url, json=None, **kwargs):
# MCPClient._send_request_http now routes through request_with_ssrf_guard,
# which calls requests.request (not httpx.post / requests.post directly).
# Patch at the point where the guard issues the actual HTTP call.
with patch("semantica.ingest.ssrf.requests.request") as mock_request:
def side_effect(method, url, json=None, **kwargs):
if not json:
return MagicMock()
method = json.get("method")
rpc_method = json.get("method")
response_mock = MagicMock()
response_mock.status_code = 200
if method == "initialize":
if rpc_method == "initialize":
response_mock.json.return_value = {
"jsonrpc": "2.0",
"id": json.get("id"),
@@ -32,7 +33,7 @@ class TestCookbookIntegration:
"serverInfo": {"name": "test_server", "version": "1.0"}
}
}
elif method == "resources/list":
elif rpc_method == "resources/list":
response_mock.json.return_value = {
"jsonrpc": "2.0",
"id": json.get("id"),
@@ -44,7 +45,7 @@ class TestCookbookIntegration:
]
}
}
elif method == "tools/list":
elif rpc_method == "tools/list":
response_mock.json.return_value = {
"jsonrpc": "2.0",
"id": json.get("id"),
@@ -56,7 +57,7 @@ class TestCookbookIntegration:
]
}
}
elif method == "resources/read":
elif rpc_method == "resources/read":
response_mock.json.return_value = {
"jsonrpc": "2.0",
"id": json.get("id"),
@@ -66,13 +67,13 @@ class TestCookbookIntegration:
]
}
}
elif method == "tools/call":
elif rpc_method == "tools/call":
tool_name = json.get("params", {}).get("name")
content = [{"type": "text", "text": "Tool Output"}]
if tool_name == "query_inventory":
content = [{"type": "text", "text": '{"warehouse_id": "WH001", "level": 100}'}]
response_mock.json.return_value = {
"jsonrpc": "2.0",
"id": json.get("id"),
@@ -86,12 +87,11 @@ class TestCookbookIntegration:
"id": json.get("id"),
"result": {}
}
return response_mock
mock_httpx_post.side_effect = side_effect
mock_requests_post.side_effect = side_effect
yield mock_httpx_post
mock_request.side_effect = side_effect
yield mock_request
def test_financial_data_integration(self, mock_mcp_server):
"""
+78 -7
View File
@@ -198,15 +198,82 @@ def test_public_api_detection_reports_auth_required() -> None:
headers={"WWW-Authenticate": "Bearer"},
)
detection = PublicAPIIngestor(rate_limit_delay=0).detect_public_api(
"https://api.example.com/private"
)
with patch(
"semantica.ingest.ssrf.socket.getaddrinfo",
return_value=[(None, None, None, None, ("93.184.216.34", 0))],
):
detection = PublicAPIIngestor(rate_limit_delay=0).detect_public_api(
"https://api.example.com/private"
)
assert detection.is_public is False
assert detection.requires_auth is True
assert detection.response_status == 401
def test_detect_public_api_propagates_ssrf_validation_error() -> None:
"""detect_public_api() must surface ValidationError, not swallow it.
request_with_ssrf_guard() raises ValidationError (not
requests.exceptions.RequestException) for SSRF-blocked hosts, so
detect_public_api()'s error handling must catch it explicitly like its
sibling ingest_public_api() already does.
"""
with patch("requests.Session") as mock_session_class:
mock_session = mock_session_class.return_value
mock_session.headers = {}
with patch(
"semantica.ingest.ssrf.socket.getaddrinfo",
return_value=[(None, None, None, None, ("127.0.0.1", 0))],
):
with pytest.raises(ValidationError):
PublicAPIIngestor(rate_limit_delay=0).detect_public_api(
"https://blocked.example.com/data"
)
def test_detect_public_api_rejects_duplicate_session_and_allow_private_ips_kwargs() -> None:
"""Passing session/allow_private_ips through **options must not crash.
Both are always supplied explicitly to request_with_ssrf_guard(); caller
copies must be dropped from **options rather than causing a
'got multiple values for keyword argument' TypeError.
"""
with patch("requests.Session") as mock_session_class:
mock_session = mock_session_class.return_value
mock_session.headers = {}
mock_session.request.return_value = _mock_response(
headers={"Content-Type": "application/json"}
)
detection = PublicAPIIngestor(rate_limit_delay=0).detect_public_api(
"https://jsonplaceholder.typicode.com/posts",
allow_private_ips=True,
session=object(),
)
assert detection.is_public is True
def test_ingest_public_api_rejects_duplicate_session_and_allow_private_ips_kwargs() -> None:
with patch("requests.Session") as mock_session_class:
mock_session = mock_session_class.return_value
mock_session.headers = {}
mock_session.request.return_value = _mock_response(
json_payload=[{"id": 1}],
headers={"Content-Type": "application/json"},
)
result = PublicAPIIngestor(rate_limit_delay=0).ingest_public_api(
"https://jsonplaceholder.typicode.com/posts",
allow_private_ips=True,
session=object(),
)
assert result.response_status == 200
def test_public_api_ingestor_rejects_authentication_inputs() -> None:
with patch("requests.Session") as mock_session_class:
mock_session = mock_session_class.return_value
@@ -238,10 +305,14 @@ def test_public_api_ingestor_parses_string_boolean_config() -> None:
config={"validate_no_auth": "false"},
rate_limit_delay=0,
)
result = ingestor.ingest_public_api(
"https://api.example.com/data",
headers={"Authorization": "Bearer token"},
)
with patch(
"semantica.ingest.ssrf.socket.getaddrinfo",
return_value=[(None, None, None, None, ("93.184.216.34", 0))],
):
result = ingestor.ingest_public_api(
"https://api.example.com/data",
headers={"Authorization": "Bearer token"},
)
assert ingestor.validate_no_auth is False
assert result.data == payload
+54 -81
View File
@@ -195,89 +195,62 @@ class TestMCPIngestor:
class TestMCPClient:
def test_call_tool(self):
# Patch requests.post globally if requests is used, or httpx.post if httpx is used.
# The code tries importing httpx, then requests.
# We should patch both or ensure we catch the right one.
# Simpler to patch sys.modules to simulate httpx missing, then patch requests.
with patch.dict(sys.modules, {'httpx': None}):
with patch("requests.post") as mock_post:
mock_response = MagicMock()
mock_response.status_code = 200
# Sequence of calls:
# 1. connect() calls _connect_http() -> calls _initialize() -> calls _send_request()
# _send_request() calls requests.post with method="initialize"
# 2. call_tool() calls _send_request() with method="tools/call"
# Response for initialize
init_response = {
"jsonrpc": "2.0",
"result": {"serverInfo": {"name": "test", "version": "1.0"}},
"id": 1
}
# Response for tool call
tool_response = {
"jsonrpc": "2.0",
"result": {"content": [{"type": "text", "text": "Tool Result"}]},
"id": 2
}
mock_response.json.side_effect = [init_response, tool_response]
mock_post.return_value = mock_response
client = MCPClient(url="http://localhost:8000")
client.connect()
result = client.call_tool("my_tool", {"arg": "val"})
# result is the dict returned by tool call?
# call_tool returns dict?
# Check MCPClient.call_tool implementation
# It calls _send_request, which returns response.json().
# But wait, call_tool might process the result.
# Let's check call_tool implementation in mcp_client.py (not read yet, but assumed).
# Wait, I read mcp_client.py but didn't check call_tool specifically.
# Assuming call_tool returns result part or whole response.
# Actually, let's verify call_tool in mcp_client.py
pass
# MCPClient._send_request_http now routes through request_with_ssrf_guard,
# which calls requests.request (not requests.post) with allow_redirects=False.
# Patch the requests.request call inside ssrf.py.
with patch("semantica.ingest.ssrf.requests.request") as mock_request:
mock_response = MagicMock()
mock_response.status_code = 200
# Response for initialize
init_response = {
"jsonrpc": "2.0",
"result": {"serverInfo": {"name": "test", "version": "1.0"}},
"id": 1,
}
# Response for tool call
tool_response = {
"jsonrpc": "2.0",
"result": {"content": [{"type": "text", "text": "Tool Result"}]},
"id": 2,
}
mock_response.json.side_effect = [init_response, tool_response]
mock_request.return_value = mock_response
client = MCPClient(url="http://localhost:8000")
client.connect()
client.call_tool("my_tool", {"arg": "val"})
def test_call_tool_mock_check(self):
# Redoing the test with more specific mocking logic
with patch.dict(sys.modules, {'httpx': None}):
with patch("requests.post") as mock_post:
mock_response = MagicMock()
mock_response.status_code = 200
# initialize response
init_response = {
"jsonrpc": "2.0",
"result": {"serverInfo": {"name": "test", "version": "1.0"}},
"id": 1
}
# tool call response - Assuming call_tool returns the 'result' part of JSON-RPC response
# If call_tool implementation wraps it, we need to know.
# Let's assume standard behavior for now.
tool_response = {
"jsonrpc": "2.0",
"result": {"content": [{"type": "text", "text": "Tool Result"}]},
"id": 2
}
mock_response.json.side_effect = [init_response, tool_response]
mock_post.return_value = mock_response
client = MCPClient(url="http://localhost:8000")
client.connect()
result = client.call_tool("my_tool", {"arg": "val"})
# Verify result.
# If call_tool returns the 'result' dict from JSON-RPC:
assert result["content"] == [{"type": "text", "text": "Tool Result"}]
# Redo with the corrected patch target.
with patch("semantica.ingest.ssrf.requests.request") as mock_request:
mock_response = MagicMock()
mock_response.status_code = 200
init_response = {
"jsonrpc": "2.0",
"result": {"serverInfo": {"name": "test", "version": "1.0"}},
"id": 1,
}
tool_response = {
"jsonrpc": "2.0",
"result": {"content": [{"type": "text", "text": "Tool Result"}]},
"id": 2,
}
mock_response.json.side_effect = [init_response, tool_response]
mock_request.return_value = mock_response
client = MCPClient(url="http://localhost:8000")
client.connect()
result = client.call_tool("my_tool", {"arg": "val"})
assert result["content"] == [{"type": "text", "text": "Tool Result"}]
class TestGDriveIngestor:
def test_init_raises_if_no_google_libs(self):
+321
View File
@@ -0,0 +1,321 @@
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from semantica.semantic_extract import methods as se_methods
from semantica.split import methods as split_methods
from semantica.split import semantic_chunker
from semantica.semantic_extract import ner_extractor as ner_extractor_module
from semantica.semantic_extract.ner_extractor import NERExtractor
@pytest.fixture(autouse=True)
def clear_cache():
se_methods.clear_spacy_model_cache()
yield
se_methods.clear_spacy_model_cache()
@pytest.fixture(autouse=True)
def force_spacy_available(monkeypatch):
# split.methods, split.semantic_chunker, and ner_extractor each compute
# their own SPACY_AVAILABLE flag from the real environment at import time;
# force all true so these tests exercise the spaCy branch regardless of
# whether spaCy is actually installed where they run.
monkeypatch.setattr(split_methods, "SPACY_AVAILABLE", True)
monkeypatch.setattr(semantic_chunker, "SPACY_AVAILABLE", True)
monkeypatch.setattr(ner_extractor_module, "SPACY_AVAILABLE", True)
def _fake_spacy(load):
return SimpleNamespace(
load=load,
util=SimpleNamespace(is_package=lambda _name: True),
)
def _nlp_mock(sentences=("Hello world.",)):
"""A stand-in spaCy Language object: callable, returns a doc with .sents."""
nlp = MagicMock()
nlp.return_value = SimpleNamespace(
sents=[SimpleNamespace(text=s) for s in sentences]
)
return nlp
class TestSpacyModelCache:
"""split.methods and split.semantic_chunker must share the cached model
defined in semantic_extract.methods instead of each calling spacy.load()
independently.
"""
def test_split_by_sentences_reuses_cached_model(self, monkeypatch):
calls = []
def fake_load(name, **kwargs):
calls.append((name, kwargs))
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
split_methods.split_by_sentences("Hello world. Bye world.")
split_methods.split_by_sentences("Another sentence here.")
split_methods.split_by_sentences("A third call.")
assert len(calls) == 1, "spacy.load should run once, not once per call"
assert calls[0][0] == "en_core_web_sm"
def test_semantic_chunker_reuses_cached_model_across_instances(self, monkeypatch):
calls = []
def fake_load(name, **kwargs):
calls.append((name, kwargs))
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
chunker1 = semantic_chunker.SemanticChunker()
chunker2 = semantic_chunker.SemanticChunker()
assert len(calls) == 1, "each new SemanticChunker should not reload the model"
assert chunker1.nlp is chunker2.nlp
def test_split_methods_and_semantic_chunker_share_the_cache(self, monkeypatch):
calls = []
def fake_load(name, **kwargs):
calls.append((name, kwargs))
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
split_methods.split_by_sentences("Test sentence for split.methods.")
semantic_chunker.SemanticChunker()
assert len(calls) == 1, (
"split.methods and split.semantic_chunker must share one cached "
"model instead of each loading their own"
)
def test_distinct_model_names_load_separately(self, monkeypatch):
calls = []
def fake_load(name, **kwargs):
calls.append((name, kwargs))
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
sm_chunker = semantic_chunker.SemanticChunker(model="en_core_web_sm")
lg_chunker = semantic_chunker.SemanticChunker(model="en_core_web_lg")
sm_chunker_again = semantic_chunker.SemanticChunker(model="en_core_web_sm")
assert [name for name, _ in calls] == ["en_core_web_sm", "en_core_web_lg"]
assert sm_chunker.nlp is sm_chunker_again.nlp
assert sm_chunker.nlp is not lg_chunker.nlp
def test_no_disable_kwarg_requested(self, monkeypatch):
"""split.methods and split.semantic_chunker both want the full
pipeline (they need .sents, which requires the parser/senter). If
either one later starts requesting a trimmed pipeline (e.g.
disable=["ner"]), the name-only cache key in load_spacy_model would
silently hand back a cached model built for a different config --
this test should catch that the moment it happens.
"""
calls = []
def fake_load(_name, **kwargs):
calls.append(kwargs)
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
split_methods.split_by_sentences("Hello world.")
se_methods.clear_spacy_model_cache()
semantic_chunker.SemanticChunker()
assert len(calls) == 2
assert all(kwargs == {} for kwargs in calls), (
"neither caller should pass any pipeline-configuration kwargs; "
"the name-only cache key in load_spacy_model cannot distinguish "
"models loaded with different component configs"
)
def test_missing_model_falls_back_without_poisoning_cache(self, monkeypatch):
attempts = []
def failing_load(name, **_kwargs):
attempts.append(name)
raise OSError(f"Can't find model '{name}'")
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(failing_load))
# split_by_sentences should fall back to regex splitting, not raise
chunks = split_methods.split_by_sentences("Hello world. Bye world.")
assert chunks, "fallback splitting should still produce chunks"
# SemanticChunker should leave .nlp as None rather than propagate
chunker = semantic_chunker.SemanticChunker()
assert chunker.nlp is None
assert len(attempts) == 2, "a failed load must not be cached"
# Once the model is available, both callers should now get it, and
# share a single successful load.
def working_load(name, **_kwargs):
attempts.append(name)
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(working_load))
chunker2 = semantic_chunker.SemanticChunker()
split_methods.split_by_sentences("One more sentence.")
assert len(attempts) == 3, (
"the model should load once after it becomes available"
)
assert chunker2.nlp is not None
def test_semantic_chunker_falls_back_when_spacy_runtime_is_broken(
self, monkeypatch
):
"""A spaCy model that is installed but unusable at runtime (e.g. a
config incompatible with the installed spaCy version) must degrade
SemanticChunker to fallback chunking, not crash __init__ -- mirrors
TestNERExtractorSpacyModelCache's equivalent broken-runtime test.
"""
def broken_load(name, **_kwargs):
raise RuntimeError("ConfigSchemaNlp is not fully defined")
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(broken_load))
chunker = semantic_chunker.SemanticChunker()
assert chunker.nlp is None
class TestNERExtractorSpacyModelCache:
"""NERExtractor(method="ml") must reuse the centralized cache in
semantic_extract.methods, not call spacy.load() on every construction.
These tests mirror TestSpacyModelCache but focus on the NERExtractor path,
confirming that all three callers (split_by_sentences, SemanticChunker, and
NERExtractor) draw from the same process-level cache.
"""
def test_ner_extractor_reuses_cached_model_across_instances(self, monkeypatch):
"""Two NERExtractor(method='ml') constructions with the same model name
must cause exactly one underlying spacy.load() call."""
calls = []
def fake_load(name, **kwargs):
calls.append(name)
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
e1 = NERExtractor(method="ml")
e2 = NERExtractor(method="ml")
e3 = NERExtractor(method="ml", model="en_core_web_sm")
assert len(calls) == 1, (
"repeated NERExtractor constructions should not reload the model"
)
assert e1.nlp is e2.nlp is e3.nlp
def test_ner_extractor_and_split_callers_share_one_cached_model(self, monkeypatch):
"""NERExtractor, SemanticChunker, and split_by_sentences must all use
the same cached Language object for the same model name."""
calls = []
def fake_load(name, **kwargs):
calls.append(name)
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
split_methods.split_by_sentences("First sentence.")
semantic_chunker.SemanticChunker()
NERExtractor(method="ml")
assert len(calls) == 1, (
"split_by_sentences, SemanticChunker, and NERExtractor must share "
"one cached model instead of each loading their own"
)
def test_ner_extractor_distinct_model_names_load_separately(self, monkeypatch):
"""Different model names must produce separate cache entries."""
calls = []
def fake_load(name, **kwargs):
calls.append(name)
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
sm = NERExtractor(method="ml", model="en_core_web_sm")
lg = NERExtractor(method="ml", model="en_core_web_lg")
sm_again = NERExtractor(method="ml", model="en_core_web_sm")
assert calls == ["en_core_web_sm", "en_core_web_lg"]
assert sm.nlp is sm_again.nlp
assert sm.nlp is not lg.nlp
def test_ner_extractor_failed_load_not_cached_and_retried(self, monkeypatch):
"""A missing model must not poison the cache. A subsequent construction
after the model becomes available must succeed and share the loaded model."""
attempts = []
def failing_load(name, **_kwargs):
attempts.append(name)
raise OSError(f"Can't find model '{name}'")
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(failing_load))
# Construction with missing model: nlp must remain None, no crash
extractor1 = NERExtractor(method="ml")
assert extractor1.nlp is None
assert len(attempts) == 1, "one load attempt expected for the missing model"
# Second construction: must retry (cache must not hold the failure)
extractor2 = NERExtractor(method="ml")
assert extractor2.nlp is None
assert len(attempts) == 2, "a failed load must not be cached"
# Now install a working model and verify recovery
def working_load(name, **_kwargs):
attempts.append(name)
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(working_load))
extractor3 = NERExtractor(method="ml")
extractor4 = NERExtractor(method="ml")
assert extractor3.nlp is not None
assert extractor3.nlp is extractor4.nlp
assert len(attempts) == 3, (
"exactly one successful load expected after the model becomes available"
)
def test_ner_extractor_non_ml_method_does_not_load_model(self, monkeypatch):
"""NERExtractor with a non-ml method must not touch the spaCy cache."""
calls = []
def fake_load(name, **kwargs):
calls.append(name)
return _nlp_mock()
monkeypatch.setattr(se_methods, "spacy", _fake_spacy(fake_load))
NERExtractor(method="pattern")
NERExtractor(method="llm")
NERExtractor(method="regex")
assert calls == [], "non-ml methods must not trigger any spacy.load()"
if __name__ == "__main__":
pytest.main([__file__])
+7 -9
View File
@@ -30,18 +30,16 @@ class TestSplitter(unittest.TestCase):
splitter = TextSplitter(method=["recursive", "token"])
self.assertEqual(splitter.methods, ["recursive", "token"])
@patch('semantica.split.semantic_chunker.spacy')
@patch('semantica.semantic_extract.methods.spacy')
def test_semantic_chunker_initialization(self, mock_spacy):
# Mock spacy.load to return a mock nlp object
# SemanticChunker now loads spaCy through the centralized
# load_spacy_model() in semantic_extract.methods, so we patch
# methods.spacy rather than the removed semantic_chunker.spacy binding.
mock_nlp = MagicMock()
mock_spacy.load.return_value = mock_nlp
# We need to ensure SPACY_AVAILABLE is True for this test context if possible,
# but it is imported at module level.
# If spacy is not installed, it sets SPACY_AVAILABLE = False.
# We might need to patch the module attribute or just test fallback if spacy missing.
chunker = SemanticChunker(chunk_size=100)
with patch('semantica.split.semantic_chunker.SPACY_AVAILABLE', True):
chunker = SemanticChunker(chunk_size=100)
self.assertEqual(chunker.chunk_size, 100)
def test_chunk_dataclass(self):
@@ -54,6 +54,11 @@ from semantica.context.decision_models import (
validate_decision,
)
# ── Export module ──────────────────────────────────────────────────────────────
# Set by the exporter's own `import pyarrow` attempt; False when pyarrow is
# missing or unimportable.
from semantica.export.parquet_exporter import PARQUET_AVAILABLE
# ── KG module ──────────────────────────────────────────────────────────────────
from semantica.kg import (
CentralityCalculator,
@@ -981,6 +986,17 @@ class TestParquetExportRealData:
Requires: pyarrow (optional dep tests skip if not installed).
"""
# ParquetExporter imports fine without pyarrow and only raises ImportError
# when an export actually runs, so guarding on that import never skips
# anything. Guard on the exporter's own availability flag instead: it is set
# by the same `import pyarrow` / `import pyarrow.parquet` the exporter gates
# on, so the skip condition cannot drift from the runtime check — including
# when pyarrow is present on the path but fails to import.
pytestmark = pytest.mark.skipif(
not PARQUET_AVAILABLE,
reason="pyarrow not installed",
)
@pytest.fixture
def kg_data(self):
return {
@@ -1000,16 +1016,12 @@ class TestParquetExportRealData:
}
def test_parquet_exporter_importable(self):
try:
from semantica.export import ParquetExporter
except ImportError as e:
pytest.skip(f"ParquetExporter not available: {e}")
from semantica.export import ParquetExporter
assert ParquetExporter is not None
def test_parquet_export_entities_to_file(self, kg_data, tmp_path):
try:
from semantica.export import ParquetExporter
except ImportError:
pytest.skip("pyarrow not installed")
from semantica.export import ParquetExporter
exporter = ParquetExporter(compression="snappy")
out_path = tmp_path / "github_entities.parquet"
@@ -1018,10 +1030,7 @@ class TestParquetExportRealData:
assert out_path.stat().st_size > 0
def test_parquet_export_relationships_to_file(self, kg_data, tmp_path):
try:
from semantica.export import ParquetExporter
except ImportError:
pytest.skip("pyarrow not installed")
from semantica.export import ParquetExporter
exporter = ParquetExporter(compression="gzip")
out_path = tmp_path / "github_relationships.parquet"
@@ -1030,10 +1039,7 @@ class TestParquetExportRealData:
assert out_path.stat().st_size > 0
def test_parquet_export_knowledge_graph(self, kg_data, tmp_path):
try:
from semantica.export import ParquetExporter
except ImportError:
pytest.skip("pyarrow not installed")
from semantica.export import ParquetExporter
exporter = ParquetExporter(compression="snappy")
base_path = tmp_path / "github_kg"
@@ -1043,30 +1049,24 @@ class TestParquetExportRealData:
assert len(files) >= 1
def test_parquet_export_snappy_compression(self, kg_data, tmp_path):
try:
from semantica.export import ParquetExporter
except ImportError:
pytest.skip("pyarrow not installed")
from semantica.export import ParquetExporter
exporter = ParquetExporter(compression="snappy")
out_path = tmp_path / "snappy_test.parquet"
exporter.export_entities(kg_data["entities"], str(out_path))
assert out_path.exists()
def test_parquet_export_none_compression(self, kg_data, tmp_path):
try:
from semantica.export import ParquetExporter
except ImportError:
pytest.skip("pyarrow not installed")
from semantica.export import ParquetExporter
exporter = ParquetExporter(compression="none")
out_path = tmp_path / "uncompressed_test.parquet"
exporter.export_entities(kg_data["entities"], str(out_path))
assert out_path.exists()
def test_parquet_convenience_function(self, kg_data, tmp_path):
try:
from semantica.export.methods import export_parquet
except ImportError:
pytest.skip("pyarrow not installed")
from semantica.export.methods import export_parquet
out_path = tmp_path / "convenience_test.parquet"
export_parquet(kg_data["entities"], str(out_path))
assert out_path.exists()
+21 -8
View File
@@ -101,9 +101,15 @@ class TestNERConfigurations(unittest.TestCase):
self.assertEqual(entities[0].metadata["extraction_method"], "ml")
self.assertEqual(entities[0].metadata["model"], "en_core_web_trf")
@patch('semantica.semantic_extract.ner_extractor.spacy')
@patch('semantica.semantic_extract.methods.spacy')
def test_ner_ml_init_falls_back_when_spacy_runtime_is_broken(self, mock_spacy):
"""Test NER init does not crash when spaCy is installed but unusable at runtime."""
"""Test NER init does not crash when spaCy is installed but unusable at runtime.
The model load now goes through load_spacy_model() in semantic_extract.methods,
so we patch methods.spacy (not ner_extractor.spacy) to inject the failure.
"""
from semantica.semantic_extract.methods import clear_spacy_model_cache
clear_spacy_model_cache()
mock_spacy.load.side_effect = RuntimeError("ConfigSchemaNlp is not fully defined")
with patch('semantica.semantic_extract.ner_extractor.SPACY_AVAILABLE', True):
@@ -112,17 +118,23 @@ class TestNERConfigurations(unittest.TestCase):
self.assertIsNone(extractor.nlp)
self.assertFalse(extractor._ml_runtime_usable)
@patch('semantica.semantic_extract.ner_extractor.spacy')
@patch('semantica.semantic_extract.methods.get_entity_method')
@patch('semantica.semantic_extract.methods.spacy')
def test_ner_ml_runtime_failure_disables_repeated_ml_load_attempts(
self,
mock_methods_spacy,
mock_get_method,
mock_init_spacy,
):
"""Test degraded ML mode skips repeated spaCy load attempts after init failure."""
mock_init_spacy.load.side_effect = RuntimeError("ConfigSchemaNlp is not fully defined")
"""Test degraded ML mode skips repeated spaCy load attempts after init failure.
The model load at construction time now goes through load_spacy_model() in
semantic_extract.methods, so methods.spacy is the single mock target for the
init-time failure. After the RuntimeError is raised, _ml_runtime_usable is
False and no further spacy.load (or extract_entities_ml) calls are made.
"""
from semantica.semantic_extract.methods import clear_spacy_model_cache
clear_spacy_model_cache()
mock_methods_spacy.load.side_effect = RuntimeError("ConfigSchemaNlp is not fully defined")
mock_ml_method = MagicMock(return_value=[])
mock_get_method.side_effect = lambda name: mock_ml_method if name == "ml" else (lambda *_args, **_kwargs: [])
@@ -132,8 +144,9 @@ class TestNERConfigurations(unittest.TestCase):
entities = extractor.extract_entities(self.text)
self.assertFalse(extractor._ml_runtime_usable)
self.assertEqual(mock_init_spacy.load.call_count, 1)
self.assertEqual(mock_methods_spacy.load.call_count, 0)
# methods.spacy.load called once during __init__ (the RuntimeError); not again
# during extract_entities because _filter_unusable_methods removes "ml".
self.assertEqual(mock_methods_spacy.load.call_count, 1)
self.assertEqual(mock_ml_method.call_count, 0)
self.assertIsInstance(entities, list)
+54
View File
@@ -209,6 +209,60 @@ def test_load_from_api_allows_private_when_configured(mock_guard, seed_manager):
call_kwargs = mock_guard.call_args[1]
assert call_kwargs["allow_private_ips"] is True
@patch("semantica.seed.seed_manager.request_with_ssrf_guard")
def test_load_from_api_does_not_mutate_caller_headers_dict(mock_guard, seed_manager):
"""Regression test for issue #947 audit: load_from_api must not mutate the
caller's headers dict in-place when api_key is provided.
Before the fix, ``request_headers = headers or {}`` aliased the caller's dict.
Writing ``request_headers["Authorization"] = ...`` then silently modified the
caller's original dict, potentially leaking credentials to subsequent calls
that reused the same headers dict without expecting it to carry Authorization.
"""
mock_response = MagicMock()
mock_response.json.return_value = {"results": []}
mock_guard.return_value = mock_response
# Caller owns this dict and expects it to be unchanged after the call.
original_headers = {"X-Custom-Header": "value"}
headers_before = dict(original_headers) # snapshot
seed_manager.load_from_api(
api_url="http://api.example.com",
api_key="secret-key",
headers=original_headers,
)
# The caller's dict must be unchanged — Authorization must NOT have been added.
assert original_headers == headers_before, (
"load_from_api must not mutate the caller's headers dict; "
f"expected {headers_before!r}, got {original_headers!r}"
)
# The guard must still have received Authorization (in its own copy).
call_kwargs = mock_guard.call_args[1]
guard_headers = call_kwargs.get("headers", {})
assert guard_headers.get("Authorization") == "Bearer secret-key"
@patch("semantica.seed.seed_manager.request_with_ssrf_guard")
def test_load_from_api_does_not_mutate_empty_headers_dict(mock_guard, seed_manager):
"""When headers=None, a fresh dict is created — no aliasing to a shared mutable default."""
mock_response = MagicMock()
mock_response.json.return_value = {"results": []}
mock_guard.return_value = mock_response
seed_manager.load_from_api(
api_url="http://api.example.com",
api_key="key",
headers=None,
)
call_kwargs = mock_guard.call_args[1]
guard_headers = call_kwargs.get("headers", {})
assert guard_headers.get("Authorization") == "Bearer key"
def test_load_source(seed_manager, temp_data_dir):
json_file = temp_data_dir / "source.json"
with open(json_file, "w") as f:
+15
View File
@@ -31,6 +31,21 @@ class TestHelpers(unittest.TestCase):
dict2 = {"b": {"d": 3}, "e": 4}
merged = helpers.merge_dicts(dict1, dict2, deep=True)
self.assertEqual(merged, {"a": 1, "b": {"c": 2, "d": 3}, "e": 4})
def test_flatten_dict(self):
data = {"a": {"b": 1, "c": 2}}
result = helpers.flatten_dict(data)
self.assertEqual(result, {"a.b": 1, "a.c": 2})
def test_flatten_dict_key_collision(self):
data = {
"a.b": 1,
"a": {
"b": 2
}
}
with self.assertRaises(ValueError):
helpers.flatten_dict(data)
def test_safe_import_returns_module_and_flag(self):
module, available = helpers.safe_import("json")