snowloader 0.2.2__tar.gz → 0.2.3__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.
- {snowloader-0.2.2 → snowloader-0.2.3}/CHANGELOG.md +12 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/PKG-INFO +1 -1
- {snowloader-0.2.2 → snowloader-0.2.3}/docs/conf.py +1 -1
- {snowloader-0.2.2 → snowloader-0.2.3}/pyproject.toml +1 -1
- {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/__init__.py +1 -1
- {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/async_connection.py +35 -12
- {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/connection.py +1 -1
- {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_async_connection.py +32 -15
- {snowloader-0.2.2 → snowloader-0.2.3}/.github/workflows/ci.yml +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/.github/workflows/docs.yml +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/.github/workflows/publish.yml +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/.gitignore +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/.readthedocs.yaml +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/LICENSE +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/README.md +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/docs/_static/logo.png +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/docs/adapters.rst +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/docs/advanced.rst +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/docs/api.rst +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/docs/async.rst +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/docs/attachments.rst +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/docs/authentication.rst +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/docs/changelog.rst +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/docs/configuration.rst +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/docs/getting-started.rst +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/docs/index.rst +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/docs/loaders.rst +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/docs/roadmap.rst +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/examples/01_basic_incidents.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/examples/02_langchain_rag.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/examples/03_llamaindex_rag.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/examples/04_delta_sync.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/examples/05_cmdb_graph.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/integrations/langchain-docs/snowloader.ipynb +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/.github/workflows/publish.yml +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/.gitignore +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/LICENSE +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/Makefile +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/README.md +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/llama_index/__init__.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/llama_index/readers/__init__.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/llama_index/readers/snowloader/__init__.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/llama_index/readers/snowloader/base.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/pyproject.toml +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/tests/__init__.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/tests/test_readers_snowloader.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/adapters/__init__.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/adapters/langchain.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/adapters/llamaindex.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/async_models.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/loaders/__init__.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/loaders/_field_utils.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/loaders/attachments.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/loaders/catalog.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/loaders/changes.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/loaders/cmdb.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/loaders/incidents.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/loaders/knowledge_base.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/loaders/problems.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/models.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/py.typed +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/utils/__init__.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/utils/html_cleaner.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/utils/parsing.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/tests/__init__.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/tests/integration/__init__.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/tests/integration/test_live.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/__init__.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_async_loaders.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_attachments.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_catalog.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_changes.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_cmdb.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_connection.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_field_utils.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_html_cleaner.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_incidents.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_kb.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_langchain_adapter.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_llamaindex_adapter.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_models.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_problems.py +0 -0
- {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_smoke_e2e.py +0 -0
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to snowloader are documented here. This project follows [Semantic Versioning](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## [0.2.3] - 2026-04-28
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- `AsyncSnowConnection` now uses `force_close=True` and `limit_per_host=concurrency` on its `aiohttp.TCPConnector`. Each request gets a fresh TCP connection, which avoids the connection-reuse failures some ServiceNow instances exhibit under sustained concurrent load.
|
|
10
|
+
- Non-object JSON bodies (e.g. `null` or a list returned with HTTP 200) are now treated as transient failures: the SDK retries up to `max_retries` and raises `SnowConnectionError` if the issue persists. Previously the v0.2.2 fallback silently treated them as empty pages, which would lose data.
|
|
11
|
+
- HTTP 500 added to the default retryable status code set on both `SnowConnection` and `AsyncSnowConnection`. ServiceNow 500s are typically transient overload, not deterministic bugs.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- `AsyncSnowConnection.aget_records` no longer crashes with `AttributeError` when a page returns a non-object JSON body. The retry-then-raise behavior surfaces the failure clearly instead of silently dropping data.
|
|
16
|
+
|
|
5
17
|
## [0.2.2] - 2026-04-28
|
|
6
18
|
|
|
7
19
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: snowloader
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Comprehensive ServiceNow data loader for AI/LLM pipelines - Incidents, CMDB, KB, Changes, Catalog & more. Works with LangChain & LlamaIndex.
|
|
5
5
|
Project-URL: Homepage, https://github.com/ronidas39/snowloader
|
|
6
6
|
Project-URL: Documentation, https://snowloader.readthedocs.io
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "snowloader"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.3"
|
|
8
8
|
description = "Comprehensive ServiceNow data loader for AI/LLM pipelines - Incidents, CMDB, KB, Changes, Catalog & more. Works with LangChain & LlamaIndex."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -21,7 +21,7 @@ from snowloader.loaders.problems import ProblemLoader
|
|
|
21
21
|
from snowloader.models import BaseSnowLoader, SnowDocument
|
|
22
22
|
from snowloader.utils.parsing import parse_labelled_int
|
|
23
23
|
|
|
24
|
-
__version__ = "0.2.
|
|
24
|
+
__version__ = "0.2.3"
|
|
25
25
|
|
|
26
26
|
try:
|
|
27
27
|
from snowloader.async_connection import AsyncSnowConnection # noqa: F401
|
|
@@ -42,7 +42,7 @@ logger = logging.getLogger(__name__)
|
|
|
42
42
|
|
|
43
43
|
_DEFAULT_MAX_RETRIES = 3
|
|
44
44
|
_DEFAULT_RETRY_BACKOFF = 1.0
|
|
45
|
-
_RETRYABLE_STATUS_CODES = {429, 502, 503, 504}
|
|
45
|
+
_RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
|
|
46
46
|
_MAX_PAGE_SIZE = 10000
|
|
47
47
|
_MIN_PAGE_SIZE = 1
|
|
48
48
|
_DEFAULT_CONCURRENCY = 16
|
|
@@ -210,9 +210,18 @@ class AsyncSnowConnection:
|
|
|
210
210
|
|
|
211
211
|
async def _ensure_session(self) -> aiohttp.ClientSession:
|
|
212
212
|
if self._session is None or self._session.closed:
|
|
213
|
+
# force_close=True disables HTTP keep-alive: each request gets a
|
|
214
|
+
# fresh TCP connection. Some ServiceNow front ends (and shared
|
|
215
|
+
# WAF/proxy layers in front of them) silently return empty / null
|
|
216
|
+
# response bodies on reused connections under concurrent load,
|
|
217
|
+
# which corrupts paginated reads. Trading a small amount of
|
|
218
|
+
# connection-setup overhead for correctness is the right call
|
|
219
|
+
# for a data-extraction SDK where missing pages are unacceptable.
|
|
213
220
|
connector = aiohttp.TCPConnector(
|
|
214
221
|
limit=self.concurrency * 2,
|
|
222
|
+
limit_per_host=self.concurrency,
|
|
215
223
|
ssl=self._verify_ssl,
|
|
224
|
+
force_close=True,
|
|
216
225
|
)
|
|
217
226
|
timeout_cfg = aiohttp.ClientTimeout(total=self.timeout)
|
|
218
227
|
auth = None
|
|
@@ -385,18 +394,32 @@ class AsyncSnowConnection:
|
|
|
385
394
|
) from exc
|
|
386
395
|
|
|
387
396
|
if not isinstance(parsed, dict):
|
|
388
|
-
#
|
|
389
|
-
#
|
|
390
|
-
#
|
|
391
|
-
#
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
397
|
+
# ServiceNow occasionally returns ``null`` or a list
|
|
398
|
+
# body on a 200 response when something upstream
|
|
399
|
+
# drops the payload (typically a connection-reuse
|
|
400
|
+
# issue under concurrent load). Silently treating
|
|
401
|
+
# this as an empty page would lose data, so retry
|
|
402
|
+
# the request as if it were a transient 5xx.
|
|
403
|
+
if attempt < self.max_retries:
|
|
404
|
+
logger.warning(
|
|
405
|
+
"API returned non-object JSON for %s %s (type=%s); "
|
|
406
|
+
"retrying (attempt %d/%d)",
|
|
407
|
+
method,
|
|
408
|
+
url,
|
|
409
|
+
type(parsed).__name__,
|
|
410
|
+
attempt + 1,
|
|
411
|
+
self.max_retries,
|
|
412
|
+
)
|
|
413
|
+
await asyncio.sleep(backoff)
|
|
414
|
+
attempt += 1
|
|
415
|
+
backoff *= 2
|
|
416
|
+
continue
|
|
417
|
+
raise SnowConnectionError(
|
|
418
|
+
f"API returned non-object JSON for {method} {url} "
|
|
419
|
+
f"after {self.max_retries} retries",
|
|
420
|
+
status_code=resp.status,
|
|
421
|
+
detail=f"Got {type(parsed).__name__} instead of dict",
|
|
398
422
|
)
|
|
399
|
-
return {"result": []}
|
|
400
423
|
return cast(dict[str, Any], parsed)
|
|
401
424
|
except aiohttp.ClientError as exc:
|
|
402
425
|
if attempt < self.max_retries:
|
|
@@ -34,7 +34,7 @@ logger = logging.getLogger(__name__)
|
|
|
34
34
|
# Retry defaults
|
|
35
35
|
_DEFAULT_MAX_RETRIES = 3
|
|
36
36
|
_DEFAULT_RETRY_BACKOFF = 1.0 # seconds, doubles each attempt
|
|
37
|
-
_RETRYABLE_STATUS_CODES = {429, 502, 503, 504}
|
|
37
|
+
_RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
|
|
38
38
|
_MAX_PAGE_SIZE = 10000
|
|
39
39
|
_MIN_PAGE_SIZE = 1
|
|
40
40
|
|
|
@@ -255,11 +255,12 @@ async def test_aget_attachment_returns_bytes() -> None:
|
|
|
255
255
|
|
|
256
256
|
|
|
257
257
|
@pytest.mark.asyncio
|
|
258
|
-
async def
|
|
259
|
-
"""
|
|
258
|
+
async def test_request_retries_null_body_then_raises() -> None:
|
|
259
|
+
"""ServiceNow occasionally returns 200 with a ``null`` body under load.
|
|
260
260
|
|
|
261
|
-
|
|
262
|
-
|
|
261
|
+
The SDK must NOT silently treat this as an empty page (that would lose
|
|
262
|
+
data). Instead it retries up to ``max_retries`` and raises if the issue
|
|
263
|
+
persists, so the caller knows something is wrong.
|
|
263
264
|
"""
|
|
264
265
|
with aioresponses() as m:
|
|
265
266
|
m.get(
|
|
@@ -280,14 +281,32 @@ async def test_request_treats_null_body_as_empty_result() -> None:
|
|
|
280
281
|
username="u",
|
|
281
282
|
password="p",
|
|
282
283
|
page_size=10,
|
|
284
|
+
max_retries=2,
|
|
285
|
+
retry_backoff=0.0,
|
|
283
286
|
) as conn:
|
|
284
|
-
|
|
285
|
-
|
|
287
|
+
with pytest.raises(SnowConnectionError) as exc_info:
|
|
288
|
+
async for _ in conn.aget_records("incident"):
|
|
289
|
+
pass
|
|
290
|
+
assert "non-object JSON" in str(exc_info.value)
|
|
286
291
|
|
|
287
292
|
|
|
288
293
|
@pytest.mark.asyncio
|
|
289
|
-
async def
|
|
290
|
-
"""
|
|
294
|
+
async def test_request_recovers_when_null_body_then_valid() -> None:
|
|
295
|
+
"""When a transient null body is followed by a valid response, the SDK
|
|
296
|
+
must recover (return the valid data) rather than fail or skip."""
|
|
297
|
+
payload = {"result": [{"sys_id": "abc", "number": "INC1"}]}
|
|
298
|
+
call_count = {"n": 0}
|
|
299
|
+
|
|
300
|
+
def callback(url: str, **kwargs: object) -> object:
|
|
301
|
+
call_count["n"] += 1
|
|
302
|
+
if call_count["n"] == 1:
|
|
303
|
+
from aioresponses.core import CallbackResult
|
|
304
|
+
|
|
305
|
+
return CallbackResult(status=200, body="null", content_type="application/json")
|
|
306
|
+
from aioresponses.core import CallbackResult
|
|
307
|
+
|
|
308
|
+
return CallbackResult(status=200, payload=payload)
|
|
309
|
+
|
|
291
310
|
with aioresponses() as m:
|
|
292
311
|
m.get(
|
|
293
312
|
_stats_url(),
|
|
@@ -295,20 +314,18 @@ async def test_request_treats_list_body_as_empty_result() -> None:
|
|
|
295
314
|
status=200,
|
|
296
315
|
repeat=True,
|
|
297
316
|
)
|
|
298
|
-
m.get(
|
|
299
|
-
_table_url(),
|
|
300
|
-
payload=[1, 2, 3],
|
|
301
|
-
status=200,
|
|
302
|
-
repeat=True,
|
|
303
|
-
)
|
|
317
|
+
m.get(_table_url(), callback=callback, repeat=True)
|
|
304
318
|
async with AsyncSnowConnection(
|
|
305
319
|
instance_url=INSTANCE,
|
|
306
320
|
username="u",
|
|
307
321
|
password="p",
|
|
308
322
|
page_size=10,
|
|
323
|
+
max_retries=3,
|
|
324
|
+
retry_backoff=0.0,
|
|
309
325
|
) as conn:
|
|
310
326
|
records = [rec async for rec in conn.aget_records("incident")]
|
|
311
|
-
assert records ==
|
|
327
|
+
assert len(records) == 1
|
|
328
|
+
assert records[0]["sys_id"] == "abc"
|
|
312
329
|
|
|
313
330
|
|
|
314
331
|
@pytest.mark.asyncio
|
|
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
|
{snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/.gitignore
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/pyproject.toml
RENAMED
|
File without changes
|
{snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/tests/__init__.py
RENAMED
|
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
|