arize-phoenix 8.23.0__py3-none-any.whl → 8.24.1__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.

Potentially problematic release.


This version of arize-phoenix might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arize-phoenix
3
- Version: 8.23.0
3
+ Version: 8.24.1
4
4
  Summary: AI Observability and Evaluation
5
5
  Project-URL: Documentation, https://docs.arize.com/phoenix/
6
6
  Project-URL: Issues, https://github.com/Arize-ai/phoenix/issues
@@ -1,12 +1,12 @@
1
1
  phoenix/__init__.py,sha256=X3eUEwd2rG8KKWWYVNNDJoqo08ihfjgHhlP29dcdNJE,5481
2
2
  phoenix/auth.py,sha256=VVMHrWN31tln3Zo4z6ofecrV4daiqJjLd8r85mqlxek,10939
3
- phoenix/config.py,sha256=nHOYg2rjnyn1ZtRlTRf9D0fMndgkXaH0cQ7Y0-wXDAM,36300
3
+ phoenix/config.py,sha256=SNBMUEo6lNn5WXP-x6B85UmADZfGEkhfeHDDfW3Af5Y,38090
4
4
  phoenix/datetime_utils.py,sha256=iJzNG6YJ6V7_u8B2iA7P2Z26FyxYbOPtx0dhJ7kNDHA,3398
5
5
  phoenix/exceptions.py,sha256=n2L2KKuecrdflB9MsCdAYCiSEvGJptIsfRkXMoJle7A,169
6
6
  phoenix/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
7
7
  phoenix/services.py,sha256=kpW1WL0kiB8XJsO6XycvZVJ-lBkNoenhQ7atCvBoSe8,5365
8
8
  phoenix/settings.py,sha256=x87BX7hWGQQZbrW_vrYqFR_izCGfO9gFc--JXUG4Tdk,754
9
- phoenix/version.py,sha256=OqhTRaQmesPSFG4mF1UGkRjba6vbZjTINuaPSRGPg64,23
9
+ phoenix/version.py,sha256=Ay9oO96i2YGmx2lcWChBnshhqL44Hxy1NdOIEHzoMOw,23
10
10
  phoenix/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
11
  phoenix/core/embedding_dimension.py,sha256=zKGbcvwOXgLf-yrJBpQyKtd-LEOPRKHnUToyAU8Owis,87
12
12
  phoenix/core/model.py,sha256=qBFraOtmwCCnWJltKNP18DDG0mULXigytlFsa6YOz6k,4837
@@ -18,8 +18,8 @@ phoenix/db/alembic.ini,sha256=GIS6HpHaKaJbbuahZg1Rc1D2_QqyCkV9r58wdARGf6w,3262
18
18
  phoenix/db/bulk_inserter.py,sha256=faNjuwLqqsw4ky8sa4D0h9u5TvEDTOjrccUW89L008E,12725
19
19
  phoenix/db/engines.py,sha256=gxcP2aNy_JyKHv1MO4d2UM47GTMy1jDcN-FQETZ3iNA,7348
20
20
  phoenix/db/enums.py,sha256=tt7iovXLhVTLZ3_LbHNGgcI44SnNjXfkKtLAZG57T54,428
21
- phoenix/db/facilitator.py,sha256=nteTD8XQSQ7a6aZBOw7ZKiJ72-_1TZXKVF4HS3fHdas,5675
22
- phoenix/db/helpers.py,sha256=daKbpY2QhTPo9a_T1xNHKI4WzWHkMmmrGIws7Hw-RZ4,4884
21
+ phoenix/db/facilitator.py,sha256=-uzv3Wr4YuUtVff4_0bLTcIFvzWzWOA3IPq8s2RA_bc,6821
22
+ phoenix/db/helpers.py,sha256=rbbHcl-STzcEpcXCYx6jbKzko7r3ggrWHHsXjZ48HsM,5352
23
23
  phoenix/db/migrate.py,sha256=oUrXH8yEbcpL4eh09aSCuUiSrhFli0eT5D_j4ZmYChY,2797
24
24
  phoenix/db/models.py,sha256=TbHgtT7WWdkQK-OtLsUqp3MwP23HGV1IaSAWTqCf5ac,45707
25
25
  phoenix/db/insertion/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -80,13 +80,13 @@ phoenix/pointcloud/pointcloud.py,sha256=SN_1wXZcwKrtSnHGZLDZGx71orqE1WyVF7E-D58d
80
80
  phoenix/pointcloud/projectors.py,sha256=TQgwc9cJDjJkin1WZyZzgl3HsYrLLiyWD7Czy4jNW3U,1088
81
81
  phoenix/pointcloud/umap_parameters.py,sha256=db_WEPoamuWtopZx7tQfAXPnoE0MS8FkAV0_ThjEx_Q,1735
82
82
  phoenix/server/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
83
- phoenix/server/app.py,sha256=zrDEF-ojWyxgNjvTTGstfLr3vWSwjmjnevVtHPpBU4U,39826
83
+ phoenix/server/app.py,sha256=f9H8ekaGiMwCMbSf2kbQIUFKywY2VmaIuJgPlrC7obU,40221
84
84
  phoenix/server/bearer_auth.py,sha256=0UudvkAS_dxna5JEJJhGUYwB6Ny-e22ssX5Mm79QwCk,5907
85
85
  phoenix/server/dml_event.py,sha256=MjJmVEKytq75chBOSyvYDusUnEbg1pHpIjR3pZkUaJA,2838
86
86
  phoenix/server/dml_event_handler.py,sha256=EZLXmCvx4pJrCkz29gxwKwmvmUkTtPCHw6klR-XM8qE,8258
87
87
  phoenix/server/grpc_server.py,sha256=SknR-iLIUqU9swiAyLhbCUREA1obOM6w49xgdK1AQDs,3839
88
88
  phoenix/server/jwt_store.py,sha256=asxzY4_ZBM2FWAMstHvhvnKUP_0AA3v3xPTL2IOgNqY,16831
89
- phoenix/server/main.py,sha256=wqK9xDPrveNAacdTEuZO1dCX7l3TWsS9vtZ2wrNl0Mg,16646
89
+ phoenix/server/main.py,sha256=qJcqXudiY65UeOWOaxP1-dqzrV-BBnbZu_IDd-qMKMk,16720
90
90
  phoenix/server/oauth2.py,sha256=EV4wcCwG0N7cJRcfGNURdP5rZgRVCeRDvXyle19A27Y,2064
91
91
  phoenix/server/prometheus.py,sha256=1KjvSfjSa2-BPjDybVMM_Kag316CsN-Zwt64YNr_snc,7825
92
92
  phoenix/server/rate_limiters.py,sha256=cFc73D2NaxqNZZDbwfIDw4So-fRVOJPBtqxOZ8Qky_s,7155
@@ -96,12 +96,12 @@ phoenix/server/types.py,sha256=gJJPBcDRkQ9VHZIt_aLqG_OBbGt1oWp4e3W3Jp61oKs,7409
96
96
  phoenix/server/api/README.md,sha256=Pyq1PLPgTzXAswrfIhGXrjI3Skq8it2jTVnanT6Ba4Q,1162
97
97
  phoenix/server/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
98
98
  phoenix/server/api/auth.py,sha256=nywpmfMI1trZTbZRD3oBj4kFjzg_vnxDljcM431T1eY,1246
99
- phoenix/server/api/context.py,sha256=OopBkMnY48TzulTtfuay3MXmhbFIWPtPoAsbhCW-Pl0,6459
99
+ phoenix/server/api/context.py,sha256=gB9bNgdqqD4Bn-6V4zlKeILl6L76jynPtOuM_5UZBNw,6557
100
100
  phoenix/server/api/exceptions.py,sha256=TA0JuY2YRnj35qGuMSQ8d0ToHum9gWm9W--3fSKHrX0,1171
101
101
  phoenix/server/api/interceptor.py,sha256=ykDnoC_apUd-llVli3m1CW18kNSIgjz2qZ6m5JmPDu8,1294
102
- phoenix/server/api/queries.py,sha256=Xd1K-6bu_DuUxczyXzf0M6EiRL7Ahnx47QVqPmo2-58,36167
102
+ phoenix/server/api/queries.py,sha256=WNH1LxB7bobfOMdBJts-9_ncfVhv2LThAKu14MRPMpk,35810
103
103
  phoenix/server/api/schema.py,sha256=fcs36xQwFF_Qe41_5cWR8wYpDvOrnbcyTeo5WNMbDsA,1702
104
- phoenix/server/api/subscriptions.py,sha256=DSIgQF6lQqkbc7D0AaI5R4g3hIHbU04H5Y2UIpwmpy0,22989
104
+ phoenix/server/api/subscriptions.py,sha256=TnZhdoNHMXp1NkUVLA-eB54woll7FvxtsB2pLt1dO0w,23001
105
105
  phoenix/server/api/utils.py,sha256=quCBRcusc6PUq9tJq7M8PgwFZp7nXgVAxtbw8feribY,833
106
106
  phoenix/server/api/dataloaders/__init__.py,sha256=LiOF9d95TPOD3pYuE906trLq-_q1VP8JQ9B6SHicbbM,4360
107
107
  phoenix/server/api/dataloaders/annotation_summaries.py,sha256=2sHmIDX7n8tuPeBTs9bMKtlMKWn_Ph9awTZqmwn2Owc,5505
@@ -201,7 +201,7 @@ phoenix/server/api/mutations/prompt_version_tag_mutations.py,sha256=t77osYb5he2A
201
201
  phoenix/server/api/mutations/span_annotations_mutations.py,sha256=sumBLUqRKlgMASWdWwYItmIJ2l7AyAp_PlIYeXYfguc,5970
202
202
  phoenix/server/api/mutations/trace_annotations_mutations.py,sha256=sEcEt8hbMt8YMiRX5o3xcJ5rWWZbDeBPMNz2teZoi3U,5945
203
203
  phoenix/server/api/mutations/trace_mutations.py,sha256=D5h2HYdlTo6yYZNq-O-PjaS9GeiZHxxVaOxDdh7fwjw,2957
204
- phoenix/server/api/mutations/user_mutations.py,sha256=FgZXQjCqmJ_0dyqiXv6NoGF7TjOO2a0aX7xDMDKtl-A,13603
204
+ phoenix/server/api/mutations/user_mutations.py,sha256=5pgtNKZwOnP1pbuegnNMwPn5u4teeWJ6vZSIcjDSCUg,14055
205
205
  phoenix/server/api/openapi/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
206
206
  phoenix/server/api/openapi/main.py,sha256=yKdzJYI4cxy_1mFcK4_7YObIcuRviBIfwNjB23RG14k,461
207
207
  phoenix/server/api/openapi/schema.py,sha256=WGmHWSIyJhtc5EIh_M3vlXU-EgHkFuTlyVofgS0kj1I,529
@@ -217,7 +217,7 @@ phoenix/server/api/routers/v1/experiment_evaluations.py,sha256=vx4CKlE84sAL1vtPi
217
217
  phoenix/server/api/routers/v1/experiment_runs.py,sha256=bInuasRv7ogiYf8fq-LwpJ5tptmMQsBNDlJAqwdymko,6378
218
218
  phoenix/server/api/routers/v1/experiments.py,sha256=V9_sxqLTE1MKGFu9H3FEdGKr70lYMbGZx813MGaavfQ,20430
219
219
  phoenix/server/api/routers/v1/models.py,sha256=r0nM2kFJ3mxDqgc5vFr1cjNuyOPs3RIKE_DS2VMdF48,1749
220
- phoenix/server/api/routers/v1/projects.py,sha256=OqUSsGlqalnwjZjFEg8f2rEq10IK41S3lIBjlInbJGA,12468
220
+ phoenix/server/api/routers/v1/projects.py,sha256=qv4RffYVGCJQuJ3FzaTVkueY8qKY0-GUbG9eSdm7oRY,14181
221
221
  phoenix/server/api/routers/v1/prompts.py,sha256=aBOUBwLDzZDIzJQkxJcR8ZKnakNJOLMwzsLKINSs1mA,26545
222
222
  phoenix/server/api/routers/v1/spans.py,sha256=uoU_bwIgz86fuvPjP5sX8goDyuCcnsTig-x3f17p60U,9625
223
223
  phoenix/server/api/routers/v1/traces.py,sha256=hSv35QIB4mwFgp53rOpz3zWIiSwbZzQnjafD790QuJU,7908
@@ -300,10 +300,11 @@ phoenix/server/api/types/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZ
300
300
  phoenix/server/api/types/node.py,sha256=BLl_IOFr0zrqUxaAtGLGui5aeM5VNVXFTzGeAKrztr0,822
301
301
  phoenix/server/api/types/pagination.py,sha256=BXm46gXZfrBS4hpiLvVSEdsbb29ctUMVJYjKXlOLxUA,9064
302
302
  phoenix/server/email/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
303
- phoenix/server/email/sender.py,sha256=Df-fZKbK_01KEkjo8UnDiYSuU69YfknyI1neZK_kmwA,3388
304
- phoenix/server/email/types.py,sha256=URLIMwtQWbbDEKLWjZTw9snCxsfxqRyoXNyByttbVWE,213
303
+ phoenix/server/email/sender.py,sha256=eC6RcLANVJH0mh20mGZ2qr-bU-OWo9po2e5og2tMzJw,4127
304
+ phoenix/server/email/types.py,sha256=IO2bTtCh-1cve-xiM4MWnunCCVNOQ3Z2cqTqF7vH-do,466
305
305
  phoenix/server/email/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
306
306
  phoenix/server/email/templates/password_reset.html,sha256=jv0Pe-06JloPZcubRWxPdAFHYEn9eDj_4SjmuoIwshI,441
307
+ phoenix/server/email/templates/welcome.html,sha256=AyVsIOtpmyYwWmmkXjuEgXwkbsag4YHHKfkmOTiNo-M,316
307
308
  phoenix/server/openapi/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
308
309
  phoenix/server/static/apple-touch-icon-114x114.png,sha256=xtFVXAYQnJkpUApg2D1hltSTuyO4Is4sD4A0ZkikiVU,9486
309
310
  phoenix/server/static/apple-touch-icon-120x120.png,sha256=iqZVAk634BbjJMozA8aHYyw15JjhIlIrG41FA2DFFaE,9957
@@ -315,10 +316,10 @@ phoenix/server/static/apple-touch-icon-76x76.png,sha256=CT_xT12I0u2i0WU8JzBZBuOQ
315
316
  phoenix/server/static/apple-touch-icon.png,sha256=fOfpjqGpWYbJ0eAurKsyoZP1EAs6ZVooBJ_SGk2ZkDs,3801
316
317
  phoenix/server/static/favicon.ico,sha256=bY0vvCKRftemZfPShwZtE93DiiQdaYaozkPGwNFr6H8,34494
317
318
  phoenix/server/static/modernizr.js,sha256=mvK-XtkNqjOral-QvzoqsyOMECXIMu5BQwSVN_wcU9c,2564
318
- phoenix/server/static/.vite/manifest.json,sha256=auJ0xZVwoBgNFZNn01_tvXU6xEq3MqpZojcaf0DOfJI,2165
319
- phoenix/server/static/assets/components-D8xtf7Q6.js,sha256=WQDwjJguBK1kHf3Ol-jJVsc2iOLOVJQQnzwn4x6N04Y,455620
320
- phoenix/server/static/assets/index-CGfsCafL.js,sha256=VJZGNtCcMeFtI0WkioE-HKzTzH8kL19MkmlP_Fa9nBg,60397
321
- phoenix/server/static/assets/pages-BiUq97Df.js,sha256=hBVLxHD0YfqscIRhv3UOvw51-4pNdlqFyrUBTxR5MF0,863007
319
+ phoenix/server/static/.vite/manifest.json,sha256=HoxYrYg2l2nq5chl9GKo0pnga42JFMShSC0Kr4wLX5o,2165
320
+ phoenix/server/static/assets/components-B6cljCxu.js,sha256=VQZ_xj8sB55iva4mG-oFIPElEaArjo0-GF8hWOQ7L8c,455620
321
+ phoenix/server/static/assets/index-DfHKoAV9.js,sha256=OmqicccaM0lPOLABn3D6U-igVTGSREXbM4ZFiKWYGMI,60397
322
+ phoenix/server/static/assets/pages-Dhitcl5V.js,sha256=CdjHOTXth5jc1mqFO-ofYgFTglKUtVlBwiYEK8SBRJE,863027
322
323
  phoenix/server/static/assets/vendor-C3H3sezv.js,sha256=Ask2DJPiTV2k5gNIopK_nOQFKCysUP5Z7h1gWVsFB3c,2510162
323
324
  phoenix/server/static/assets/vendor-Cg6lcjUC.css,sha256=nZrkr0u6NNElFGvpWHk9GTHeGoibCXCli1bE7mXZGZg,1816
324
325
  phoenix/server/static/assets/vendor-arizeai-DT8pwHfH.js,sha256=JraS2i2tsj4ToS_KGjq9BWS5zv4_FohPgF-vPV3MxEE,193248
@@ -365,9 +366,9 @@ phoenix/utilities/project.py,sha256=auVpARXkDb-JgeX5f2aStyFIkeKvGwN9l7qrFeJMVxI,
365
366
  phoenix/utilities/re.py,sha256=6YyUWIkv0zc2SigsxfOWIHzdpjKA_TZo2iqKq7zJKvw,2081
366
367
  phoenix/utilities/span_store.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
367
368
  phoenix/utilities/template_formatters.py,sha256=gh9PJD6WEGw7TEYXfSst1UR4pWWwmjxMLrDVQ_CkpkQ,2779
368
- arize_phoenix-8.23.0.dist-info/METADATA,sha256=aKyIILwAF-pzlVLUCEB4T74-6RF7z_WxDU5Unqb-hEI,24495
369
- arize_phoenix-8.23.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
370
- arize_phoenix-8.23.0.dist-info/entry_points.txt,sha256=Pgpn8Upxx9P8z8joPXZWl2LlnAlGc3gcQoVchb06X1Q,94
371
- arize_phoenix-8.23.0.dist-info/licenses/IP_NOTICE,sha256=JBqyyCYYxGDfzQ0TtsQgjts41IJoa-hiwDrBjCb9gHM,469
372
- arize_phoenix-8.23.0.dist-info/licenses/LICENSE,sha256=HFkW9REuMOkvKRACuwLPT0hRydHb3zNg-fdFt94td18,3794
373
- arize_phoenix-8.23.0.dist-info/RECORD,,
369
+ arize_phoenix-8.24.1.dist-info/METADATA,sha256=u080-Lqu15myFGIszydtax26yxvITRhiE_WXHsaQTtY,24495
370
+ arize_phoenix-8.24.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
371
+ arize_phoenix-8.24.1.dist-info/entry_points.txt,sha256=Pgpn8Upxx9P8z8joPXZWl2LlnAlGc3gcQoVchb06X1Q,94
372
+ arize_phoenix-8.24.1.dist-info/licenses/IP_NOTICE,sha256=JBqyyCYYxGDfzQ0TtsQgjts41IJoa-hiwDrBjCb9gHM,469
373
+ arize_phoenix-8.24.1.dist-info/licenses/LICENSE,sha256=HFkW9REuMOkvKRACuwLPT0hRydHb3zNg-fdFt94td18,3794
374
+ arize_phoenix-8.24.1.dist-info/RECORD,,
phoenix/config.py CHANGED
@@ -8,10 +8,11 @@ from enum import Enum
8
8
  from importlib.metadata import version
9
9
  from pathlib import Path
10
10
  from typing import Any, Optional, Union, cast, overload
11
- from urllib.parse import quote_plus, urlparse
11
+ from urllib.parse import quote_plus, urljoin, urlparse
12
12
 
13
13
  import wrapt
14
14
  from email_validator import EmailNotValidError, validate_email
15
+ from starlette.datastructures import URL
15
16
 
16
17
  from phoenix.utilities.logging import log_a_list
17
18
 
@@ -182,6 +183,19 @@ If the username or email address already exists in the database, the user record
182
183
  modified, e.g., changed from non-admin to admin. Changing this environment variable for the next
183
184
  startup will not undo any records created in previous startups.
184
185
  """
186
+ ENV_PHOENIX_ROOT_URL = "PHOENIX_ROOT_URL"
187
+ """
188
+ This is the full URL used to access Phoenix from a web browser. This setting is important when
189
+ you have a reverse proxy in front of Phoenix. If the reverse proxy exposes Phoenix through a
190
+ sub-path, add that sub-path to the end of this URL setting.
191
+
192
+ WARNING: When a sub-path is needed, you must also specify the sub-path via the environment
193
+ variable PHOENIX_HOST_ROOT_PATH. Setting just this URL setting is not enough.
194
+
195
+ Examples:
196
+ - With a sub-path: "https://example.com/phoenix"
197
+ - Without a sub-path: "https://phoenix.example.com"
198
+ """
185
199
 
186
200
 
187
201
  # SMTP settings
@@ -828,7 +842,7 @@ def get_env_host() -> str:
828
842
 
829
843
 
830
844
  def get_env_host_root_path() -> str:
831
- if (host_root_path := getenv(ENV_PHOENIX_HOST_ROOT_PATH)) is None:
845
+ if not (host_root_path := getenv(ENV_PHOENIX_HOST_ROOT_PATH)):
832
846
  return HOST_ROOT_PATH
833
847
  if not host_root_path.startswith("/"):
834
848
  raise ValueError(
@@ -894,7 +908,33 @@ def get_env_client_headers() -> dict[str, str]:
894
908
  return headers
895
909
 
896
910
 
911
+ def get_env_root_url() -> URL:
912
+ """
913
+ Get the URL used to access Phoenix from a web browser
914
+
915
+ Returns:
916
+ URL: The root URL of the Phoenix server
917
+
918
+ Note:
919
+ This is intended to replace the legacy `get_base_url()` helper function. In
920
+ particular, `get_env_collector_endpoint()` is really for the client and should be
921
+ deprecated on the server side.
922
+ """
923
+ if root_url := getenv(ENV_PHOENIX_ROOT_URL):
924
+ result = urlparse(root_url)
925
+ if not result.scheme or not result.netloc:
926
+ raise ValueError(
927
+ f"The environment variable `{ENV_PHOENIX_ROOT_URL}` must be a valid URL."
928
+ )
929
+ return URL(root_url)
930
+ host = get_env_host()
931
+ if host == "0.0.0.0":
932
+ host = "127.0.0.1"
933
+ return URL(urljoin(f"http://{host}:{get_env_port()}", get_env_host_root_path()))
934
+
935
+
897
936
  def get_base_url() -> str:
937
+ """Deprecated: Use get_env_root_url() instead, but note the difference in behavior."""
898
938
  host = get_env_host()
899
939
  if host == "0.0.0.0":
900
940
  host = "127.0.0.1"
@@ -1065,4 +1105,10 @@ def get_env_allowed_origins() -> Optional[list[str]]:
1065
1105
  return allowed_origins.split(",")
1066
1106
 
1067
1107
 
1108
+ def verify_server_environment_variables() -> None:
1109
+ """Verify that the environment variables are set correctly. Raises an error otherwise."""
1110
+ get_env_root_url()
1111
+
1112
+
1068
1113
  SKLEARN_VERSION = cast(tuple[int, int], tuple(map(int, version("scikit-learn").split(".", 2)[:2])))
1114
+ PLAYGROUND_PROJECT_NAME = "playground"
phoenix/db/facilitator.py CHANGED
@@ -1,15 +1,17 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import logging
4
5
  import secrets
6
+ from asyncio import gather
5
7
  from functools import partial
8
+ from typing import Optional
6
9
 
7
10
  from sqlalchemy import (
8
11
  distinct,
9
12
  insert,
10
13
  select,
11
14
  )
12
- from sqlalchemy.ext.asyncio import AsyncSession
13
15
 
14
16
  from phoenix.auth import (
15
17
  DEFAULT_ADMIN_EMAIL,
@@ -25,8 +27,11 @@ from phoenix.config import (
25
27
  )
26
28
  from phoenix.db import models
27
29
  from phoenix.db.enums import COLUMN_ENUMS, UserRole
30
+ from phoenix.server.email.types import WelcomeEmailSender
28
31
  from phoenix.server.types import DbSessionFactory
29
32
 
33
+ logger = logging.getLogger(__name__)
34
+
30
35
 
31
36
  class Facilitator:
32
37
  """
@@ -36,20 +41,25 @@ class Facilitator:
36
41
  carried out as callbacks at the very beginning of Starlette's lifespan process.
37
42
  """
38
43
 
39
- def __init__(self, *, db: DbSessionFactory) -> None:
44
+ def __init__(
45
+ self,
46
+ *,
47
+ db: DbSessionFactory,
48
+ email_sender: Optional[WelcomeEmailSender] = None,
49
+ ) -> None:
40
50
  self._db = db
51
+ self._email_sender = email_sender
41
52
 
42
53
  async def __call__(self) -> None:
43
54
  for fn in (
44
55
  _ensure_enums,
45
56
  _ensure_user_roles,
46
- _ensure_admins,
57
+ partial(_ensure_admins, email_sender=self._email_sender),
47
58
  ):
48
- async with self._db() as session:
49
- await fn(session)
59
+ await fn(self._db)
50
60
 
51
61
 
52
- async def _ensure_enums(session: AsyncSession) -> None:
62
+ async def _ensure_enums(db: DbSessionFactory) -> None:
53
63
  """
54
64
  Ensure that all enum values are present in their respective tables. If any values are missing,
55
65
  they will be added. If any values are present in the database but not in the enum, an error will
@@ -57,99 +67,122 @@ async def _ensure_enums(session: AsyncSession) -> None:
57
67
  """
58
68
  for column, enum in COLUMN_ENUMS.items():
59
69
  table = column.class_
60
- existing = set([_ async for _ in await session.stream_scalars(select(distinct(column)))])
61
- expected = set(e.value for e in enum)
62
- if unexpected := existing - expected:
63
- raise ValueError(f"Unexpected values in {table.name}.{column.key}: {unexpected}")
64
- if not (missing := expected - existing):
65
- continue
66
- await session.execute(insert(table), [{column.key: v} for v in missing])
70
+ async with db() as session:
71
+ existing = set(
72
+ [_ async for _ in await session.stream_scalars(select(distinct(column)))]
73
+ )
74
+ expected = set(e.value for e in enum)
75
+ if unexpected := existing - expected:
76
+ raise ValueError(f"Unexpected values in {table.name}.{column.key}: {unexpected}")
77
+ if not (missing := expected - existing):
78
+ continue
79
+ await session.execute(insert(table), [{column.key: v} for v in missing])
67
80
 
68
81
 
69
- async def _ensure_user_roles(session: AsyncSession) -> None:
82
+ async def _ensure_user_roles(db: DbSessionFactory) -> None:
70
83
  """
71
84
  Ensure that the system and admin roles are present in the database. If they are not, they will
72
85
  be added. The system user will have the email "system@localhost" and the admin user will have
73
86
  the email "admin@localhost".
74
87
  """
75
- role_ids = {
76
- name: id_
77
- async for name, id_ in await session.stream(
78
- select(models.UserRole.name, models.UserRole.id)
79
- )
80
- }
81
- existing_roles = [
82
- name
83
- async for name in await session.stream_scalars(
84
- select(distinct(models.UserRole.name)).join_from(models.User, models.UserRole)
85
- )
86
- ]
87
- if (system_role := UserRole.SYSTEM.value) not in existing_roles and (
88
- system_role_id := role_ids.get(system_role)
89
- ) is not None:
90
- system_user = models.User(
91
- user_role_id=system_role_id,
92
- username=DEFAULT_SYSTEM_USERNAME,
93
- email=DEFAULT_SYSTEM_EMAIL,
94
- reset_password=False,
95
- password_salt=secrets.token_bytes(DEFAULT_SECRET_LENGTH),
96
- password_hash=secrets.token_bytes(DEFAULT_SECRET_LENGTH),
97
- )
98
- session.add(system_user)
99
- if (admin_role := UserRole.ADMIN.value) not in existing_roles and (
100
- admin_role_id := role_ids.get(admin_role)
101
- ) is not None:
102
- salt = secrets.token_bytes(DEFAULT_SECRET_LENGTH)
103
- password = get_env_default_admin_initial_password()
104
- compute = partial(compute_password_hash, password=password, salt=salt)
105
- loop = asyncio.get_running_loop()
106
- hash_ = await loop.run_in_executor(None, compute)
107
- admin_user = models.User(
108
- user_role_id=admin_role_id,
109
- username=DEFAULT_ADMIN_USERNAME,
110
- email=DEFAULT_ADMIN_EMAIL,
111
- password_salt=salt,
112
- password_hash=hash_,
113
- reset_password=True,
114
- )
115
- session.add(admin_user)
116
- await session.flush()
88
+ async with db() as session:
89
+ role_ids = {
90
+ name: id_
91
+ async for name, id_ in await session.stream(
92
+ select(models.UserRole.name, models.UserRole.id)
93
+ )
94
+ }
95
+ existing_roles = [
96
+ name
97
+ async for name in await session.stream_scalars(
98
+ select(distinct(models.UserRole.name)).join_from(models.User, models.UserRole)
99
+ )
100
+ ]
101
+ if (system_role := UserRole.SYSTEM.value) not in existing_roles and (
102
+ system_role_id := role_ids.get(system_role)
103
+ ) is not None:
104
+ system_user = models.User(
105
+ user_role_id=system_role_id,
106
+ username=DEFAULT_SYSTEM_USERNAME,
107
+ email=DEFAULT_SYSTEM_EMAIL,
108
+ reset_password=False,
109
+ password_salt=secrets.token_bytes(DEFAULT_SECRET_LENGTH),
110
+ password_hash=secrets.token_bytes(DEFAULT_SECRET_LENGTH),
111
+ )
112
+ session.add(system_user)
113
+ if (admin_role := UserRole.ADMIN.value) not in existing_roles and (
114
+ admin_role_id := role_ids.get(admin_role)
115
+ ) is not None:
116
+ salt = secrets.token_bytes(DEFAULT_SECRET_LENGTH)
117
+ password = get_env_default_admin_initial_password()
118
+ compute = partial(compute_password_hash, password=password, salt=salt)
119
+ loop = asyncio.get_running_loop()
120
+ hash_ = await loop.run_in_executor(None, compute)
121
+ admin_user = models.User(
122
+ user_role_id=admin_role_id,
123
+ username=DEFAULT_ADMIN_USERNAME,
124
+ email=DEFAULT_ADMIN_EMAIL,
125
+ password_salt=salt,
126
+ password_hash=hash_,
127
+ reset_password=True,
128
+ )
129
+ session.add(admin_user)
130
+ await session.flush()
117
131
 
118
132
 
119
- async def _ensure_admins(session: AsyncSession) -> None:
133
+ async def _ensure_admins(
134
+ db: DbSessionFactory,
135
+ *,
136
+ email_sender: Optional[WelcomeEmailSender] = None,
137
+ ) -> None:
120
138
  """
121
139
  Ensure that all startup admin users are present in the database. If any are missing, they will
122
140
  be added. Existing records will not be modified.
123
141
  """
124
142
  if not (admins := get_env_admins()):
125
143
  return
126
- existing_emails = set(
127
- await session.scalars(select(models.User.email).where(models.User.email.in_(admins.keys())))
128
- )
129
- admins = {email: username for email, username in admins.items() if email not in existing_emails}
130
- if not admins:
131
- return
132
- existing_usernames = set(
133
- await session.scalars(
134
- select(models.User.username).where(models.User.username.in_(admins.values()))
144
+ async with db() as session:
145
+ existing_emails = set(
146
+ await session.scalars(
147
+ select(models.User.email).where(models.User.email.in_(admins.keys()))
148
+ )
135
149
  )
136
- )
137
- admins = {
138
- email: username for email, username in admins.items() if username not in existing_usernames
139
- }
140
- if not admins:
141
- return
142
- admin_role_id = await session.scalar(
143
- select(models.UserRole.id).filter_by(name=UserRole.ADMIN.value)
144
- )
145
- assert admin_role_id is not None, "Admin role not found in database"
146
- for email, username in admins.items():
147
- values = dict(
148
- user_role_id=admin_role_id,
149
- username=username,
150
- email=email,
151
- password_salt=secrets.token_bytes(DEFAULT_SECRET_LENGTH),
152
- password_hash=secrets.token_bytes(DEFAULT_SECRET_LENGTH),
153
- reset_password=True,
150
+ admins = {
151
+ email: username for email, username in admins.items() if email not in existing_emails
152
+ }
153
+ if not admins:
154
+ return
155
+ existing_usernames = set(
156
+ await session.scalars(
157
+ select(models.User.username).where(models.User.username.in_(admins.values()))
158
+ )
154
159
  )
155
- await session.execute(insert(models.User).values(values))
160
+ admins = {
161
+ email: username
162
+ for email, username in admins.items()
163
+ if username not in existing_usernames
164
+ }
165
+ if not admins:
166
+ return
167
+ admin_role_id = await session.scalar(
168
+ select(models.UserRole.id).filter_by(name=UserRole.ADMIN.value)
169
+ )
170
+ assert admin_role_id is not None, "Admin role not found in database"
171
+ for email, username in admins.items():
172
+ values = dict(
173
+ user_role_id=admin_role_id,
174
+ username=username,
175
+ email=email,
176
+ password_salt=secrets.token_bytes(DEFAULT_SECRET_LENGTH),
177
+ password_hash=secrets.token_bytes(DEFAULT_SECRET_LENGTH),
178
+ reset_password=True,
179
+ )
180
+ await session.execute(insert(models.User).values(values))
181
+ if email_sender is None:
182
+ return
183
+ for exc in await gather(
184
+ *(email_sender.send_welcome_email(email, username) for email, username in admins.items()),
185
+ return_exceptions=True,
186
+ ):
187
+ if isinstance(exc, Exception):
188
+ logger.error(f"Failed to send welcome email: {exc}")
phoenix/db/helpers.py CHANGED
@@ -19,6 +19,7 @@ from sqlalchemy import (
19
19
  )
20
20
  from typing_extensions import assert_never
21
21
 
22
+ from phoenix.config import PLAYGROUND_PROJECT_NAME
22
23
  from phoenix.db import models
23
24
 
24
25
 
@@ -157,3 +158,18 @@ def get_dataset_example_revisions(
157
158
  ),
158
159
  )
159
160
  )
161
+
162
+
163
+ _AnyTuple = TypeVar("_AnyTuple", bound=tuple[Any, ...])
164
+
165
+
166
+ def exclude_experiment_projects(
167
+ stmt: Select[_AnyTuple],
168
+ ) -> Select[_AnyTuple]:
169
+ return stmt.outerjoin(
170
+ models.Experiment,
171
+ and_(
172
+ models.Project.name == models.Experiment.project_name,
173
+ models.Experiment.project_name != PLAYGROUND_PROJECT_NAME,
174
+ ),
175
+ ).where(models.Experiment.project_name.is_(None))
@@ -53,6 +53,7 @@ from phoenix.server.api.dataloaders import (
53
53
  )
54
54
  from phoenix.server.bearer_auth import PhoenixUser
55
55
  from phoenix.server.dml_event import DmlEvent
56
+ from phoenix.server.email.types import EmailSender
56
57
  from phoenix.server.types import (
57
58
  CanGetLastUpdatedAt,
58
59
  CanPutItem,
@@ -124,6 +125,7 @@ class Context(BaseContext):
124
125
  auth_enabled: bool = False
125
126
  secret: Optional[str] = None
126
127
  token_store: Optional[TokenStore] = None
128
+ email_sender: Optional[EmailSender] = None
127
129
 
128
130
  def get_secret(self) -> str:
129
131
  """A type-safe way to get the application secret. Throws an error if the secret is not set.
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  import secrets
2
3
  from contextlib import AsyncExitStack
3
4
  from datetime import datetime, timezone
@@ -32,6 +33,8 @@ from phoenix.server.api.types.User import User, to_gql_user
32
33
  from phoenix.server.bearer_auth import PhoenixUser
33
34
  from phoenix.server.types import AccessTokenId, ApiKeyId, PasswordResetTokenId, RefreshTokenId
34
35
 
36
+ logger = logging.getLogger(__name__)
37
+
35
38
 
36
39
  @strawberry.input
37
40
  class CreateUserInput:
@@ -39,6 +42,7 @@ class CreateUserInput:
39
42
  username: str
40
43
  password: str
41
44
  role: UserRoleInput
45
+ send_welcome_email: Optional[bool] = False
42
46
 
43
47
 
44
48
  @strawberry.input
@@ -111,6 +115,12 @@ class UserMutationMixin:
111
115
  await session.flush()
112
116
  except (PostgreSQLIntegrityError, SQLiteIntegrityError) as error:
113
117
  raise Conflict(_user_operation_error_message(error))
118
+ if input.send_welcome_email and info.context.email_sender is not None:
119
+ try:
120
+ await info.context.email_sender.send_welcome_email(user.email, user.username)
121
+ except Exception as error:
122
+ # Log the error but do not raise it
123
+ logger.error(f"Failed to send welcome email: {error}")
114
124
  return UserMutationPayload(user=to_gql_user(user))
115
125
 
116
126
  @strawberry.mutation(permission_classes=[IsNotReadOnly, IsAdmin, IsLocked]) # type: ignore
@@ -19,7 +19,7 @@ from phoenix.config import (
19
19
  getenv,
20
20
  )
21
21
  from phoenix.db import enums, models
22
- from phoenix.db.helpers import SupportedSQLDialect
22
+ from phoenix.db.helpers import SupportedSQLDialect, exclude_experiment_projects
23
23
  from phoenix.db.models import DatasetExample as OrmExample
24
24
  from phoenix.db.models import DatasetExampleRevision as OrmRevision
25
25
  from phoenix.db.models import DatasetVersion as OrmVersion
@@ -42,7 +42,6 @@ from phoenix.server.api.input_types.ClusterInput import ClusterInput
42
42
  from phoenix.server.api.input_types.Coordinates import InputCoordinate2D, InputCoordinate3D
43
43
  from phoenix.server.api.input_types.DatasetSort import DatasetSort
44
44
  from phoenix.server.api.input_types.InvocationParameters import InvocationParameter
45
- from phoenix.server.api.subscriptions import PLAYGROUND_PROJECT_NAME
46
45
  from phoenix.server.api.types.Cluster import Cluster, to_gql_clusters
47
46
  from phoenix.server.api.types.Dataset import Dataset, to_gql_dataset
48
47
  from phoenix.server.api.types.DatasetExample import DatasetExample
@@ -233,18 +232,8 @@ class Query:
233
232
  last=last,
234
233
  before=before if isinstance(before, CursorString) else None,
235
234
  )
236
- stmt = (
237
- select(models.Project)
238
- .outerjoin(
239
- models.Experiment,
240
- and_(
241
- models.Project.name == models.Experiment.project_name,
242
- models.Experiment.project_name != PLAYGROUND_PROJECT_NAME,
243
- ),
244
- )
245
- .where(models.Experiment.project_name.is_(None))
246
- .order_by(models.Project.id)
247
- )
235
+ stmt = select(models.Project).order_by(models.Project.id)
236
+ stmt = exclude_experiment_projects(stmt)
248
237
  async with info.context.db() as session:
249
238
  projects = await session.stream_scalars(stmt)
250
239
  data = [