qurl-python 0.3.0__tar.gz → 0.4.0__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 (42) hide show
  1. {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/workflows/claude-code-review.yml +39 -40
  2. {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/workflows/claude.yml +39 -41
  3. qurl_python-0.4.0/.release-please-manifest.json +1 -0
  4. {qurl_python-0.3.0 → qurl_python-0.4.0}/CHANGELOG.md +11 -0
  5. {qurl_python-0.3.0 → qurl_python-0.4.0}/PKG-INFO +24 -3
  6. {qurl_python-0.3.0 → qurl_python-0.4.0}/README.md +23 -2
  7. {qurl_python-0.3.0 → qurl_python-0.4.0}/pyproject.toml +1 -1
  8. {qurl_python-0.3.0 → qurl_python-0.4.0}/src/layerv_qurl/_utils.py +10 -3
  9. {qurl_python-0.3.0 → qurl_python-0.4.0}/src/layerv_qurl/types.py +17 -4
  10. {qurl_python-0.3.0 → qurl_python-0.4.0}/tests/test_client.py +164 -2
  11. qurl_python-0.3.0/.release-please-manifest.json +0 -1
  12. {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  13. {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  14. {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  15. {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  16. {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/dependabot.yml +0 -0
  17. {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/workflows/ci.yml +0 -0
  18. {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/workflows/codeql.yml +0 -0
  19. {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/workflows/dependency-age-check-actions.yml +0 -0
  20. {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/workflows/dependency-age-check-pip.yml +0 -0
  21. {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/workflows/dependency-review.yml +0 -0
  22. {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/workflows/issue-priority.yml +0 -0
  23. {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/workflows/pr-title.yml +0 -0
  24. {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/workflows/release-please.yml +0 -0
  25. {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/workflows/secrets-scan.yml +0 -0
  26. {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/workflows/validate-issue-templates.yml +0 -0
  27. {qurl_python-0.3.0 → qurl_python-0.4.0}/.gitignore +0 -0
  28. {qurl_python-0.3.0 → qurl_python-0.4.0}/CLAUDE.md +0 -0
  29. {qurl_python-0.3.0 → qurl_python-0.4.0}/CONTRIBUTING.md +0 -0
  30. {qurl_python-0.3.0 → qurl_python-0.4.0}/LICENSE +0 -0
  31. {qurl_python-0.3.0 → qurl_python-0.4.0}/SECURITY.md +0 -0
  32. {qurl_python-0.3.0 → qurl_python-0.4.0}/release-please-config.json +0 -0
  33. {qurl_python-0.3.0 → qurl_python-0.4.0}/src/layerv_qurl/__init__.py +0 -0
  34. {qurl_python-0.3.0 → qurl_python-0.4.0}/src/layerv_qurl/async_client.py +0 -0
  35. {qurl_python-0.3.0 → qurl_python-0.4.0}/src/layerv_qurl/client.py +0 -0
  36. {qurl_python-0.3.0 → qurl_python-0.4.0}/src/layerv_qurl/errors.py +0 -0
  37. {qurl_python-0.3.0 → qurl_python-0.4.0}/src/layerv_qurl/langchain.py +0 -0
  38. {qurl_python-0.3.0 → qurl_python-0.4.0}/src/layerv_qurl/py.typed +0 -0
  39. {qurl_python-0.3.0 → qurl_python-0.4.0}/tests/__init__.py +0 -0
  40. {qurl_python-0.3.0 → qurl_python-0.4.0}/tests/test_conformance.py +0 -0
  41. {qurl_python-0.3.0 → qurl_python-0.4.0}/tests/test_langchain.py +0 -0
  42. {qurl_python-0.3.0 → qurl_python-0.4.0}/tests/test_portal.py +0 -0
@@ -161,7 +161,7 @@ jobs:
161
161
  # step, so the verify step never runs and the PR gets a bare
162
162
  # `cancelled` check with no comment and nothing saying why.
163
163
  timeout-minutes: 10
164
- uses: anthropics/claude-code-action@e5ad3c7725bc2459721893f88879fef9dbcf97b0 # v1.0.202
164
+ uses: anthropics/claude-code-action@12dd8d74c712f5f3669365b2369b558c495b1104 # v1.0.238
165
165
  with:
166
166
  anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
167
167
  github_token: ${{ github.token }}
@@ -223,7 +223,9 @@ jobs:
223
223
  # Use only the action's GitHub MCP subprocess for reads and the final
224
224
  # comment. It receives the repository token but not the Anthropic key.
225
225
  # Local, network, delegation, and GitHub file-write tools stay denied.
226
- claude_args: '--model claude-opus-5 --disallowed-tools "Bash,Read,Glob,Grep,LS,Task,Edit,Write,MultiEdit,NotebookEdit,WebFetch,WebSearch,mcp__github_file_ops__commit_files,mcp__github_file_ops__delete_files,mcp__github__create_or_update_file,mcp__github__push_files,mcp__github__delete_file" --allowed-tools "mcp__github__get_pull_request,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_review_comments,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_file_contents,mcp__github__search_code,mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__search_issues,mcp__github__search_pull_requests,mcp__github__list_issues,mcp__github__list_pull_requests,mcp__github__add_issue_comment"'
226
+ # Review model setting: Opus 5.5 at medium effort. Keep the model and
227
+ # effort identical in claude.yml; change both together.
228
+ claude_args: '--model claude-opus-5-5 --effort medium --disallowed-tools "Bash,Read,Glob,Grep,LS,Task,Edit,Write,MultiEdit,NotebookEdit,WebFetch,WebSearch,mcp__github_file_ops__commit_files,mcp__github_file_ops__delete_files,mcp__github__create_or_update_file,mcp__github__push_files,mcp__github__delete_file" --allowed-tools "mcp__github__get_pull_request,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_review_comments,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_file_contents,mcp__github__search_code,mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__search_issues,mcp__github__search_pull_requests,mcp__github__list_issues,mcp__github__list_pull_requests,mcp__github__add_issue_comment"'
227
229
 
228
230
  - name: Verify terminal Claude review
229
231
  if: success() && steps.claude-review.outcome == 'success'
@@ -270,7 +272,7 @@ jobs:
270
272
  # restoreConfigFromBase() fetches the base branch through it. Asserting the
271
273
  # LOCAL_ORIGIN pin survived therefore tested the action's version, not
272
274
  # Claude's behavior, and failed closed on every real PR. Still
273
- # present at v1.0.210, so bumping does not fix it.
275
+ # present at v1.0.238, so bumping does not fix it.
274
276
  #
275
277
  # The property that pin bought still has to hold: origin may only ever
276
278
  # address THIS repository, so no credential-bearing remote can point
@@ -286,20 +288,30 @@ jobs:
286
288
  # matters on GHES. A future action version that drops the ".git" suffix,
287
289
  # adds a port, or normalizes the path differently fails CLOSED here - safe,
288
290
  # but it breaks every run until the action_origin construction below is
289
- # updated. The failure prints the observed and expected destinations, so
290
- # start there.
291
+ # updated. The failure prints the observed host and the expected
292
+ # destination, so start there. An origin outside the allowlists prints
293
+ # neither.
294
+ # The authority is everything up to the first "/", and userinfo is everything
295
+ # in it before the LAST "@". Host and userinfo are then held to allowlists,
296
+ # because a URL client ends the host at "#", "?" or "\" as well: without that,
297
+ # https://evil.example#@github.com/o/r.git parses here as host github.com
298
+ # while git connects to evil.example. A shape outside the allowlists (an IPv6
299
+ # literal host, userinfo with other punctuation, no scheme at all) makes the
300
+ # function fail, and the caller fails closed.
291
301
  origin_destination() {
292
- local url="$1" scheme rest authority path
293
- if [[ "${url}" == *://* ]]; then
294
- scheme="${url%%://*}"
295
- rest="${url#*://}"
296
- authority="${rest%%/*}"
297
- path="${rest#"${authority}"}"
298
- authority="${authority##*@}"
299
- printf '%s://%s%s' "${scheme}" "${authority}" "${path}"
300
- return
302
+ local url="$1" scheme rest authority host userinfo
303
+ [[ "${url}" == *://* ]] || return 1
304
+ scheme="${url%%://*}"
305
+ rest="${url#*://}"
306
+ authority="${rest%%/*}"
307
+ host="${authority##*@}"
308
+ if [[ "${authority}" == *@* ]]; then
309
+ userinfo="${authority%@*}"
310
+ [[ "${userinfo}" =~ ^[A-Za-z0-9._~%:_-]+$ ]] || return 1
301
311
  fi
302
- printf '%s' "${url}"
312
+ [[ "${scheme}" =~ ^[A-Za-z][A-Za-z0-9+.-]*$ &&
313
+ "${host}" =~ ^[A-Za-z0-9.-]+(:[0-9]+)?$ ]] || return 1
314
+ printf '%s://%s%s' "${scheme}" "${host}" "${rest#"${authority}"}"
303
315
  }
304
316
 
305
317
  if [[ -z "${GITHUB_SERVER_URL:-}" || -z "${GITHUB_REPOSITORY:-}" ||
@@ -332,33 +344,20 @@ jobs:
332
344
  exit 1
333
345
  fi
334
346
  for origin_candidate in "${origin_urls[0]}" "${origin_push_urls[0]}"; do
335
- # Never echo origin_candidate itself - it carries the token. Only the
336
- # userinfo-stripped destination is safe to print.
337
- origin_dest="$(origin_destination "${origin_candidate}")"
338
- # After a correct strip the authority holds no "@", so a userinfo-position
339
- # credential can only survive in the PATH: that is the mis-split signal -
340
- # userinfo holding an unencoded "/" leaves the "@" past the first path
341
- # separator, where "${authority##*@}" never reaches it. Test the path
342
- # specifically rather than the whole string, so scp-style remotes
343
- # (git@host:o/r.git, no scheme, credential is a key not userinfo) still
344
- # print their real destination. This only redacts userinfo-position
345
- # credentials; a secret elsewhere in the URL would still print, which
346
- # needs the secret to be known already. A schemeless remote
347
- # (tok@host:o/r.git) also prints verbatim: that is deliberate, so
348
- # scp-style remotes keep their diagnostic value, and Actions secret
349
- # masking covers registered secrets.
350
- safe_dest="${origin_dest}"
351
- if [[ "${origin_dest}" == *://* ]]; then
352
- dest_rest="${origin_dest#*://}"
353
- dest_authority="${dest_rest%%/*}"
354
- if [[ "${dest_rest#"${dest_authority}"}" == *@* ]]; then
355
- safe_dest='<redacted: destination path retained userinfo>'
356
- fi
347
+ # Never echo origin_candidate itself - it may carry the token.
348
+ if [[ "${origin_candidate}" == "${LOCAL_ORIGIN}" ]]; then
349
+ continue
357
350
  fi
358
- if [[ "${origin_candidate}" != "${LOCAL_ORIGIN}" ]] &&
359
- [[ "${origin_dest,,}" != "${action_origin,,}" ]] &&
351
+ if ! origin_dest="$(origin_destination "${origin_candidate}")"; then
352
+ echo "::error::The review origin is neither '${LOCAL_ORIGIN}' nor a recognizable URL of this repository; failing closed."
353
+ exit 1
354
+ fi
355
+ if [[ "${origin_dest,,}" != "${action_origin,,}" ]] &&
360
356
  [[ "${origin_dest,,}" != "${action_origin_bare,,}" ]]; then
361
- echo "::error::The review origin moved off this repository; failing closed. Got '${safe_dest}', expected '${action_origin}' (with or without .git) or '${LOCAL_ORIGIN}'."
357
+ # Print only scheme and host: the path of a hostile URL can carry anything,
358
+ # including the token.
359
+ origin_host="${origin_dest#*://}"
360
+ echo "::error::The review origin moved off this repository; failing closed. Got host '${origin_dest%%://*}://${origin_host%%/*}', expected '${action_origin}' (with or without .git) or '${LOCAL_ORIGIN}'."
362
361
  exit 1
363
362
  fi
364
363
  done
@@ -328,7 +328,7 @@ jobs:
328
328
  if: success()
329
329
  # v1.0.180 keeps interactive commands on the same fail-closed runner as
330
330
  # automatic review and avoids the v1.0.140/141 issue-comment crash.
331
- uses: anthropics/claude-code-action@e5ad3c7725bc2459721893f88879fef9dbcf97b0 # v1.0.202
331
+ uses: anthropics/claude-code-action@12dd8d74c712f5f3669365b2369b558c495b1104 # v1.0.238
332
332
  with:
333
333
  anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
334
334
  # Omit github_token so the action uses its OIDC/GitHub App token path.
@@ -341,12 +341,13 @@ jobs:
341
341
  # Keep comment-triggered analysis on the same proven model as the
342
342
  # automatic PR review. The action's implicit default may select the
343
343
  # 1M-context variant, which this repository credential cannot start.
344
- # scripts/check-claude-model-lockstep.py enforces this coupling.
344
+ # Nothing in this repository enforces this coupling: change the model and
345
+ # effort here and in claude-code-review.yml together.
345
346
  # Do not add extra allowed tools by default: the action's base GitHub
346
347
  # tools are sufficient here. The automatic review workflow uses a
347
348
  # narrow GitHub MCP allowlist because its explicit prompt needs PR
348
349
  # inspection.
349
- claude_args: '--model claude-opus-5'
350
+ claude_args: '--model claude-opus-5-5 --effort medium'
350
351
 
351
352
  - name: Verify reviewed pull request head
352
353
  if: success() && steps.claude.outcome == 'success'
@@ -394,7 +395,7 @@ jobs:
394
395
  # restoreConfigFromBase() fetches the base branch through it. Asserting the
395
396
  # LOCAL_ORIGIN pin survived therefore tested the action's version, not
396
397
  # Claude's behavior, and failed closed on every real PR (#268). Still
397
- # present at v1.0.210, so bumping does not fix it.
398
+ # present at v1.0.238, so bumping does not fix it.
398
399
  #
399
400
  # The property that pin bought still has to hold: origin may only ever
400
401
  # address THIS repository, so no credential-bearing remote can point
@@ -410,20 +411,30 @@ jobs:
410
411
  # matters on GHES. A future action version that drops the ".git" suffix,
411
412
  # adds a port, or normalizes the path differently fails CLOSED here - safe,
412
413
  # but it breaks every run until the action_origin construction below is
413
- # updated. The failure prints the observed and expected destinations, so
414
- # start there.
414
+ # updated. The failure prints the observed host and the expected
415
+ # destination, so start there. An origin outside the allowlists prints
416
+ # neither.
417
+ # The authority is everything up to the first "/", and userinfo is everything
418
+ # in it before the LAST "@". Host and userinfo are then held to allowlists,
419
+ # because a URL client ends the host at "#", "?" or "\" as well: without that,
420
+ # https://evil.example#@github.com/o/r.git parses here as host github.com
421
+ # while git connects to evil.example. A shape outside the allowlists (an IPv6
422
+ # literal host, userinfo with other punctuation, no scheme at all) makes the
423
+ # function fail, and the caller fails closed.
415
424
  origin_destination() {
416
- local url="$1" scheme rest authority path
417
- if [[ "${url}" == *://* ]]; then
418
- scheme="${url%%://*}"
419
- rest="${url#*://}"
420
- authority="${rest%%/*}"
421
- path="${rest#"${authority}"}"
422
- authority="${authority##*@}"
423
- printf '%s://%s%s' "${scheme}" "${authority}" "${path}"
424
- return
425
+ local url="$1" scheme rest authority host userinfo
426
+ [[ "${url}" == *://* ]] || return 1
427
+ scheme="${url%%://*}"
428
+ rest="${url#*://}"
429
+ authority="${rest%%/*}"
430
+ host="${authority##*@}"
431
+ if [[ "${authority}" == *@* ]]; then
432
+ userinfo="${authority%@*}"
433
+ [[ "${userinfo}" =~ ^[A-Za-z0-9._~%:_-]+$ ]] || return 1
425
434
  fi
426
- printf '%s' "${url}"
435
+ [[ "${scheme}" =~ ^[A-Za-z][A-Za-z0-9+.-]*$ &&
436
+ "${host}" =~ ^[A-Za-z0-9.-]+(:[0-9]+)?$ ]] || return 1
437
+ printf '%s://%s%s' "${scheme}" "${host}" "${rest#"${authority}"}"
427
438
  }
428
439
 
429
440
  if [[ -z "${GITHUB_SERVER_URL:-}" || -z "${GITHUB_REPOSITORY:-}" ||
@@ -456,33 +467,20 @@ jobs:
456
467
  exit 1
457
468
  fi
458
469
  for origin_candidate in "${origin_urls[0]}" "${origin_push_urls[0]}"; do
459
- # Never echo origin_candidate itself - it carries the token. Only the
460
- # userinfo-stripped destination is safe to print.
461
- origin_dest="$(origin_destination "${origin_candidate}")"
462
- # After a correct strip the authority holds no "@", so a userinfo-position
463
- # credential can only survive in the PATH: that is the mis-split signal -
464
- # userinfo holding an unencoded "/" leaves the "@" past the first path
465
- # separator, where "${authority##*@}" never reaches it. Test the path
466
- # specifically rather than the whole string, so scp-style remotes
467
- # (git@host:o/r.git, no scheme, credential is a key not userinfo) still
468
- # print their real destination. This only redacts userinfo-position
469
- # credentials; a secret elsewhere in the URL would still print, which
470
- # needs the secret to be known already. A schemeless remote
471
- # (tok@host:o/r.git) also prints verbatim: that is deliberate, so
472
- # scp-style remotes keep their diagnostic value, and Actions secret
473
- # masking covers registered secrets.
474
- safe_dest="${origin_dest}"
475
- if [[ "${origin_dest}" == *://* ]]; then
476
- dest_rest="${origin_dest#*://}"
477
- dest_authority="${dest_rest%%/*}"
478
- if [[ "${dest_rest#"${dest_authority}"}" == *@* ]]; then
479
- safe_dest='<redacted: destination path retained userinfo>'
480
- fi
470
+ # Never echo origin_candidate itself - it may carry the token.
471
+ if [[ "${origin_candidate}" == "${LOCAL_ORIGIN}" ]]; then
472
+ continue
481
473
  fi
482
- if [[ "${origin_candidate}" != "${LOCAL_ORIGIN}" ]] &&
483
- [[ "${origin_dest,,}" != "${action_origin,,}" ]] &&
474
+ if ! origin_dest="$(origin_destination "${origin_candidate}")"; then
475
+ echo "::error::The local origin is neither '${LOCAL_ORIGIN}' nor a recognizable URL of this repository; failing closed."
476
+ exit 1
477
+ fi
478
+ if [[ "${origin_dest,,}" != "${action_origin,,}" ]] &&
484
479
  [[ "${origin_dest,,}" != "${action_origin_bare,,}" ]]; then
485
- echo "::error::The local origin moved off this repository; failing closed. Got '${safe_dest}', expected '${action_origin}' (with or without .git) or '${LOCAL_ORIGIN}'."
480
+ # Print only scheme and host: the path of a hostile URL can carry anything,
481
+ # including the token.
482
+ origin_host="${origin_dest#*://}"
483
+ echo "::error::The local origin moved off this repository; failing closed. Got host '${origin_dest%%://*}://${origin_host%%/*}', expected '${action_origin}' (with or without .git) or '${LOCAL_ORIGIN}'."
486
484
  exit 1
487
485
  fi
488
486
  done
@@ -0,0 +1 @@
1
+ {".":"0.4.0"}
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0](https://github.com/layervai/qurl-python/compare/v0.3.0...v0.4.0) (2026-10-08)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * preserve canonical resource quotas and monthly usage ([#157](https://github.com/layervai/qurl-python/issues/157))
9
+
10
+ ### Bug Fixes
11
+
12
+ * preserve canonical resource quotas and monthly usage ([#157](https://github.com/layervai/qurl-python/issues/157)) ([e017e78](https://github.com/layervai/qurl-python/commit/e017e7807c5012a93de387ec73a57043d1028806))
13
+
3
14
  ## [0.3.0](https://github.com/layervai/qurl-python/compare/v0.2.2...v0.3.0) (2026-09-08)
4
15
 
5
16
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: qurl-python
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Python SDK for the qURL™ API — secure, time-limited access links. Quantum URL is how you enter the hidden layer of the internet.
5
5
  Project-URL: Homepage, https://github.com/layervai/qurl-python
6
6
  Project-URL: Repository, https://github.com/layervai/qurl-python
@@ -332,10 +332,31 @@ All error classes inherit from `QURLError`, so `except QURLError` catches everyt
332
332
  ```python
333
333
  quota = client.get_quota()
334
334
  print(f"Plan: {quota.plan}")
335
- print(f"Active qURLs: {quota.usage.active_qurls}")
336
- print(f"Rate limit: {quota.rate_limits.create_per_minute}/min")
335
+ usage = quota.usage
336
+ active = usage.active_resources if usage is not None else None
337
+ if active is None and usage is not None:
338
+ active = usage.active_qurls
339
+ print("Active protected resources:", active if active is not None else "unavailable")
340
+ if quota.rate_limits is not None:
341
+ print(f"Rate limit: {quota.rate_limits.create_per_minute}/min")
337
342
  ```
338
343
 
344
+ Use `max_active_resources`, `active_resources`, and `active_resources_percent`
345
+ for resource quotas. `max_data_transfer_bytes` is the monthly byte allowance;
346
+ `data_transfer_bytes` is measured usage, available on both quota usage and
347
+ current-period responses. This usage always covers the current UTC calendar
348
+ month, independently of a paid account's billing period. Missing or null values
349
+ remain `None`, distinct from a reported zero. The [service OpenAPI spec](https://github.com/layervai/qurl-service/blob/01d7c47361b5f72f890428f1a53bd04e8d7f67cc/api/openapi.yaml#L5796)
350
+ defines `-1` as unlimited for `max_active_resources` and its legacy alias
351
+ `max_active_qurls`, as well as `max_data_transfer_bytes`.
352
+
353
+ **Migration:** the deprecated `max_active_qurls` and `active_qurls` fields still
354
+ represent protected resources. Missing values now return `None` instead of
355
+ `0`; check `is not None` before arithmetic or displaying a count. Canonical
356
+ and legacy fields preserve their own wire values, so an older response that
357
+ omits canonical fields leaves those fields `None`. Prefer the canonical fields
358
+ when available.
359
+
339
360
  JWT-authenticated dashboard endpoints are also available for usage, customer
340
361
  settings, billing sessions, invoices, and API-key management. API-key auth
341
362
  continues to work for normal qURL, resource, domain, webhook, connector, and
@@ -298,10 +298,31 @@ All error classes inherit from `QURLError`, so `except QURLError` catches everyt
298
298
  ```python
299
299
  quota = client.get_quota()
300
300
  print(f"Plan: {quota.plan}")
301
- print(f"Active qURLs: {quota.usage.active_qurls}")
302
- print(f"Rate limit: {quota.rate_limits.create_per_minute}/min")
301
+ usage = quota.usage
302
+ active = usage.active_resources if usage is not None else None
303
+ if active is None and usage is not None:
304
+ active = usage.active_qurls
305
+ print("Active protected resources:", active if active is not None else "unavailable")
306
+ if quota.rate_limits is not None:
307
+ print(f"Rate limit: {quota.rate_limits.create_per_minute}/min")
303
308
  ```
304
309
 
310
+ Use `max_active_resources`, `active_resources`, and `active_resources_percent`
311
+ for resource quotas. `max_data_transfer_bytes` is the monthly byte allowance;
312
+ `data_transfer_bytes` is measured usage, available on both quota usage and
313
+ current-period responses. This usage always covers the current UTC calendar
314
+ month, independently of a paid account's billing period. Missing or null values
315
+ remain `None`, distinct from a reported zero. The [service OpenAPI spec](https://github.com/layervai/qurl-service/blob/01d7c47361b5f72f890428f1a53bd04e8d7f67cc/api/openapi.yaml#L5796)
316
+ defines `-1` as unlimited for `max_active_resources` and its legacy alias
317
+ `max_active_qurls`, as well as `max_data_transfer_bytes`.
318
+
319
+ **Migration:** the deprecated `max_active_qurls` and `active_qurls` fields still
320
+ represent protected resources. Missing values now return `None` instead of
321
+ `0`; check `is not None` before arithmetic or displaying a count. Canonical
322
+ and legacy fields preserve their own wire values, so an older response that
323
+ omits canonical fields leaves those fields `None`. Prefer the canonical fields
324
+ when available.
325
+
305
326
  JWT-authenticated dashboard endpoints are also available for usage, customer
306
327
  settings, billing sessions, invoices, and API-key management. API-key auth
307
328
  continues to work for normal qURL, resource, domain, webhook, connector, and
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "qurl-python"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Python SDK for the qURL™ API — secure, time-limited access links. Quantum URL is how you enter the hidden layer of the internet."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -989,8 +989,10 @@ def _parse_rate_limits(data: dict[str, Any] | None) -> RateLimits | None:
989
989
  create_per_hour=data.get("create_per_hour", 0),
990
990
  list_per_minute=data.get("list_per_minute", 0),
991
991
  resolve_per_minute=data.get("resolve_per_minute", 0),
992
- max_active_qurls=data.get("max_active_qurls", 0),
992
+ max_active_qurls=data.get("max_active_qurls"),
993
993
  max_expiry_seconds=data.get("max_expiry_seconds", 0),
994
+ max_active_resources=data.get("max_active_resources"),
995
+ max_data_transfer_bytes=data.get("max_data_transfer_bytes"),
994
996
  )
995
997
 
996
998
 
@@ -999,11 +1001,14 @@ def _parse_usage_block(data: dict[str, Any] | None) -> Usage | None:
999
1001
  return None
1000
1002
  return Usage(
1001
1003
  qurls_created=data.get("qurls_created", 0),
1002
- active_qurls=data.get("active_qurls", 0),
1004
+ active_qurls=data.get("active_qurls"),
1003
1005
  # Nullable per the API spec — the field is null when
1004
1006
  # max_active_qurls is unlimited.
1005
1007
  active_qurls_percent=data.get("active_qurls_percent"),
1006
1008
  total_accesses=data.get("total_accesses", 0),
1009
+ active_resources=data.get("active_resources"),
1010
+ active_resources_percent=data.get("active_resources_percent"),
1011
+ data_transfer_bytes=data.get("data_transfer_bytes"),
1007
1012
  )
1008
1013
 
1009
1014
 
@@ -1289,8 +1294,10 @@ def parse_current_period_usage(data: dict[str, Any]) -> CurrentPeriodUsage:
1289
1294
  period_start=_parse_dt(data.get("period_start")),
1290
1295
  period_end=_parse_dt(data.get("period_end")),
1291
1296
  qurls_created=data.get("qurls_created", 0),
1292
- active_qurls=data.get("active_qurls", 0),
1297
+ active_qurls=data.get("active_qurls"),
1293
1298
  cost_estimate=_parse_usage_cost(data.get("cost_estimate")),
1299
+ active_resources=data.get("active_resources"),
1300
+ data_transfer_bytes=data.get("data_transfer_bytes"),
1294
1301
  )
1295
1302
 
1296
1303
 
@@ -211,14 +211,18 @@ class ListOutput:
211
211
 
212
212
  @dataclass
213
213
  class RateLimits:
214
- """Rate limit configuration."""
214
+ """Rate limits and plan allowances."""
215
215
 
216
216
  create_per_minute: int = 0
217
217
  create_per_hour: int = 0
218
218
  list_per_minute: int = 0
219
219
  resolve_per_minute: int = 0
220
- max_active_qurls: int = 0
220
+ # Deprecated alias for max_active_resources, not a qURL-count cap.
221
+ max_active_qurls: int | None = None
221
222
  max_expiry_seconds: int = 0
223
+ # Missing/null limits remain unknown; -1 is the unlimited sentinel.
224
+ max_active_resources: int | None = None
225
+ max_data_transfer_bytes: int | None = None
222
226
 
223
227
 
224
228
  @dataclass
@@ -226,10 +230,15 @@ class Usage:
226
230
  """Usage statistics."""
227
231
 
228
232
  qurls_created: int = 0
229
- active_qurls: int = 0
233
+ # Deprecated alias for active_resources, not a count of qURLs.
234
+ active_qurls: int | None = None
230
235
  # Changed from float=0.0 — callers must None-check before arithmetic.
231
236
  active_qurls_percent: float | None = None
232
237
  total_accesses: int = 0
238
+ active_resources: int | None = None
239
+ active_resources_percent: float | None = None
240
+ # Measured bytes this UTC calendar month; None is distinct from zero.
241
+ data_transfer_bytes: int | None = None
233
242
 
234
243
 
235
244
  @dataclass
@@ -549,8 +558,12 @@ class CurrentPeriodUsage:
549
558
  period_start: datetime | None
550
559
  period_end: datetime | None
551
560
  qurls_created: int
552
- active_qurls: int
561
+ # Deprecated alias for active_resources, not a count of qURLs.
562
+ active_qurls: int | None
553
563
  cost_estimate: UsageCostEstimate | None = None
564
+ active_resources: int | None = None
565
+ # Measured bytes this UTC calendar month, independent of the billing period.
566
+ data_transfer_bytes: int | None = None
554
567
 
555
568
 
556
569
  @dataclass
@@ -781,6 +781,8 @@ async def test_quota_typed(
781
781
  assert result.rate_limits.create_per_minute == 60
782
782
  assert result.rate_limits.max_active_qurls == 5000
783
783
  assert result.rate_limits.max_expiry_seconds == 604800
784
+ assert result.rate_limits.max_active_resources is None
785
+ assert result.rate_limits.max_data_transfer_bytes is None
784
786
 
785
787
  # Typed Usage
786
788
  assert result.usage is not None
@@ -788,6 +790,166 @@ async def test_quota_typed(
788
790
  assert result.usage.qurls_created == 10
789
791
  assert result.usage.total_accesses == 42
790
792
  assert result.usage.active_qurls_percent == 0.1
793
+ assert result.usage.active_resources is None
794
+ assert result.usage.active_resources_percent is None
795
+ assert result.usage.data_transfer_bytes is None
796
+
797
+
798
+ @pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"])
799
+ @pytest.mark.parametrize(
800
+ ("limits", "usage", "expected"),
801
+ [
802
+ pytest.param(
803
+ {"max_active_resources": 10, "max_data_transfer_bytes": 10_737_418_240},
804
+ {
805
+ "active_resources": 8,
806
+ "active_resources_percent": 80.0,
807
+ "data_transfer_bytes": 23_456_789,
808
+ },
809
+ (10, 10_737_418_240, 8, 80.0, 23_456_789),
810
+ id="canonical-only",
811
+ ),
812
+ pytest.param(
813
+ {"max_active_resources": 0, "max_data_transfer_bytes": 0},
814
+ {"active_resources": 0, "active_resources_percent": 0.0, "data_transfer_bytes": 0},
815
+ (0, 0, 0, 0.0, 0),
816
+ id="zero",
817
+ ),
818
+ pytest.param(
819
+ {"max_active_resources": -1, "max_data_transfer_bytes": -1},
820
+ {
821
+ "active_resources": 87,
822
+ "active_resources_percent": None,
823
+ "data_transfer_bytes": 9_007_199_254_740_993,
824
+ },
825
+ (-1, -1, 87, None, 9_007_199_254_740_993),
826
+ id="unlimited-and-large-bytes",
827
+ ),
828
+ pytest.param({}, {}, (None, None, None, None, None), id="absent-fields"),
829
+ pytest.param(
830
+ {"max_active_resources": None, "max_data_transfer_bytes": None},
831
+ {
832
+ "active_resources": None,
833
+ "active_resources_percent": None,
834
+ "data_transfer_bytes": None,
835
+ },
836
+ (None, None, None, None, None),
837
+ id="null-fields",
838
+ ),
839
+ ],
840
+ )
841
+ @respx.mock
842
+ async def test_quota_preserves_canonical_resources_and_monthly_bytes(
843
+ client: QURLClient,
844
+ async_client: AsyncQURLClient,
845
+ use_async: bool,
846
+ limits: dict[str, Any],
847
+ usage: dict[str, Any],
848
+ expected: tuple[int | None, int | None, int | None, float | None, int | None],
849
+ ) -> None:
850
+ respx.get(f"{BASE_URL}/v1/quota").mock(
851
+ return_value=httpx.Response(
852
+ 200,
853
+ json={
854
+ "data": {
855
+ "plan": "free",
856
+ "rate_limits": limits,
857
+ "usage": usage,
858
+ }
859
+ },
860
+ )
861
+ )
862
+
863
+ result = await async_client.get_quota() if use_async else client.get_quota()
864
+ assert result.rate_limits is not None
865
+ assert result.usage is not None
866
+ assert (
867
+ result.rate_limits.max_active_resources,
868
+ result.rate_limits.max_data_transfer_bytes,
869
+ result.usage.active_resources,
870
+ result.usage.active_resources_percent,
871
+ result.usage.data_transfer_bytes,
872
+ ) == expected
873
+ # An omitted legacy alias must not invent a zero-resource account.
874
+ assert result.rate_limits.max_active_qurls is None
875
+ assert result.usage.active_qurls is None
876
+
877
+
878
+ @pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"])
879
+ @respx.mock
880
+ async def test_quota_preserves_divergent_canonical_and_legacy_values(
881
+ client: QURLClient, async_client: AsyncQURLClient, use_async: bool
882
+ ) -> None:
883
+ respx.get(f"{BASE_URL}/v1/quota").mock(
884
+ return_value=httpx.Response(
885
+ 200,
886
+ json={
887
+ "data": {
888
+ "rate_limits": {"max_active_resources": 10, "max_active_qurls": 5000},
889
+ "usage": {
890
+ "active_resources": 8,
891
+ "active_qurls": 3,
892
+ "active_resources_percent": 80.0,
893
+ "active_qurls_percent": 0.06,
894
+ },
895
+ }
896
+ },
897
+ )
898
+ )
899
+
900
+ result = await async_client.get_quota() if use_async else client.get_quota()
901
+ assert result.rate_limits is not None
902
+ assert result.rate_limits.max_active_resources == 10
903
+ assert result.rate_limits.max_active_qurls == 5000
904
+ assert result.usage is not None
905
+ assert result.usage.active_resources == 8
906
+ assert result.usage.active_qurls == 3
907
+ assert result.usage.active_resources_percent == 80.0
908
+ assert result.usage.active_qurls_percent == 0.06
909
+
910
+
911
+ @pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"])
912
+ @pytest.mark.parametrize("wire_name", ["active_resources", "active_qurls"])
913
+ @pytest.mark.parametrize(
914
+ ("fields", "expected_count", "expected_bytes"),
915
+ [
916
+ pytest.param({"count": 8, "data_transfer_bytes": 23_456_789}, 8, 23_456_789, id="observed"),
917
+ pytest.param({"count": 0, "data_transfer_bytes": 0}, 0, 0, id="zero"),
918
+ pytest.param(
919
+ {"count": 87, "data_transfer_bytes": 9_007_199_254_740_993},
920
+ 87,
921
+ 9_007_199_254_740_993,
922
+ id="large-bytes",
923
+ ),
924
+ pytest.param({}, None, None, id="absent"),
925
+ pytest.param({"count": None, "data_transfer_bytes": None}, None, None, id="null"),
926
+ ],
927
+ )
928
+ @respx.mock
929
+ async def test_current_period_preserves_resource_counts_and_monthly_bytes(
930
+ client: QURLClient,
931
+ async_client: AsyncQURLClient,
932
+ use_async: bool,
933
+ wire_name: str,
934
+ fields: dict[str, int | None],
935
+ expected_count: int | None,
936
+ expected_bytes: int | None,
937
+ ) -> None:
938
+ payload = {k: v for k, v in fields.items() if k != "count"}
939
+ if "count" in fields:
940
+ payload[wire_name] = fields["count"]
941
+ respx.get(f"{BASE_URL}/v1/usage/current-period").mock(
942
+ return_value=httpx.Response(200, json={"data": payload})
943
+ )
944
+
945
+ result = (
946
+ await async_client.get_usage_current_period()
947
+ if use_async
948
+ else client.get_usage_current_period()
949
+ )
950
+ assert result.active_resources == (expected_count if wire_name == "active_resources" else None)
951
+ assert result.active_qurls == (expected_count if wire_name == "active_qurls" else None)
952
+ assert result.data_transfer_bytes == expected_bytes
791
953
 
792
954
 
793
955
  @respx.mock
@@ -809,7 +971,7 @@ def test_quota_active_qurls_percent_null(client: QURLClient) -> None:
809
971
  "period_end": "2026-04-01T00:00:00Z",
810
972
  "rate_limits": {
811
973
  "create_per_minute": 1000,
812
- "max_active_qurls": 0, # unlimited on enterprise
974
+ "max_active_qurls": -1, # unlimited on enterprise
813
975
  },
814
976
  "usage": {
815
977
  "qurls_created": 500,
@@ -4605,7 +4767,7 @@ def test_account_parsers_tolerate_partial_usage_payloads(client: QURLClient) ->
4605
4767
  usage = client.get_usage_current_period()
4606
4768
  assert usage.tier == "unknown"
4607
4769
  assert usage.qurls_created == 0
4608
- assert usage.active_qurls == 0
4770
+ assert usage.active_qurls is None
4609
4771
  assert usage.cost_estimate is None
4610
4772
 
4611
4773
  respx.get(f"{BASE_URL}/v1/usage/daily").mock(
@@ -1 +0,0 @@
1
- {".":"0.3.0"}
File without changes
File without changes
File without changes
File without changes
File without changes