arize-phoenix 3.16.0__py3-none-any.whl → 7.7.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.

Potentially problematic release.


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

Files changed (338) hide show
  1. arize_phoenix-7.7.0.dist-info/METADATA +261 -0
  2. arize_phoenix-7.7.0.dist-info/RECORD +345 -0
  3. {arize_phoenix-3.16.0.dist-info → arize_phoenix-7.7.0.dist-info}/WHEEL +1 -1
  4. arize_phoenix-7.7.0.dist-info/entry_points.txt +3 -0
  5. phoenix/__init__.py +86 -14
  6. phoenix/auth.py +309 -0
  7. phoenix/config.py +675 -45
  8. phoenix/core/model.py +32 -30
  9. phoenix/core/model_schema.py +102 -109
  10. phoenix/core/model_schema_adapter.py +48 -45
  11. phoenix/datetime_utils.py +24 -3
  12. phoenix/db/README.md +54 -0
  13. phoenix/db/__init__.py +4 -0
  14. phoenix/db/alembic.ini +85 -0
  15. phoenix/db/bulk_inserter.py +294 -0
  16. phoenix/db/engines.py +208 -0
  17. phoenix/db/enums.py +20 -0
  18. phoenix/db/facilitator.py +113 -0
  19. phoenix/db/helpers.py +159 -0
  20. phoenix/db/insertion/constants.py +2 -0
  21. phoenix/db/insertion/dataset.py +227 -0
  22. phoenix/db/insertion/document_annotation.py +171 -0
  23. phoenix/db/insertion/evaluation.py +191 -0
  24. phoenix/db/insertion/helpers.py +98 -0
  25. phoenix/db/insertion/span.py +193 -0
  26. phoenix/db/insertion/span_annotation.py +158 -0
  27. phoenix/db/insertion/trace_annotation.py +158 -0
  28. phoenix/db/insertion/types.py +256 -0
  29. phoenix/db/migrate.py +86 -0
  30. phoenix/db/migrations/data_migration_scripts/populate_project_sessions.py +199 -0
  31. phoenix/db/migrations/env.py +114 -0
  32. phoenix/db/migrations/script.py.mako +26 -0
  33. phoenix/db/migrations/versions/10460e46d750_datasets.py +317 -0
  34. phoenix/db/migrations/versions/3be8647b87d8_add_token_columns_to_spans_table.py +126 -0
  35. phoenix/db/migrations/versions/4ded9e43755f_create_project_sessions_table.py +66 -0
  36. phoenix/db/migrations/versions/cd164e83824f_users_and_tokens.py +157 -0
  37. phoenix/db/migrations/versions/cf03bd6bae1d_init.py +280 -0
  38. phoenix/db/models.py +807 -0
  39. phoenix/exceptions.py +5 -1
  40. phoenix/experiments/__init__.py +6 -0
  41. phoenix/experiments/evaluators/__init__.py +29 -0
  42. phoenix/experiments/evaluators/base.py +158 -0
  43. phoenix/experiments/evaluators/code_evaluators.py +184 -0
  44. phoenix/experiments/evaluators/llm_evaluators.py +473 -0
  45. phoenix/experiments/evaluators/utils.py +236 -0
  46. phoenix/experiments/functions.py +772 -0
  47. phoenix/experiments/tracing.py +86 -0
  48. phoenix/experiments/types.py +726 -0
  49. phoenix/experiments/utils.py +25 -0
  50. phoenix/inferences/__init__.py +0 -0
  51. phoenix/{datasets → inferences}/errors.py +6 -5
  52. phoenix/{datasets → inferences}/fixtures.py +49 -42
  53. phoenix/{datasets/dataset.py → inferences/inferences.py} +121 -105
  54. phoenix/{datasets → inferences}/schema.py +11 -11
  55. phoenix/{datasets → inferences}/validation.py +13 -14
  56. phoenix/logging/__init__.py +3 -0
  57. phoenix/logging/_config.py +90 -0
  58. phoenix/logging/_filter.py +6 -0
  59. phoenix/logging/_formatter.py +69 -0
  60. phoenix/metrics/__init__.py +5 -4
  61. phoenix/metrics/binning.py +4 -3
  62. phoenix/metrics/metrics.py +2 -1
  63. phoenix/metrics/mixins.py +7 -6
  64. phoenix/metrics/retrieval_metrics.py +2 -1
  65. phoenix/metrics/timeseries.py +5 -4
  66. phoenix/metrics/wrappers.py +9 -3
  67. phoenix/pointcloud/clustering.py +5 -5
  68. phoenix/pointcloud/pointcloud.py +7 -5
  69. phoenix/pointcloud/projectors.py +5 -6
  70. phoenix/pointcloud/umap_parameters.py +53 -52
  71. phoenix/server/api/README.md +28 -0
  72. phoenix/server/api/auth.py +44 -0
  73. phoenix/server/api/context.py +152 -9
  74. phoenix/server/api/dataloaders/__init__.py +91 -0
  75. phoenix/server/api/dataloaders/annotation_summaries.py +139 -0
  76. phoenix/server/api/dataloaders/average_experiment_run_latency.py +54 -0
  77. phoenix/server/api/dataloaders/cache/__init__.py +3 -0
  78. phoenix/server/api/dataloaders/cache/two_tier_cache.py +68 -0
  79. phoenix/server/api/dataloaders/dataset_example_revisions.py +131 -0
  80. phoenix/server/api/dataloaders/dataset_example_spans.py +38 -0
  81. phoenix/server/api/dataloaders/document_evaluation_summaries.py +144 -0
  82. phoenix/server/api/dataloaders/document_evaluations.py +31 -0
  83. phoenix/server/api/dataloaders/document_retrieval_metrics.py +89 -0
  84. phoenix/server/api/dataloaders/experiment_annotation_summaries.py +79 -0
  85. phoenix/server/api/dataloaders/experiment_error_rates.py +58 -0
  86. phoenix/server/api/dataloaders/experiment_run_annotations.py +36 -0
  87. phoenix/server/api/dataloaders/experiment_run_counts.py +49 -0
  88. phoenix/server/api/dataloaders/experiment_sequence_number.py +44 -0
  89. phoenix/server/api/dataloaders/latency_ms_quantile.py +188 -0
  90. phoenix/server/api/dataloaders/min_start_or_max_end_times.py +85 -0
  91. phoenix/server/api/dataloaders/project_by_name.py +31 -0
  92. phoenix/server/api/dataloaders/record_counts.py +116 -0
  93. phoenix/server/api/dataloaders/session_io.py +79 -0
  94. phoenix/server/api/dataloaders/session_num_traces.py +30 -0
  95. phoenix/server/api/dataloaders/session_num_traces_with_error.py +32 -0
  96. phoenix/server/api/dataloaders/session_token_usages.py +41 -0
  97. phoenix/server/api/dataloaders/session_trace_latency_ms_quantile.py +55 -0
  98. phoenix/server/api/dataloaders/span_annotations.py +26 -0
  99. phoenix/server/api/dataloaders/span_dataset_examples.py +31 -0
  100. phoenix/server/api/dataloaders/span_descendants.py +57 -0
  101. phoenix/server/api/dataloaders/span_projects.py +33 -0
  102. phoenix/server/api/dataloaders/token_counts.py +124 -0
  103. phoenix/server/api/dataloaders/trace_by_trace_ids.py +25 -0
  104. phoenix/server/api/dataloaders/trace_root_spans.py +32 -0
  105. phoenix/server/api/dataloaders/user_roles.py +30 -0
  106. phoenix/server/api/dataloaders/users.py +33 -0
  107. phoenix/server/api/exceptions.py +48 -0
  108. phoenix/server/api/helpers/__init__.py +12 -0
  109. phoenix/server/api/helpers/dataset_helpers.py +217 -0
  110. phoenix/server/api/helpers/experiment_run_filters.py +763 -0
  111. phoenix/server/api/helpers/playground_clients.py +948 -0
  112. phoenix/server/api/helpers/playground_registry.py +70 -0
  113. phoenix/server/api/helpers/playground_spans.py +455 -0
  114. phoenix/server/api/input_types/AddExamplesToDatasetInput.py +16 -0
  115. phoenix/server/api/input_types/AddSpansToDatasetInput.py +14 -0
  116. phoenix/server/api/input_types/ChatCompletionInput.py +38 -0
  117. phoenix/server/api/input_types/ChatCompletionMessageInput.py +24 -0
  118. phoenix/server/api/input_types/ClearProjectInput.py +15 -0
  119. phoenix/server/api/input_types/ClusterInput.py +2 -2
  120. phoenix/server/api/input_types/CreateDatasetInput.py +12 -0
  121. phoenix/server/api/input_types/CreateSpanAnnotationInput.py +18 -0
  122. phoenix/server/api/input_types/CreateTraceAnnotationInput.py +18 -0
  123. phoenix/server/api/input_types/DataQualityMetricInput.py +5 -2
  124. phoenix/server/api/input_types/DatasetExampleInput.py +14 -0
  125. phoenix/server/api/input_types/DatasetSort.py +17 -0
  126. phoenix/server/api/input_types/DatasetVersionSort.py +16 -0
  127. phoenix/server/api/input_types/DeleteAnnotationsInput.py +7 -0
  128. phoenix/server/api/input_types/DeleteDatasetExamplesInput.py +13 -0
  129. phoenix/server/api/input_types/DeleteDatasetInput.py +7 -0
  130. phoenix/server/api/input_types/DeleteExperimentsInput.py +7 -0
  131. phoenix/server/api/input_types/DimensionFilter.py +4 -4
  132. phoenix/server/api/input_types/GenerativeModelInput.py +17 -0
  133. phoenix/server/api/input_types/Granularity.py +1 -1
  134. phoenix/server/api/input_types/InvocationParameters.py +162 -0
  135. phoenix/server/api/input_types/PatchAnnotationInput.py +19 -0
  136. phoenix/server/api/input_types/PatchDatasetExamplesInput.py +35 -0
  137. phoenix/server/api/input_types/PatchDatasetInput.py +14 -0
  138. phoenix/server/api/input_types/PerformanceMetricInput.py +5 -2
  139. phoenix/server/api/input_types/ProjectSessionSort.py +29 -0
  140. phoenix/server/api/input_types/SpanAnnotationSort.py +17 -0
  141. phoenix/server/api/input_types/SpanSort.py +134 -69
  142. phoenix/server/api/input_types/TemplateOptions.py +10 -0
  143. phoenix/server/api/input_types/TraceAnnotationSort.py +17 -0
  144. phoenix/server/api/input_types/UserRoleInput.py +9 -0
  145. phoenix/server/api/mutations/__init__.py +28 -0
  146. phoenix/server/api/mutations/api_key_mutations.py +167 -0
  147. phoenix/server/api/mutations/chat_mutations.py +593 -0
  148. phoenix/server/api/mutations/dataset_mutations.py +591 -0
  149. phoenix/server/api/mutations/experiment_mutations.py +75 -0
  150. phoenix/server/api/{types/ExportEventsMutation.py → mutations/export_events_mutations.py} +21 -18
  151. phoenix/server/api/mutations/project_mutations.py +57 -0
  152. phoenix/server/api/mutations/span_annotations_mutations.py +128 -0
  153. phoenix/server/api/mutations/trace_annotations_mutations.py +127 -0
  154. phoenix/server/api/mutations/user_mutations.py +329 -0
  155. phoenix/server/api/openapi/__init__.py +0 -0
  156. phoenix/server/api/openapi/main.py +17 -0
  157. phoenix/server/api/openapi/schema.py +16 -0
  158. phoenix/server/api/queries.py +738 -0
  159. phoenix/server/api/routers/__init__.py +11 -0
  160. phoenix/server/api/routers/auth.py +284 -0
  161. phoenix/server/api/routers/embeddings.py +26 -0
  162. phoenix/server/api/routers/oauth2.py +488 -0
  163. phoenix/server/api/routers/v1/__init__.py +64 -0
  164. phoenix/server/api/routers/v1/datasets.py +1017 -0
  165. phoenix/server/api/routers/v1/evaluations.py +362 -0
  166. phoenix/server/api/routers/v1/experiment_evaluations.py +115 -0
  167. phoenix/server/api/routers/v1/experiment_runs.py +167 -0
  168. phoenix/server/api/routers/v1/experiments.py +308 -0
  169. phoenix/server/api/routers/v1/pydantic_compat.py +78 -0
  170. phoenix/server/api/routers/v1/spans.py +267 -0
  171. phoenix/server/api/routers/v1/traces.py +208 -0
  172. phoenix/server/api/routers/v1/utils.py +95 -0
  173. phoenix/server/api/schema.py +44 -247
  174. phoenix/server/api/subscriptions.py +597 -0
  175. phoenix/server/api/types/Annotation.py +21 -0
  176. phoenix/server/api/types/AnnotationSummary.py +55 -0
  177. phoenix/server/api/types/AnnotatorKind.py +16 -0
  178. phoenix/server/api/types/ApiKey.py +27 -0
  179. phoenix/server/api/types/AuthMethod.py +9 -0
  180. phoenix/server/api/types/ChatCompletionMessageRole.py +11 -0
  181. phoenix/server/api/types/ChatCompletionSubscriptionPayload.py +46 -0
  182. phoenix/server/api/types/Cluster.py +25 -24
  183. phoenix/server/api/types/CreateDatasetPayload.py +8 -0
  184. phoenix/server/api/types/DataQualityMetric.py +31 -13
  185. phoenix/server/api/types/Dataset.py +288 -63
  186. phoenix/server/api/types/DatasetExample.py +85 -0
  187. phoenix/server/api/types/DatasetExampleRevision.py +34 -0
  188. phoenix/server/api/types/DatasetVersion.py +14 -0
  189. phoenix/server/api/types/Dimension.py +32 -31
  190. phoenix/server/api/types/DocumentEvaluationSummary.py +9 -8
  191. phoenix/server/api/types/EmbeddingDimension.py +56 -49
  192. phoenix/server/api/types/Evaluation.py +25 -31
  193. phoenix/server/api/types/EvaluationSummary.py +30 -50
  194. phoenix/server/api/types/Event.py +20 -20
  195. phoenix/server/api/types/ExampleRevisionInterface.py +14 -0
  196. phoenix/server/api/types/Experiment.py +152 -0
  197. phoenix/server/api/types/ExperimentAnnotationSummary.py +13 -0
  198. phoenix/server/api/types/ExperimentComparison.py +17 -0
  199. phoenix/server/api/types/ExperimentRun.py +119 -0
  200. phoenix/server/api/types/ExperimentRunAnnotation.py +56 -0
  201. phoenix/server/api/types/GenerativeModel.py +9 -0
  202. phoenix/server/api/types/GenerativeProvider.py +85 -0
  203. phoenix/server/api/types/Inferences.py +80 -0
  204. phoenix/server/api/types/InferencesRole.py +23 -0
  205. phoenix/server/api/types/LabelFraction.py +7 -0
  206. phoenix/server/api/types/MimeType.py +2 -2
  207. phoenix/server/api/types/Model.py +54 -54
  208. phoenix/server/api/types/PerformanceMetric.py +8 -5
  209. phoenix/server/api/types/Project.py +407 -142
  210. phoenix/server/api/types/ProjectSession.py +139 -0
  211. phoenix/server/api/types/Segments.py +4 -4
  212. phoenix/server/api/types/Span.py +221 -176
  213. phoenix/server/api/types/SpanAnnotation.py +43 -0
  214. phoenix/server/api/types/SpanIOValue.py +15 -0
  215. phoenix/server/api/types/SystemApiKey.py +9 -0
  216. phoenix/server/api/types/TemplateLanguage.py +10 -0
  217. phoenix/server/api/types/TimeSeries.py +19 -15
  218. phoenix/server/api/types/TokenUsage.py +11 -0
  219. phoenix/server/api/types/Trace.py +154 -0
  220. phoenix/server/api/types/TraceAnnotation.py +45 -0
  221. phoenix/server/api/types/UMAPPoints.py +7 -7
  222. phoenix/server/api/types/User.py +60 -0
  223. phoenix/server/api/types/UserApiKey.py +45 -0
  224. phoenix/server/api/types/UserRole.py +15 -0
  225. phoenix/server/api/types/node.py +13 -107
  226. phoenix/server/api/types/pagination.py +156 -57
  227. phoenix/server/api/utils.py +34 -0
  228. phoenix/server/app.py +864 -115
  229. phoenix/server/bearer_auth.py +163 -0
  230. phoenix/server/dml_event.py +136 -0
  231. phoenix/server/dml_event_handler.py +256 -0
  232. phoenix/server/email/__init__.py +0 -0
  233. phoenix/server/email/sender.py +97 -0
  234. phoenix/server/email/templates/__init__.py +0 -0
  235. phoenix/server/email/templates/password_reset.html +19 -0
  236. phoenix/server/email/types.py +11 -0
  237. phoenix/server/grpc_server.py +102 -0
  238. phoenix/server/jwt_store.py +505 -0
  239. phoenix/server/main.py +305 -116
  240. phoenix/server/oauth2.py +52 -0
  241. phoenix/server/openapi/__init__.py +0 -0
  242. phoenix/server/prometheus.py +111 -0
  243. phoenix/server/rate_limiters.py +188 -0
  244. phoenix/server/static/.vite/manifest.json +87 -0
  245. phoenix/server/static/assets/components-Cy9nwIvF.js +2125 -0
  246. phoenix/server/static/assets/index-BKvHIxkk.js +113 -0
  247. phoenix/server/static/assets/pages-CUi2xCVQ.js +4449 -0
  248. phoenix/server/static/assets/vendor-DvC8cT4X.js +894 -0
  249. phoenix/server/static/assets/vendor-DxkFTwjz.css +1 -0
  250. phoenix/server/static/assets/vendor-arizeai-Do1793cv.js +662 -0
  251. phoenix/server/static/assets/vendor-codemirror-BzwZPyJM.js +24 -0
  252. phoenix/server/static/assets/vendor-recharts-_Jb7JjhG.js +59 -0
  253. phoenix/server/static/assets/vendor-shiki-Cl9QBraO.js +5 -0
  254. phoenix/server/static/assets/vendor-three-DwGkEfCM.js +2998 -0
  255. phoenix/server/telemetry.py +68 -0
  256. phoenix/server/templates/index.html +82 -23
  257. phoenix/server/thread_server.py +3 -3
  258. phoenix/server/types.py +275 -0
  259. phoenix/services.py +27 -18
  260. phoenix/session/client.py +743 -68
  261. phoenix/session/data_extractor.py +31 -7
  262. phoenix/session/evaluation.py +3 -9
  263. phoenix/session/session.py +263 -219
  264. phoenix/settings.py +22 -0
  265. phoenix/trace/__init__.py +2 -22
  266. phoenix/trace/attributes.py +338 -0
  267. phoenix/trace/dsl/README.md +116 -0
  268. phoenix/trace/dsl/filter.py +663 -213
  269. phoenix/trace/dsl/helpers.py +73 -21
  270. phoenix/trace/dsl/query.py +574 -201
  271. phoenix/trace/exporter.py +24 -19
  272. phoenix/trace/fixtures.py +368 -32
  273. phoenix/trace/otel.py +71 -219
  274. phoenix/trace/projects.py +3 -2
  275. phoenix/trace/schemas.py +33 -11
  276. phoenix/trace/span_evaluations.py +21 -16
  277. phoenix/trace/span_json_decoder.py +6 -4
  278. phoenix/trace/span_json_encoder.py +2 -2
  279. phoenix/trace/trace_dataset.py +47 -32
  280. phoenix/trace/utils.py +21 -4
  281. phoenix/utilities/__init__.py +0 -26
  282. phoenix/utilities/client.py +132 -0
  283. phoenix/utilities/deprecation.py +31 -0
  284. phoenix/utilities/error_handling.py +3 -2
  285. phoenix/utilities/json.py +109 -0
  286. phoenix/utilities/logging.py +8 -0
  287. phoenix/utilities/project.py +2 -2
  288. phoenix/utilities/re.py +49 -0
  289. phoenix/utilities/span_store.py +0 -23
  290. phoenix/utilities/template_formatters.py +99 -0
  291. phoenix/version.py +1 -1
  292. arize_phoenix-3.16.0.dist-info/METADATA +0 -495
  293. arize_phoenix-3.16.0.dist-info/RECORD +0 -178
  294. phoenix/core/project.py +0 -617
  295. phoenix/core/traces.py +0 -100
  296. phoenix/experimental/evals/__init__.py +0 -73
  297. phoenix/experimental/evals/evaluators.py +0 -413
  298. phoenix/experimental/evals/functions/__init__.py +0 -4
  299. phoenix/experimental/evals/functions/classify.py +0 -453
  300. phoenix/experimental/evals/functions/executor.py +0 -353
  301. phoenix/experimental/evals/functions/generate.py +0 -138
  302. phoenix/experimental/evals/functions/processing.py +0 -76
  303. phoenix/experimental/evals/models/__init__.py +0 -14
  304. phoenix/experimental/evals/models/anthropic.py +0 -175
  305. phoenix/experimental/evals/models/base.py +0 -170
  306. phoenix/experimental/evals/models/bedrock.py +0 -221
  307. phoenix/experimental/evals/models/litellm.py +0 -134
  308. phoenix/experimental/evals/models/openai.py +0 -448
  309. phoenix/experimental/evals/models/rate_limiters.py +0 -246
  310. phoenix/experimental/evals/models/vertex.py +0 -173
  311. phoenix/experimental/evals/models/vertexai.py +0 -186
  312. phoenix/experimental/evals/retrievals.py +0 -96
  313. phoenix/experimental/evals/templates/__init__.py +0 -50
  314. phoenix/experimental/evals/templates/default_templates.py +0 -472
  315. phoenix/experimental/evals/templates/template.py +0 -195
  316. phoenix/experimental/evals/utils/__init__.py +0 -172
  317. phoenix/experimental/evals/utils/threads.py +0 -27
  318. phoenix/server/api/helpers.py +0 -11
  319. phoenix/server/api/routers/evaluation_handler.py +0 -109
  320. phoenix/server/api/routers/span_handler.py +0 -70
  321. phoenix/server/api/routers/trace_handler.py +0 -60
  322. phoenix/server/api/types/DatasetRole.py +0 -23
  323. phoenix/server/static/index.css +0 -6
  324. phoenix/server/static/index.js +0 -7447
  325. phoenix/storage/span_store/__init__.py +0 -23
  326. phoenix/storage/span_store/text_file.py +0 -85
  327. phoenix/trace/dsl/missing.py +0 -60
  328. phoenix/trace/langchain/__init__.py +0 -3
  329. phoenix/trace/langchain/instrumentor.py +0 -35
  330. phoenix/trace/llama_index/__init__.py +0 -3
  331. phoenix/trace/llama_index/callback.py +0 -102
  332. phoenix/trace/openai/__init__.py +0 -3
  333. phoenix/trace/openai/instrumentor.py +0 -30
  334. {arize_phoenix-3.16.0.dist-info → arize_phoenix-7.7.0.dist-info}/licenses/IP_NOTICE +0 -0
  335. {arize_phoenix-3.16.0.dist-info → arize_phoenix-7.7.0.dist-info}/licenses/LICENSE +0 -0
  336. /phoenix/{datasets → db/insertion}/__init__.py +0 -0
  337. /phoenix/{experimental → db/migrations}/__init__.py +0 -0
  338. /phoenix/{storage → db/migrations/data_migration_scripts}/__init__.py +0 -0
@@ -0,0 +1,163 @@
1
+ from abc import ABC
2
+ from collections.abc import Awaitable, Callable
3
+ from datetime import datetime, timedelta, timezone
4
+ from functools import cached_property
5
+ from typing import Any, Optional, cast
6
+
7
+ import grpc
8
+ from fastapi import HTTPException, Request, WebSocket, WebSocketException
9
+ from grpc_interceptor import AsyncServerInterceptor
10
+ from grpc_interceptor.exceptions import Unauthenticated
11
+ from starlette.authentication import AuthCredentials, AuthenticationBackend, BaseUser
12
+ from starlette.requests import HTTPConnection
13
+ from starlette.status import HTTP_401_UNAUTHORIZED
14
+
15
+ from phoenix.auth import (
16
+ PHOENIX_ACCESS_TOKEN_COOKIE_NAME,
17
+ CanReadToken,
18
+ ClaimSetStatus,
19
+ Token,
20
+ )
21
+ from phoenix.db import enums
22
+ from phoenix.db.enums import UserRole
23
+ from phoenix.db.models import User as OrmUser
24
+ from phoenix.server.types import (
25
+ AccessToken,
26
+ AccessTokenAttributes,
27
+ AccessTokenClaims,
28
+ ApiKeyClaims,
29
+ RefreshToken,
30
+ RefreshTokenAttributes,
31
+ RefreshTokenClaims,
32
+ TokenStore,
33
+ UserClaimSet,
34
+ UserId,
35
+ )
36
+
37
+
38
+ class HasTokenStore(ABC):
39
+ def __init__(self, token_store: CanReadToken) -> None:
40
+ super().__init__()
41
+ self._token_store = token_store
42
+
43
+
44
+ class BearerTokenAuthBackend(HasTokenStore, AuthenticationBackend):
45
+ async def authenticate(
46
+ self,
47
+ conn: HTTPConnection,
48
+ ) -> Optional[tuple[AuthCredentials, BaseUser]]:
49
+ if header := conn.headers.get("Authorization"):
50
+ scheme, _, token = header.partition(" ")
51
+ if scheme.lower() != "bearer" or not token:
52
+ return None
53
+ elif access_token := conn.cookies.get(PHOENIX_ACCESS_TOKEN_COOKIE_NAME):
54
+ token = access_token
55
+ else:
56
+ return None
57
+ claims = await self._token_store.read(Token(token))
58
+ if not (isinstance(claims, UserClaimSet) and isinstance(claims.subject, UserId)):
59
+ return None
60
+ if not isinstance(claims, (ApiKeyClaims, AccessTokenClaims)):
61
+ return None
62
+ return AuthCredentials(), PhoenixUser(claims.subject, claims)
63
+
64
+
65
+ class PhoenixUser(BaseUser):
66
+ def __init__(self, user_id: UserId, claims: UserClaimSet) -> None:
67
+ self._user_id = user_id
68
+ self.claims = claims
69
+ assert claims.attributes
70
+ self._is_admin = (
71
+ claims.status is ClaimSetStatus.VALID
72
+ and claims.attributes.user_role == enums.UserRole.ADMIN
73
+ )
74
+
75
+ @cached_property
76
+ def is_admin(self) -> bool:
77
+ return self._is_admin
78
+
79
+ @cached_property
80
+ def identity(self) -> UserId:
81
+ return self._user_id
82
+
83
+ @cached_property
84
+ def is_authenticated(self) -> bool:
85
+ return True
86
+
87
+
88
+ class ApiKeyInterceptor(HasTokenStore, AsyncServerInterceptor):
89
+ async def intercept(
90
+ self,
91
+ method: Callable[[Any, grpc.ServicerContext], Awaitable[Any]],
92
+ request_or_iterator: Any,
93
+ context: grpc.ServicerContext,
94
+ method_name: str,
95
+ ) -> Any:
96
+ for datum in context.invocation_metadata():
97
+ if datum.key.lower() == "authorization":
98
+ scheme, _, token = datum.value.partition(" ")
99
+ if scheme.lower() != "bearer" or not token:
100
+ break
101
+ claims = await self._token_store.read(Token(token))
102
+ if not (isinstance(claims, UserClaimSet) and isinstance(claims.subject, UserId)):
103
+ break
104
+ if not isinstance(claims, (ApiKeyClaims, AccessTokenClaims)):
105
+ raise Unauthenticated(details="Invalid token")
106
+ if claims.status is ClaimSetStatus.EXPIRED:
107
+ raise Unauthenticated(details="Expired token")
108
+ if claims.status is ClaimSetStatus.VALID:
109
+ return await method(request_or_iterator, context)
110
+ raise Unauthenticated()
111
+ raise Unauthenticated()
112
+
113
+
114
+ async def is_authenticated(
115
+ # fastapi dependencies require non-optional types
116
+ request: Request = cast(Request, None),
117
+ websocket: WebSocket = cast(WebSocket, None),
118
+ ) -> None:
119
+ """
120
+ Raises a 401 if the request or websocket connection is not authenticated.
121
+ """
122
+ assert request or websocket
123
+ if request and not isinstance((user := request.user), PhoenixUser):
124
+ raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Invalid token")
125
+ if websocket and not isinstance((user := websocket.user), PhoenixUser):
126
+ raise WebSocketException(code=HTTP_401_UNAUTHORIZED, reason="Invalid token")
127
+ claims = user.claims
128
+ if claims.status is ClaimSetStatus.EXPIRED:
129
+ raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Expired token")
130
+ if claims.status is not ClaimSetStatus.VALID:
131
+ raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Invalid token")
132
+
133
+
134
+ async def create_access_and_refresh_tokens(
135
+ *,
136
+ token_store: TokenStore,
137
+ user: OrmUser,
138
+ access_token_expiry: timedelta,
139
+ refresh_token_expiry: timedelta,
140
+ ) -> tuple[AccessToken, RefreshToken]:
141
+ issued_at = datetime.now(timezone.utc)
142
+ user_id = UserId(user.id)
143
+ user_role = UserRole(user.role.name)
144
+ refresh_token_claims = RefreshTokenClaims(
145
+ subject=user_id,
146
+ issued_at=issued_at,
147
+ expiration_time=issued_at + refresh_token_expiry,
148
+ attributes=RefreshTokenAttributes(
149
+ user_role=user_role,
150
+ ),
151
+ )
152
+ refresh_token, refresh_token_id = await token_store.create_refresh_token(refresh_token_claims)
153
+ access_token_claims = AccessTokenClaims(
154
+ subject=user_id,
155
+ issued_at=issued_at,
156
+ expiration_time=issued_at + access_token_expiry,
157
+ attributes=AccessTokenAttributes(
158
+ user_role=user_role,
159
+ refresh_token_id=refresh_token_id,
160
+ ),
161
+ )
162
+ access_token, _ = await token_store.create_access_token(access_token_claims)
163
+ return access_token, refresh_token
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC
4
+ from dataclasses import dataclass, field
5
+ from typing import ClassVar
6
+
7
+ from phoenix.db import models
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class DmlEvent(ABC):
12
+ """
13
+ Event corresponding to a Data Manipulation Language (DML)
14
+ operation, e.g. insertion, update, or deletion.
15
+ """
16
+
17
+ table: ClassVar[type[models.Base]]
18
+ ids: tuple[int, ...] = field(default_factory=tuple)
19
+
20
+ def __bool__(self) -> bool:
21
+ return bool(self.ids)
22
+
23
+ def __hash__(self) -> int:
24
+ return id(self)
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ProjectDmlEvent(DmlEvent):
29
+ table = models.Project
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class ProjectDeleteEvent(ProjectDmlEvent): ...
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class SpanDmlEvent(ProjectDmlEvent): ...
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class SpanInsertEvent(SpanDmlEvent): ...
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class SpanDeleteEvent(SpanDmlEvent): ...
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class DatasetDmlEvent(DmlEvent):
50
+ table = models.Dataset
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class DatasetInsertEvent(DatasetDmlEvent): ...
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class DatasetDeleteEvent(DatasetDmlEvent): ...
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class ExperimentDmlEvent(DmlEvent):
63
+ table = models.Experiment
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class ExperimentInsertEvent(ExperimentDmlEvent): ...
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class ExperimentDeleteEvent(ExperimentDmlEvent): ...
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class ExperimentRunDmlEvent(DmlEvent):
76
+ table = models.ExperimentRun
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class ExperimentRunInsertEvent(ExperimentRunDmlEvent): ...
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class ExperimentRunDeleteEvent(ExperimentRunDmlEvent): ...
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class ExperimentRunAnnotationDmlEvent(DmlEvent):
89
+ table = models.ExperimentRunAnnotation
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class ExperimentRunAnnotationInsertEvent(ExperimentRunAnnotationDmlEvent): ...
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class ExperimentRunAnnotationDeleteEvent(ExperimentRunAnnotationDmlEvent): ...
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class SpanAnnotationDmlEvent(DmlEvent):
102
+ table = models.SpanAnnotation
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class SpanAnnotationInsertEvent(SpanAnnotationDmlEvent): ...
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class SpanAnnotationDeleteEvent(SpanAnnotationDmlEvent): ...
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class TraceAnnotationDmlEvent(DmlEvent):
115
+ table = models.TraceAnnotation
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class TraceAnnotationInsertEvent(TraceAnnotationDmlEvent): ...
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class TraceAnnotationDeleteEvent(TraceAnnotationDmlEvent): ...
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class DocumentAnnotationDmlEvent(DmlEvent):
128
+ table = models.DocumentAnnotation
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class DocumentAnnotationInsertEvent(DocumentAnnotationDmlEvent): ...
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class DocumentAnnotationDeleteEvent(DocumentAnnotationDmlEvent): ...
@@ -0,0 +1,256 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from asyncio import gather
5
+ from collections.abc import Callable, Iterable, Iterator, Mapping
6
+ from inspect import getmro
7
+ from itertools import chain
8
+ from typing import Any, Generic, Optional, TypedDict, TypeVar, Union, cast
9
+
10
+ from sqlalchemy import Select, select
11
+ from typing_extensions import TypeAlias, Unpack
12
+
13
+ from phoenix.db.models import (
14
+ Base,
15
+ DocumentAnnotation,
16
+ Project,
17
+ Span,
18
+ SpanAnnotation,
19
+ Trace,
20
+ TraceAnnotation,
21
+ )
22
+ from phoenix.server.api.dataloaders import CacheForDataLoaders
23
+ from phoenix.server.dml_event import (
24
+ DmlEvent,
25
+ DocumentAnnotationDmlEvent,
26
+ SpanAnnotationDmlEvent,
27
+ SpanDeleteEvent,
28
+ SpanDmlEvent,
29
+ TraceAnnotationDmlEvent,
30
+ )
31
+ from phoenix.server.types import (
32
+ BatchedCaller,
33
+ CanSetLastUpdatedAt,
34
+ DbSessionFactory,
35
+ )
36
+
37
+ _DmlEventT = TypeVar("_DmlEventT", bound=DmlEvent)
38
+
39
+
40
+ class _DmlEventQueue(Generic[_DmlEventT]):
41
+ def __init__(self, **kwargs: Any) -> None:
42
+ super().__init__(**kwargs)
43
+ self._events: set[_DmlEventT] = set()
44
+
45
+ @property
46
+ def empty(self) -> bool:
47
+ return not self._events
48
+
49
+ def put(self, event: _DmlEventT) -> None:
50
+ self._events.add(event)
51
+
52
+ def clear(self) -> None:
53
+ self._events.clear()
54
+
55
+ def __iter__(self) -> Iterator[_DmlEventT]:
56
+ yield from self._events
57
+
58
+
59
+ class _HandlerParams(TypedDict):
60
+ db: DbSessionFactory
61
+ last_updated_at: CanSetLastUpdatedAt
62
+ cache_for_dataloaders: Optional[CacheForDataLoaders]
63
+ sleep_seconds: float
64
+
65
+
66
+ class _HasLastUpdatedAt(ABC):
67
+ def __init__(
68
+ self,
69
+ last_updated_at: CanSetLastUpdatedAt,
70
+ **kwargs: Any,
71
+ ) -> None:
72
+ super().__init__(**kwargs)
73
+ self._last_updated_at = last_updated_at
74
+
75
+
76
+ class _HasCacheForDataLoaders(ABC):
77
+ def __init__(
78
+ self,
79
+ cache_for_dataloaders: Optional[CacheForDataLoaders] = None,
80
+ **kwargs: Any,
81
+ ) -> None:
82
+ super().__init__(**kwargs)
83
+ self._cache_for_dataloaders = cache_for_dataloaders
84
+
85
+
86
+ class _DmlEventHandler(
87
+ _HasLastUpdatedAt,
88
+ _HasCacheForDataLoaders,
89
+ BatchedCaller[_DmlEventT],
90
+ Generic[_DmlEventT],
91
+ ABC,
92
+ ):
93
+ _batch_factory = cast(Callable[[], _DmlEventQueue[_DmlEventT]], _DmlEventQueue)
94
+
95
+ def __init__(self, *, db: DbSessionFactory, **kwargs: Any) -> None:
96
+ super().__init__(**kwargs)
97
+ self._db = db
98
+
99
+ def __hash__(self) -> int:
100
+ return id(self)
101
+
102
+
103
+ class _GenericDmlEventHandler(_DmlEventHandler[DmlEvent]):
104
+ async def __call__(self) -> None:
105
+ for e in self._batch:
106
+ for id_ in e.ids:
107
+ self._update(e.table, id_)
108
+
109
+ def _update(self, table: type[Base], id_: int) -> None:
110
+ self._last_updated_at.set(table, id_)
111
+
112
+
113
+ class _SpanDmlEventHandler(_DmlEventHandler[SpanDmlEvent]):
114
+ async def __call__(self) -> None:
115
+ if cache := self._cache_for_dataloaders:
116
+ for id_ in set(chain.from_iterable(e.ids for e in self._batch)):
117
+ self._clear(cache, id_)
118
+
119
+ @staticmethod
120
+ def _clear(cache: CacheForDataLoaders, project_id: int) -> None:
121
+ cache.latency_ms_quantile.invalidate(project_id)
122
+ cache.token_count.invalidate(project_id)
123
+ cache.record_count.invalidate(project_id)
124
+ cache.min_start_or_max_end_time.invalidate(project_id)
125
+
126
+
127
+ class _SpanDeleteEventHandler(_SpanDmlEventHandler):
128
+ @staticmethod
129
+ def _clear(cache: CacheForDataLoaders, project_id: int) -> None:
130
+ cache.annotation_summary.invalidate_project(project_id)
131
+ cache.document_evaluation_summary.invalidate_project(project_id)
132
+
133
+
134
+ _AnnotationTable: TypeAlias = Union[
135
+ type[SpanAnnotation],
136
+ type[TraceAnnotation],
137
+ type[DocumentAnnotation],
138
+ ]
139
+
140
+ _AnnotationDmlEventT = TypeVar(
141
+ "_AnnotationDmlEventT",
142
+ SpanAnnotationDmlEvent,
143
+ TraceAnnotationDmlEvent,
144
+ DocumentAnnotationDmlEvent,
145
+ )
146
+
147
+
148
+ class _AnnotationDmlEventHandler(
149
+ _DmlEventHandler[_AnnotationDmlEventT],
150
+ Generic[_AnnotationDmlEventT],
151
+ ABC,
152
+ ):
153
+ _table: _AnnotationTable
154
+ _base_stmt: Union[Select[tuple[int, str]], Select[tuple[int]]] = (
155
+ select(Project.id).join_from(Project, Trace).distinct()
156
+ )
157
+
158
+ def __init__(self, **kwargs: Unpack[_HandlerParams]) -> None:
159
+ super().__init__(**kwargs)
160
+ self._stmt = self._base_stmt
161
+ if self._cache_for_dataloaders:
162
+ self._stmt = self._stmt.add_columns(self._table.name)
163
+
164
+ def _get_stmt(self) -> Union[Select[tuple[int, str]], Select[tuple[int]]]:
165
+ ids = set(chain.from_iterable(e.ids for e in self._batch))
166
+ return self._stmt.where(self._table.id.in_(ids))
167
+
168
+ @staticmethod
169
+ @abstractmethod
170
+ def _clear(cache: CacheForDataLoaders, project_id: int, name: str) -> None: ...
171
+
172
+ async def __call__(self) -> None:
173
+ async with self._db() as session:
174
+ async for row in await session.stream(self._get_stmt()):
175
+ self._last_updated_at.set(Project, row.id)
176
+ if cache := self._cache_for_dataloaders:
177
+ self._clear(cache, row.id, row.name)
178
+
179
+
180
+ class _SpanAnnotationDmlEventHandler(_AnnotationDmlEventHandler[SpanAnnotationDmlEvent]):
181
+ _table = SpanAnnotation
182
+
183
+ def __init__(self, **kwargs: Unpack[_HandlerParams]) -> None:
184
+ super().__init__(**kwargs)
185
+ self._stmt = self._stmt.join_from(Trace, Span).join_from(Span, self._table)
186
+
187
+ @staticmethod
188
+ def _clear(cache: CacheForDataLoaders, project_id: int, name: str) -> None:
189
+ cache.annotation_summary.invalidate((project_id, name, "span"))
190
+
191
+
192
+ class _TraceAnnotationDmlEventHandler(_AnnotationDmlEventHandler[TraceAnnotationDmlEvent]):
193
+ _table = TraceAnnotation
194
+
195
+ def __init__(self, **kwargs: Unpack[_HandlerParams]) -> None:
196
+ super().__init__(**kwargs)
197
+ self._stmt = self._stmt.join_from(Trace, self._table)
198
+
199
+ @staticmethod
200
+ def _clear(cache: CacheForDataLoaders, project_id: int, name: str) -> None:
201
+ cache.annotation_summary.invalidate((project_id, name, "trace"))
202
+
203
+
204
+ class _DocumentAnnotationDmlEventHandler(_AnnotationDmlEventHandler[DocumentAnnotationDmlEvent]):
205
+ _table = DocumentAnnotation
206
+
207
+ def __init__(self, **kwargs: Unpack[_HandlerParams]) -> None:
208
+ super().__init__(**kwargs)
209
+ self._stmt = self._stmt.join_from(Trace, Span).join_from(Span, self._table)
210
+
211
+ @staticmethod
212
+ def _clear(cache: CacheForDataLoaders, project_id: int, name: str) -> None:
213
+ cache.document_evaluation_summary.invalidate((project_id, name))
214
+
215
+
216
+ class DmlEventHandler:
217
+ def __init__(
218
+ self,
219
+ *,
220
+ db: DbSessionFactory,
221
+ last_updated_at: CanSetLastUpdatedAt,
222
+ cache_for_dataloaders: Optional[CacheForDataLoaders] = None,
223
+ sleep_seconds: float = 0.1,
224
+ ) -> None:
225
+ kwargs = _HandlerParams(
226
+ db=db,
227
+ last_updated_at=last_updated_at,
228
+ cache_for_dataloaders=cache_for_dataloaders,
229
+ sleep_seconds=sleep_seconds,
230
+ )
231
+ self._handlers: Mapping[type[DmlEvent], Iterable[_DmlEventHandler[Any]]] = {
232
+ DmlEvent: [_GenericDmlEventHandler(**kwargs)],
233
+ SpanDmlEvent: [_SpanDmlEventHandler(**kwargs)],
234
+ SpanDeleteEvent: [_SpanDeleteEventHandler(**kwargs)],
235
+ SpanAnnotationDmlEvent: [_SpanAnnotationDmlEventHandler(**kwargs)],
236
+ TraceAnnotationDmlEvent: [_TraceAnnotationDmlEventHandler(**kwargs)],
237
+ DocumentAnnotationDmlEvent: [_DocumentAnnotationDmlEventHandler(**kwargs)],
238
+ }
239
+ self._all_handlers = frozenset(chain.from_iterable(self._handlers.values()))
240
+
241
+ async def __aenter__(self) -> None:
242
+ await gather(*(h.start() for h in self._all_handlers))
243
+
244
+ async def __aexit__(self, *args: Any, **kwargs: Any) -> None:
245
+ await gather(*(h.stop() for h in self._all_handlers))
246
+
247
+ def put(self, event: DmlEvent) -> None:
248
+ if not (isinstance(event, DmlEvent) and event):
249
+ return
250
+ for cls in getmro(type(event)):
251
+ if not (issubclass(cls, DmlEvent) and (handlers := self._handlers.get(cls))):
252
+ continue
253
+ for h in handlers:
254
+ h.put(event)
255
+ if cls is DmlEvent:
256
+ break
File without changes
@@ -0,0 +1,97 @@
1
+ import asyncio
2
+ import smtplib
3
+ import ssl
4
+ from email.message import EmailMessage
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
9
+
10
+ EMAIL_TEMPLATE_FOLDER = Path(__file__).parent / "templates"
11
+
12
+
13
+ class SimpleEmailSender:
14
+ def __init__(
15
+ self,
16
+ smtp_server: str,
17
+ smtp_port: int,
18
+ username: str,
19
+ password: str,
20
+ sender_email: str,
21
+ connection_method: Literal["STARTTLS", "SSL", "PLAIN"] = "STARTTLS",
22
+ validate_certs: bool = True,
23
+ ) -> None:
24
+ self.smtp_server = smtp_server
25
+ self.smtp_port = smtp_port
26
+ self.username = username
27
+ self.password = password
28
+ self.sender_email = sender_email
29
+ self.connection_method = connection_method.upper()
30
+ self.validate_certs = validate_certs
31
+
32
+ self.env = Environment(
33
+ loader=FileSystemLoader(EMAIL_TEMPLATE_FOLDER),
34
+ autoescape=select_autoescape(["html", "xml"]),
35
+ )
36
+
37
+ async def send_password_reset_email(
38
+ self,
39
+ email: str,
40
+ reset_url: str,
41
+ ) -> None:
42
+ subject = "[Phoenix] Password Reset Request"
43
+ template_name = "password_reset.html"
44
+
45
+ template = self.env.get_template(template_name)
46
+ html_content = template.render(reset_url=reset_url)
47
+
48
+ msg = EmailMessage()
49
+ msg["Subject"] = subject
50
+ msg["From"] = self.sender_email
51
+ msg["To"] = email
52
+ msg.set_content(html_content, subtype="html")
53
+
54
+ def send_email() -> None:
55
+ context: ssl.SSLContext
56
+ if self.validate_certs:
57
+ context = ssl.create_default_context()
58
+ else:
59
+ context = ssl._create_unverified_context()
60
+
61
+ methods_to_try = [self.connection_method]
62
+ # add secure method fallbacks
63
+ if self.connection_method != "PLAIN":
64
+ if self.connection_method != "STARTTLS":
65
+ methods_to_try.append("STARTTLS")
66
+ elif self.connection_method != "SSL":
67
+ methods_to_try.append("SSL")
68
+
69
+ for method in methods_to_try:
70
+ try:
71
+ if method == "STARTTLS":
72
+ server = smtplib.SMTP(self.smtp_server, self.smtp_port)
73
+ server.ehlo()
74
+ server.starttls(context=context)
75
+ server.ehlo()
76
+ elif method == "SSL":
77
+ server = smtplib.SMTP_SSL(self.smtp_server, self.smtp_port, context=context)
78
+ server.ehlo()
79
+ elif method == "PLAIN":
80
+ server = smtplib.SMTP(self.smtp_server, self.smtp_port)
81
+ server.ehlo()
82
+ else:
83
+ continue # Unsupported method
84
+
85
+ if self.username and self.password:
86
+ server.login(self.username, self.password)
87
+
88
+ server.send_message(msg)
89
+ server.quit()
90
+ break # Success
91
+ except Exception as e:
92
+ print(f"Failed to send email using {method}: {e}")
93
+ continue
94
+ else:
95
+ raise Exception("All connection methods failed")
96
+
97
+ await asyncio.to_thread(send_email)
File without changes
@@ -0,0 +1,19 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>Password Reset</title>
6
+ </head>
7
+ <body>
8
+ <p>Hello.</p>
9
+ <p>
10
+ You have requested a password reset. Please click on the link below to
11
+ reset your password:
12
+ </p>
13
+ <p>
14
+ <a id="reset-url" href="{{ reset_url }}">Reset Password</a
15
+ >
16
+ </p>
17
+ <p>If you did not make this request, please contact your administrator.</p>
18
+ </body>
19
+ </html>
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol
4
+
5
+
6
+ class EmailSender(Protocol):
7
+ async def send_password_reset_email(
8
+ self,
9
+ email: str,
10
+ reset_url: str,
11
+ ) -> None: ...