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.
- querygraph/__init__.py +14 -0
- querygraph/__main__.py +4 -0
- querygraph/agents.py +89 -0
- querygraph/base58.py +15 -0
- querygraph/cdif.py +205 -0
- querygraph/cli.py +123 -0
- querygraph/codata.py +38 -0
- querygraph/croissant.py +86 -0
- querygraph/dataverse.py +155 -0
- querygraph/did.py +51 -0
- querygraph/lakehouse.py +115 -0
- querygraph/lineage.py +106 -0
- querygraph/navigator.py +141 -0
- querygraph/odrl.py +60 -0
- querygraph/odrl_rights.py +50 -0
- querygraph/osi.py +155 -0
- querygraph/qglake.py +99 -0
- querygraph/rbac.py +31 -0
- querygraph/typedid.py +211 -0
- querygraph/validation.py +41 -0
- querygraph-0.2.0.dist-info/METADATA +172 -0
- querygraph-0.2.0.dist-info/RECORD +24 -0
- querygraph-0.2.0.dist-info/WHEEL +4 -0
- querygraph-0.2.0.dist-info/entry_points.txt +2 -0
querygraph/dataverse.py
ADDED
|
@@ -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
|
querygraph/lakehouse.py
ADDED
|
@@ -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
|
querygraph/navigator.py
ADDED
|
@@ -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
|
+
)
|