qurl-python 0.2.1__py3-none-any.whl → 0.2.2__py3-none-any.whl

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.
layerv_qurl/__init__.py CHANGED
@@ -1,10 +1,16 @@
1
- """qURL Python SDK — secure, time-limited access links for AI agents."""
1
+ """qURL Python SDK — protect a private URL once, then mint short-lived portal links for it.
2
+
3
+ A portal is cryptographic, just-in-time permission for one actor to reach
4
+ one private resource. Core flow: :meth:`QURLClient.protect_url` →
5
+ :meth:`ProtectedResource.create_portal`; programmatic openers use
6
+ :meth:`QURLClient.enter_portal`.
7
+ """
2
8
 
3
9
  from importlib.metadata import PackageNotFoundError
4
10
  from importlib.metadata import version as _pkg_version
5
11
 
6
- from layerv_qurl.async_client import AsyncQURLClient
7
- from layerv_qurl.client import QURLClient
12
+ from layerv_qurl.async_client import AsyncProtectedResource, AsyncQURLClient
13
+ from layerv_qurl.client import ProtectedResource, QURLClient
8
14
  from layerv_qurl.errors import (
9
15
  AuthenticationError,
10
16
  AuthorizationError,
@@ -52,6 +58,7 @@ from layerv_qurl.types import (
52
58
  ListOutput,
53
59
  MintOutput,
54
60
  NHPServerPeerInfo,
61
+ Portal,
55
62
  PortalSession,
56
63
  Quota,
57
64
  QuotaPlan,
@@ -61,6 +68,7 @@ from layerv_qurl.types import (
61
68
  ResolveOutput,
62
69
  Resource,
63
70
  ResourceDetail,
71
+ ResourceHandle,
64
72
  ResourceListOutput,
65
73
  ResourceType,
66
74
  Session,
@@ -80,7 +88,9 @@ from layerv_qurl.types import (
80
88
  )
81
89
 
82
90
  __all__ = [
91
+ "AsyncProtectedResource",
83
92
  "AsyncQURLClient",
93
+ "ProtectedResource",
84
94
  "QURLClient",
85
95
  # Errors
86
96
  "AuthenticationError",
@@ -121,12 +131,14 @@ __all__ = [
121
131
  "Invoice",
122
132
  "InvoiceListOutput",
123
133
  "NHPServerPeerInfo",
134
+ "Portal",
124
135
  "PortalSession",
125
136
  "QuotaPlan",
126
137
  "QURLStatus",
127
138
  "RedeemAccessCodeOutput",
128
139
  "Resource",
129
140
  "ResourceDetail",
141
+ "ResourceHandle",
130
142
  "ResourceListOutput",
131
143
  "ResourceType",
132
144
  "Session",
layerv_qurl/_utils.py CHANGED
@@ -19,11 +19,11 @@ import secrets
19
19
  import time
20
20
  import uuid
21
21
  from collections.abc import Iterable, Mapping, Set
22
- from datetime import datetime
22
+ from datetime import datetime, timedelta
23
23
  from importlib.metadata import PackageNotFoundError
24
24
  from importlib.metadata import version as _pkg_version
25
25
  from typing import TYPE_CHECKING, Any, TypeVar
26
- from urllib.parse import quote
26
+ from urllib.parse import quote, urlsplit
27
27
 
28
28
  from layerv_qurl.errors import (
29
29
  AuthenticationError,
@@ -69,6 +69,7 @@ from layerv_qurl.types import (
69
69
  ListOutput,
70
70
  MintOutput,
71
71
  NHPServerPeerInfo,
72
+ Portal,
72
73
  PortalSession,
73
74
  Quota,
74
75
  RateLimits,
@@ -76,6 +77,7 @@ from layerv_qurl.types import (
76
77
  ResolveOutput,
77
78
  Resource,
78
79
  ResourceDetail,
80
+ ResourceHandle,
79
81
  ResourceListOutput,
80
82
  Session,
81
83
  SessionListOutput,
@@ -659,6 +661,310 @@ def parse_resolve_output(data: dict[str, Any]) -> ResolveOutput:
659
661
  )
660
662
 
661
663
 
664
+ # ---- Portal verbs (mirrors qurl-go) --------------------------------------
665
+ # Shared by client.py and async_client.py so the portal surface stays in
666
+ # lockstep between the sync and async clients and with qurl-go's
667
+ # client-side option guardrails.
668
+
669
+ #: Client-side floor for portal lifetimes, mirroring qurl-go's ``ValidFor``.
670
+ #: The LayerV API remains the source of truth for account limits; there is
671
+ #: intentionally no SDK-side maximum.
672
+ MIN_PORTAL_VALID_FOR_SECONDS = 60
673
+ #: Client-side floor for session durations, mirroring qurl-go's
674
+ #: ``WithSessionDuration`` (at least one second; zero is rejected rather
675
+ #: than treated as the server default).
676
+ MIN_SESSION_DURATION_SECONDS = 1
677
+
678
+
679
+ def serialize_api_duration(
680
+ value: str | timedelta | None, *, field_name: str, minimum_seconds: int
681
+ ) -> str | None:
682
+ """Serialize a duration option to the API's h/m/s duration grammar.
683
+
684
+ Strings pass through unvalidated — the server is authoritative, same
685
+ as the REST-shaped ``expires_in`` fields. :class:`~datetime.timedelta`
686
+ values get qurl-go's client-side guardrails: whole seconds only and at
687
+ least ``minimum_seconds``. Serialized with hours as the largest unit
688
+ so all API duration fields use the same grammar across SDKs.
689
+ """
690
+ if value is None:
691
+ return None
692
+ if isinstance(value, str):
693
+ if not value.strip():
694
+ raise ValueError(f"{field_name}: must be a non-empty duration string")
695
+ return value
696
+ if isinstance(value, timedelta):
697
+ total = value.total_seconds()
698
+ # Minimum before whole-seconds, matching qurl-go's check order so
699
+ # e.g. 30.5s reports "at least 60s" in both SDKs.
700
+ if total < minimum_seconds:
701
+ raise ValueError(f"{field_name}: must be at least {minimum_seconds}s")
702
+ seconds = int(total)
703
+ if seconds != total:
704
+ raise ValueError(f"{field_name}: must be whole seconds")
705
+ if seconds % 3600 == 0:
706
+ return f"{seconds // 3600}h"
707
+ if seconds % 60 == 0:
708
+ return f"{seconds // 60}m"
709
+ return f"{seconds}s"
710
+ raise ValueError(
711
+ f"{field_name}: must be a duration string (e.g. '5m') or datetime.timedelta"
712
+ )
713
+
714
+
715
+ def validate_portal_target_url(target_url: str) -> None:
716
+ """Reject malformed target URLs and embedded credentials (qurl-go parity).
717
+
718
+ The portal verbs mirror qurl-go's ``ProtectURL`` guardrails: http(s)
719
+ scheme, a host, and no userinfo. The REST-shaped compatibility layer
720
+ keeps its original prefix-only check so its accepted inputs don't
721
+ change.
722
+
723
+ A userinfo URL contains credentials that must stay out of logs and
724
+ tracebacks, so the userinfo check runs FIRST with a non-echoing error
725
+ — before ``_require_http_url``, which echoes its input. Otherwise a
726
+ credentialed URL with a bad scheme (``ftp://user:pass@host``) would
727
+ leak the password through the scheme error before ever reaching the
728
+ userinfo branch.
729
+ """
730
+ if not isinstance(target_url, str) or not target_url.strip():
731
+ raise ValueError("target_url: must be a non-empty string")
732
+ try:
733
+ parts = urlsplit(target_url)
734
+ # Accessed inside the try: .hostname can also raise ValueError
735
+ # for malformed netlocs (e.g. unbalanced IPv6 brackets).
736
+ username, password, hostname = parts.username, parts.password, parts.hostname
737
+ except ValueError:
738
+ raise ValueError("target_url: malformed URL") from None
739
+ if username is not None or password is not None:
740
+ # Reject credentials before any echoing validator sees the URL.
741
+ raise ValueError(
742
+ "target_url: must not include embedded credentials (userinfo)"
743
+ )
744
+ # Safe to echo now: the URL carries no userinfo. This enforces the
745
+ # http(s) scheme and length and gives a helpful (input-echoing) error
746
+ # for credential-free malformed URLs.
747
+ _require_http_url(target_url, "target_url")
748
+ if not hostname:
749
+ raise ValueError("target_url: must include a host")
750
+
751
+
752
+ def build_protect_body(
753
+ *,
754
+ target_url: str,
755
+ description: str | None,
756
+ tags: list[str] | None,
757
+ custom_domain: str | None,
758
+ alias: str | None,
759
+ ) -> dict[str, Any]:
760
+ """Validate protect-url inputs and build the create-resource request body."""
761
+ validate_portal_target_url(target_url)
762
+ validate_update_input(
763
+ description=description, tags=tags, custom_domain=custom_domain
764
+ )
765
+ validate_alias(alias)
766
+ return build_body(
767
+ {
768
+ "target_url": target_url,
769
+ "description": description,
770
+ "tags": tags,
771
+ "custom_domain": custom_domain,
772
+ "alias": alias,
773
+ }
774
+ )
775
+
776
+
777
+ def require_connector_resource(
778
+ resources: list[Resource], connector_id: str
779
+ ) -> Resource:
780
+ """Select the single alias-matching resource from a connector lookup.
781
+
782
+ Mirrors qurl-go's ``ConnectorResource`` checks: the slug lookup must
783
+ return exactly one resource, and its alias must equal the connector
784
+ id. Raises the portal surface's client-detected errors (``status=0``)
785
+ otherwise — see :meth:`QURLClient.connector_resource` for the mapping.
786
+
787
+ ASSUMPTION: ``resources`` is the complete result set for the slug —
788
+ ``slug`` is an exact, unique server-side filter, so a connector maps
789
+ to at most one resource. This is the same contract qurl-go relies on;
790
+ the caller passes a single ``list_resources(slug=...)`` page and does
791
+ not paginate. If ``slug`` ever became a non-exact (prefix/substring)
792
+ filter whose matches could span pages, the ``len > 1`` ambiguity guard
793
+ below could be evaded and this lookup would need to page through the
794
+ full set.
795
+ """
796
+ if not resources:
797
+ raise NotFoundError(
798
+ status=0,
799
+ code="resource_not_found",
800
+ title="Resource Not Found",
801
+ detail=f"connector_resource: no resource found for connector {connector_id!r}",
802
+ )
803
+ if len(resources) > 1:
804
+ raise QURLError(
805
+ status=0,
806
+ code="ambiguous_resource",
807
+ title="Ambiguous Resource",
808
+ detail=(
809
+ f"connector_resource: connector {connector_id!r} returned "
810
+ f"{len(resources)} resources"
811
+ ),
812
+ )
813
+ resource = resources[0]
814
+ if resource.alias != connector_id:
815
+ raise ValidationError(
816
+ status=0,
817
+ code="unexpected_response",
818
+ title="Unexpected Response",
819
+ detail=(
820
+ f"connector_resource: connector {connector_id!r} returned a "
821
+ "resource with a missing or different alias"
822
+ ),
823
+ )
824
+ return resource
825
+
826
+
827
+ def build_portal_body(
828
+ *,
829
+ valid_for: str | timedelta | None,
830
+ label: str | None,
831
+ one_time_use: bool | None,
832
+ max_sessions: int | None,
833
+ session_duration: str | timedelta | None,
834
+ ) -> dict[str, Any]:
835
+ """Validate portal options and build a create-portal request body."""
836
+ # qurl-go's WithLabel rejects empty labels; the REST-shaped mint_link
837
+ # keeps its permissive behavior, so the check lives here rather than
838
+ # in validate_mint_input.
839
+ if label is not None and not label.strip():
840
+ raise ValueError("label: must not be empty")
841
+ validate_mint_input(label=label, max_sessions=max_sessions)
842
+ return build_body(
843
+ {
844
+ "expires_in": serialize_api_duration(
845
+ valid_for,
846
+ field_name="valid_for",
847
+ minimum_seconds=MIN_PORTAL_VALID_FOR_SECONDS,
848
+ ),
849
+ "label": label,
850
+ "one_time_use": one_time_use,
851
+ "max_sessions": max_sessions,
852
+ "session_duration": serialize_api_duration(
853
+ session_duration,
854
+ field_name="session_duration",
855
+ minimum_seconds=MIN_SESSION_DURATION_SECONDS,
856
+ ),
857
+ }
858
+ )
859
+
860
+
861
+ def parse_portal(data: dict[str, Any]) -> Portal:
862
+ """Parse a Portal from a create-portal API response.
863
+
864
+ Fails closed like qurl-go when the response lacks its identity
865
+ fields — a portal without a resource id or link is not actionable.
866
+ Everything else is optional because the two mint endpoints differ in
867
+ which ancillary fields they return.
868
+ """
869
+ resource_id = data.get("resource_id")
870
+ qurl_link = data.get("qurl_link")
871
+ if not resource_id or not qurl_link:
872
+ raise ValidationError(
873
+ status=0,
874
+ code="unexpected_response",
875
+ title="Unexpected Response",
876
+ detail="create portal: API response is missing resource_id or qurl_link",
877
+ )
878
+ return Portal(
879
+ resource_id=resource_id,
880
+ link=qurl_link,
881
+ site=data.get("qurl_site"),
882
+ expires_at=_parse_dt(data.get("expires_at")),
883
+ # Same empty-string → None normalization as parse_create_output.
884
+ qurl_id=data.get("qurl_id") or None,
885
+ label=data.get("label"),
886
+ )
887
+
888
+
889
+ # qurl-go's offline signed-fragment links have the four-part shape
890
+ # <version>.<claims>.<secret>.<sig>. We match any fragment of 3+
891
+ # dot-separated base64url parts — broad enough to catch malformed or
892
+ # future-versioned signed links — only to give them a precise error below
893
+ # (a bare access token never contains a dot, so there's no overlap).
894
+ _SIGNED_FRAGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+){2,}$")
895
+ # Access tokens carry the stable ``at_`` prefix and a lowercase
896
+ # base64url-ish charset (the server enforces the exact
897
+ # ``^at_[a-z0-9_-]{22}$`` form). We pin the prefix and charset — both
898
+ # stable — but leave the length open so a future token-length change
899
+ # doesn't make the SDK reject links the platform still mints. This is a
900
+ # cheap "looks like a token" sanity check; the server stays authoritative.
901
+ #
902
+ # INVARIANT: this pattern must stay a strict subset of ``_RESOURCE_ID_RE``.
903
+ # ``enter_portal`` validates the extracted token here (a non-echoing error)
904
+ # and then hands it to ``resolve`` → ``validate_id``, whose error DOES echo
905
+ # the value. As long as anything matching this pattern also matches
906
+ # ``_RESOURCE_ID_RE``, that echoing check never fires on the enter_portal
907
+ # path, so a credential can't leak into a ``validate_id`` error. Loosening
908
+ # this charset beyond ``_RESOURCE_ID_RE`` would reopen that leak.
909
+ _ACCESS_TOKEN_RE = re.compile(r"^at_[a-z0-9_-]+$")
910
+
911
+
912
+ def extract_access_token(qurl_link: str) -> str:
913
+ """Extract the access token from a qURL link, or pass a bare token through.
914
+
915
+ Platform-issued links carry the access token in the URL fragment
916
+ (``https://qurl.link/#at_...``); a bare ``at_...`` token is accepted
917
+ as-is. Error messages deliberately do not echo the input — qURL links
918
+ are credentials and must stay out of logs and tracebacks.
919
+ """
920
+ if not isinstance(qurl_link, str) or not qurl_link.strip():
921
+ raise ValueError("qurl_link: must be a non-empty string")
922
+ # Tolerate surrounding whitespace/newlines from a copy-paste.
923
+ qurl_link = qurl_link.strip()
924
+ # The platform fragment is exactly the token (qurl-service builds
925
+ # links as ``https://<domain>/#<tokenID>`` with nothing appended), so
926
+ # the whole fragment is the token. If links ever grew fragment
927
+ # parameters (``#at_xxx&ref=...``), this would need to parse the token
928
+ # out of the fragment rather than equate them.
929
+ token = qurl_link.split("#", 1)[1] if "#" in qurl_link else qurl_link
930
+ if _SIGNED_FRAGMENT_RE.match(token):
931
+ raise ValueError(
932
+ "qurl_link: this is a signed qURL link (offline fragment format), "
933
+ "which cannot be opened through the API resolver — enter_portal "
934
+ "only accepts platform links (https://qurl.link/#at_...) or bare "
935
+ "access tokens"
936
+ )
937
+ if not _ACCESS_TOKEN_RE.match(token):
938
+ raise ValueError(
939
+ "qurl_link: no access token found — pass a platform qURL link "
940
+ "(https://qurl.link/#at_...) or a bare access token (at_...)"
941
+ )
942
+ return token
943
+
944
+
945
+ def resource_handle_from_resolve(out: ResolveOutput) -> ResourceHandle:
946
+ """Map a ResolveOutput onto the portal-verb ResourceHandle.
947
+
948
+ Fails closed like qurl-go's ``EnterPortal``: a grant that carries no
949
+ resource URL is not actionable, so raise instead of handing back an
950
+ empty handle. Uses the same client-detected-failure convention as
951
+ the batch_create shape guard (``status=0``, ``unexpected_response``).
952
+ """
953
+ if not out.target_url:
954
+ raise ValidationError(
955
+ status=0,
956
+ code="unexpected_response",
957
+ title="Unexpected Response",
958
+ detail="enter_portal: access was granted but the API returned no resource URL",
959
+ )
960
+ open_seconds = out.access_grant.expires_in if out.access_grant else 0
961
+ return ResourceHandle(
962
+ resource_url=out.target_url,
963
+ open_seconds=open_seconds,
964
+ resource_id=out.resource_id,
965
+ )
966
+
967
+
662
968
  def parse_quota(data: dict[str, Any]) -> Quota:
663
969
  """Parse a Quota from API response data."""
664
970
  return Quota(