Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion logfire/integrations/pydantic.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@
from __future__ import annotations

import functools
import importlib.util
import inspect
import os
import re
import site
import sysconfig
from collections.abc import Callable
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, TypedDict, TypeVar

import pydantic
Expand Down Expand Up @@ -368,6 +372,56 @@ def new_schema_validator(
_pydantic_plugin_config_value: PydanticPlugin | None = None


@lru_cache
def _non_user_code_prefixes() -> tuple[Path, ...]:
prefixes = {
sysconfig.get_path('purelib'),
sysconfig.get_path('platlib'),
sysconfig.get_path('stdlib'),
sysconfig.get_path('platstdlib'),
site.getusersitepackages(),
str(Path(logfire.__file__).resolve().parent),
}
return tuple(Path(prefix).resolve() for prefix in prefixes if prefix)


def _path_has_prefix(path: Path, prefix: Path) -> bool:
try:
path.relative_to(prefix)
except ValueError:
return False
else:
return True


@lru_cache
def _module_is_non_user_code(module: str) -> bool:
if not module:
return False

try:
spec = importlib.util.find_spec(module)
except Exception:
return False

if spec is None:
return False

if spec.origin in ('built-in', 'frozen'):
return True

module_paths: list[Path] = []
if spec.origin and not spec.origin.startswith('<'):
module_paths.append(Path(spec.origin).resolve())
module_paths.extend(Path(path).resolve() for path in spec.submodule_search_locations or ())

if not module_paths:
return False

prefixes = _non_user_code_prefixes()
return all(any(_path_has_prefix(path, prefix) for prefix in prefixes) for path in module_paths)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def get_pydantic_plugin_config() -> PydanticPlugin:
"""Get the Pydantic plugin config."""
if _pydantic_plugin_config_value is not None:
Expand Down Expand Up @@ -400,7 +454,7 @@ def _include_model(schema_type_path: SchemaTypePath) -> bool:
# check if the model is in include models
if include:
return any(re.search(f'{pattern}$', f'{module}::{schema_type_path.name}') for pattern in include)
return True
return not _module_is_non_user_code(module)


@lru_cache # only patch once
Expand Down
109 changes: 99 additions & 10 deletions tests/test_pydantic_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The new test functions added before the existing snapshot-based tests have shifted line numbers in this file, but the inline snapshots still contain the old code.lineno: 123 values captured before the change. Since the plugin records the physical line number where validation occurs, and the MyModel(x='a') calls now reside at different lines (265, 341, etc.), all 23 inline snapshots with code.lineno will fail when the test suite runs with inline_snapshot in review mode. Run pytest --inline-snapshot=update to refresh the snapshot values, then commit the updated line numbers.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/test_pydantic_plugin.py, line 197:

<comment>The new test functions added before the existing snapshot-based tests have shifted line numbers in this file, but the inline snapshots still contain the old `code.lineno: 123` values captured before the change. Since the plugin records the physical line number where validation occurs, and the `MyModel(x='a')` calls now reside at different lines (265, 341, etc.), all 23 inline snapshots with `code.lineno` will fail when the test suite runs with inline_snapshot in review mode. Run `pytest --inline-snapshot=update` to refresh the snapshot values, then commit the updated line numbers.</comment>

<file context>
@@ -157,6 +159,89 @@ def test_logfire_plugin_include_exclude_models(
+            return SimpleNamespace(origin=__file__, submodule_search_locations=[])
+        return None
+
+    with patch('logfire.integrations.pydantic.importlib.util.find_spec', side_effect=fake_find_spec):
+        assert _new_pydantic_plugin_result('third_party_pkg.models', 'ThirdPartyModel') == (None, None, None)
+        assert _new_pydantic_plugin_result('tests.test_pydantic_plugin', 'MyModel') != (None, None, None)
</file context>

import importlib.metadata
import os
from typing import TYPE_CHECKING, Annotated, Any
import sysconfig
from types import SimpleNamespace
from typing import TYPE_CHECKING, Annotated, Any, cast
from unittest.mock import patch

import cloudpickle
import pydantic
import pytest
import sqlmodel
from dirty_equals import IsInt
from inline_snapshot import snapshot
from opentelemetry.sdk.metrics.export import AggregationTemporality, InMemoryMetricReader
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from pydantic import (
AfterValidator,
Expand All @@ -26,6 +27,7 @@
from pydantic_core import core_schema

import logfire
import logfire.integrations.pydantic as logfire_pydantic
from logfire._internal.config import GLOBAL_CONFIG
from logfire._internal.utils import get_version
from logfire.integrations.pydantic import (
Expand Down Expand Up @@ -157,6 +159,95 @@ def test_logfire_plugin_include_exclude_models(
assert result == (None, None, None)


def _new_pydantic_plugin_result(
module: str,
name: str,
*,
include: set[str] | None = None,
exclude: set[str] | None = None,
) -> tuple[object | None, object | None, object | None]:
getattr(logfire_pydantic, '_module_is_non_user_code').cache_clear()
getattr(logfire_pydantic, '_non_user_code_prefixes').cache_clear()
logfire.configure(
send_to_logfire=False,
metrics=logfire.MetricsOptions(additional_readers=[InMemoryMetricReader()]),
)
logfire.instrument_pydantic(record='all', include=include or set(), exclude=exclude or set())
plugin = LogfirePydanticPlugin()
return cast(
tuple[object | None, object | None, object | None],
plugin.new_schema_validator(
core_schema.int_schema(), None, SchemaTypePath(module=module, name=name), 'BaseModel', None, {}
),
)


def test_pydantic_plugin_excludes_non_user_modules_by_default() -> None:
purelib = sysconfig.get_path('purelib')
assert purelib is not None

def fake_find_spec(module: str) -> SimpleNamespace | None:
if module == 'third_party_pkg.models':
return SimpleNamespace(
origin=os.path.join(purelib, 'third_party_pkg', 'models.py'),
submodule_search_locations=[],
)
if module == 'tests.test_pydantic_plugin':
return SimpleNamespace(origin=__file__, submodule_search_locations=[])
return None

with patch('logfire.integrations.pydantic.importlib.util.find_spec', side_effect=fake_find_spec):
assert _new_pydantic_plugin_result('third_party_pkg.models', 'ThirdPartyModel') == (None, None, None)
assert _new_pydantic_plugin_result('tests.test_pydantic_plugin', 'MyModel') != (None, None, None)


def test_pydantic_plugin_include_overrides_default_non_user_filter() -> None:
purelib = sysconfig.get_path('purelib')
assert purelib is not None

def fake_find_spec(module: str) -> SimpleNamespace | None:
if module == 'third_party_pkg.models':
return SimpleNamespace(
origin=os.path.join(purelib, 'third_party_pkg', 'models.py'),
submodule_search_locations=[],
)
return None

with patch('logfire.integrations.pydantic.importlib.util.find_spec', side_effect=fake_find_spec):
assert _new_pydantic_plugin_result('third_party_pkg.models', 'ThirdPartyModel') == (None, None, None)
assert _new_pydantic_plugin_result(
'third_party_pkg.models',
'ThirdPartyModel',
include={'third_party_pkg.models::ThirdPartyModel'},
) != (None, None, None)


def test_pydantic_plugin_handles_find_spec_failures_as_user_code() -> None:
def fake_find_spec(module: str) -> SimpleNamespace | None:
if module == 'broken_pkg.models':
raise RuntimeError('broken parent import')
if module == 'invalid_name':
raise ValueError('invalid module name')
if module == 'namespace_pkg.models':
return SimpleNamespace(origin=None, submodule_search_locations=[])
return None

with patch('logfire.integrations.pydantic.importlib.util.find_spec', side_effect=fake_find_spec):
assert _new_pydantic_plugin_result('broken_pkg.models', 'BrokenModel') != (None, None, None)
assert _new_pydantic_plugin_result('invalid_name', 'InvalidModel') != (None, None, None)
assert _new_pydantic_plugin_result('namespace_pkg.models', 'NamespaceModel') != (None, None, None)


def test_pydantic_plugin_excludes_builtin_modules_by_default() -> None:
def fake_find_spec(module: str) -> SimpleNamespace | None:
if module == 'builtins':
return SimpleNamespace(origin='built-in', submodule_search_locations=None)
return None

with patch('logfire.integrations.pydantic.importlib.util.find_spec', side_effect=fake_find_spec):
assert _new_pydantic_plugin_result('builtins', 'int') == (None, None, None)


def test_get_schema_name():
# In particular this tests schemas with type 'definitions'

Expand Down Expand Up @@ -236,7 +327,7 @@ class MyModel(BaseModel, plugin_settings={'logfire': {'record': 'failure'}}):
'exemplars': [],
},
],
'aggregation_temporality': AggregationTemporality.DELTA,
'aggregation_temporality': 1,
'is_monotonic': True,
},
}
Expand Down Expand Up @@ -290,7 +381,7 @@ class MyModel(BaseModel, plugin_settings={'logfire': {'record': 'metrics'}}):
'exemplars': [],
},
],
'aggregation_temporality': AggregationTemporality.DELTA,
'aggregation_temporality': 1,
'is_monotonic': True,
},
}
Expand Down Expand Up @@ -352,7 +443,7 @@ class MyModel(BaseModel, plugin_settings={'logfire': {'record': 'all'}}):
'exemplars': [],
}
],
'aggregation_temporality': AggregationTemporality.DELTA,
'aggregation_temporality': 1,
'is_monotonic': True,
},
}
Expand Down Expand Up @@ -438,7 +529,7 @@ class MyModel(BaseModel, plugin_settings={'logfire': {'record': 'failure'}}):
'exemplars': [],
}
],
'aggregation_temporality': AggregationTemporality.DELTA,
'aggregation_temporality': 1,
'is_monotonic': True,
},
}
Expand Down Expand Up @@ -1292,9 +1383,7 @@ class Hero(sqlmodel.SQLModel, table=True):
'logfire.level_num': 9,
'logfire.span_type': 'span',
'success': True,
'result': '{"id":1}'
if get_version(pydantic.__version__) >= get_version('2.7.0')
else '"Hero(id=1)"',
'result': '{"id":1}',
'logfire.msg': 'Pydantic Hero validate_python succeeded',
'logfire.json_schema': '{"type":"object","properties":{"schema_name":{},"validation_method":{},"input_data":{"type":"object"},"success":{},"result":{"type":"object","title":"Hero","x-python-datatype":"PydanticModel"}}}',
},
Expand Down
Loading