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.
- {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/workflows/claude-code-review.yml +39 -40
- {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/workflows/claude.yml +39 -41
- qurl_python-0.4.0/.release-please-manifest.json +1 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/CHANGELOG.md +11 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/PKG-INFO +24 -3
- {qurl_python-0.3.0 → qurl_python-0.4.0}/README.md +23 -2
- {qurl_python-0.3.0 → qurl_python-0.4.0}/pyproject.toml +1 -1
- {qurl_python-0.3.0 → qurl_python-0.4.0}/src/layerv_qurl/_utils.py +10 -3
- {qurl_python-0.3.0 → qurl_python-0.4.0}/src/layerv_qurl/types.py +17 -4
- {qurl_python-0.3.0 → qurl_python-0.4.0}/tests/test_client.py +164 -2
- qurl_python-0.3.0/.release-please-manifest.json +0 -1
- {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/dependabot.yml +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/workflows/ci.yml +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/workflows/codeql.yml +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/workflows/dependency-age-check-actions.yml +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/workflows/dependency-age-check-pip.yml +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/workflows/dependency-review.yml +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/workflows/issue-priority.yml +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/workflows/pr-title.yml +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/workflows/release-please.yml +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/workflows/secrets-scan.yml +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/.github/workflows/validate-issue-templates.yml +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/.gitignore +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/CLAUDE.md +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/CONTRIBUTING.md +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/LICENSE +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/SECURITY.md +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/release-please-config.json +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/src/layerv_qurl/__init__.py +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/src/layerv_qurl/async_client.py +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/src/layerv_qurl/client.py +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/src/layerv_qurl/errors.py +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/src/layerv_qurl/langchain.py +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/src/layerv_qurl/py.typed +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/tests/__init__.py +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/tests/test_conformance.py +0 -0
- {qurl_python-0.3.0 → qurl_python-0.4.0}/tests/test_langchain.py +0 -0
- {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@
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
|
336
|
-
|
|
337
|
-
|
|
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
|
|
359
|
-
|
|
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
|
-
|
|
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@
|
|
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
|
-
#
|
|
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.
|
|
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
|
|
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
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
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
|
|
460
|
-
|
|
461
|
-
|
|
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
|
|
483
|
-
|
|
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
|
-
|
|
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
|
+
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
|
-
|
|
336
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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.
|
|
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"
|
|
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"
|
|
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"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
|
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
|
|
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
|