pyspiral 0.2.5__cp310-abi3-macosx_11_0_arm64.whl → 0.4.0__cp310-abi3-macosx_11_0_arm64.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.
- {pyspiral-0.2.5.dist-info → pyspiral-0.4.0.dist-info}/METADATA +12 -14
- pyspiral-0.4.0.dist-info/RECORD +98 -0
- {pyspiral-0.2.5.dist-info → pyspiral-0.4.0.dist-info}/WHEEL +1 -1
- spiral/__init__.py +6 -7
- spiral/_lib.abi3.so +0 -0
- spiral/adbc.py +21 -14
- spiral/api/__init__.py +15 -172
- spiral/api/admin.py +12 -26
- spiral/api/client.py +160 -0
- spiral/api/filesystems.py +100 -72
- spiral/api/organizations.py +45 -58
- spiral/api/projects.py +171 -134
- spiral/api/telemetry.py +19 -0
- spiral/api/types.py +20 -0
- spiral/api/workloads.py +32 -25
- spiral/{arrow.py → arrow_.py} +12 -0
- spiral/cli/__init__.py +2 -5
- spiral/cli/admin.py +7 -12
- spiral/cli/app.py +23 -6
- spiral/cli/console.py +1 -1
- spiral/cli/fs.py +83 -18
- spiral/cli/iceberg/__init__.py +7 -0
- spiral/cli/iceberg/namespaces.py +47 -0
- spiral/cli/iceberg/tables.py +60 -0
- spiral/cli/indexes/__init__.py +19 -0
- spiral/cli/login.py +14 -5
- spiral/cli/orgs.py +90 -0
- spiral/cli/printer.py +9 -1
- spiral/cli/projects.py +136 -0
- spiral/cli/state.py +2 -0
- spiral/cli/tables/__init__.py +121 -0
- spiral/cli/telemetry.py +18 -0
- spiral/cli/types.py +8 -10
- spiral/cli/{workload.py → workloads.py} +11 -11
- spiral/{catalog.py → client.py} +22 -21
- spiral/core/client/__init__.pyi +117 -0
- spiral/core/index/__init__.pyi +15 -0
- spiral/core/table/__init__.pyi +108 -0
- spiral/core/{manifests → table/manifests}/__init__.pyi +5 -23
- spiral/core/table/metastore/__init__.pyi +62 -0
- spiral/core/{spec → table/spec}/__init__.pyi +49 -92
- spiral/datetime_.py +27 -0
- spiral/expressions/__init__.py +40 -17
- spiral/expressions/base.py +5 -5
- spiral/expressions/list_.py +1 -1
- spiral/expressions/mp4.py +62 -0
- spiral/expressions/png.py +18 -0
- spiral/expressions/qoi.py +18 -0
- spiral/expressions/refs.py +23 -9
- spiral/expressions/struct.py +7 -5
- spiral/expressions/text.py +62 -0
- spiral/expressions/tiff.py +88 -88
- spiral/expressions/udf.py +3 -3
- spiral/iceberg/__init__.py +3 -0
- spiral/iceberg/client.py +33 -0
- spiral/indexes/__init__.py +5 -0
- spiral/indexes/client.py +137 -0
- spiral/indexes/index.py +34 -0
- spiral/indexes/scan.py +22 -0
- spiral/project.py +19 -110
- spiral/{proto → protogen}/_/scandal/__init__.py +32 -77
- spiral/protogen/_/spiral/table/__init__.py +22 -0
- spiral/protogen/substrait/__init__.py +3399 -0
- spiral/protogen/substrait/extensions/__init__.py +115 -0
- spiral/server.py +17 -0
- spiral/settings.py +31 -87
- spiral/substrait_.py +10 -6
- spiral/tables/__init__.py +12 -0
- spiral/tables/client.py +130 -0
- spiral/{dataset.py → tables/dataset.py} +36 -25
- spiral/tables/debug/manifests.py +70 -0
- spiral/tables/debug/metrics.py +56 -0
- spiral/{debug.py → tables/debug/scan.py} +6 -9
- spiral/tables/maintenance.py +12 -0
- spiral/tables/scan.py +193 -0
- spiral/tables/snapshot.py +78 -0
- spiral/tables/table.py +157 -0
- spiral/tables/transaction.py +52 -0
- pyspiral-0.2.5.dist-info/RECORD +0 -81
- spiral/api/tables.py +0 -94
- spiral/api/tokens.py +0 -56
- spiral/authn/authn.py +0 -89
- spiral/authn/device.py +0 -206
- spiral/authn/github_.py +0 -33
- spiral/authn/modal_.py +0 -18
- spiral/cli/org.py +0 -90
- spiral/cli/project.py +0 -107
- spiral/cli/table.py +0 -20
- spiral/cli/token.py +0 -27
- spiral/config.py +0 -26
- spiral/core/core/__init__.pyi +0 -53
- spiral/core/metastore/__init__.pyi +0 -91
- spiral/proto/_/spfs/__init__.py +0 -36
- spiral/proto/_/spiral/table/__init__.py +0 -225
- spiral/proto/_/spiraldb/metastore/__init__.py +0 -499
- spiral/proto/__init__.py +0 -0
- spiral/proto/scandal/__init__.py +0 -45
- spiral/proto/spiral/__init__.py +0 -0
- spiral/proto/spiral/table/__init__.py +0 -96
- spiral/scan_.py +0 -168
- spiral/table.py +0 -157
- {pyspiral-0.2.5.dist-info → pyspiral-0.4.0.dist-info}/entry_points.txt +0 -0
- /spiral/{authn/__init__.py → core/__init__.pyi} +0 -0
- /spiral/{core → protogen/_}/__init__.py +0 -0
- /spiral/{proto/_ → protogen/_/arrow}/__init__.py +0 -0
- /spiral/{proto/_/arrow → protogen/_/arrow/flight}/__init__.py +0 -0
- /spiral/{proto/_/arrow/flight → protogen/_/arrow/flight/protocol}/__init__.py +0 -0
- /spiral/{proto → protogen}/_/arrow/flight/protocol/sql/__init__.py +0 -0
- /spiral/{proto/_/arrow/flight/protocol → protogen/_/spiral}/__init__.py +0 -0
- /spiral/{proto → protogen/_}/substrait/__init__.py +0 -0
- /spiral/{proto → protogen/_}/substrait/extensions/__init__.py +0 -0
- /spiral/{proto/_/spiral → protogen}/__init__.py +0 -0
- /spiral/{proto → protogen}/util.py +0 -0
- /spiral/{proto/_/spiraldb → tables/debug}/__init__.py +0 -0
pyspiral-0.2.5.dist-info/RECORD
DELETED
@@ -1,81 +0,0 @@
|
|
1
|
-
pyspiral-0.2.5.dist-info/METADATA,sha256=v9fKmJB3Y9Qss3pzr-rtw4PGnry0iBaFLN3_fwefN2s,1699
|
2
|
-
pyspiral-0.2.5.dist-info/WHEEL,sha256=j3ku1HwtRttgdyoybPiqmsz03FP6lDUkPQNFM63xZJo,103
|
3
|
-
pyspiral-0.2.5.dist-info/entry_points.txt,sha256=uft7u-a6g40NLt4Q6BleWbK4NY0M8nZuYPpP8DV0EOk,45
|
4
|
-
spiral/catalog.py,sha256=BtthmRApU1RSb6KbUfVTM2aYeLsnlO0nKDYHBYhdr9M,2496
|
5
|
-
spiral/scan_.py,sha256=rbLl85yeOkRuLsbk6QKng0_U4gtGGsW6C-aJiJSxuv8,5946
|
6
|
-
spiral/substrait_.py,sha256=5ZXnYcsXEdrBogECnoL6IMlsjsseYHEnVARgRpy2vt8,12671
|
7
|
-
spiral/config.py,sha256=ovtE5D3r6_g90ZRDJhlJyWhOBlxLjagvomOyA-VZmdc,911
|
8
|
-
spiral/core/core/__init__.pyi,sha256=-OCtTgqjUN7sACAmgrAA_TrqmuP7epNSyL9XjqnNTa4,1914
|
9
|
-
spiral/core/spec/__init__.pyi,sha256=zwOPhpBS_iOrPkOdc4ySpgzICZNbteMZf4c2wdkWw1Y,7251
|
10
|
-
spiral/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
11
|
-
spiral/core/manifests/__init__.pyi,sha256=DXr4Ab_Xo11AzrJlSj7FTSJc8qoO-SkL-_ik4kB855U,1516
|
12
|
-
spiral/core/metastore/__init__.pyi,sha256=pdKED91GVJ9XWxTWc9gwkHvVHV_RKxvL43Ofs5ndmew,3145
|
13
|
-
spiral/types_.py,sha256=W_jyO7F6rpPiH69jhgSgV7OxQZbOlb1Ho3InpKUP6Eo,155
|
14
|
-
spiral/proto/scandal/__init__.py,sha256=wAAEkPN4S4XDpGQtw1MV5zFUeHM01XSAa5tgUgcALvg,777
|
15
|
-
spiral/proto/util.py,sha256=smnvVo6nYH3FfDm9jqhNLaXz4bbTBaQezHQDCTvZyiQ,1486
|
16
|
-
spiral/proto/spiral/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
17
|
-
spiral/proto/spiral/table/__init__.py,sha256=_F1f52RMkZsXofPXpJb2KE8KR5l6zxCtrGrabR1uDxo,2816
|
18
|
-
spiral/proto/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
19
|
-
spiral/proto/_/scandal/__init__.py,sha256=rQJdbN3UKDJ8vOJ5V7l3KumNHlRyY8iw25HCLsIDB4I,6582
|
20
|
-
spiral/proto/_/arrow/flight/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
21
|
-
spiral/proto/_/arrow/flight/protocol/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
22
|
-
spiral/proto/_/arrow/flight/protocol/sql/__init__.py,sha256=_xhj9QkWEW1qZ-iVxcQ8k4EjYr7KJ5ofitJGqVUGQi4,79921
|
23
|
-
spiral/proto/_/arrow/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
24
|
-
spiral/proto/_/spiral/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
25
|
-
spiral/proto/_/spiral/table/__init__.py,sha256=sjK2dmvB09PqV3lxKMEk5QoHjC37HMW0MnxR1QDuBg0,7387
|
26
|
-
spiral/proto/_/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
27
|
-
spiral/proto/_/spiraldb/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
28
|
-
spiral/proto/_/spiraldb/metastore/__init__.py,sha256=40Egtg8MRYTaTTYRKOHkwuiyXEkw3Yg7ETCQskIzpIg,16873
|
29
|
-
spiral/proto/_/spfs/__init__.py,sha256=9WtIXr7HGslKWRHHieFDo8N_qnGL4QQyLOCWEkOKRvk,1017
|
30
|
-
spiral/proto/substrait/__init__.py,sha256=pV4-T-lwAHKkfFrNYSUGY4IkbIvuKjSo_imzF7BLj_s,126526
|
31
|
-
spiral/proto/substrait/extensions/__init__.py,sha256=yD7dg0TBqn-GK_L0qeVof1GKnwSLg_kPyQSV3kcSljs,3655
|
32
|
-
spiral/arrow.py,sha256=LBPwZcGkP4kXb42_kl5IUwWW3DO84CV3QJDwCHjG5Dg,7225
|
33
|
-
spiral/__init__.py,sha256=4EiQkY17qHT9dpxu41fdOV1kqGl_b-HXNRQg--ZwJbo,286
|
34
|
-
spiral/cli/console.py,sha256=-OP0bB_efxhWh4lZ95KRdu-SRgSUMJ47Rbi9FHv1TlY,2577
|
35
|
-
spiral/cli/org.py,sha256=ezWhoGUkJQQAwI1jKvDP8uZPNlnou_hXtRDa1us5cSE,2935
|
36
|
-
spiral/cli/token.py,sha256=dv30aa745bbS-c3tyzQUTSxGG_N0kCt8G4bip9fP_EM,968
|
37
|
-
spiral/cli/__init__.py,sha256=CoiAJ7FDgqjG_TrU-6SpP1hyZloIlEP4wcwnrF8flHM,2237
|
38
|
-
spiral/cli/types.py,sha256=4cphJs-i0vfq_CcnHxT9FpHiZdGwxME5GnOmIGB6Thw,1436
|
39
|
-
spiral/cli/workload.py,sha256=-XreFPJcX7kZvcYE3oQMeGkkYoXi25R5nuktJPg-PuY,2000
|
40
|
-
spiral/cli/admin.py,sha256=3pIs6PxDugMtdgzfRpn_HfBDv-cwAYtF0cJP2INB01A,579
|
41
|
-
spiral/cli/fs.py,sha256=8sQAgMahAq0gtXJqnuiKUkx4sO6vEEF4Iaq_-AF3wro,1524
|
42
|
-
spiral/cli/app.py,sha256=2oZfDTgj_gZ-lFMMzzJJTnvVzQhp_iedvH-FJnaaMW0,1487
|
43
|
-
spiral/cli/table.py,sha256=eh2NAk0GlfvthwRNeIbcZTsRWU3ypFx_uu9OaOLHPUo,628
|
44
|
-
spiral/cli/login.py,sha256=C7VpqVyYO2daUeIWHoelWnSGN7cju8YEuqOy12ImH4c,381
|
45
|
-
spiral/cli/printer.py,sha256=5HD3UcszFfPk-dK8U5akuvtXqMB7PMgOB1DFYMqspG8,1625
|
46
|
-
spiral/cli/__main__.py,sha256=kNaKM2xgJo7GRogf83nYldLM-RGUR6vymdGwZxywQu0,71
|
47
|
-
spiral/cli/project.py,sha256=aCxvw8UVwSmh_anArizIQ3_pLVT6QH9jYwMsGfpJUgM,4486
|
48
|
-
spiral/cli/state.py,sha256=1quvei8TnDTT6mDRo58P8FUfy4w16Z9sggBz7cFgllY,70
|
49
|
-
spiral/dataset.py,sha256=jUeXvE4B5nKh9VNmHxvROQbEkTUxqyYS8oS6EQyRbng,7555
|
50
|
-
spiral/grpc_.py,sha256=f3czdP1Mxme42Y5--a5ogYq1TTiWn-J_MlGjwJ2mWwM,1015
|
51
|
-
spiral/debug.py,sha256=t590eAUtNWwMTsSdkjVNN7J1iMqY2p4PRJ3BWR_ozho,8999
|
52
|
-
spiral/expressions/tiff.py,sha256=k5GMzm4FmGBJMyja-D6u_kt_-vHYIdj4bnYYZut5lK4,7579
|
53
|
-
spiral/expressions/io.py,sha256=gJ2a0FKMmdxarWKENulPRwH7KDvSJTIh_OUxX306xAM,3045
|
54
|
-
spiral/expressions/__init__.py,sha256=T2POn0Z-mQj9PY8ukYWYn5A832Fz7Y6F8RRASo6Xl3A,5638
|
55
|
-
spiral/expressions/list_.py,sha256=nbo4xQAuqBsQGajq_JgORaJl8_CDvOAv14zMbqmtZh4,1814
|
56
|
-
spiral/expressions/http.py,sha256=begUydWoFHEqjeLkATvI_v66Ez6_rR-OQBWO5cHbb9c,2742
|
57
|
-
spiral/expressions/refs.py,sha256=F3xr7wmrbAawMkLTWcvVwczbOMNOnIttSMRDuzjZHbo,1774
|
58
|
-
spiral/expressions/udf.py,sha256=vOlrdxiVpt7vdSgiTKX_XR86YKyu02Fdwb9xlINCby4,1363
|
59
|
-
spiral/expressions/str_.py,sha256=tY8RXW3JWvr1-bEfCZtk5FAf11wKJnXPuA9EoeJ9tA4,1265
|
60
|
-
spiral/expressions/base.py,sha256=mTBwS6CwdDaV8uotjZUiKi7GHQzX2TRPpnseJJUDrR0,4776
|
61
|
-
spiral/expressions/struct.py,sha256=MuxoBP6ESpwmjzusG-_HxHGYKvQQz6AZWzvw7vNUHJM,2007
|
62
|
-
spiral/settings.py,sha256=e_F6GQea6ljXzCRgFxMBMecoGhFpEhePhMRQkYOoVO4,4555
|
63
|
-
spiral/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
64
|
-
spiral/api/filesystems.py,sha256=NPf5PCyCQ7eEU723fOdKcuGPgn83pthkb_jJ9aB0fwM,3431
|
65
|
-
spiral/api/workloads.py,sha256=4MWs2pp9AWvx6cZhgyW-ehyCRxpHc_NAQgHaoYOEBfg,1266
|
66
|
-
spiral/api/__init__.py,sha256=Ub3IYeQUJ_z0-Y_SXmNasxb6uefKynQkuUZgRM1enyc,6660
|
67
|
-
spiral/api/tokens.py,sha256=WaSRr_l3i81t5u2qi3kWW-fySbyKFm-PQK1ZlmoSByc,1464
|
68
|
-
spiral/api/admin.py,sha256=HJBrRJScbcdDuFhF_06E0EyE-_Y0osfYPxVoRAyEoTc,837
|
69
|
-
spiral/api/organizations.py,sha256=-PO93HTX02IxhXM6SJpAhnAXpVW1WjthFO4-AOzZAC4,2670
|
70
|
-
spiral/api/tables.py,sha256=uPoWkkcW7lJUxU0fUgDK5Gy_DT8y7gJFXdgxQpqcc5w,2548
|
71
|
-
spiral/api/projects.py,sha256=-VGlu5V3TJ3XLCGu85bPHRFiktIADnAxfLWIH8Rmxug,4986
|
72
|
-
spiral/table.py,sha256=iJ-Lhu9ieSNg728cvUNRERcxKz7pj8hMCXPGBFHg7tI,4845
|
73
|
-
spiral/authn/modal_.py,sha256=agcnR3dYTslkH2K_a2Eis_2JWn9Ps11FVrGG_jkOdGk,472
|
74
|
-
spiral/authn/device.py,sha256=ohHSVLW3a-qLNYQGN-3kXxV_836xOe0UYBN8i63cqAQ,6796
|
75
|
-
spiral/authn/authn.py,sha256=OCGJAUfoKLiXw9xAcAnX6i6mBRlvsl6qEFcimqQOu7g,2555
|
76
|
-
spiral/authn/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
77
|
-
spiral/authn/github_.py,sha256=K-0RUHDreINjnCDHyT9aeVDRk6WtNP7noBYEcwdz2W4,1313
|
78
|
-
spiral/adbc.py,sha256=H_bzevPy5teyZKzjczh1gQ_zPcfk5sNASiJKQvyab9E,13830
|
79
|
-
spiral/project.py,sha256=q9jHql7hz5OQzPfDfvE_hN3K9cZotD0cxkdug9EUTwM,4889
|
80
|
-
spiral/_lib.abi3.so,sha256=Te1P4F2PJPY2E7qn69_ApKZwKkIp93sdpf21XZJZ6jU,61005104
|
81
|
-
pyspiral-0.2.5.dist-info/RECORD,,
|
spiral/api/tables.py
DELETED
@@ -1,94 +0,0 @@
|
|
1
|
-
from typing import Annotated
|
2
|
-
|
3
|
-
from pydantic import (
|
4
|
-
AfterValidator,
|
5
|
-
BaseModel,
|
6
|
-
StringConstraints,
|
7
|
-
)
|
8
|
-
|
9
|
-
from . import ArrowSchema, Paged, PagedRequest, PagedResponse, ProjectId, ServiceBase
|
10
|
-
|
11
|
-
|
12
|
-
def _validate_root_uri(uri: str) -> str:
|
13
|
-
if uri.endswith("/"):
|
14
|
-
raise ValueError("Root URI must not end with a slash.")
|
15
|
-
return uri
|
16
|
-
|
17
|
-
|
18
|
-
RootUri = Annotated[str, AfterValidator(_validate_root_uri)]
|
19
|
-
DatasetName = Annotated[str, StringConstraints(max_length=128, pattern=r"^[a-zA-Z_][a-zA-Z0-9_-]+$")]
|
20
|
-
TableName = Annotated[str, StringConstraints(max_length=128, pattern=r"^[a-zA-Z_][a-zA-Z0-9_-]+$")]
|
21
|
-
|
22
|
-
|
23
|
-
class TableMetadata(BaseModel):
|
24
|
-
key_schema: ArrowSchema
|
25
|
-
root_uri: RootUri
|
26
|
-
spfs_mount_id: str | None = None
|
27
|
-
|
28
|
-
# TODO(marko): Randomize this on creation of metadata.
|
29
|
-
# Column group salt is used to compute column group IDs.
|
30
|
-
# It's used to ensure that column group IDs are unique
|
31
|
-
# across different tables, even if paths are the same.
|
32
|
-
# It's never modified.
|
33
|
-
column_group_salt: int = 0
|
34
|
-
|
35
|
-
|
36
|
-
class Table(BaseModel):
|
37
|
-
id: str
|
38
|
-
project_id: ProjectId
|
39
|
-
dataset: DatasetName
|
40
|
-
table: TableName
|
41
|
-
metadata: TableMetadata
|
42
|
-
|
43
|
-
|
44
|
-
class CreateTable:
|
45
|
-
class Request(BaseModel):
|
46
|
-
project_id: ProjectId
|
47
|
-
dataset: DatasetName
|
48
|
-
table: TableName
|
49
|
-
key_schema: ArrowSchema
|
50
|
-
root_uri: RootUri | None = None
|
51
|
-
exist_ok: bool = False
|
52
|
-
|
53
|
-
class Response(BaseModel):
|
54
|
-
table: Table
|
55
|
-
|
56
|
-
|
57
|
-
class FindTable:
|
58
|
-
class Request(BaseModel):
|
59
|
-
project_id: ProjectId
|
60
|
-
dataset: DatasetName = None
|
61
|
-
table: TableName = None
|
62
|
-
|
63
|
-
class Response(BaseModel):
|
64
|
-
table: Table | None
|
65
|
-
|
66
|
-
|
67
|
-
class GetTable:
|
68
|
-
class Request(BaseModel):
|
69
|
-
id: str
|
70
|
-
|
71
|
-
class Response(BaseModel):
|
72
|
-
table: Table
|
73
|
-
|
74
|
-
|
75
|
-
class ListTables:
|
76
|
-
class Request(PagedRequest):
|
77
|
-
project_id: ProjectId
|
78
|
-
dataset: DatasetName | None = None
|
79
|
-
|
80
|
-
class Response(PagedResponse[Table]): ...
|
81
|
-
|
82
|
-
|
83
|
-
class TableService(ServiceBase):
|
84
|
-
def create(self, req: CreateTable.Request) -> CreateTable.Response:
|
85
|
-
return self.client.post("/table/create", req, CreateTable.Response)
|
86
|
-
|
87
|
-
def find(self, req: FindTable.Request) -> FindTable.Response:
|
88
|
-
return self.client.put("/table/find", req, FindTable.Response)
|
89
|
-
|
90
|
-
def get(self, req: GetTable.Request) -> GetTable.Response:
|
91
|
-
return self.client.put(f"/table/{req.id}", GetTable.Response)
|
92
|
-
|
93
|
-
def list(self, req: ListTables.Request) -> Paged[Table]:
|
94
|
-
return self.client.paged("/table/list", req, ListTables.Response)
|
spiral/api/tokens.py
DELETED
@@ -1,56 +0,0 @@
|
|
1
|
-
from pydantic import BaseModel
|
2
|
-
|
3
|
-
from . import Paged, PagedRequest, PagedResponse, ServiceBase
|
4
|
-
|
5
|
-
|
6
|
-
class Token(BaseModel):
|
7
|
-
id: str
|
8
|
-
project_id: str
|
9
|
-
on_behalf_of: str
|
10
|
-
|
11
|
-
|
12
|
-
class ExchangeToken:
|
13
|
-
class Request(BaseModel): ...
|
14
|
-
|
15
|
-
class Response(BaseModel):
|
16
|
-
token: str
|
17
|
-
|
18
|
-
|
19
|
-
class IssueToken:
|
20
|
-
class Request(BaseModel): ...
|
21
|
-
|
22
|
-
class Response(BaseModel):
|
23
|
-
token: Token
|
24
|
-
token_secret: str
|
25
|
-
|
26
|
-
|
27
|
-
class RevokeToken:
|
28
|
-
class Request(BaseModel):
|
29
|
-
token_id: str
|
30
|
-
|
31
|
-
class Response(BaseModel):
|
32
|
-
token: Token
|
33
|
-
|
34
|
-
|
35
|
-
class ListTokens:
|
36
|
-
class Request(PagedRequest):
|
37
|
-
project_id: str
|
38
|
-
on_behalf_of: str | None = None
|
39
|
-
|
40
|
-
class Response(PagedResponse[Token]): ...
|
41
|
-
|
42
|
-
|
43
|
-
class TokenService(ServiceBase):
|
44
|
-
def exchange(self) -> ExchangeToken.Response:
|
45
|
-
"""Exchange a basic / identity token to a short-lived Spiral token."""
|
46
|
-
return self.client.post("/token/exchange", ExchangeToken.Request(), ExchangeToken.Response)
|
47
|
-
|
48
|
-
def issue(self) -> IssueToken.Response:
|
49
|
-
"""Issue an API token on behalf of a principal."""
|
50
|
-
return self.client.post("/token/issue", IssueToken.Request(), IssueToken.Response)
|
51
|
-
|
52
|
-
def revoke(self, request: RevokeToken.Request) -> RevokeToken.Response:
|
53
|
-
return self.client.put("/token/revoke", request, RevokeToken.Response)
|
54
|
-
|
55
|
-
def list(self, request: ListTokens.Request) -> Paged[Token]:
|
56
|
-
return self.client.paged("/token/list", request, ListTokens.Response)
|
spiral/authn/authn.py
DELETED
@@ -1,89 +0,0 @@
|
|
1
|
-
import base64
|
2
|
-
import logging
|
3
|
-
import os
|
4
|
-
|
5
|
-
from spiral.api import Authn, SpiralAPI
|
6
|
-
|
7
|
-
ENV_TOKEN_ID = "SPIRAL_TOKEN_ID"
|
8
|
-
ENV_TOKEN_SECRET = "SPIRAL_TOKEN_SECRET"
|
9
|
-
|
10
|
-
log = logging.getLogger(__name__)
|
11
|
-
|
12
|
-
|
13
|
-
class FallbackAuthn(Authn):
|
14
|
-
"""Credential provider that tries multiple providers in order."""
|
15
|
-
|
16
|
-
def __init__(self, providers: list[Authn]):
|
17
|
-
self._providers = providers
|
18
|
-
|
19
|
-
def token(self) -> str | None:
|
20
|
-
for provider in self._providers:
|
21
|
-
token = provider.token()
|
22
|
-
if token is not None:
|
23
|
-
return token
|
24
|
-
return None
|
25
|
-
|
26
|
-
|
27
|
-
class TokenAuthn(Authn):
|
28
|
-
"""Credential provider that returns a fixed token."""
|
29
|
-
|
30
|
-
def __init__(self, token: str):
|
31
|
-
self._token = token
|
32
|
-
|
33
|
-
def token(self) -> str:
|
34
|
-
return self._token
|
35
|
-
|
36
|
-
|
37
|
-
class EnvironmentAuthn(Authn):
|
38
|
-
"""Credential provider that returns a basic token from the environment.
|
39
|
-
|
40
|
-
NOTE: Returns basic token. Must be exchanged.
|
41
|
-
"""
|
42
|
-
|
43
|
-
def token(self) -> str | None:
|
44
|
-
if ENV_TOKEN_ID not in os.environ:
|
45
|
-
return None
|
46
|
-
if ENV_TOKEN_SECRET not in os.environ:
|
47
|
-
raise ValueError(f"{ENV_TOKEN_SECRET} is missing.")
|
48
|
-
|
49
|
-
token_id = os.environ[ENV_TOKEN_ID]
|
50
|
-
token_secret = os.environ[ENV_TOKEN_SECRET]
|
51
|
-
basic_token = base64.b64encode(f"{token_id}:{token_secret}".encode()).decode("utf-8")
|
52
|
-
|
53
|
-
return basic_token
|
54
|
-
|
55
|
-
|
56
|
-
class DeviceAuthProvider(Authn):
|
57
|
-
"""Auth provider that uses the device flow to authenticate a Spiral user."""
|
58
|
-
|
59
|
-
def __init__(self, device_auth):
|
60
|
-
# NOTE(ngates): device_auth: spiral.auth.device_code.DeviceAuth
|
61
|
-
# We don't type it to satisfy our import linter
|
62
|
-
self._device_auth = device_auth
|
63
|
-
|
64
|
-
def token(self) -> str | None:
|
65
|
-
# TODO(ngates): only run this if we're in a notebook, CLI, or otherwise on the user's machine.
|
66
|
-
return self._device_auth.authenticate().access_token
|
67
|
-
|
68
|
-
|
69
|
-
class TokenExchangeProvider(Authn):
|
70
|
-
"""Auth provider that exchanges a basic token for a Spiral token."""
|
71
|
-
|
72
|
-
def __init__(self, authn: Authn, base_url: str):
|
73
|
-
self._authn = authn
|
74
|
-
self._token_service = SpiralAPI(authn, base_url).token
|
75
|
-
|
76
|
-
self._sp_token = None
|
77
|
-
|
78
|
-
def token(self) -> str | None:
|
79
|
-
if self._sp_token is not None:
|
80
|
-
return self._sp_token
|
81
|
-
|
82
|
-
# Don't try to exchange if token is not discovered.
|
83
|
-
if self._authn.token() is None:
|
84
|
-
return None
|
85
|
-
|
86
|
-
log.debug("Exchanging token")
|
87
|
-
self._sp_token = self._token_service.exchange().token
|
88
|
-
|
89
|
-
return self._sp_token
|
spiral/authn/device.py
DELETED
@@ -1,206 +0,0 @@
|
|
1
|
-
import logging
|
2
|
-
import sys
|
3
|
-
import textwrap
|
4
|
-
import time
|
5
|
-
import webbrowser
|
6
|
-
from pathlib import Path
|
7
|
-
|
8
|
-
import httpx
|
9
|
-
import jwt
|
10
|
-
from pydantic import BaseModel
|
11
|
-
|
12
|
-
log = logging.getLogger(__name__)
|
13
|
-
|
14
|
-
|
15
|
-
class TokensModel(BaseModel):
|
16
|
-
access_token: str
|
17
|
-
refresh_token: str
|
18
|
-
|
19
|
-
@property
|
20
|
-
def organization_id(self) -> str | None:
|
21
|
-
return self.unverified_access_token().get("org_id")
|
22
|
-
|
23
|
-
def unverified_access_token(self):
|
24
|
-
return jwt.decode(self.access_token, options={"verify_signature": False})
|
25
|
-
|
26
|
-
|
27
|
-
class AuthModel(BaseModel):
|
28
|
-
tokens: TokensModel | None = None
|
29
|
-
|
30
|
-
|
31
|
-
class DeviceAuth:
|
32
|
-
def __init__(
|
33
|
-
self,
|
34
|
-
auth_file: Path,
|
35
|
-
domain: str,
|
36
|
-
client_id: str,
|
37
|
-
http: httpx.Client = None,
|
38
|
-
):
|
39
|
-
self._auth_file = auth_file
|
40
|
-
self._domain = domain
|
41
|
-
self._client_id = client_id
|
42
|
-
self._http = http or httpx.Client()
|
43
|
-
|
44
|
-
if self._auth_file.exists():
|
45
|
-
with self._auth_file.open("r") as f:
|
46
|
-
self._auth = AuthModel.model_validate_json(f.read())
|
47
|
-
else:
|
48
|
-
self._auth = AuthModel()
|
49
|
-
|
50
|
-
self._default_scope = ["email", "profile"]
|
51
|
-
|
52
|
-
def is_authenticated(self) -> bool:
|
53
|
-
"""Check if the user is authenticated."""
|
54
|
-
tokens = self._auth.tokens
|
55
|
-
if tokens is None:
|
56
|
-
return False
|
57
|
-
|
58
|
-
# Give ourselves a 30-second buffer before the token expires.
|
59
|
-
return tokens.unverified_access_token()["exp"] - 30 > time.time()
|
60
|
-
|
61
|
-
def authenticate(self, force: bool = False, refresh: bool = False, organization_id: str = None) -> TokensModel:
|
62
|
-
"""Blocking call to authenticate the user.
|
63
|
-
|
64
|
-
Triggers a device code flow and polls for the user to login.
|
65
|
-
"""
|
66
|
-
if force:
|
67
|
-
return self._device_code(organization_id)
|
68
|
-
|
69
|
-
if refresh:
|
70
|
-
if self._auth.tokens is None:
|
71
|
-
raise ValueError("No tokens to refresh.")
|
72
|
-
tokens = self._refresh(self._auth.tokens, organization_id)
|
73
|
-
if not tokens:
|
74
|
-
raise ValueError("Failed to refresh token.")
|
75
|
-
return tokens
|
76
|
-
|
77
|
-
# Check for mis-matched organization.
|
78
|
-
if organization_id is not None:
|
79
|
-
tokens = self._auth.tokens
|
80
|
-
if tokens is not None and tokens.unverified_access_token().get("org_id") != organization_id:
|
81
|
-
tokens = self._refresh(self._auth.tokens, organization_id)
|
82
|
-
if tokens is None:
|
83
|
-
return self._device_code(organization_id)
|
84
|
-
|
85
|
-
if self.is_authenticated():
|
86
|
-
return self._auth.tokens
|
87
|
-
|
88
|
-
# Try to refresh.
|
89
|
-
tokens = self._auth.tokens
|
90
|
-
if tokens is not None:
|
91
|
-
tokens = self._refresh(tokens)
|
92
|
-
if tokens is not None:
|
93
|
-
return tokens
|
94
|
-
|
95
|
-
# Otherwise, we kick off the device code flow.
|
96
|
-
return self._device_code(organization_id)
|
97
|
-
|
98
|
-
def logout(self):
|
99
|
-
self._remove_tokens()
|
100
|
-
|
101
|
-
def _device_code(self, organization_id: str | None):
|
102
|
-
scope = " ".join(self._default_scope)
|
103
|
-
res = self._http.post(
|
104
|
-
f"{self._domain}/auth/device/code",
|
105
|
-
data={
|
106
|
-
"client_id": self._client_id,
|
107
|
-
"scope": scope,
|
108
|
-
"organization_id": organization_id,
|
109
|
-
},
|
110
|
-
)
|
111
|
-
res = res.raise_for_status().json()
|
112
|
-
device_code = res["device_code"]
|
113
|
-
user_code = res["user_code"]
|
114
|
-
expires_at = res["expires_in"] + time.time()
|
115
|
-
interval = res["interval"]
|
116
|
-
verification_uri_complete = res["verification_uri_complete"]
|
117
|
-
|
118
|
-
# We need to detect if the user is running in a terminal, in Jupyter, etc.
|
119
|
-
# For now, we'll try to open the browser.
|
120
|
-
sys.stderr.write(
|
121
|
-
textwrap.dedent(
|
122
|
-
f"""
|
123
|
-
Please login here: {verification_uri_complete}
|
124
|
-
Your code is {user_code}.
|
125
|
-
"""
|
126
|
-
)
|
127
|
-
)
|
128
|
-
|
129
|
-
# Try to open the browser (this also works if the Jupiter notebook is running on the user's machine).
|
130
|
-
opened = webbrowser.open(verification_uri_complete)
|
131
|
-
|
132
|
-
# If we have a server-side Jupyter notebook, we can try to open with client-side JavaScript.
|
133
|
-
if not opened and _in_notebook():
|
134
|
-
from IPython.display import Javascript, display
|
135
|
-
|
136
|
-
display(Javascript(f'window.open("{verification_uri_complete}");'))
|
137
|
-
|
138
|
-
# In the meantime, we need to poll for the user to login.
|
139
|
-
while True:
|
140
|
-
if time.time() > expires_at:
|
141
|
-
raise TimeoutError("Login timed out.")
|
142
|
-
time.sleep(interval)
|
143
|
-
res = self._http.post(
|
144
|
-
f"{self._domain}/auth/token",
|
145
|
-
data={
|
146
|
-
"client_id": self._client_id,
|
147
|
-
"device_code": device_code,
|
148
|
-
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
149
|
-
},
|
150
|
-
)
|
151
|
-
if not res.is_success:
|
152
|
-
continue
|
153
|
-
|
154
|
-
tokens = TokensModel(
|
155
|
-
access_token=res.json()["access_token"],
|
156
|
-
refresh_token=res.json()["refresh_token"],
|
157
|
-
)
|
158
|
-
self._save_tokens(tokens)
|
159
|
-
return self._auth.tokens
|
160
|
-
|
161
|
-
def _refresh(self, tokens: TokensModel, organization_id: str = None) -> TokensModel | None:
|
162
|
-
"""Attempt to use the refresh token."""
|
163
|
-
log.debug("Refreshing token %s", self._client_id)
|
164
|
-
|
165
|
-
res = self._http.post(
|
166
|
-
f"{self._domain}/auth/refresh",
|
167
|
-
data={
|
168
|
-
"client_id": self._client_id,
|
169
|
-
"grant_type": "refresh_token",
|
170
|
-
"refresh_token": tokens.refresh_token,
|
171
|
-
"organization_id": organization_id,
|
172
|
-
},
|
173
|
-
)
|
174
|
-
if not res.is_success:
|
175
|
-
print("Failed to refresh token", res.status_code, res.text)
|
176
|
-
return None
|
177
|
-
|
178
|
-
tokens = TokensModel(
|
179
|
-
access_token=res.json()["access_token"],
|
180
|
-
refresh_token=res.json()["refresh_token"],
|
181
|
-
)
|
182
|
-
self._save_tokens(tokens)
|
183
|
-
return tokens
|
184
|
-
|
185
|
-
def _save_tokens(self, tokens: TokensModel):
|
186
|
-
self._auth = self._auth.model_copy(update={"tokens": tokens})
|
187
|
-
self._auth_file.parent.mkdir(parents=True, exist_ok=True)
|
188
|
-
with self._auth_file.open("w") as f:
|
189
|
-
f.write(self._auth.model_dump_json(exclude_defaults=True))
|
190
|
-
|
191
|
-
def _remove_tokens(self):
|
192
|
-
self._auth_file.unlink(missing_ok=True)
|
193
|
-
self._auth = self._auth.model_copy(update={"tokens": None})
|
194
|
-
|
195
|
-
|
196
|
-
def _in_notebook():
|
197
|
-
try:
|
198
|
-
from IPython import get_ipython
|
199
|
-
|
200
|
-
if "IPKernelApp" not in get_ipython().config: # pragma: no cover
|
201
|
-
return False
|
202
|
-
except ImportError:
|
203
|
-
return False
|
204
|
-
except AttributeError:
|
205
|
-
return False
|
206
|
-
return True
|
spiral/authn/github_.py
DELETED
@@ -1,33 +0,0 @@
|
|
1
|
-
import os
|
2
|
-
|
3
|
-
import httpx
|
4
|
-
|
5
|
-
from spiral.api import Authn
|
6
|
-
|
7
|
-
|
8
|
-
class GitHubActionsProvider(Authn):
|
9
|
-
AUDIENCE = "https://iss.spiraldb.com"
|
10
|
-
|
11
|
-
def __init__(self):
|
12
|
-
self._gh_token = None
|
13
|
-
|
14
|
-
def token(self) -> str | None:
|
15
|
-
if self._gh_token is not None:
|
16
|
-
return self._gh_token
|
17
|
-
|
18
|
-
if os.environ.get("GITHUB_ACTIONS") == "true":
|
19
|
-
# Next, we check to see if we're running in GitHub actions and if so, grab an ID token.
|
20
|
-
if "ACTIONS_ID_TOKEN_REQUEST_TOKEN" in os.environ and "ACTIONS_ID_TOKEN_REQUEST_URL" in os.environ:
|
21
|
-
if not hasattr(self, "__gh_token"):
|
22
|
-
resp = httpx.get(
|
23
|
-
f"{os.environ['ACTIONS_ID_TOKEN_REQUEST_URL']}&audience={self.AUDIENCE}",
|
24
|
-
headers={"Authorization": f'Bearer {os.environ["ACTIONS_ID_TOKEN_REQUEST_TOKEN"]}'},
|
25
|
-
)
|
26
|
-
if not resp.is_success:
|
27
|
-
raise ValueError(f"Failed to get GitHub Actions ID token: {resp.text}", resp)
|
28
|
-
self._gh_token = resp.json()["value"]
|
29
|
-
else:
|
30
|
-
raise ValueError("Please set 'id-token: write' permission for this GitHub Actions workflow.")
|
31
|
-
|
32
|
-
# For now, we don't exchange the token for a Spiral one.
|
33
|
-
return self._gh_token
|
spiral/authn/modal_.py
DELETED
@@ -1,18 +0,0 @@
|
|
1
|
-
import os
|
2
|
-
|
3
|
-
from spiral.api import Authn
|
4
|
-
|
5
|
-
|
6
|
-
class ModalProvider(Authn):
|
7
|
-
def __init__(self):
|
8
|
-
self._modal_token = None
|
9
|
-
|
10
|
-
def token(self) -> str | None:
|
11
|
-
if self._modal_token is not None:
|
12
|
-
return self._modal_token
|
13
|
-
|
14
|
-
if os.environ.get("MODAL_IDENTITY_TOKEN") is not None:
|
15
|
-
self._modal_token = os.environ["MODAL_IDENTITY_TOKEN"]
|
16
|
-
|
17
|
-
# For now, we don't exchange the token for a Spiral one.
|
18
|
-
return self._modal_token
|
spiral/cli/org.py
DELETED
@@ -1,90 +0,0 @@
|
|
1
|
-
import webbrowser
|
2
|
-
from typing import Annotated
|
3
|
-
|
4
|
-
import jwt
|
5
|
-
import rich
|
6
|
-
import typer
|
7
|
-
from rich.table import Table
|
8
|
-
from typer import Option
|
9
|
-
|
10
|
-
from spiral.api.organizations import CreateOrganization, InviteUser, OrganizationRole, PortalLink
|
11
|
-
from spiral.cli import AsyncTyper, OptionalStr, state
|
12
|
-
from spiral.cli.types import OrganizationArg
|
13
|
-
|
14
|
-
app = AsyncTyper()
|
15
|
-
|
16
|
-
|
17
|
-
@app.command(help="Switch the active organization.")
|
18
|
-
def switch(org_id: OrganizationArg):
|
19
|
-
state.settings.spiraldb.device_auth().authenticate(refresh=True, organization_id=org_id)
|
20
|
-
rich.print(f"Switched to organization: {org_id}")
|
21
|
-
|
22
|
-
|
23
|
-
@app.command(help="Create a new organization.")
|
24
|
-
def create(
|
25
|
-
name: Annotated[OptionalStr, Option(help="The human-readable name of the organization.")] = None,
|
26
|
-
):
|
27
|
-
res = state.settings.api.organization.create_organization(CreateOrganization.Request(name=name))
|
28
|
-
|
29
|
-
# Authenticate to the new organization
|
30
|
-
state.settings.spiraldb.device_auth().authenticate(refresh=True, organization_id=res.organization.id)
|
31
|
-
|
32
|
-
rich.print(f"{res.organization.name} [dim]{res.organization.id}[/dim]")
|
33
|
-
|
34
|
-
|
35
|
-
@app.command(help="List organizations.")
|
36
|
-
def ls():
|
37
|
-
org_id = current_org_id()
|
38
|
-
|
39
|
-
table = Table("", "id", "name", "role", title="Organizations")
|
40
|
-
for m in state.settings.api.organization.list_user_memberships():
|
41
|
-
table.add_row("👉" if m.organization.id == org_id else "", m.organization.id, m.organization.name, m.role)
|
42
|
-
|
43
|
-
rich.print(table)
|
44
|
-
|
45
|
-
|
46
|
-
@app.command(help="Invite a user to the organization.")
|
47
|
-
def invite(email: str, role: OrganizationRole = "member", expires_in_days: int = 7):
|
48
|
-
state.settings.api.organization.invite_user(
|
49
|
-
InviteUser.Request(email=email, role=role, expires_in_days=expires_in_days)
|
50
|
-
)
|
51
|
-
rich.print(f"Invited {email} as a {role.value}.")
|
52
|
-
|
53
|
-
|
54
|
-
@app.command(help="Configure single sign-on for your organization.")
|
55
|
-
def sso():
|
56
|
-
_do_action(PortalLink.Intent.SSO)
|
57
|
-
|
58
|
-
|
59
|
-
@app.command(help="Configure directory services for your organization.")
|
60
|
-
def directory():
|
61
|
-
_do_action(PortalLink.Intent.DIRECTORY)
|
62
|
-
|
63
|
-
|
64
|
-
@app.command(help="Configure audit logs for your organization.")
|
65
|
-
def audit_logs():
|
66
|
-
_do_action(PortalLink.Intent.AUDIT_LOGS)
|
67
|
-
|
68
|
-
|
69
|
-
@app.command(help="Configure log streams for your organization.")
|
70
|
-
def log_streams():
|
71
|
-
_do_action(PortalLink.Intent.LOG_STREAMS)
|
72
|
-
|
73
|
-
|
74
|
-
@app.command(help="Configure domains for your organization.")
|
75
|
-
def domains():
|
76
|
-
_do_action(PortalLink.Intent.DOMAIN_VERIFICATION)
|
77
|
-
|
78
|
-
|
79
|
-
def _do_action(intent: PortalLink.Intent):
|
80
|
-
res = state.settings.api.organization.portal_link(PortalLink.Request(intent=intent))
|
81
|
-
rich.print(f"Opening the configuration portal:\n{res.url}")
|
82
|
-
webbrowser.open(res.url)
|
83
|
-
|
84
|
-
|
85
|
-
def current_org_id():
|
86
|
-
org_id = jwt.decode(state.settings.authn.token(), options={"verify_signature": False}).get("org_id")
|
87
|
-
if not org_id:
|
88
|
-
rich.print("[red]You are not logged in to an organization.[/red]")
|
89
|
-
raise typer.Exit(1)
|
90
|
-
return org_id
|