Skip to content
Permalink

Comparing changes

This is a direct comparison between two commits made in this repository or its related repositories. View the default comparison for this range or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: RobertCraigie/prisma-client-py
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: fdfa2fc553a148bfdcb8bab81b5e5e73b8b2658a
Choose a base ref
..
head repository: RobertCraigie/prisma-client-py
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: 1b16ec4d0f1c45ded09dc5b067eb360a94c6a5e6
Choose a head ref
Showing with 684 additions and 43 deletions.
  1. +1 −1 .github/workflows/lint-pr.yml
  2. +5 −2 .readthedocs.yaml
  3. +1 −1 README.md
  4. +3 −0 databases/sync_tests/test_errors.py
  5. +55 −0 databases/sync_tests/test_metrics.py
  6. +14 −10 databases/templates/schema.prisma.jinja2
  7. +3 −0 databases/tests/test_errors.py
  8. +61 −0 databases/tests/test_metrics.py
  9. +1 −1 docs/index.md
  10. +1 −1 docs/reference/binaries.md
  11. +2 −2 docs/reference/config.md
  12. +38 −0 docs/reference/metrics.md
  13. +1 −0 mkdocs.yml
  14. +6 −1 src/prisma/__init__.py
  15. +4 −0 src/prisma/_async_http.py
  16. +2 −2 src/prisma/_config.py
  17. +50 −0 src/prisma/_metrics.py
  18. +4 −0 src/prisma/_sync_http.py
  19. +3 −0 src/prisma/generator/models.py
  20. +40 −1 src/prisma/generator/templates/client.py.jinja
  21. +30 −1 src/prisma/generator/templates/engine/abstract.py.jinja
  22. +19 −2 src/prisma/generator/templates/engine/http.py.jinja
  23. +50 −3 src/prisma/generator/templates/engine/query.py.jinja
  24. +2 −0 src/prisma/generator/templates/types.py.jinja
  25. +6 −1 src/prisma/http_abstract.py
  26. +40 −1 tests/test_generation/exhaustive/__snapshots__/test_exhaustive/test_async[client.py].raw
  27. +30 −1 tests/test_generation/exhaustive/__snapshots__/test_exhaustive/test_async[engineabstract.py].raw
  28. +19 −2 tests/test_generation/exhaustive/__snapshots__/test_exhaustive/test_async[enginehttp.py].raw
  29. +50 −3 tests/test_generation/exhaustive/__snapshots__/test_exhaustive/test_async[enginequery.py].raw
  30. +2 −0 tests/test_generation/exhaustive/__snapshots__/test_exhaustive/test_async[types.py].raw
  31. +40 −1 tests/test_generation/exhaustive/__snapshots__/test_exhaustive/test_sync[client.py].raw
  32. +30 −1 tests/test_generation/exhaustive/__snapshots__/test_exhaustive/test_sync[engineabstract.py].raw
  33. +19 −2 tests/test_generation/exhaustive/__snapshots__/test_exhaustive/test_sync[enginehttp.py].raw
  34. +50 −3 tests/test_generation/exhaustive/__snapshots__/test_exhaustive/test_sync[enginequery.py].raw
  35. +2 −0 tests/test_generation/exhaustive/__snapshots__/test_exhaustive/test_sync[types.py].raw
2 changes: 1 addition & 1 deletion .github/workflows/lint-pr.yml
Original file line number Diff line number Diff line change
@@ -9,6 +9,6 @@ jobs:
enforce-semantic-title:
runs-on: ubuntu-22.04
steps:
- uses: amannn/action-semantic-pull-request@v5.3.0
- uses: amannn/action-semantic-pull-request@v5.4.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
7 changes: 5 additions & 2 deletions .readthedocs.yaml
Original file line number Diff line number Diff line change
@@ -2,13 +2,16 @@
# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details

# Required
version: 2

build:
os: ubuntu-22.04
tools:
python: "3.11"

mkdocs:
configuration: mkdocs.yml

python:
version: "3.8"
install:
- requirements: pipelines/requirements/docs.txt
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@
<img src="https://img.shields.io/discord/933860922039099444?color=blue&label=chat&logo=discord" alt="Chat on Discord">
</a>
<a href="https://prisma.io">
<img src="https://img.shields.io/static/v1?label=prisma&message=4.15.0&color=blue&logo=prisma" alt="Supported Prisma version is 4.15.0">
<img src="https://img.shields.io/static/v1?label=prisma&message=5.4.2&color=blue&logo=prisma" alt="Supported Prisma version is 5.4.2">
</a>
<a href="https://github.com/grantjenks/blue">
<img src="https://camo.githubusercontent.com/dbdbcf26db37abfa1f2ab7e6c28c8b3a199f2dad98e4ef53a50e9c45c7e4ace8/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f636f64652532307374796c652d626c75652d626c75652e737667" alt="Code style: blue">
3 changes: 3 additions & 0 deletions databases/sync_tests/test_errors.py
Original file line number Diff line number Diff line change
@@ -5,6 +5,9 @@
from prisma.errors import FieldNotFoundError, ForeignKeyViolationError


@pytest.mark.xfail(
reason='This was broken in v5, we now raise a different error - nobody should be relying on this behaviour and its tricky to fix'
)
def test_field_not_found_error(client: Prisma) -> None:
"""The FieldNotFoundError is raised when an unknown field is passed to
both queries and mutations.
55 changes: 55 additions & 0 deletions databases/sync_tests/test_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from prisma import Prisma
from prisma._compat import model_json


def test_prometheus(client: Prisma) -> None:
"""Metrics can be returned in Prometheus format"""
client.user.create(data={'name': 'Robert'})

metrics = client.get_metrics(format='prometheus')

assert 'prisma_client_queries_total' in metrics
assert 'prisma_datasource_queries_total' in metrics
assert 'prisma_client_queries_active' in metrics
assert 'prisma_client_queries_duration_histogram_ms_bucket' in metrics


def test_json_string(client: Prisma) -> None:
"""Metrics can be serlialized to JSON"""
client.user.create(data={'name': 'Robert'})

metrics = client.get_metrics()
assert isinstance(model_json(metrics), str)


def test_json(client: Prisma) -> None:
"""Metrics returned in the JSON format"""
client.user.create(data={'name': 'Robert'})

metrics = client.get_metrics(format='json')

assert len(metrics.counters) > 0
assert metrics.counters[0].value > 0

assert len(metrics.gauges) > 0
gauge = next(
filter(
lambda g: g.key == 'prisma_pool_connections_open', metrics.gauges
)
)
assert gauge.value > 0

assert len(metrics.histograms) > 0
assert metrics.histograms[0].value.sum > 0
assert metrics.histograms[0].value.count > 0

assert len(metrics.histograms[0].value.buckets) > 0

for bucket in metrics.histograms[0].value.buckets:
assert bucket.max_value >= 0
assert bucket.total_count >= 0


def test_global_labels(client: Prisma) -> None:
metrics = client.get_metrics(global_labels={'foo': 'bar'})
assert metrics.counters[0].labels == {'foo': 'bar'}
24 changes: 14 additions & 10 deletions databases/templates/schema.prisma.jinja2
Original file line number Diff line number Diff line change
@@ -39,20 +39,20 @@ model Profile {
}

model Post {
id String @id @default(uuid())
created_at DateTime @default(now())
updated_at DateTime @updatedAt
id String @id @default(uuid())
created_at DateTime @default(now())
updated_at DateTime @updatedAt
title String
description String?
published Boolean @default(false)
views Int @default(0)
author User? @relation(fields: [author_id], references: [id])
published Boolean @default(false)
views Int @default(0)
author User? @relation(fields: [author_id], references: [id])
author_id String?
categories Category[]
categories Category[]
}

model Category {
id String @id @default(uuid())
id String @id @default(uuid())
posts Post[]
name String
}
@@ -80,10 +80,10 @@ model Types {

// TODO: optional for these too
{% if config.supports_feature('enum') %}
enum Role @default(USER)
enum Role @default(USER)
{% endif %}
{% if config.supports_feature('json') %}
json_obj Json? @default("{}")
json_obj Json? @default("{}")
{% endif %}
}

@@ -123,6 +123,7 @@ model ListsDefaults {
roles Role[] @default([USER])
{% endif %}
}

{% endif %}

// these models are here for testing different combinations of unique constraints
@@ -173,6 +174,7 @@ model Unique6 {

@@unique([name, role])
}

{% endif %}

model Id1 {
@@ -211,6 +213,7 @@ model Id5 {

@@unique([name, role])
}

{% endif %}

{% if config.supports_feature('enum') %}
@@ -219,4 +222,5 @@ enum Role {
ADMIN
EDITOR
}

{% endif %}
3 changes: 3 additions & 0 deletions databases/tests/test_errors.py
Original file line number Diff line number Diff line change
@@ -6,6 +6,9 @@
from prisma.errors import FieldNotFoundError, ForeignKeyViolationError


@pytest.mark.xfail(
reason='This was broken in v5, we now raise a different error - nobody should be relying on this behaviour and its tricky to fix'
)
@pytest.mark.asyncio
async def test_field_not_found_error(client: Prisma) -> None:
"""The FieldNotFoundError is raised when an unknown field is passed to
61 changes: 61 additions & 0 deletions databases/tests/test_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import pytest

from prisma import Prisma
from prisma._compat import model_json


@pytest.mark.asyncio
async def test_prometheus(client: Prisma) -> None:
"""Metrics can be returned in Prometheus format"""
await client.user.create(data={'name': 'Robert'})

metrics = await client.get_metrics(format='prometheus')

assert 'prisma_client_queries_total' in metrics
assert 'prisma_datasource_queries_total' in metrics
assert 'prisma_client_queries_active' in metrics
assert 'prisma_client_queries_duration_histogram_ms_bucket' in metrics


@pytest.mark.asyncio
async def test_json_string(client: Prisma) -> None:
"""Metrics can be serlialized to JSON"""
await client.user.create(data={'name': 'Robert'})

metrics = await client.get_metrics()
assert isinstance(model_json(metrics), str)


@pytest.mark.asyncio
async def test_json(client: Prisma) -> None:
"""Metrics returned in the JSON format"""
await client.user.create(data={'name': 'Robert'})

metrics = await client.get_metrics(format='json')

assert len(metrics.counters) > 0
assert metrics.counters[0].value > 0

assert len(metrics.gauges) > 0
gauge = next(
filter(
lambda g: g.key == 'prisma_pool_connections_open', metrics.gauges
)
)
assert gauge.value > 0

assert len(metrics.histograms) > 0
assert metrics.histograms[0].value.sum > 0
assert metrics.histograms[0].value.count > 0

assert len(metrics.histograms[0].value.buckets) > 0

for bucket in metrics.histograms[0].value.buckets:
assert bucket.max_value >= 0
assert bucket.total_count >= 0


@pytest.mark.asyncio
async def test_global_labels(client: Prisma) -> None:
metrics = await client.get_metrics(global_labels={'foo': 'bar'})
assert metrics.counters[0].labels == {'foo': 'bar'}
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@
<img src="https://img.shields.io/discord/933860922039099444?color=blue&label=chat&logo=discord" alt="Chat on Discord">
</a>
<a href="https://prisma.io">
<img src="https://img.shields.io/static/v1?label=prisma&message=4.15.0&color=blue&logo=prisma" alt="Supported Prisma version is 4.15.0">
<img src="https://img.shields.io/static/v1?label=prisma&message=5.4.2&color=blue&logo=prisma" alt="Supported Prisma version is 5.4.2">
</a>
<a href="https://github.com/grantjenks/blue">
<img src="https://camo.githubusercontent.com/dbdbcf26db37abfa1f2ab7e6c28c8b3a199f2dad98e4ef53a50e9c45c7e4ace8/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f636f64652532307374796c652d626c75652d626c75652e737667" alt="Code style: blue">
2 changes: 1 addition & 1 deletion docs/reference/binaries.md
Original file line number Diff line number Diff line change
@@ -9,7 +9,7 @@ Prisma Client Python _should_ automatically download the correct binaries for yo
- Clone the prisma-engines repository at the current version that the python client supports:

```
git clone https://github.com/prisma/prisma-engines --branch=4.15.0
git clone https://github.com/prisma/prisma-engines --branch=5.4.2
```

- Build the binaries following the steps found [here](https://github.com/prisma/prisma-engines#building-prisma-engines)
4 changes: 2 additions & 2 deletions docs/reference/config.md
Original file line number Diff line number Diff line change
@@ -220,15 +220,15 @@ This option controls the version of Prisma to use. It should be noted that this

| Option | Environment Variable | Default |
| ---------------- | --------------------- | -------- |
| `prisma_version` | `PRISMA_VERSION` | `4.15.0` |
| `prisma_version` | `PRISMA_VERSION` | `5.4.2` |

### Expected Engine Version

This is an internal option that is here as a safeguard for the `prisma_version` option. If you modify the `prisma_version` option then you must also update this option to use the corresponding engine version. You can find a list of engine versions [here](https://github.com/prisma/prisma-engines).

| Option | Environment Variable | Default |
| ------------------------- | -------------------------------- | ------------------------------------------ |
| `expected_engine_version` | `PRISMA_EXPECTED_ENGINE_VERSION` | `8fbc245156db7124f997f4cecdd8d1219e360944` |
| `expected_engine_version` | `PRISMA_EXPECTED_ENGINE_VERSION` | `ac9d7041ed77bcc8a8dbd2ab6616b39013829574` |


### Binary Platform
38 changes: 38 additions & 0 deletions docs/reference/metrics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Metrics

Prisma Metrics gives you detailed insight into how Prisma is interactting with your Database. For more detailed information see the [Prisma Documentation](https://www.prisma.io/docs/concepts/components/prisma-client/metrics).

To access metrics in the Python client

Metrics can be accessed in the Python client through the `Prisma.get_metrics()` method. Two different formats are available, `json` and `prometheus`.

## JSON Format

The default format is `json` which returns a `prisma.Metrics` instance:

```py
from prisma import Prisma

client = Prisma()

metrics = client.get_metrics()
print(metrics.counters[0])
```

See the [Prisma Documentation](https://www.prisma.io/docs/concepts/components/prisma-client/metrics#retrieve-metrics-in-json-format) for more details on the structure of the metrics object.

## Prometheus Format

The `prometheus` format returns a `str` which is a valid [Prometheus data](https://prometheus.io/).


```py
from prisma import Prisma

client = Prisma()

metrics = client.get_metrics(format='prometheus')
print(metrics)
```

See the [Prisma Documentation](https://www.prisma.io/docs/concepts/components/prisma-client/metrics#retrieve-metrics-in-prometheus-format) for more details on the structure of the data.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
@@ -74,6 +74,7 @@ nav:
- Custom Generators: reference/custom-generators.md
- Logging: reference/logging.md
- Binaries: reference/binaries.md
- Metrics: reference/metrics.md
- Contributing:
- How to Contribute: contributing/contributing.md
- Architecture: contributing/architecture.md
7 changes: 6 additions & 1 deletion src/prisma/__init__.py
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@
__author__ = 'RobertCraigie'
__license__ = 'APACHE'
__copyright__ = 'Copyright 2020-2023 RobertCraigie'
__version__ = '0.10.1a'
__version__ = '0.11.0'

from typing import TYPE_CHECKING

@@ -13,6 +13,11 @@
from . import errors as errors
from .validator import *
from ._types import PrismaMethod as PrismaMethod
from ._metrics import (
Metric as Metric,
Metrics as Metrics,
MetricHistogram as MetricHistogram,
)


try:
4 changes: 4 additions & 0 deletions src/prisma/_async_http.py
Original file line number Diff line number Diff line change
@@ -50,6 +50,10 @@ class Response(AbstractResponse[httpx.Response]):
def status(self) -> int:
return self.original.status_code

@property
def headers(self) -> httpx.Headers:
return self.original.headers

async def json(self, **kwargs: Any) -> Any:
return json.loads(await self.original.aread(), **kwargs)

4 changes: 2 additions & 2 deletions src/prisma/_config.py
Original file line number Diff line number Diff line change
@@ -24,13 +24,13 @@ class DefaultConfig(BaseSettings):
# doesn't change then the CLI is incorrectly cached
prisma_version: str = Field(
env='PRISMA_VERSION',
default='4.15.0',
default='5.4.2',
)

# Engine binary versions can be found under https://github.com/prisma/prisma-engine/commits/main
expected_engine_version: str = Field(
env='PRISMA_EXPECTED_ENGINE_VERSION',
default='8fbc245156db7124f997f4cecdd8d1219e360944',
default='ac9d7041ed77bcc8a8dbd2ab6616b39013829574',
)

# Home directory, used to build the `binary_cache_dir` option by default, useful in multi-user
50 changes: 50 additions & 0 deletions src/prisma/_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# copied from https://github.com/prisma/prisma/blob/23d5ef0672372035a84552b6b457197ca19f486d/packages/client/src/runtime/core/engines/common/types/Metrics.ts
from __future__ import annotations

from typing import Generic, List, TypeVar, Dict, NamedTuple

from pydantic import BaseModel

from ._compat import GenericModel, model_rebuild


__all__ = (
'Metrics',
'Metric',
'MetricHistogram',
)


_T = TypeVar('_T')


# TODO: check if int / float is right


class Metrics(BaseModel):
counters: List[Metric[int]]
gauges: List[Metric[float]]
histograms: List[Metric[MetricHistogram]]


class Metric(GenericModel, Generic[_T]):
key: str
value: _T
labels: Dict[str, str]
description: str


class MetricHistogram(BaseModel):
sum: float
count: int
buckets: List[HistogramBucket]


class HistogramBucket(NamedTuple):
max_value: float
total_count: int


model_rebuild(Metric)
model_rebuild(Metrics)
model_rebuild(MetricHistogram)
4 changes: 4 additions & 0 deletions src/prisma/_sync_http.py
Original file line number Diff line number Diff line change
@@ -46,6 +46,10 @@ class Response(AbstractResponse[httpx.Response]):
def status(self) -> int:
return self.original.status_code

@property
def headers(self) -> httpx.Headers:
return self.original.headers

def json(self, **kwargs: Any) -> Any:
return self.original.json(**kwargs)

3 changes: 3 additions & 0 deletions src/prisma/generator/models.py
Original file line number Diff line number Diff line change
@@ -480,6 +480,9 @@ def warn_binary_targets(

return targets

def has_preview_feature(self, feature: str) -> bool:
return feature in self.preview_features


class ValueFromEnvVar(BaseModel):
value: str
41 changes: 40 additions & 1 deletion src/prisma/generator/templates/client.py.jinja
Original file line number Diff line number Diff line change
@@ -11,7 +11,7 @@ from types import TracebackType
from pydantic import BaseModel

from . import types, models, errors, actions
from .types import DatasourceOverride, HttpConfig
from .types import DatasourceOverride, HttpConfig, MetricsFormat
from ._types import BaseModelT, PrismaMethod
from .bases import _PrismaModel
from .engine import AbstractEngine, QueryEngine, TransactionId
@@ -20,6 +20,7 @@ from .generator.models import EngineType, OptionalValueFromEnvVar, BinaryPaths
from ._compat import removeprefix, model_parse
from ._constants import DEFAULT_CONNECT_TIMEOUT, DEFAULT_TX_MAX_WAIT, DEFAULT_TX_TIMEOUT
from ._raw_query import deserialize_raw_results
from ._metrics import Metrics

__all__ = (
'ENGINE_TYPE',
@@ -423,6 +424,44 @@ class Prisma:
"""Returns True if the client is wrapped within a transaction"""
return self._tx_id is not None

@overload
{{ maybe_async_def }}get_metrics(
self,
format: Literal['json'] = 'json',
*,
global_labels: dict[str, str] | None = None,
) -> Metrics:
...

@overload
{{ maybe_async_def }}get_metrics(
self,
format: Literal['prometheus'],
*,
global_labels: dict[str, str] | None = None,
) -> str:
...

{{ maybe_async_def }}get_metrics(
self,
format: MetricsFormat = 'json',
*,
global_labels: dict[str, str] | None = None,
) -> str | Metrics:
"""Metrics give you a detailed insight into how the Prisma Client interacts with your database.

You can retrieve metrics in either JSON or Prometheus formats.

For more details see https://www.prisma.io/docs/concepts/components/prisma-client/metrics.
"""
response = {{ maybe_await }}self._engine.metrics(format=format, global_labels=global_labels)
if format == 'prometheus':
# For the prometheus format we return the response as-is
assert isinstance(response, str)
return response

return model_parse(Metrics, response)

# TODO: don't return Any
{{ maybe_async_def }}_execute(
self,
31 changes: 30 additions & 1 deletion src/prisma/generator/templates/engine/abstract.py.jinja
Original file line number Diff line number Diff line change
@@ -5,7 +5,7 @@
from abc import ABC, abstractmethod
from datetime import timedelta
from ._types import TransactionId
from ..types import DatasourceOverride
from ..types import DatasourceOverride, MetricsFormat
from .._compat import get_running_loop
from .._constants import DEFAULT_CONNECT_TIMEOUT

@@ -77,3 +77,32 @@ class AbstractEngine(ABC):
{{ maybe_async_def }}rollback_transaction(self, tx_id: TransactionId) -> None:
"""Rollback an interactive transaction, the given transaction will no longer be usable"""
...

@overload
@abstractmethod
{{ maybe_async_def }}metrics(
self,
*,
format: Literal['json'],
global_labels: dict[str, str] | None,
) -> dict[str, Any]:
...

@overload
@abstractmethod
{{ maybe_async_def }}metrics(
self,
*,
format: Literal['prometheus'],
global_labels: dict[str, str] | None,
) -> str:
...

@abstractmethod
{{ maybe_async_def }}metrics(
self,
*,
format: MetricsFormat,
global_labels: dict[str, str] | None,
) -> str | dict[str, Any]:
...
21 changes: 19 additions & 2 deletions src/prisma/generator/templates/engine/http.py.jinja
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@
{% from '_utils.py.jinja' import sleep, is_async, maybe_async_def, maybe_await with context %}
# -- template engine/http.py.jinja --

import json
import logging
from datetime import timedelta

@@ -53,25 +54,28 @@ class HTTPEngine(AbstractEngine):
if self.session and not self.session.closed:
{{ maybe_await }}self.session.close()

# TODO: improve return types
{{ maybe_async_def }}request(
self,
method: Method,
path: str,
*,
content: Any = None,
headers: Optional[Dict[str, str]] = None,
parse_response: bool = True,
) -> Any:
if self.url is None:
raise errors.NotConnectedError('Not connected to the query engine')

kwargs = {
'headers': {
'Content-Type': 'application/json',
'Accept': 'application/json',
**self.headers,
}
}

if parse_response:
kwargs['headers']['Accept'] = 'application/json'

if headers is not None:
kwargs['headers'].update(headers)

@@ -87,9 +91,22 @@ class HTTPEngine(AbstractEngine):
log.debug('%s %s returned status %s', method, url, resp.status)

if 300 > resp.status >= 200:
# In certain cases we just want to return the response content as-is.
#
# This is useful for metrics which can be returned in a Prometheus format
# which is incompatible with JSON.
if not parse_response:
text = {{ maybe_await }}resp.text()
log.debug('%s %s returned text: %s', method, url, text)
return text

response = {{ maybe_await }}resp.json()
log.debug('%s %s returned %s', method, url, response)

if isinstance(response, str):
# workaround for https://github.com/prisma/prisma-engines/pull/4246
response = json.loads(response)

errors_data = response.get('errors')
if errors_data:
return utils.handle_response_errors(resp, errors_data)
53 changes: 50 additions & 3 deletions src/prisma/generator/templates/engine/query.py.jinja
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@
# -- template engine/query.py.jinja --

import os
import json
import time
import atexit
import signal
@@ -19,7 +20,7 @@ from .. import config
from ..utils import DEBUG
from ..binaries import platform
from ..utils import time_since, _env_bool
from ..types import DatasourceOverride
from ..types import DatasourceOverride, MetricsFormat
from ..builder import dumps
from .._constants import DEFAULT_CONNECT_TIMEOUT
from ._types import TransactionId
@@ -136,7 +137,13 @@ class QueryEngine(HTTPEngine):
if self._log_queries:
env.update(LOG_QUERIES='y')

args: List[str] = [str(file.absolute()), '-p', str(port), '--enable-raw-queries']
args: List[str] = [
str(file.absolute()),
'-p',
str(port),
'--enable-metrics',
'--enable-raw-queries',
]
if _env_bool('__PRISMA_PY_PLAYGROUND'):
env.update(RUST_LOG='info')
args.append('--enable-playground')
@@ -165,10 +172,14 @@ class QueryEngine(HTTPEngine):
try:
data = {{ maybe_await }}self.request('GET', '/status')
except Exception as exc:
# TODO(someday): only retry on ConnectionError
if isinstance(exc, AttributeError):
raise

last_exc = exc
log.debug(
'Could not connect to query engine due to %s; retrying...',
type(exc).__name__,
exc,
)
{{ sleep(0.1) }}
continue
@@ -219,6 +230,42 @@ class QueryEngine(HTTPEngine):
'POST', f'/transaction/{tx_id}/rollback'
)

@overload
{{ maybe_async_def }}metrics(
self,
*,
format: Literal['json'],
global_labels: dict[str, str] | None,
) -> dict[str, Any]:
...

@overload
{{ maybe_async_def }}metrics(
self,
*,
format: Literal['prometheus'],
global_labels: dict[str, str] | None,
) -> str:
...

{{ maybe_async_def }}metrics(
self,
*,
format: MetricsFormat,
global_labels: dict[str, str] | None,
) -> str | dict[str, Any]:
if global_labels is not None:
content = json.dumps(global_labels)
else:
content = None

return {{ maybe_await }}self.request( # type: ignore[no-any-return]
'GET',
f'/metrics?format={format}',
content=content,
parse_response=format == 'json',
)

# black does not respect the fmt: off comment without this
# fmt: on

2 changes: 2 additions & 0 deletions src/prisma/generator/templates/types.py.jinja
Original file line number Diff line number Diff line change
@@ -72,6 +72,8 @@ from .utils import _NoneType
SortMode = Literal['default', 'insensitive']
SortOrder = Literal['asc', 'desc']

MetricsFormat = Literal['json', 'prometheus']


class _DatasourceOverrideOptional(TypedDict, total=False):
env: str
7 changes: 6 additions & 1 deletion src/prisma/http_abstract.py
Original file line number Diff line number Diff line change
@@ -11,7 +11,7 @@
cast,
)

from httpx import Limits, Timeout
from httpx import Headers, Limits, Timeout

from ._types import Method
from .utils import _NoneType
@@ -113,6 +113,11 @@ def __init__(self, original: Response) -> None:
def status(self) -> int:
...

@property
@abstractmethod
def headers(self) -> Headers:
...

@abstractmethod
def json(self) -> MaybeCoroutine[Any]:
...
Original file line number Diff line number Diff line change
@@ -49,7 +49,7 @@ from types import TracebackType
from pydantic import BaseModel

from . import types, models, errors, actions
from .types import DatasourceOverride, HttpConfig
from .types import DatasourceOverride, HttpConfig, MetricsFormat
from ._types import BaseModelT, PrismaMethod
from .bases import _PrismaModel
from .engine import AbstractEngine, QueryEngine, TransactionId
@@ -58,6 +58,7 @@ from .generator.models import EngineType, OptionalValueFromEnvVar, BinaryPaths
from ._compat import removeprefix, model_parse
from ._constants import DEFAULT_CONNECT_TIMEOUT, DEFAULT_TX_MAX_WAIT, DEFAULT_TX_TIMEOUT
from ._raw_query import deserialize_raw_results
from ._metrics import Metrics

__all__ = (
'ENGINE_TYPE',
@@ -461,6 +462,44 @@ class Prisma:
"""Returns True if the client is wrapped within a transaction"""
return self._tx_id is not None

@overload
async def get_metrics(
self,
format: Literal['json'] = 'json',
*,
global_labels: dict[str, str] | None = None,
) -> Metrics:
...

@overload
async def get_metrics(
self,
format: Literal['prometheus'],
*,
global_labels: dict[str, str] | None = None,
) -> str:
...

async def get_metrics(
self,
format: MetricsFormat = 'json',
*,
global_labels: dict[str, str] | None = None,
) -> str | Metrics:
"""Metrics give you a detailed insight into how the Prisma Client interacts with your database.

You can retrieve metrics in either JSON or Prometheus formats.

For more details see https://www.prisma.io/docs/concepts/components/prisma-client/metrics.
"""
response = await self._engine.metrics(format=format, global_labels=global_labels)
if format == 'prometheus':
# For the prometheus format we return the response as-is
assert isinstance(response, str)
return response

return model_parse(Metrics, response)

# TODO: don't return Any
async def _execute(
self,
Original file line number Diff line number Diff line change
@@ -43,7 +43,7 @@ LiteralString = str
from abc import ABC, abstractmethod
from datetime import timedelta
from ._types import TransactionId
from ..types import DatasourceOverride
from ..types import DatasourceOverride, MetricsFormat
from .._compat import get_running_loop
from .._constants import DEFAULT_CONNECT_TIMEOUT

@@ -115,4 +115,33 @@ class AbstractEngine(ABC):
async def rollback_transaction(self, tx_id: TransactionId) -> None:
"""Rollback an interactive transaction, the given transaction will no longer be usable"""
...

@overload
@abstractmethod
async def metrics(
self,
*,
format: Literal['json'],
global_labels: dict[str, str] | None,
) -> dict[str, Any]:
...

@overload
@abstractmethod
async def metrics(
self,
*,
format: Literal['prometheus'],
global_labels: dict[str, str] | None,
) -> str:
...

@abstractmethod
async def metrics(
self,
*,
format: MetricsFormat,
global_labels: dict[str, str] | None,
) -> str | dict[str, Any]:
...
'''
Original file line number Diff line number Diff line change
@@ -40,6 +40,7 @@ from typing_extensions import TypedDict, Literal
LiteralString = str
# -- template engine/http.py.jinja --

import json
import logging
from datetime import timedelta

@@ -83,25 +84,28 @@ class HTTPEngine(AbstractEngine):
if self.session and not self.session.closed:
await self.session.close()

# TODO: improve return types
async def request(
self,
method: Method,
path: str,
*,
content: Any = None,
headers: Optional[Dict[str, str]] = None,
parse_response: bool = True,
) -> Any:
if self.url is None:
raise errors.NotConnectedError('Not connected to the query engine')

kwargs = {
'headers': {
'Content-Type': 'application/json',
'Accept': 'application/json',
**self.headers,
}
}

if parse_response:
kwargs['headers']['Accept'] = 'application/json'

if headers is not None:
kwargs['headers'].update(headers)

@@ -117,9 +121,22 @@ class HTTPEngine(AbstractEngine):
log.debug('%s %s returned status %s', method, url, resp.status)

if 300 > resp.status >= 200:
# In certain cases we just want to return the response content as-is.
#
# This is useful for metrics which can be returned in a Prometheus format
# which is incompatible with JSON.
if not parse_response:
text = await resp.text()
log.debug('%s %s returned text: %s', method, url, text)
return text

response = await resp.json()
log.debug('%s %s returned %s', method, url, response)

if isinstance(response, str):
# workaround for https://github.com/prisma/prisma-engines/pull/4246
response = json.loads(response)

errors_data = response.get('errors')
if errors_data:
return utils.handle_response_errors(resp, errors_data)
Original file line number Diff line number Diff line change
@@ -42,6 +42,7 @@ LiteralString = str
# -- template engine/query.py.jinja --

import os
import json
import time
import atexit
import signal
@@ -57,7 +58,7 @@ from .. import config
from ..utils import DEBUG
from ..binaries import platform
from ..utils import time_since, _env_bool
from ..types import DatasourceOverride
from ..types import DatasourceOverride, MetricsFormat
from ..builder import dumps
from .._constants import DEFAULT_CONNECT_TIMEOUT
from ._types import TransactionId
@@ -171,7 +172,13 @@ class QueryEngine(HTTPEngine):
if self._log_queries:
env.update(LOG_QUERIES='y')

args: List[str] = [str(file.absolute()), '-p', str(port), '--enable-raw-queries']
args: List[str] = [
str(file.absolute()),
'-p',
str(port),
'--enable-metrics',
'--enable-raw-queries',
]
if _env_bool('__PRISMA_PY_PLAYGROUND'):
env.update(RUST_LOG='info')
args.append('--enable-playground')
@@ -200,10 +207,14 @@ class QueryEngine(HTTPEngine):
try:
data = await self.request('GET', '/status')
except Exception as exc:
# TODO(someday): only retry on ConnectionError
if isinstance(exc, AttributeError):
raise

last_exc = exc
log.debug(
'Could not connect to query engine due to %s; retrying...',
type(exc).__name__,
exc,
)
await asyncio.sleep(0.1)

@@ -256,6 +267,42 @@ class QueryEngine(HTTPEngine):
'POST', f'/transaction/{tx_id}/rollback'
)

@overload
async def metrics(
self,
*,
format: Literal['json'],
global_labels: dict[str, str] | None,
) -> dict[str, Any]:
...

@overload
async def metrics(
self,
*,
format: Literal['prometheus'],
global_labels: dict[str, str] | None,
) -> str:
...

async def metrics(
self,
*,
format: MetricsFormat,
global_labels: dict[str, str] | None,
) -> str | dict[str, Any]:
if global_labels is not None:
content = json.dumps(global_labels)
else:
content = None

return await self.request( # type: ignore[no-any-return]
'GET',
f'/metrics?format={format}',
content=content,
parse_response=format == 'json',
)

# black does not respect the fmt: off comment without this
# fmt: on

Original file line number Diff line number Diff line change
@@ -54,6 +54,8 @@ from .utils import _NoneType
SortMode = Literal['default', 'insensitive']
SortOrder = Literal['asc', 'desc']

MetricsFormat = Literal['json', 'prometheus']


class _DatasourceOverrideOptional(TypedDict, total=False):
env: str
Original file line number Diff line number Diff line change
@@ -49,7 +49,7 @@ from types import TracebackType
from pydantic import BaseModel

from . import types, models, errors, actions
from .types import DatasourceOverride, HttpConfig
from .types import DatasourceOverride, HttpConfig, MetricsFormat
from ._types import BaseModelT, PrismaMethod
from .bases import _PrismaModel
from .engine import AbstractEngine, QueryEngine, TransactionId
@@ -58,6 +58,7 @@ from .generator.models import EngineType, OptionalValueFromEnvVar, BinaryPaths
from ._compat import removeprefix, model_parse
from ._constants import DEFAULT_CONNECT_TIMEOUT, DEFAULT_TX_MAX_WAIT, DEFAULT_TX_TIMEOUT
from ._raw_query import deserialize_raw_results
from ._metrics import Metrics

__all__ = (
'ENGINE_TYPE',
@@ -461,6 +462,44 @@ class Prisma:
"""Returns True if the client is wrapped within a transaction"""
return self._tx_id is not None

@overload
def get_metrics(
self,
format: Literal['json'] = 'json',
*,
global_labels: dict[str, str] | None = None,
) -> Metrics:
...

@overload
def get_metrics(
self,
format: Literal['prometheus'],
*,
global_labels: dict[str, str] | None = None,
) -> str:
...

def get_metrics(
self,
format: MetricsFormat = 'json',
*,
global_labels: dict[str, str] | None = None,
) -> str | Metrics:
"""Metrics give you a detailed insight into how the Prisma Client interacts with your database.

You can retrieve metrics in either JSON or Prometheus formats.

For more details see https://www.prisma.io/docs/concepts/components/prisma-client/metrics.
"""
response = self._engine.metrics(format=format, global_labels=global_labels)
if format == 'prometheus':
# For the prometheus format we return the response as-is
assert isinstance(response, str)
return response

return model_parse(Metrics, response)

# TODO: don't return Any
def _execute(
self,
Original file line number Diff line number Diff line change
@@ -43,7 +43,7 @@ LiteralString = str
from abc import ABC, abstractmethod
from datetime import timedelta
from ._types import TransactionId
from ..types import DatasourceOverride
from ..types import DatasourceOverride, MetricsFormat
from .._compat import get_running_loop
from .._constants import DEFAULT_CONNECT_TIMEOUT

@@ -115,4 +115,33 @@ class AbstractEngine(ABC):
def rollback_transaction(self, tx_id: TransactionId) -> None:
"""Rollback an interactive transaction, the given transaction will no longer be usable"""
...

@overload
@abstractmethod
def metrics(
self,
*,
format: Literal['json'],
global_labels: dict[str, str] | None,
) -> dict[str, Any]:
...

@overload
@abstractmethod
def metrics(
self,
*,
format: Literal['prometheus'],
global_labels: dict[str, str] | None,
) -> str:
...

@abstractmethod
def metrics(
self,
*,
format: MetricsFormat,
global_labels: dict[str, str] | None,
) -> str | dict[str, Any]:
...
'''
Original file line number Diff line number Diff line change
@@ -40,6 +40,7 @@ from typing_extensions import TypedDict, Literal
LiteralString = str
# -- template engine/http.py.jinja --

import json
import logging
from datetime import timedelta

@@ -83,25 +84,28 @@ class HTTPEngine(AbstractEngine):
if self.session and not self.session.closed:
self.session.close()

# TODO: improve return types
def request(
self,
method: Method,
path: str,
*,
content: Any = None,
headers: Optional[Dict[str, str]] = None,
parse_response: bool = True,
) -> Any:
if self.url is None:
raise errors.NotConnectedError('Not connected to the query engine')

kwargs = {
'headers': {
'Content-Type': 'application/json',
'Accept': 'application/json',
**self.headers,
}
}

if parse_response:
kwargs['headers']['Accept'] = 'application/json'

if headers is not None:
kwargs['headers'].update(headers)

@@ -117,9 +121,22 @@ class HTTPEngine(AbstractEngine):
log.debug('%s %s returned status %s', method, url, resp.status)

if 300 > resp.status >= 200:
# In certain cases we just want to return the response content as-is.
#
# This is useful for metrics which can be returned in a Prometheus format
# which is incompatible with JSON.
if not parse_response:
text = resp.text()
log.debug('%s %s returned text: %s', method, url, text)
return text

response = resp.json()
log.debug('%s %s returned %s', method, url, response)

if isinstance(response, str):
# workaround for https://github.com/prisma/prisma-engines/pull/4246
response = json.loads(response)

errors_data = response.get('errors')
if errors_data:
return utils.handle_response_errors(resp, errors_data)
Original file line number Diff line number Diff line change
@@ -42,6 +42,7 @@ LiteralString = str
# -- template engine/query.py.jinja --

import os
import json
import time
import atexit
import signal
@@ -57,7 +58,7 @@ from .. import config
from ..utils import DEBUG
from ..binaries import platform
from ..utils import time_since, _env_bool
from ..types import DatasourceOverride
from ..types import DatasourceOverride, MetricsFormat
from ..builder import dumps
from .._constants import DEFAULT_CONNECT_TIMEOUT
from ._types import TransactionId
@@ -172,7 +173,13 @@ class QueryEngine(HTTPEngine):
if self._log_queries:
env.update(LOG_QUERIES='y')

args: List[str] = [str(file.absolute()), '-p', str(port), '--enable-raw-queries']
args: List[str] = [
str(file.absolute()),
'-p',
str(port),
'--enable-metrics',
'--enable-raw-queries',
]
if _env_bool('__PRISMA_PY_PLAYGROUND'):
env.update(RUST_LOG='info')
args.append('--enable-playground')
@@ -201,10 +208,14 @@ class QueryEngine(HTTPEngine):
try:
data = self.request('GET', '/status')
except Exception as exc:
# TODO(someday): only retry on ConnectionError
if isinstance(exc, AttributeError):
raise

last_exc = exc
log.debug(
'Could not connect to query engine due to %s; retrying...',
type(exc).__name__,
exc,
)
time.sleep(0.1)

@@ -257,6 +268,42 @@ class QueryEngine(HTTPEngine):
'POST', f'/transaction/{tx_id}/rollback'
)

@overload
def metrics(
self,
*,
format: Literal['json'],
global_labels: dict[str, str] | None,
) -> dict[str, Any]:
...

@overload
def metrics(
self,
*,
format: Literal['prometheus'],
global_labels: dict[str, str] | None,
) -> str:
...

def metrics(
self,
*,
format: MetricsFormat,
global_labels: dict[str, str] | None,
) -> str | dict[str, Any]:
if global_labels is not None:
content = json.dumps(global_labels)
else:
content = None

return self.request( # type: ignore[no-any-return]
'GET',
f'/metrics?format={format}',
content=content,
parse_response=format == 'json',
)

# black does not respect the fmt: off comment without this
# fmt: on

Original file line number Diff line number Diff line change
@@ -54,6 +54,8 @@ from .utils import _NoneType
SortMode = Literal['default', 'insensitive']
SortOrder = Literal['asc', 'desc']

MetricsFormat = Literal['json', 'prometheus']


class _DatasourceOverrideOptional(TypedDict, total=False):
env: str