snowloader 0.2.2__tar.gz → 0.2.4__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.
Files changed (83) hide show
  1. {snowloader-0.2.2 → snowloader-0.2.4}/CHANGELOG.md +18 -0
  2. {snowloader-0.2.2 → snowloader-0.2.4}/PKG-INFO +1 -1
  3. {snowloader-0.2.2 → snowloader-0.2.4}/docs/conf.py +1 -1
  4. {snowloader-0.2.2 → snowloader-0.2.4}/pyproject.toml +1 -1
  5. {snowloader-0.2.2 → snowloader-0.2.4}/src/snowloader/__init__.py +1 -1
  6. {snowloader-0.2.2 → snowloader-0.2.4}/src/snowloader/async_connection.py +54 -13
  7. {snowloader-0.2.2 → snowloader-0.2.4}/src/snowloader/connection.py +1 -1
  8. {snowloader-0.2.2 → snowloader-0.2.4}/tests/unit/test_async_connection.py +61 -13
  9. {snowloader-0.2.2 → snowloader-0.2.4}/.github/workflows/ci.yml +0 -0
  10. {snowloader-0.2.2 → snowloader-0.2.4}/.github/workflows/docs.yml +0 -0
  11. {snowloader-0.2.2 → snowloader-0.2.4}/.github/workflows/publish.yml +0 -0
  12. {snowloader-0.2.2 → snowloader-0.2.4}/.gitignore +0 -0
  13. {snowloader-0.2.2 → snowloader-0.2.4}/.readthedocs.yaml +0 -0
  14. {snowloader-0.2.2 → snowloader-0.2.4}/LICENSE +0 -0
  15. {snowloader-0.2.2 → snowloader-0.2.4}/README.md +0 -0
  16. {snowloader-0.2.2 → snowloader-0.2.4}/docs/_static/logo.png +0 -0
  17. {snowloader-0.2.2 → snowloader-0.2.4}/docs/adapters.rst +0 -0
  18. {snowloader-0.2.2 → snowloader-0.2.4}/docs/advanced.rst +0 -0
  19. {snowloader-0.2.2 → snowloader-0.2.4}/docs/api.rst +0 -0
  20. {snowloader-0.2.2 → snowloader-0.2.4}/docs/async.rst +0 -0
  21. {snowloader-0.2.2 → snowloader-0.2.4}/docs/attachments.rst +0 -0
  22. {snowloader-0.2.2 → snowloader-0.2.4}/docs/authentication.rst +0 -0
  23. {snowloader-0.2.2 → snowloader-0.2.4}/docs/changelog.rst +0 -0
  24. {snowloader-0.2.2 → snowloader-0.2.4}/docs/configuration.rst +0 -0
  25. {snowloader-0.2.2 → snowloader-0.2.4}/docs/getting-started.rst +0 -0
  26. {snowloader-0.2.2 → snowloader-0.2.4}/docs/index.rst +0 -0
  27. {snowloader-0.2.2 → snowloader-0.2.4}/docs/loaders.rst +0 -0
  28. {snowloader-0.2.2 → snowloader-0.2.4}/docs/roadmap.rst +0 -0
  29. {snowloader-0.2.2 → snowloader-0.2.4}/examples/01_basic_incidents.py +0 -0
  30. {snowloader-0.2.2 → snowloader-0.2.4}/examples/02_langchain_rag.py +0 -0
  31. {snowloader-0.2.2 → snowloader-0.2.4}/examples/03_llamaindex_rag.py +0 -0
  32. {snowloader-0.2.2 → snowloader-0.2.4}/examples/04_delta_sync.py +0 -0
  33. {snowloader-0.2.2 → snowloader-0.2.4}/examples/05_cmdb_graph.py +0 -0
  34. {snowloader-0.2.2 → snowloader-0.2.4}/integrations/langchain-docs/snowloader.ipynb +0 -0
  35. {snowloader-0.2.2 → snowloader-0.2.4}/integrations/llama-index-readers-snowloader/.github/workflows/publish.yml +0 -0
  36. {snowloader-0.2.2 → snowloader-0.2.4}/integrations/llama-index-readers-snowloader/.gitignore +0 -0
  37. {snowloader-0.2.2 → snowloader-0.2.4}/integrations/llama-index-readers-snowloader/LICENSE +0 -0
  38. {snowloader-0.2.2 → snowloader-0.2.4}/integrations/llama-index-readers-snowloader/Makefile +0 -0
  39. {snowloader-0.2.2 → snowloader-0.2.4}/integrations/llama-index-readers-snowloader/README.md +0 -0
  40. {snowloader-0.2.2 → snowloader-0.2.4}/integrations/llama-index-readers-snowloader/llama_index/__init__.py +0 -0
  41. {snowloader-0.2.2 → snowloader-0.2.4}/integrations/llama-index-readers-snowloader/llama_index/readers/__init__.py +0 -0
  42. {snowloader-0.2.2 → snowloader-0.2.4}/integrations/llama-index-readers-snowloader/llama_index/readers/snowloader/__init__.py +0 -0
  43. {snowloader-0.2.2 → snowloader-0.2.4}/integrations/llama-index-readers-snowloader/llama_index/readers/snowloader/base.py +0 -0
  44. {snowloader-0.2.2 → snowloader-0.2.4}/integrations/llama-index-readers-snowloader/pyproject.toml +0 -0
  45. {snowloader-0.2.2 → snowloader-0.2.4}/integrations/llama-index-readers-snowloader/tests/__init__.py +0 -0
  46. {snowloader-0.2.2 → snowloader-0.2.4}/integrations/llama-index-readers-snowloader/tests/test_readers_snowloader.py +0 -0
  47. {snowloader-0.2.2 → snowloader-0.2.4}/src/snowloader/adapters/__init__.py +0 -0
  48. {snowloader-0.2.2 → snowloader-0.2.4}/src/snowloader/adapters/langchain.py +0 -0
  49. {snowloader-0.2.2 → snowloader-0.2.4}/src/snowloader/adapters/llamaindex.py +0 -0
  50. {snowloader-0.2.2 → snowloader-0.2.4}/src/snowloader/async_models.py +0 -0
  51. {snowloader-0.2.2 → snowloader-0.2.4}/src/snowloader/loaders/__init__.py +0 -0
  52. {snowloader-0.2.2 → snowloader-0.2.4}/src/snowloader/loaders/_field_utils.py +0 -0
  53. {snowloader-0.2.2 → snowloader-0.2.4}/src/snowloader/loaders/attachments.py +0 -0
  54. {snowloader-0.2.2 → snowloader-0.2.4}/src/snowloader/loaders/catalog.py +0 -0
  55. {snowloader-0.2.2 → snowloader-0.2.4}/src/snowloader/loaders/changes.py +0 -0
  56. {snowloader-0.2.2 → snowloader-0.2.4}/src/snowloader/loaders/cmdb.py +0 -0
  57. {snowloader-0.2.2 → snowloader-0.2.4}/src/snowloader/loaders/incidents.py +0 -0
  58. {snowloader-0.2.2 → snowloader-0.2.4}/src/snowloader/loaders/knowledge_base.py +0 -0
  59. {snowloader-0.2.2 → snowloader-0.2.4}/src/snowloader/loaders/problems.py +0 -0
  60. {snowloader-0.2.2 → snowloader-0.2.4}/src/snowloader/models.py +0 -0
  61. {snowloader-0.2.2 → snowloader-0.2.4}/src/snowloader/py.typed +0 -0
  62. {snowloader-0.2.2 → snowloader-0.2.4}/src/snowloader/utils/__init__.py +0 -0
  63. {snowloader-0.2.2 → snowloader-0.2.4}/src/snowloader/utils/html_cleaner.py +0 -0
  64. {snowloader-0.2.2 → snowloader-0.2.4}/src/snowloader/utils/parsing.py +0 -0
  65. {snowloader-0.2.2 → snowloader-0.2.4}/tests/__init__.py +0 -0
  66. {snowloader-0.2.2 → snowloader-0.2.4}/tests/integration/__init__.py +0 -0
  67. {snowloader-0.2.2 → snowloader-0.2.4}/tests/integration/test_live.py +0 -0
  68. {snowloader-0.2.2 → snowloader-0.2.4}/tests/unit/__init__.py +0 -0
  69. {snowloader-0.2.2 → snowloader-0.2.4}/tests/unit/test_async_loaders.py +0 -0
  70. {snowloader-0.2.2 → snowloader-0.2.4}/tests/unit/test_attachments.py +0 -0
  71. {snowloader-0.2.2 → snowloader-0.2.4}/tests/unit/test_catalog.py +0 -0
  72. {snowloader-0.2.2 → snowloader-0.2.4}/tests/unit/test_changes.py +0 -0
  73. {snowloader-0.2.2 → snowloader-0.2.4}/tests/unit/test_cmdb.py +0 -0
  74. {snowloader-0.2.2 → snowloader-0.2.4}/tests/unit/test_connection.py +0 -0
  75. {snowloader-0.2.2 → snowloader-0.2.4}/tests/unit/test_field_utils.py +0 -0
  76. {snowloader-0.2.2 → snowloader-0.2.4}/tests/unit/test_html_cleaner.py +0 -0
  77. {snowloader-0.2.2 → snowloader-0.2.4}/tests/unit/test_incidents.py +0 -0
  78. {snowloader-0.2.2 → snowloader-0.2.4}/tests/unit/test_kb.py +0 -0
  79. {snowloader-0.2.2 → snowloader-0.2.4}/tests/unit/test_langchain_adapter.py +0 -0
  80. {snowloader-0.2.2 → snowloader-0.2.4}/tests/unit/test_llamaindex_adapter.py +0 -0
  81. {snowloader-0.2.2 → snowloader-0.2.4}/tests/unit/test_models.py +0 -0
  82. {snowloader-0.2.2 → snowloader-0.2.4}/tests/unit/test_problems.py +0 -0
  83. {snowloader-0.2.2 → snowloader-0.2.4}/tests/unit/test_smoke_e2e.py +0 -0
@@ -2,6 +2,24 @@
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.4] - 2026-04-28
6
+
7
+ ### Changed
8
+
9
+ - Truncated or malformed JSON responses are now treated as transient failures and retried up to `max_retries` instead of raising immediately. Some ServiceNow front ends occasionally return cut-off response bodies under sustained concurrent load; this change keeps long extractions alive through those blips.
10
+
11
+ ## [0.2.3] - 2026-04-28
12
+
13
+ ### Changed
14
+
15
+ - `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.
16
+ - 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.
17
+ - HTTP 500 added to the default retryable status code set on both `SnowConnection` and `AsyncSnowConnection`. ServiceNow 500s are typically transient overload, not deterministic bugs.
18
+
19
+ ### Fixed
20
+
21
+ - `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.
22
+
5
23
  ## [0.2.2] - 2026-04-28
6
24
 
7
25
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snowloader
3
- Version: 0.2.2
3
+ Version: 0.2.4
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
@@ -10,7 +10,7 @@ sys.path.insert(0, os.path.abspath("../src"))
10
10
  project = "snowloader"
11
11
  copyright = "2026, Roni Das"
12
12
  author = "Roni Das"
13
- release = "0.2.2"
13
+ release = "0.2.4"
14
14
 
15
15
  # -- General configuration ---------------------------------------------------
16
16
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "snowloader"
7
- version = "0.2.2"
7
+ version = "0.2.4"
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.2"
24
+ __version__ = "0.2.4"
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
@@ -378,25 +387,57 @@ class AsyncSnowConnection:
378
387
  try:
379
388
  parsed = await resp.json(content_type=None)
380
389
  except (aiohttp.ContentTypeError, ValueError) as exc:
390
+ # Truncated or malformed JSON happens occasionally
391
+ # under sustained concurrent load (server-side write
392
+ # gets cut off). Retry like a transient failure.
393
+ if attempt < self.max_retries:
394
+ logger.warning(
395
+ "API returned non-JSON / truncated response "
396
+ "for %s %s: %s; retrying (attempt %d/%d)",
397
+ method,
398
+ url,
399
+ exc,
400
+ attempt + 1,
401
+ self.max_retries,
402
+ )
403
+ await asyncio.sleep(backoff)
404
+ attempt += 1
405
+ backoff *= 2
406
+ continue
381
407
  raise SnowConnectionError(
382
- f"API returned non-JSON response for {method} {url}",
408
+ f"API returned non-JSON response for {method} {url} "
409
+ f"after {self.max_retries} retries",
383
410
  status_code=resp.status,
384
411
  detail=last_body,
385
412
  ) from exc
386
413
 
387
414
  if not isinstance(parsed, dict):
388
- # Some ServiceNow paths can return ``null`` or a list
389
- # under transient load. Treat anything that is not a
390
- # JSON object as an empty result so downstream code
391
- # does not crash on ``data.get(...)``.
392
- logger.warning(
393
- "API returned non-object JSON for %s %s (type=%s); "
394
- "treating as empty result.",
395
- method,
396
- url,
397
- type(parsed).__name__,
415
+ # ServiceNow occasionally returns ``null`` or a list
416
+ # body on a 200 response when something upstream
417
+ # drops the payload (typically a connection-reuse
418
+ # issue under concurrent load). Silently treating
419
+ # this as an empty page would lose data, so retry
420
+ # the request as if it were a transient 5xx.
421
+ if attempt < self.max_retries:
422
+ logger.warning(
423
+ "API returned non-object JSON for %s %s (type=%s); "
424
+ "retrying (attempt %d/%d)",
425
+ method,
426
+ url,
427
+ type(parsed).__name__,
428
+ attempt + 1,
429
+ self.max_retries,
430
+ )
431
+ await asyncio.sleep(backoff)
432
+ attempt += 1
433
+ backoff *= 2
434
+ continue
435
+ raise SnowConnectionError(
436
+ f"API returned non-object JSON for {method} {url} "
437
+ f"after {self.max_retries} retries",
438
+ status_code=resp.status,
439
+ detail=f"Got {type(parsed).__name__} instead of dict",
398
440
  )
399
- return {"result": []}
400
441
  return cast(dict[str, Any], parsed)
401
442
  except aiohttp.ClientError as exc:
402
443
  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,12 +255,8 @@ async def test_aget_attachment_returns_bytes() -> None:
255
255
 
256
256
 
257
257
  @pytest.mark.asyncio
258
- async def test_request_treats_null_body_as_empty_result() -> None:
259
- """Regression: ServiceNow can return JSON null under transient load.
260
-
261
- Previously this crashed aget_records on ``data.get('result')``. Now we
262
- log a warning and treat it as an empty result.
263
- """
258
+ async def test_request_retries_malformed_json_then_raises() -> None:
259
+ """Truncated or malformed JSON triggers retry, then raises after max_retries."""
264
260
  with aioresponses() as m:
265
261
  m.get(
266
262
  _stats_url(),
@@ -270,7 +266,7 @@ async def test_request_treats_null_body_as_empty_result() -> None:
270
266
  )
271
267
  m.get(
272
268
  _table_url(),
273
- body="null",
269
+ body='{"result": [{"sys_id": "abc"', # truncated mid-string
274
270
  status=200,
275
271
  content_type="application/json",
276
272
  repeat=True,
@@ -280,14 +276,23 @@ async def test_request_treats_null_body_as_empty_result() -> None:
280
276
  username="u",
281
277
  password="p",
282
278
  page_size=10,
279
+ max_retries=2,
280
+ retry_backoff=0.0,
283
281
  ) as conn:
284
- records = [rec async for rec in conn.aget_records("incident")]
285
- assert records == []
282
+ with pytest.raises(SnowConnectionError) as exc_info:
283
+ async for _ in conn.aget_records("incident"):
284
+ pass
285
+ assert "non-JSON" in str(exc_info.value)
286
286
 
287
287
 
288
288
  @pytest.mark.asyncio
289
- async def test_request_treats_list_body_as_empty_result() -> None:
290
- """Regression: defensive handling for unexpected non-object JSON shapes."""
289
+ async def test_request_retries_null_body_then_raises() -> None:
290
+ """ServiceNow occasionally returns 200 with a ``null`` body under load.
291
+
292
+ The SDK must NOT silently treat this as an empty page (that would lose
293
+ data). Instead it retries up to ``max_retries`` and raises if the issue
294
+ persists, so the caller knows something is wrong.
295
+ """
291
296
  with aioresponses() as m:
292
297
  m.get(
293
298
  _stats_url(),
@@ -297,18 +302,61 @@ async def test_request_treats_list_body_as_empty_result() -> None:
297
302
  )
298
303
  m.get(
299
304
  _table_url(),
300
- payload=[1, 2, 3],
305
+ body="null",
306
+ status=200,
307
+ content_type="application/json",
308
+ repeat=True,
309
+ )
310
+ async with AsyncSnowConnection(
311
+ instance_url=INSTANCE,
312
+ username="u",
313
+ password="p",
314
+ page_size=10,
315
+ max_retries=2,
316
+ retry_backoff=0.0,
317
+ ) as conn:
318
+ with pytest.raises(SnowConnectionError) as exc_info:
319
+ async for _ in conn.aget_records("incident"):
320
+ pass
321
+ assert "non-object JSON" in str(exc_info.value)
322
+
323
+
324
+ @pytest.mark.asyncio
325
+ async def test_request_recovers_when_null_body_then_valid() -> None:
326
+ """When a transient null body is followed by a valid response, the SDK
327
+ must recover (return the valid data) rather than fail or skip."""
328
+ payload = {"result": [{"sys_id": "abc", "number": "INC1"}]}
329
+ call_count = {"n": 0}
330
+
331
+ def callback(url: str, **kwargs: object) -> object:
332
+ call_count["n"] += 1
333
+ if call_count["n"] == 1:
334
+ from aioresponses.core import CallbackResult
335
+
336
+ return CallbackResult(status=200, body="null", content_type="application/json")
337
+ from aioresponses.core import CallbackResult
338
+
339
+ return CallbackResult(status=200, payload=payload)
340
+
341
+ with aioresponses() as m:
342
+ m.get(
343
+ _stats_url(),
344
+ payload={"result": {"stats": {"count": "1"}}},
301
345
  status=200,
302
346
  repeat=True,
303
347
  )
348
+ m.get(_table_url(), callback=callback, repeat=True)
304
349
  async with AsyncSnowConnection(
305
350
  instance_url=INSTANCE,
306
351
  username="u",
307
352
  password="p",
308
353
  page_size=10,
354
+ max_retries=3,
355
+ retry_backoff=0.0,
309
356
  ) as conn:
310
357
  records = [rec async for rec in conn.aget_records("incident")]
311
- assert records == []
358
+ assert len(records) == 1
359
+ assert records[0]["sys_id"] == "abc"
312
360
 
313
361
 
314
362
  @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