querygraph 0.2.0__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.
@@ -0,0 +1,155 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+ from urllib.request import urlopen
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from querygraph.croissant import CroissantDataset, Field as CroissantField, FileObject, RecordSet
11
+
12
+
13
+ class DataverseFile(BaseModel):
14
+ id: int | str
15
+ label: str
16
+ download_url: str
17
+ content_type: str = "application/octet-stream"
18
+
19
+
20
+ class DataverseDataset(BaseModel):
21
+ id: int | str
22
+ persistent_id: str
23
+ title: str
24
+ description: str = ""
25
+ landing_page: str
26
+ subjects: list[str] = Field(default_factory=list)
27
+ keywords: list[str] = Field(default_factory=list)
28
+ files: list[DataverseFile] = Field(default_factory=list)
29
+
30
+ @classmethod
31
+ def from_native_api(cls, payload: dict[str, Any]) -> "DataverseDataset":
32
+ data = payload.get("data", payload)
33
+ citation = data.get("latestVersion", data).get("metadataBlocks", {}).get("citation", {})
34
+ fields = citation.get("fields", [])
35
+ values = {_field_name(field): _field_value(field) for field in fields}
36
+ files = []
37
+ for file_entry in data.get("latestVersion", data).get("files", []):
38
+ data_file = file_entry.get("dataFile", file_entry)
39
+ file_id = data_file.get("id", file_entry.get("id", "file"))
40
+ files.append(
41
+ DataverseFile(
42
+ id=file_id,
43
+ label=data_file.get("filename", file_entry.get("label", str(file_id))),
44
+ download_url=data_file.get(
45
+ "downloadUrl",
46
+ f"https://dataverse.harvard.edu/api/access/datafile/{file_id}",
47
+ ),
48
+ content_type=data_file.get("contentType", "application/octet-stream"),
49
+ )
50
+ )
51
+ return cls(
52
+ id=data.get("id", values.get("datasetId", "dataset")),
53
+ persistent_id=data.get("persistentId", values.get("persistentId", "")),
54
+ title=values.get("title", data.get("title", "Dataverse dataset")),
55
+ description=_first_text(values.get("dsDescription")) or data.get("description", ""),
56
+ landing_page=data.get("persistentUrl", data.get("url", "")),
57
+ subjects=_as_text_list(values.get("subject")),
58
+ keywords=_keyword_values(values.get("keyword")),
59
+ files=files,
60
+ )
61
+
62
+ @classmethod
63
+ def from_json_file(cls, path: str | Path) -> "DataverseDataset":
64
+ return cls.from_native_api(json.loads(Path(path).read_text()))
65
+
66
+ @classmethod
67
+ def fetch(cls, url: str) -> "DataverseDataset":
68
+ with urlopen(url) as response: # nosec - user-supplied CLI/library URL.
69
+ return cls.from_native_api(json.loads(response.read().decode("utf-8")))
70
+
71
+ def to_croissant(self) -> CroissantDataset:
72
+ dataset_id = f"{self.landing_page.rstrip('/')}/#dataset" if self.landing_page else f"urn:dataverse:{self.id}"
73
+ return CroissantDataset(
74
+ id=dataset_id,
75
+ name=self.title,
76
+ description=self.description,
77
+ license="https://creativecommons.org/licenses/by/4.0/",
78
+ creators=["Dataverse"],
79
+ files=[
80
+ FileObject(
81
+ id=f"{dataset_id}/file/{file.id}",
82
+ name=file.label,
83
+ content_url=file.download_url,
84
+ encoding_format=file.content_type,
85
+ )
86
+ for file in self.files
87
+ ],
88
+ record_sets=[
89
+ RecordSet(
90
+ id=f"{dataset_id}/recordset/files",
91
+ name="Dataverse files",
92
+ fields=[
93
+ CroissantField(
94
+ "dataset_persistent_id",
95
+ "sc:Text",
96
+ "Dataverse persistent dataset identifier.",
97
+ ).semantic_type("https://schema.org/identifier"),
98
+ CroissantField(
99
+ "file_name",
100
+ "sc:Text",
101
+ "Dataverse file name.",
102
+ ).semantic_type("https://schema.org/name"),
103
+ CroissantField(
104
+ "download_url",
105
+ "sc:URL",
106
+ "Dataverse file download URL.",
107
+ ).semantic_type("https://schema.org/contentUrl"),
108
+ ],
109
+ )
110
+ ],
111
+ keywords=[*self.subjects, *self.keywords],
112
+ )
113
+
114
+
115
+ def _field_name(field: dict[str, Any]) -> str:
116
+ return str(field.get("typeName", field.get("name", "")))
117
+
118
+
119
+ def _field_value(field: dict[str, Any]) -> Any:
120
+ return field.get("value", field.get("values"))
121
+
122
+
123
+ def _first_text(value: Any) -> str | None:
124
+ if isinstance(value, list) and value:
125
+ item = value[0]
126
+ if isinstance(item, dict):
127
+ return str(item.get("dsDescriptionValue", item.get("value", "")))
128
+ return str(item)
129
+ if isinstance(value, dict):
130
+ return str(value.get("dsDescriptionValue", value.get("value", "")))
131
+ if value:
132
+ return str(value)
133
+ return None
134
+
135
+
136
+ def _as_text_list(value: Any) -> list[str]:
137
+ if value is None:
138
+ return []
139
+ if isinstance(value, list):
140
+ return [str(item) for item in value]
141
+ return [str(value)]
142
+
143
+
144
+ def _keyword_values(value: Any) -> list[str]:
145
+ if not isinstance(value, list):
146
+ return _as_text_list(value)
147
+ out = []
148
+ for item in value:
149
+ if isinstance(item, dict):
150
+ keyword = item.get("keywordValue") or item.get("value")
151
+ if keyword:
152
+ out.append(str(keyword))
153
+ else:
154
+ out.append(str(item))
155
+ return out
querygraph/did.py ADDED
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from hashlib import sha256
5
+
6
+ from querygraph.base58 import b58encode
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class DidDocument:
11
+ id: str
12
+ controller: str
13
+ public_key_multibase: str
14
+ context: list[str] | None = None
15
+ service_endpoint: str | None = None
16
+
17
+ @classmethod
18
+ def new_oyd(cls, seed: bytes | str, controller: str) -> "DidDocument":
19
+ seed_bytes = seed.encode() if isinstance(seed, str) else seed
20
+ digest = sha256(seed_bytes).digest()
21
+ multihash = bytes([0x12, 0x20]) + digest
22
+ fingerprint = b58encode(multihash)
23
+ return cls(
24
+ context=[
25
+ "https://www.w3.org/ns/did/v1",
26
+ "https://w3id.org/security/suites/ed25519-2020/v1",
27
+ ],
28
+ id=f"did:oyd:z{fingerprint}",
29
+ controller=controller,
30
+ public_key_multibase=f"z{b58encode(digest)}",
31
+ )
32
+
33
+ def with_service_endpoint(self, endpoint: str) -> "DidDocument":
34
+ return DidDocument(
35
+ context=self.context,
36
+ id=self.id,
37
+ controller=self.controller,
38
+ public_key_multibase=self.public_key_multibase,
39
+ service_endpoint=endpoint,
40
+ )
41
+
42
+ def to_json(self) -> dict:
43
+ doc = {
44
+ "id": self.id,
45
+ "controller": self.controller,
46
+ "public_key_multibase": self.public_key_multibase,
47
+ "service_endpoint": self.service_endpoint,
48
+ }
49
+ if self.context is not None:
50
+ doc["@context"] = self.context
51
+ return doc
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class TableSpec:
11
+ logical_name: str
12
+ bare_name: str
13
+ rows: int
14
+ location: Path
15
+
16
+
17
+ def load_table_specs(
18
+ manifest: str | Path,
19
+ warehouse: str | Path,
20
+ ) -> list[TableSpec]:
21
+ report = json.loads(Path(manifest).read_text())
22
+ warehouse_path = Path(warehouse).resolve()
23
+ specs: list[TableSpec] = []
24
+ for dataset in report.get("datasets", []):
25
+ for file in dataset.get("files", []):
26
+ table = file.get("table")
27
+ rows = file.get("rows")
28
+ if not table or rows is None:
29
+ continue
30
+ bare = table.split(".", 1)[-1]
31
+ specs.append(
32
+ TableSpec(
33
+ logical_name=table,
34
+ bare_name=bare,
35
+ rows=int(rows),
36
+ location=find_latest_parquet_dir(warehouse_path, bare),
37
+ )
38
+ )
39
+ return specs
40
+
41
+
42
+ def find_latest_parquet_dir(warehouse: Path, table: str) -> Path:
43
+ matches = sorted(
44
+ [path for path in warehouse.iterdir() if path.is_dir() and path.name.startswith(f"{table}-")],
45
+ key=lambda path: path.stat().st_mtime,
46
+ reverse=True,
47
+ )
48
+ if not matches:
49
+ raise FileNotFoundError(f"no Parquet directory found for {table} in {warehouse}")
50
+ return matches[0]
51
+
52
+
53
+ def spark_session(remote: str = "sc://127.0.0.1:50051"):
54
+ try:
55
+ from pyspark.sql import SparkSession
56
+ except ImportError as exc: # pragma: no cover - depends on optional extra.
57
+ raise RuntimeError("Install querygraph[lakehouse] to use PySpark helpers.") from exc
58
+ return SparkSession.builder.remote(remote).getOrCreate()
59
+
60
+
61
+ def register_lakehouse(
62
+ *,
63
+ manifest: str | Path = ".querygraph/lakehouse/manifest/load-report.json",
64
+ warehouse: str | Path = "spark-warehouse",
65
+ remote: str = "sc://127.0.0.1:50051",
66
+ create_global_temp: bool = True,
67
+ ) -> list[dict[str, Any]]:
68
+ spark = spark_session(remote)
69
+ results: list[dict[str, Any]] = []
70
+ for spec in load_table_specs(manifest, warehouse):
71
+ df = spark.read.parquet(str(spec.location))
72
+ df.createOrReplaceTempView(spec.bare_name)
73
+ if create_global_temp:
74
+ df.createOrReplaceGlobalTempView(spec.bare_name)
75
+ observed = df.count()
76
+ results.append(
77
+ {
78
+ "table": spec.bare_name,
79
+ "logicalName": spec.logical_name,
80
+ "rows": observed,
81
+ "expectedRows": spec.rows,
82
+ "location": str(spec.location),
83
+ "status": "ok" if observed == spec.rows else "mismatch",
84
+ }
85
+ )
86
+ return results
87
+
88
+
89
+ def register_audit(
90
+ *,
91
+ warehouse: str | Path = "spark-warehouse",
92
+ remote: str = "sc://127.0.0.1:50051",
93
+ create_global_temp: bool = True,
94
+ tables: tuple[str, ...] = ("openlineage_events", "openlineage_attestations"),
95
+ ) -> list[dict[str, Any]]:
96
+ spark = spark_session(remote)
97
+ warehouse_path = Path(warehouse).resolve()
98
+ results: list[dict[str, Any]] = []
99
+ for table in tables:
100
+ location = find_latest_parquet_dir(warehouse_path, table)
101
+ df = spark.read.parquet(str(location))
102
+ df.createOrReplaceTempView(table)
103
+ if create_global_temp:
104
+ df.createOrReplaceGlobalTempView(table)
105
+ results.append({"table": table, "rows": df.count(), "location": str(location)})
106
+ return results
107
+
108
+
109
+ def example_queries(scope: str = "global_temp") -> list[str]:
110
+ return [
111
+ f"SELECT COUNT(*) AS rows FROM {scope}.government_finance__countydata",
112
+ f"SELECT COUNT(*) AS rows FROM {scope}.codata_constants_2022__codata_constants_2022",
113
+ f"SELECT quantity, value, unit FROM {scope}.codata_constants_2022__codata_constants_2022 LIMIT 5",
114
+ f"SELECT event_hash, event_type, job_name FROM {scope}.openlineage_events LIMIT 10",
115
+ ]
querygraph/lineage.py ADDED
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import UTC, datetime
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from querygraph.typedid import TypeDidEnvelope, sha256_hex
11
+
12
+
13
+ class OpenLineageRunEvent(BaseModel):
14
+ eventType: str = "COMPLETE"
15
+ eventTime: datetime = Field(default_factory=lambda: datetime.now(UTC))
16
+ run: dict[str, Any]
17
+ job: dict[str, Any]
18
+ inputs: list[dict[str, Any]] = Field(default_factory=list)
19
+ outputs: list[dict[str, Any]] = Field(default_factory=list)
20
+ producer: str = "https://querygraph.ai/qg-python"
21
+ schemaURL: str = "https://openlineage.io/spec/2-0-2/OpenLineage.json"
22
+
23
+ @classmethod
24
+ def for_agent_run(
25
+ cls,
26
+ *,
27
+ request: TypeDidEnvelope,
28
+ job_name: str,
29
+ inputs: list[str],
30
+ outputs: list[str],
31
+ namespace: str = "querygraph.python",
32
+ ) -> "OpenLineageRunEvent":
33
+ return cls(
34
+ run={
35
+ "runId": f"querygraph-python-{request.signature[-12:]}",
36
+ "facets": {
37
+ "queryGraph_typeDid": {
38
+ "_producer": "https://querygraph.ai/qg-python",
39
+ "_schemaURL": "https://querygraph.ai/schemas/openlineage/querygraph-typedid-facet/0.1.0.json",
40
+ "protocol": request.protocol,
41
+ "conversationId": request.conversation_id,
42
+ "payloadSha256": request.payload_sha256,
43
+ "signature": request.signature,
44
+ }
45
+ },
46
+ },
47
+ job={"namespace": namespace, "name": job_name},
48
+ inputs=[{"namespace": "sail", "name": item} for item in inputs],
49
+ outputs=[{"namespace": "querygraph", "name": item} for item in outputs],
50
+ )
51
+
52
+ def event_hash(self) -> str:
53
+ return sha256_hex(self.model_dump_json(exclude_none=True))
54
+
55
+
56
+ class LineageAttestation(BaseModel):
57
+ issuer: str
58
+ subject: str
59
+ event_hash: str
60
+ merkle_root: str
61
+ signature_type: str = "QueryGraphDemoSha256Signature"
62
+ verification_method: str
63
+ signature: str
64
+ signed_payload_sha256: str
65
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
66
+
67
+ @classmethod
68
+ def from_event(
69
+ cls,
70
+ *,
71
+ issuer: str,
72
+ subject: str,
73
+ event_hash: str,
74
+ ) -> "LineageAttestation":
75
+ created_at = datetime.now(UTC)
76
+ merkle_root = sha256_hex(f"querygraph-lineage\n{event_hash}")
77
+ payload = "\n".join(
78
+ [
79
+ "querygraph-lineage-attestation-v1",
80
+ f"issuer:{issuer}",
81
+ f"subject:{subject}",
82
+ f"event_hash:{event_hash}",
83
+ f"merkle_root:{merkle_root}",
84
+ f"created_at:{created_at.isoformat()}",
85
+ ]
86
+ )
87
+ return cls(
88
+ issuer=issuer,
89
+ subject=subject,
90
+ event_hash=event_hash,
91
+ merkle_root=merkle_root,
92
+ verification_method=f"{issuer}#querygraph-demo-key",
93
+ signature=f"sha256:{sha256_hex(payload)}",
94
+ signed_payload_sha256=sha256_hex(payload),
95
+ created_at=created_at,
96
+ )
97
+
98
+
99
+ def append_jsonl(path: str | Path, value: BaseModel | dict[str, Any]) -> Path:
100
+ target = Path(path)
101
+ target.parent.mkdir(parents=True, exist_ok=True)
102
+ data = value.model_dump(mode="json") if isinstance(value, BaseModel) else value
103
+ with target.open("a", encoding="utf-8") as handle:
104
+ handle.write(json.dumps(data, sort_keys=True))
105
+ handle.write("\n")
106
+ return target
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import UTC, datetime
5
+
6
+ from querygraph.cdif import CdifResource
7
+ from querygraph.croissant import CroissantDataset, Field, FileObject, RecordSet
8
+ from querygraph.did import DidDocument
9
+ from querygraph.odrl import Action, Policy, Rule
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class NavigatorInput:
14
+ dataset_name: str
15
+ description: str
16
+ landing_page: str
17
+ data_url: str
18
+ creator: str
19
+ agent_name: str
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class NavigatorOutput:
24
+ generated_at: datetime
25
+ croissant: dict
26
+ cdif: dict
27
+ did: DidDocument
28
+ odrl: dict
29
+ bundle: dict
30
+
31
+
32
+ class AiNavigator:
33
+ def build(self, input: NavigatorInput) -> NavigatorOutput:
34
+ did = DidDocument.new_oyd(
35
+ f"{input.agent_name}:{input.creator}:{input.dataset_name}",
36
+ input.agent_name,
37
+ ).with_service_endpoint(input.landing_page)
38
+
39
+ dataset_id = f"{input.landing_page.rstrip('/')}/#dataset"
40
+ dataset = CroissantDataset(
41
+ id=dataset_id,
42
+ name=input.dataset_name,
43
+ description=input.description,
44
+ license="https://creativecommons.org/licenses/by/4.0/",
45
+ creators=[input.creator],
46
+ files=[
47
+ FileObject(
48
+ id=f"{dataset_id}/file/source",
49
+ name="source-data",
50
+ content_url=input.data_url,
51
+ encoding_format="application/octet-stream",
52
+ )
53
+ ],
54
+ record_sets=[
55
+ RecordSet(
56
+ id=f"{dataset_id}/recordset/default",
57
+ name="default observations",
58
+ fields=[
59
+ Field(
60
+ "subject",
61
+ "sc:Text",
62
+ "Primary entity or observation subject",
63
+ ).semantic_type("https://schema.org/about"),
64
+ Field(
65
+ "value",
66
+ "sc:Text",
67
+ "Observed value, label, or narrative",
68
+ ).semantic_type("https://schema.org/value"),
69
+ Field(
70
+ "source",
71
+ "sc:URL",
72
+ "Evidence or provenance URL",
73
+ ).semantic_type("https://schema.org/citation"),
74
+ ],
75
+ )
76
+ ],
77
+ keywords=["AI Navigator", "Croissant", "CDIF", "DID", "ODRL"],
78
+ )
79
+
80
+ policy = Policy(
81
+ id=f"{dataset_id}/policy/default",
82
+ target=dataset_id,
83
+ assigner=did.id,
84
+ permissions=[
85
+ Rule(
86
+ action=Action.READ,
87
+ assignee="public",
88
+ constraint="attribution required",
89
+ ),
90
+ Rule(
91
+ action=Action.INDEX,
92
+ assignee=did.id,
93
+ constraint="local semantic indexing for AI Navigator",
94
+ ),
95
+ ],
96
+ prohibitions=[
97
+ Rule(
98
+ action=Action.DERIVE,
99
+ assignee="public",
100
+ constraint="no model training without separate agreement",
101
+ )
102
+ ],
103
+ )
104
+ odrl_json = policy.to_json_ld()
105
+ cdif = CdifResource.from_croissant(
106
+ dataset, input.landing_page, input.data_url
107
+ ).with_odrl_policy(policy.id, odrl_json)
108
+
109
+ croissant_json = dataset.to_json_ld()
110
+ cdif_json = cdif.to_json_ld()
111
+ generated_at = datetime.now(UTC)
112
+ did_json = did.to_json()
113
+ bundle = {
114
+ "@context": {
115
+ "schema": "https://schema.org/",
116
+ "cr": "http://mlcommons.org/croissant/",
117
+ "cdif": "https://cdif.codata.org/",
118
+ "dcat": "http://www.w3.org/ns/dcat#",
119
+ "dct": "http://purl.org/dc/terms/",
120
+ "odrl": "http://www.w3.org/ns/odrl/2/",
121
+ "querygraph": "https://querygraph.ai/ns#",
122
+ },
123
+ "@type": "querygraph:AiNavigatorSemanticBundle",
124
+ "generatedAt": generated_at.isoformat().replace("+00:00", "Z"),
125
+ "identity": did_json,
126
+ "layers": {
127
+ "semanticCroissant": croissant_json,
128
+ "cdif": cdif_json,
129
+ "did": did_json,
130
+ "odrl": odrl_json,
131
+ },
132
+ }
133
+
134
+ return NavigatorOutput(
135
+ generated_at=generated_at,
136
+ croissant=croissant_json,
137
+ cdif=cdif_json,
138
+ did=did,
139
+ odrl=odrl_json,
140
+ bundle=bundle,
141
+ )
querygraph/odrl.py ADDED
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+
6
+
7
+ class Action(Enum):
8
+ USE = "odrl:use"
9
+ READ = "odrl:read"
10
+ DERIVE = "odrl:derive"
11
+ TRANSLATE = "querygraph:translate"
12
+ INDEX = "querygraph:index"
13
+
14
+ def iri(self) -> str:
15
+ return self.value
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Rule:
20
+ action: Action
21
+ assignee: str
22
+ constraint: str | None = None
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Policy:
27
+ id: str
28
+ target: str
29
+ assigner: str
30
+ permissions: list[Rule]
31
+ prohibitions: list[Rule]
32
+
33
+ def allows(self, assignee: str, action: Action) -> bool:
34
+ prohibited = any(
35
+ rule.assignee == assignee and rule.action == action
36
+ for rule in self.prohibitions
37
+ )
38
+ permitted = any(
39
+ rule.assignee == assignee and rule.action == action
40
+ for rule in self.permissions
41
+ )
42
+ return permitted and not prohibited
43
+
44
+ def to_json_ld(self) -> dict:
45
+ return {
46
+ "@type": "odrl:Policy",
47
+ "@id": self.id,
48
+ "odrl:target": self.target,
49
+ "odrl:assigner": self.assigner,
50
+ "odrl:permission": [_rule_json(rule) for rule in self.permissions],
51
+ "odrl:prohibition": [_rule_json(rule) for rule in self.prohibitions],
52
+ }
53
+
54
+
55
+ def _rule_json(rule: Rule) -> dict:
56
+ return {
57
+ "odrl:action": rule.action.iri(),
58
+ "odrl:assignee": rule.assignee,
59
+ "odrl:constraint": rule.constraint,
60
+ }
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from querygraph.odrl import Action, Policy
6
+ from querygraph.rbac import RbacPolicy
7
+ from querygraph.typedid import AccessReceipt
8
+
9
+
10
+ class OdrlDecision(BaseModel):
11
+ principal: str
12
+ resource: str
13
+ action: str
14
+ rbac_allowed: bool
15
+ odrl_allowed: bool
16
+ allowed: bool
17
+ receipt: AccessReceipt
18
+
19
+
20
+ class OdrlRightsLayer(BaseModel):
21
+ """ODRL policy evaluation with RBAC and QueryGraph access receipts."""
22
+
23
+ rbac: RbacPolicy
24
+ odrl: Policy
25
+
26
+ def decide(self, principal: str, resource: str, action: Action) -> OdrlDecision:
27
+ rbac_allowed = self.rbac.allows(principal, resource, action.value)
28
+ odrl_allowed = self.odrl.allows(principal, action)
29
+ allowed = rbac_allowed and odrl_allowed
30
+ receipt = AccessReceipt(
31
+ principal=principal,
32
+ resource=resource,
33
+ action=action.iri(),
34
+ allowed=allowed,
35
+ reason=(
36
+ "RBAC and ODRL permitted action"
37
+ if allowed
38
+ else "RBAC or ODRL denied action"
39
+ ),
40
+ policy_id=self.odrl.id,
41
+ )
42
+ return OdrlDecision(
43
+ principal=principal,
44
+ resource=resource,
45
+ action=action.iri(),
46
+ rbac_allowed=rbac_allowed,
47
+ odrl_allowed=odrl_allowed,
48
+ allowed=allowed,
49
+ receipt=receipt,
50
+ )