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.
Files changed (83) hide show
  1. {snowloader-0.2.2 → snowloader-0.2.3}/CHANGELOG.md +12 -0
  2. {snowloader-0.2.2 → snowloader-0.2.3}/PKG-INFO +1 -1
  3. {snowloader-0.2.2 → snowloader-0.2.3}/docs/conf.py +1 -1
  4. {snowloader-0.2.2 → snowloader-0.2.3}/pyproject.toml +1 -1
  5. {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/__init__.py +1 -1
  6. {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/async_connection.py +35 -12
  7. {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/connection.py +1 -1
  8. {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_async_connection.py +32 -15
  9. {snowloader-0.2.2 → snowloader-0.2.3}/.github/workflows/ci.yml +0 -0
  10. {snowloader-0.2.2 → snowloader-0.2.3}/.github/workflows/docs.yml +0 -0
  11. {snowloader-0.2.2 → snowloader-0.2.3}/.github/workflows/publish.yml +0 -0
  12. {snowloader-0.2.2 → snowloader-0.2.3}/.gitignore +0 -0
  13. {snowloader-0.2.2 → snowloader-0.2.3}/.readthedocs.yaml +0 -0
  14. {snowloader-0.2.2 → snowloader-0.2.3}/LICENSE +0 -0
  15. {snowloader-0.2.2 → snowloader-0.2.3}/README.md +0 -0
  16. {snowloader-0.2.2 → snowloader-0.2.3}/docs/_static/logo.png +0 -0
  17. {snowloader-0.2.2 → snowloader-0.2.3}/docs/adapters.rst +0 -0
  18. {snowloader-0.2.2 → snowloader-0.2.3}/docs/advanced.rst +0 -0
  19. {snowloader-0.2.2 → snowloader-0.2.3}/docs/api.rst +0 -0
  20. {snowloader-0.2.2 → snowloader-0.2.3}/docs/async.rst +0 -0
  21. {snowloader-0.2.2 → snowloader-0.2.3}/docs/attachments.rst +0 -0
  22. {snowloader-0.2.2 → snowloader-0.2.3}/docs/authentication.rst +0 -0
  23. {snowloader-0.2.2 → snowloader-0.2.3}/docs/changelog.rst +0 -0
  24. {snowloader-0.2.2 → snowloader-0.2.3}/docs/configuration.rst +0 -0
  25. {snowloader-0.2.2 → snowloader-0.2.3}/docs/getting-started.rst +0 -0
  26. {snowloader-0.2.2 → snowloader-0.2.3}/docs/index.rst +0 -0
  27. {snowloader-0.2.2 → snowloader-0.2.3}/docs/loaders.rst +0 -0
  28. {snowloader-0.2.2 → snowloader-0.2.3}/docs/roadmap.rst +0 -0
  29. {snowloader-0.2.2 → snowloader-0.2.3}/examples/01_basic_incidents.py +0 -0
  30. {snowloader-0.2.2 → snowloader-0.2.3}/examples/02_langchain_rag.py +0 -0
  31. {snowloader-0.2.2 → snowloader-0.2.3}/examples/03_llamaindex_rag.py +0 -0
  32. {snowloader-0.2.2 → snowloader-0.2.3}/examples/04_delta_sync.py +0 -0
  33. {snowloader-0.2.2 → snowloader-0.2.3}/examples/05_cmdb_graph.py +0 -0
  34. {snowloader-0.2.2 → snowloader-0.2.3}/integrations/langchain-docs/snowloader.ipynb +0 -0
  35. {snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/.github/workflows/publish.yml +0 -0
  36. {snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/.gitignore +0 -0
  37. {snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/LICENSE +0 -0
  38. {snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/Makefile +0 -0
  39. {snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/README.md +0 -0
  40. {snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/llama_index/__init__.py +0 -0
  41. {snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/llama_index/readers/__init__.py +0 -0
  42. {snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/llama_index/readers/snowloader/__init__.py +0 -0
  43. {snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/llama_index/readers/snowloader/base.py +0 -0
  44. {snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/pyproject.toml +0 -0
  45. {snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/tests/__init__.py +0 -0
  46. {snowloader-0.2.2 → snowloader-0.2.3}/integrations/llama-index-readers-snowloader/tests/test_readers_snowloader.py +0 -0
  47. {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/adapters/__init__.py +0 -0
  48. {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/adapters/langchain.py +0 -0
  49. {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/adapters/llamaindex.py +0 -0
  50. {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/async_models.py +0 -0
  51. {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/loaders/__init__.py +0 -0
  52. {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/loaders/_field_utils.py +0 -0
  53. {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/loaders/attachments.py +0 -0
  54. {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/loaders/catalog.py +0 -0
  55. {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/loaders/changes.py +0 -0
  56. {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/loaders/cmdb.py +0 -0
  57. {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/loaders/incidents.py +0 -0
  58. {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/loaders/knowledge_base.py +0 -0
  59. {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/loaders/problems.py +0 -0
  60. {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/models.py +0 -0
  61. {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/py.typed +0 -0
  62. {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/utils/__init__.py +0 -0
  63. {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/utils/html_cleaner.py +0 -0
  64. {snowloader-0.2.2 → snowloader-0.2.3}/src/snowloader/utils/parsing.py +0 -0
  65. {snowloader-0.2.2 → snowloader-0.2.3}/tests/__init__.py +0 -0
  66. {snowloader-0.2.2 → snowloader-0.2.3}/tests/integration/__init__.py +0 -0
  67. {snowloader-0.2.2 → snowloader-0.2.3}/tests/integration/test_live.py +0 -0
  68. {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/__init__.py +0 -0
  69. {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_async_loaders.py +0 -0
  70. {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_attachments.py +0 -0
  71. {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_catalog.py +0 -0
  72. {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_changes.py +0 -0
  73. {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_cmdb.py +0 -0
  74. {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_connection.py +0 -0
  75. {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_field_utils.py +0 -0
  76. {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_html_cleaner.py +0 -0
  77. {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_incidents.py +0 -0
  78. {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_kb.py +0 -0
  79. {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_langchain_adapter.py +0 -0
  80. {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_llamaindex_adapter.py +0 -0
  81. {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_models.py +0 -0
  82. {snowloader-0.2.2 → snowloader-0.2.3}/tests/unit/test_problems.py +0 -0
  83. {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.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
@@ -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.3"
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.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.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
- # 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__,
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 test_request_treats_null_body_as_empty_result() -> None:
259
- """Regression: ServiceNow can return JSON null under transient load.
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
- Previously this crashed aget_records on ``data.get('result')``. Now we
262
- log a warning and treat it as an empty result.
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
- records = [rec async for rec in conn.aget_records("incident")]
285
- assert records == []
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 test_request_treats_list_body_as_empty_result() -> None:
290
- """Regression: defensive handling for unexpected non-object JSON shapes."""
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