Files
posthog/pyproject.toml

953 lines
45 KiB
TOML

[project]
name = "posthog"
version = "0.0.0"
requires-python = "==3.14.7" # Pin full version to control Python upgrades explicitly
# Prefer ~= (compat) over >= (floor). Only pin exact versions (==) when necessary.
#
# ~= signals to others (security, devex, dependabot) that deps can be updated within
# that range without pulling the team in. Unbounded >= gives no such signal.
#
dependencies = [
"aioboto3==15.4.0",
"aiohttp==3.13.5",
"aiokafka~=0.13.0",
"anthropic==0.80.0",
"asyncstdlib==3.13.1",
"beautifulsoup4==4.12.3",
"boto3==1.40.49",
"brotli==1.2.1",
"celery==5.6.3",
"celery-redbeat==2.3.3",
"certifi==2026.10.19",
"clickhouse-connect==0.10.0",
"clickhouse-driver==0.2.10",
"clickhouse-pool==0.5.3",
"circular-dict==1.9",
"cryptography==46.0.7",
"PyNaCl==1.5.0",
"dagster==1.12.11",
"dagster-cloud==1.12.11",
"dagster-aws==0.28.11",
"dagster-celery==0.28.11",
"dagster-docker==0.28.11",
"dagster-k8s==0.28.11",
"dagster-pipes==1.12.11",
"dagster-postgres==0.28.11",
"dagster-slack==0.28.11",
"dagster-webserver==1.12.11",
"deltalake[pyarrow]==1.6.1",
"dj-database-url~=3.0.0",
"django~=5.2.0",
"django-axes[ipware]==8.3.1",
"django-cors-headers~=4.9.0",
"django-extensions~=4.1.0",
"django-filter~=26.1",
"django-loginas==0.3.14",
"django-prometheus~=2.5.0",
"django-redis==7.0.0",
"django-structlog[celery]~=10.1.0",
"django-two-factor-auth~=1.18.0",
"djangorestframework~=3.17.2",
"djangorestframework-csv~=3.0.2",
"djangorestframework-dataclasses~=1.4.0",
"dlt==1.18.2",
"dnspython==2.6.1",
"drf-exceptions-hog==0.4.0",
"drf-extensions~=0.8.0",
"drf-spectacular~=0.30.0",
"drf-spectacular-sidecar~=2026.9.1",
"geoip2==4.6.0",
"google-ads==31.2.0",
"google-cloud-bigquery==3.40.1",
"google-cloud-bigquery-storage==2.32.0",
"google-cloud-iam==2.21",
"google-genai==1.75.0",
# 2.8+ gives each worker its own SO_REUSEPORT socket on Linux. The query pool runs
# bin/granian_shared_socket.py to keep one shared socket; its test must pass on any bump.
"granian[uvloop,reload,pname]==2.8.3",
"grimp~=3.14",
"grpcio~=1.84.1",
"gspread==6.2.1",
"hogql-parser==1.3.85",
"hogql-parser-rs==1.3.109", # built from rust/hogql/parser via the build-hogql-parser-rs workflow + published to PyPI; rebuild locally with `uv pip install -e rust/hogql/parser` when iterating
"humanize~=4.12.3",
"infi-clickhouse-orm",
"ipython~=9.3.0",
"jsonref==1.1.0",
"kafka-python==2.3.0",
"kombu==5.6.2",
"jsonpatch~=1.33",
"langchain~=0.3.27",
"langchain-community~=0.3.31",
"langchain-core~=0.3.81",
"langchain-openai~=0.3.35",
"langchain-anthropic~=0.3.22",
"langgraph==0.4.10",
"langgraph-checkpoint==4.1.1",
"lxml==6.0.2",
"lzstring==1.0.4",
"markdown-it-py~=3.0.0",
"mcp~=1.26.0",
"mimesis==5.2.1",
"mistralai==1.6.0",
"more-itertools==9.0.0",
"nanoid==2.0.0",
"natsort==8.4.0",
"nh3==0.3.5",
"numpy~=2.3.2",
"openai==2.30.0",
"openpyxl==3.1.5",
"orjson==3.11.9",
"ormsgpack~=1.12",
"pandas~=2.3.3",
"paramiko~=3.5.0",
"pillow==12.3.0",
"protobuf~=6.31.1",
"posthoganalytics==7.75.11",
"polars==1.37.1",
"psycopg2-binary==2.9.11",
"psycopg[binary]==3.2.10",
"pyarrow==23.0.1",
"pydantic~=2.12.0",
"pypdf==6.10.2",
"pyod==2.0.7",
"pyjwt~=2.12.1",
"pymssql==2.3.8",
"pymysql==1.1.1",
"pymongo==4.15.2",
"pyroscope-io~=1.2.4",
"croniter~=6.0.0",
"python-dateutil~=2.9.0",
"python-docx==1.2.0",
"python-snappy~=0.7.3",
"python3-saml==1.16.0",
"pytz==2023.3",
"rapidfuzz==3.14.5",
"redis==5.3.1",
"requests~=2.33.0",
"retry==0.9.2",
"s3fs==2025.9.0",
"scikit-learn~=1.7.0",
"scipy~=1.16.3",
"semantic-version==2.8.5",
"simple-salesforce~=1.12.9",
"slack-sdk==3.42.0",
"snowflake-connector-python==4.4.0",
"social-auth-app-django~=5.8.0",
"social-auth-core~=4.8.7",
"django-scim2==0.23.0",
"sqlparse==0.5.4",
"sshtunnel==0.4.0",
"statshog==1.0.6",
"stripe==12.2.0",
"structlog==25.5.0",
"temporalio==1.33.0",
"tenacity==9.1.4",
"tiktoken==0.12.*",
"tldextract~=5.1",
"trafilatura==2.0.0",
"trino==0.338.0",
"types-cachetools==5.5.0.20240820",
"css-inline==0.18.0",
"click~=8.0",
# Distributed owners.yaml resolver. A runtime dependency, not just tooling: stamphog's digest
# reads a repo's team -> Slack channel registry through it (products/stamphog/backend/logic/
# channel_resolution.py). Adds no third-party code — its own deps, pyyaml and click, are here.
"owners-yaml",
"personhog-proto",
"umap-learn~=0.5.0",
"tbb==2022.3.1; sys_platform == 'linux' and platform_machine == 'x86_64'",
"whitenoise~=6.10.0",
"xgboost==3.2.0",
# CPU inference for the inbox ranking network. Keep in step with the `inbox-ranking` training group.
"onnxruntime~=1.30.0",
"xmlsec==1.3.17",
"zstd==1.5.7.2",
"zxcvbn==4.5.0",
"pyyaml~=6.0.2",
"chdb==4.3.0",
# chdb allows any chdb-core>=26.7.0, and engines before 26.9.0 accept a TLS certificate issued for another host.
"chdb-core==26.9.0",
"elevenlabs~=2.1.0",
"django-oauth-toolkit~=3.2.0",
"azure-ai-projects~=1.0.0b11",
"azure-ai-inference~=1.0.0b9",
"azure-storage-blob~=12.27.0",
"opentelemetry-sdk==1.35.0",
"opentelemetry-instrumentation-aiokafka==0.56b0",
"opentelemetry-instrumentation-aiohttp-client==0.56b0",
"opentelemetry-instrumentation-asgi==0.56b0",
"opentelemetry-instrumentation-celery==0.56b0",
"opentelemetry-instrumentation-django==0.56b0",
"opentelemetry-instrumentation-kafka-python==0.56b0",
"opentelemetry-instrumentation-psycopg==0.56b0",
"opentelemetry-instrumentation-redis==0.56b0",
"opentelemetry-instrumentation-requests==0.56b0",
"opentelemetry-exporter-otlp-proto-grpc==1.35.0",
"asgiref==3.11.0",
"user-agents~=2.2.0",
"django-admin-inline-paginator===0.4.0",
"fastavro~=1.12.0",
"pydantic-avro~=0.9.0",
"playwright~=1.60.0",
"linkedin-api-client~=0.3.0",
"glom~=24.11.0",
"modal[api-proxy-support]~=1.5.4",
"databricks-sql-connector~=4.3.0",
"databricks-sdk~=0.118.0",
"free-email-domains~=1.0.1",
"disposable-email-domains~=0.0.140",
"pymediainfo~=7.0.1",
"cachetools~=5.5.2",
"gitpython~=3.1.44",
"duckdb~=1.5.2",
"bingads~=13.0.26",
"markdown-to-mrkdwn~=0.2.0",
"webauthn>=2.1.0,<2.3.0",
"botocore[crt]~=1.40.49",
"defusedxml~=0.7.1",
"confluent-kafka~=2.13",
"blake3>=1.0.8,<2",
"pixelhog~=1.3.1",
"google-re2>=1.1.20251105,<2",
"scrubadub~=2.0.1",
"braintrust-core>=0.0.59,<1",
"wrapt==1.17.3",
"limits==5.8.0",
"deltalite==0.1.15",
"posthog-hogland==0.4.1",
"requests-oauthlib~=2.0.0",
"adlfs~=2026.8.0",
]
[dependency-groups]
dev = [
{ include-group = "proto-codegen" },
"aioresponses==0.7.6",
"autoevals==0.2.0",
"boto3-stubs[s3]~=1.38.30",
"browser-cookie3~=0.20",
"braintrust==0.2.4",
"braintrust-langchain==0.0.4",
"dagster-dg-cli~=1.12.11",
"datamodel-code-generator~=0.36.0",
"debugpy~=1.8.0",
"import-linter~=2.11",
"deepdiff~=8.6.1",
"django-linear-migrations~=2.19.0",
"django-stubs~=5.2.0",
# django-stubs requires this unbounded, so an upgrade run drifts it to a version
# that models a different Django. Hold it on the line django-stubs is on.
"django-stubs-ext~=5.2.5",
"djangorestframework-stubs~=3.16.0",
"faker==17.5.0",
"fakeredis[lua]==2.23.3",
"flameprof~=0.4",
"hypothesis~=6.151.9",
"pyinstrument~=5.1",
# 16.6.1 and later write a retried failure to JUnit as an extra passing <testcase>, which reads as pass-on-retry.
"pytest-rerunfailures==16.6",
"time-machine==3.5.1",
"mypy~=2.1.0",
"mypy-extensions~=1.1.0",
"ty==0.0.80",
# openapi-spec-validator needed for prance as a validation backend
"openapi-spec-validator==0.7.1",
"orjson==3.11.9",
"packaging==25.0",
"parameterized==0.9.0",
"prompt-toolkit~=3.0.51",
"prance==23.6.21.0",
"pyarrow==23.0.1",
"pyarrow-stubs==20.0.0.20260625",
"pytest~=9.1.1",
"pytest-asyncio~=1.4.0",
"pytest-aiohttp~=1.1.1",
"pytest-cov~=7.1.0",
"pytest-django~=4.14.0",
"pytest-durations~=1.9.0",
"pytest-env~=1.7.1",
"pytest-icdiff~=0.9",
"pytest-mock~=3.15.1",
"pytest-split~=0.11.0", # EXPERIMENT: git-pinned below to the optimal_chunks fork
"pytest-timeout~=2.4.0",
"pytest-xdist~=3.8.0",
"python-dateutil~=2.9.0",
"requests-mock~=1.12.1",
"responses==0.23.1",
"stpyv8==13.1.201.22; sys_platform != 'linux' or (platform_machine != 'aarch64' and platform_machine != 'arm64')",
"syrupy~=6.1.1",
"tach~=0.34",
"types-aioboto3[s3]~=14.3.0",
"types-aiobotocore~=2.22.0",
"types-boto3[essential,ses,sesv2]==1.40.49",
"types-croniter~=6.0.0",
"types-markdown==3.3.9",
"types-paramiko==3.4.0.20240423",
"types-pymysql==1.1.0.20240524",
"types-python-dateutil~=2.9.0",
"types-pytz==2023.3",
"types-pyyaml==6.0.1",
"types-redis==4.6.0.20241004",
# >= 2.31.0.7 versions of types-requests require urllib>=2, which is incompatible with our dependencies
"types-requests==2.31.0.6",
"types-retry==0.9.9.4",
"types-tzlocal~=5.1.0.1",
"pywatchman~=4.0",
"watchdog==5.0.3",
"testcontainers[mysql]~=4.14",
# Developer CLI
"hogli",
"libcst>=1.8.6,<2.0.0",
]
sentiment = [
"optimum[onnxruntime]~=1.25.0",
"torch~=2.9.0",
]
# The inbox ranking multi-head network (products/signals/dags/inbox_ranking/multihead/). Only
# Dockerfile.dagster-ml installs it. torch comes from the CPU wheel index, see [tool.uv.sources].
inbox-ranking = [
"onnx~=1.21.0",
"onnxruntime~=1.30.0",
"torch~=2.9.0",
]
hogql-parser-parity = [
"shrinkray==26.4.14.0",
]
proto-codegen = [
"grpcio-tools~=1.75.1",
"ruff~=0.15.20",
]
[tool.uv]
required-version = ">=0.13.4"
add-bounds = "major"
exclude-newer = "7 days"
exclude-newer-package = { brotli = false, certifi = false, hogql-parser = false, hogql-parser-rs = false, deltalite = false, posthoganalytics = false, pixelhog = false, django = false, modal = false, posthog-hogland = false, google-ads = false, grpcio = false }
# posthoganalytics 8 removes legacy capture. The SDK release bot rewrites the
# dependency spec on every release, so this constraint is what stops a new major from
# landing by accident. Lift it in the migration PR that moves the call sites.
constraint-dependencies = ["posthoganalytics<8"]
# sentiment takes torch from PyPI with the CUDA libraries, inbox-ranking takes the CPU-only build.
conflicts = [[{ group = "sentiment" }, { group = "inbox-ranking" }]]
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
explicit = true
[tool.uv.workspace]
members = ["tools/hogli", "packages/owners-yaml", "packages/personhog-proto"]
[tool.uv.sources]
hogli = { workspace = true }
owners-yaml = { workspace = true }
personhog-proto = { workspace = true }
infi-clickhouse-orm = { git = "https://github.com/PostHog/infi.clickhouse_orm", rev = "fa20b806e32cf748dfee3545d8926b9c36aff789" }
# pytest-split: pinned to PostHog's fork (immutable tag) for the order-preserving
# optimal_chunks split. Fork: PostHog/pytest-split (of jerry-git/pytest-split).
# To pull upstream fixes: rebase the fork's feat branch onto upstream, cut the next
# v0.11.0-posthog.N tag, and bump this pin.
pytest-split = { git = "https://github.com/PostHog/pytest-split", tag = "v0.11.0-posthog.8" }
torch = [{ index = "pytorch-cpu", group = "inbox-ranking" }]
# xgboost's Linux wheel declares nvidia-nccl-cu12 (322 MB) for multi-GPU training. We use xgboost on
# CPU, so drop it to keep the wheel out of the image.
[[tool.uv.dependency-metadata]]
name = "xgboost"
version = "3.2.0"
requires-dist = ["numpy", "scipy"]
[tool.isort]
profile = "black"
[tool.ruff]
line-length = 120
# Pinned apart from requires-python. Raising it to py314 makes ruff format drop the parentheses
# in `except (A, B):` (PEP 758) across the repo, and makes UP037 flag every quoted annotation.
target-version = "py313"
force-exclude = true
exclude = [
".git",
"./.semgrep",
"./nodejs/node_modules/",
"./plugins/node_modules/",
"./env",
"./posthog/hogql/grammar",
"products/*/skills/*/scripts",
# imported desktop app; Biome-linted by its own desktop-* CI
"products/desktop",
]
[tool.ruff.lint]
ignore = [
"B017", # ~38: assertRaises(Exception) too broad
"B904", # ~488: raise without from in except
"B905", # ~153: zip() without strict=
"C901", # ~527: function too complex
"E501", # ~4697: line too long
"E722", # ~62: bare except
"E731", # ~185: lambda assignment
"F541", # ~1139: f-string missing placeholders
"UP007", # ~377: Union[X,Y] → X | Y (auto-fixable)
"UP032", # ~175: use f-string (auto-fixable)
"UP045", # ~3064: Optional[X] → X | None (auto-fixable)
"UP046", # ~39: generic class → PEP 695 (unsafe)
"UP047", # ~37: generic func → PEP 695 (unsafe)
"UP042", # ~28: str+Enum → StrEnum (unsafe: changes str()/format() output)
]
select = [
"B",
"C4",
"C9",
"DJ012",
"E",
"F",
"I",
"PLC0415",
"RUF005",
"RUF013",
"RUF015",
"RUF019",
"T100",
"T2",
"TID253",
"TRY201",
"TRY400",
"UP",
"W",
]
[tool.ruff.lint.per-file-ignores]
# protoc's _pb2.py files import dependencies for the side effect of loading their descriptors
# into the pool before AddSerializedFile(). ruff must not strip or move those imports.
"packages/personhog-proto/**/*_pb2.py" = ["F401", "E402"]
# deltalite's test suite bootstraps its harness/planner via sys.path (the package is a
# maturin-built extension living under rust/, not part of the Django app), so imports
# are necessarily lazy / post-path-insert (PLC0415, E402); the planner's small CLI
# prints its report (T201).
"rust/deltalite/python/**" = ["PLC0415", "E402", "T201"]
# Django settings aggregator: star imports are how settings modules merge into one namespace.
# Not portable to explicit re-exports without losing the settings-loading semantics.
"posthog/settings/**" = ["F403", "F405"]
# PLC0415 exemptions. products/ and services/llm-gateway/ have their own ruff configs.
# Run `ruff check <path> --select PLC0415 --statistics` before removing an entry.
".github/**" = ["PLC0415"]
"bin/**" = ["PLC0415"]
"common/**" = ["PLC0415"]
"ee/**" = ["PLC0415"]
"manage.py" = ["PLC0415"]
"posthog/**" = ["PLC0415"]
"scripts/**" = ["PLC0415"]
"services/stripe-mock/**" = ["PLC0415"]
"tools/**" = ["PLC0415"]
[tool.ruff.lint.flake8-tidy-imports]
# Heavy SDKs that noticeably slow startup. They must be imported lazily (inside the
# function that uses them), not at module top, so they stay off the Django app-load path.
# Only modules with a single, already-lazy call site are listed — vendor source/destination
# leaf modules legitimately import their own SDK at module level and are loaded on demand.
banned-module-level-imports = ["elevenlabs", "matplotlib", "trafilatura"]
[tool.ruff.lint.mccabe]
max-complexity = 10
[tool.ruff.lint.isort]
combine-as-imports = true
force-wrap-aliases = true
length-sort-straight = true
split-on-trailing-comma = false
section-order = [
"future",
"standard-library",
"testing",
"django",
"third-party",
"posthog.schema",
"posthog.hogql",
"posthog",
"products",
"first-party",
"local-folder",
]
[tool.ruff.lint.isort.sections]
"django" = ["django"] # Group all Django imports into a separate section.
"testing" = [ # Group all testing imports into a separate section.
"pytest",
"time_machine",
"unittest",
"posthog.test.base",
]
# Separate groups for our own stuff
"posthog.schema" = ["posthog.schema"]
"posthog.hogql" = ["posthog.hogql"]
"posthog" = ["posthog"]
"products" = ["products"]
[tool.djlint]
profile = "django"
extension = "html"
indent = 4
# Formatting covers every Django template; linting is scoped to the email ones, so these
# exclusions are read against those. H021 inline styles and H023 entity references are how
# HTML email has to be written, since mail clients strip stylesheets and render bare
# characters inconsistently. H016 title and H030 meta description are page rules that mean
# nothing in an email.
ignore = "H021,H023,H016,H030"
[tool.dagster]
python_version = "3.14"
[tool.mypy]
python_version = "3.14"
plugins = [
"mypy_django_plugin.main",
"mypy_drf_plugin.main",
"pydantic.mypy",
]
strict_optional = true
no_implicit_optional = true
warn_unused_ignores = true
check_untyped_defs = true
warn_unreachable = true
strict_equality = true
ignore_missing_imports = true
exclude_gitignore = true
# conftest.py: the root pytest bootstrap collides with services/stripe-mock/tests/conftest.py
# (both resolve to top-level module "conftest")
# rust/deltalite/python: pytest suite for the maturin-built extension; nothing Django
# imports it and it has its own CI (ci-deltalite-python.yml).
# packages/owners-yaml: a standalone distribution that must build and run with no Django. This config
# loads the Django and DRF plugins and the app's settings, so it is the wrong checker for it. ty
# covers it repo-wide instead, and ci-python builds and exercises the wheel in an empty venv.
exclude = ["^(bin/.*|tools/.*|packages/owners-yaml/.*|packages/personhog-proto/.*|rust/deltalite/python/.*|products/desktop/.*|products/stamphog/packages/.*|products/[^/]+/skills/[^/]+/scripts/.*|gunicorn\\.config\\.py|manage\\.py|conftest\\.py)$"]
[[tool.mypy.overrides]]
module = "bin.*"
ignore_errors = true
[[tool.mypy.overrides]]
module = "posthog.hogql.grammar.*"
ignore_errors = true
# Profiling code from the nextgensquash tooling — pyinstrument/py-spy
# wrappers do dynamic method swaps that don't fit the static stubs.
[[tool.mypy.overrides]]
module = "posthog.management.migration_profiling.*"
ignore_errors = true
[[tool.mypy.overrides]]
module = "posthog.tasks.*"
disallow_untyped_defs = true
check_untyped_defs = true
[tool.django-stubs]
django_settings_module = "posthog.settings"
[tool.pyright]
venvPath = ".flox/cache"
venv = "venv"
pythonVersion = "3.14"
[tool.ty.environment]
python-version = "3.14"
[tool.ty.src]
exclude = [
"bin/**",
"manage.py",
"posthog/hogql/grammar/**",
"packages/personhog-proto/**",
"posthog/schema.py",
"posthog/schema_enums.py",
"posthog/temporal/proxy_service/proto/**",
# PEP 723 scripts with their own deps; mypy excludes these dirs too
"products/*/skills/*/scripts/**",
"products/stamphog/packages/**",
"tools/**",
"products/warehouse_sources/backend/temporal/data_imports/sources/generated_configs/**",
"rust/deltalite/python/**",
]
# ---------------------------------------------------------------------------
# import-linter: enforce internal product architecture
# ---------------------------------------------------------------------------
# Ensures products follow the facade pattern internally:
# presentation → facade → logic/models (not shortcuts).
# Complements tach (inter-module boundaries) with intra-product enforcement.
[tool.importlinter]
root_packages = ["products", "owners_yaml"]
include_external_packages = true
# A Python distribution under packages/ is a leaf: it must install and run outside the
# monorepo venv, so it can import nothing from the app. tach resolves a uv distribution as
# third-party and cannot police that boundary. import-linter resolves by installed import
# name, so it can.
[[tool.importlinter.contracts]]
name = "packages/owners-yaml is a leaf"
type = "forbidden"
source_modules = ["owners_yaml"]
forbidden_modules = ["posthog", "ee", "products", "common", "django", "rest_framework"]
# Presentation must only reach product internals through the facade.
# Forbid all backend imports, then allowlist facade + self-imports.
# This catches any new internal modules (cache.py, helpers.py, etc.)
# automatically — no blocklist to maintain.
[[tool.importlinter.contracts]]
name = "presentation must use facade"
type = "forbidden"
source_modules = ["products.*.backend.presentation"]
forbidden_modules = [
"products.*.backend",
]
allow_indirect_imports = true
ignore_imports = [
# Allowed: presentation can import facade and itself
"products.**.backend.presentation.** -> products.**.backend.facade.**",
"products.**.backend.presentation.** -> products.**.backend.presentation.**",
# TODO: cdp presentation wave — the helpers, serializers and icon service other products' views
# reuse still live in cdp internals, and products/cdp/backend/presentation/ re-exports them.
# Move each one into presentation and delete its entry.
"products.cdp.backend.presentation.hog_functions -> products.cdp.backend.filters",
"products.cdp.backend.presentation.hog_functions -> products.cdp.backend.validation",
"products.cdp.backend.presentation.serializers -> products.cdp.backend.validation",
"products.cdp.backend.presentation.hog_functions -> products.cdp.backend.flag_gated_templates",
"products.cdp.backend.presentation.icons -> products.cdp.backend.services.icons",
# TODO: existing violations — fix by routing through facade
"products.alerts.backend.presentation.views.alert -> products.alerts.backend.evaluation.contract",
"products.alerts.backend.presentation.views.alert -> products.alerts.backend.evaluation.detector",
"products.alerts.backend.presentation.views.alert -> products.alerts.backend.evaluation.validation",
"products.alerts.backend.presentation.views.alert -> products.alerts.backend.insight_alert_state_machine",
"products.alerts.backend.presentation.views.alert -> products.alerts.backend.models.alert",
"products.alerts.backend.presentation.views.insight_alerts -> products.alerts.backend.models.alert",
"products.mcp_analytics.backend.presentation.views -> products.mcp_analytics.backend.logic",
"products.mcp_analytics.backend.presentation.views -> products.mcp_analytics.backend.models",
"products.mcp_analytics.backend.presentation.serializers -> products.mcp_analytics.backend.models",
"products.mcp_store.backend.presentation.agent_views -> products.mcp_store.backend.agents",
"products.mcp_store.backend.presentation.agent_views -> products.mcp_store.backend.gateway",
"products.mcp_store.backend.presentation.agent_views -> products.mcp_store.backend.models",
"products.mcp_store.backend.presentation.agent_views -> products.mcp_store.backend.policy",
"products.mcp_store.backend.presentation.agent_views -> products.mcp_store.backend.proxy",
"products.mcp_store.backend.presentation.gateway_views -> products.mcp_store.backend.agents",
"products.mcp_store.backend.presentation.gateway_views -> products.mcp_store.backend.gateway",
"products.mcp_store.backend.presentation.gateway_views -> products.mcp_store.backend.models",
"products.mcp_store.backend.presentation.gateway_views -> products.mcp_store.backend.policy",
"products.mcp_store.backend.presentation.views -> products.mcp_store.backend.agents",
"products.mcp_store.backend.presentation.views -> products.mcp_store.backend.gateway",
"products.mcp_store.backend.presentation.views -> products.mcp_store.backend.models",
"products.mcp_store.backend.presentation.views -> products.mcp_store.backend.oauth",
"products.mcp_store.backend.presentation.views -> products.mcp_store.backend.policy",
"products.mcp_store.backend.presentation.views -> products.mcp_store.backend.proxy",
"products.mcp_store.backend.presentation.views -> products.mcp_store.backend.tasks",
"products.mcp_store.backend.presentation.views -> products.mcp_store.backend.tools",
"products.notifications.backend.presentation.views -> products.notifications.backend.cache",
"products.notifications.backend.presentation.views -> products.notifications.backend.models",
"products.tracing.backend.presentation.views -> products.tracing.backend.logic",
"products.tracing.backend.presentation.views -> products.tracing.backend.sparkline_query_runner",
"products.tracing.backend.presentation.views -> products.tracing.backend.has_spans_query_runner",
# Saved-views ModelViewSet needs the model class for its serializer; same accepted pattern as
# logs' views_api. TODO: route through facade with the broader presentation cleanup.
"products.tracing.backend.presentation.views_api -> products.tracing.backend.models",
# logs presentation isolation (temporary): the moved viewsets still reach in-product
# internals directly. Removed by the follow-up that thins each viewset to go through
# the facade.
"products.logs.backend.presentation.views.api -> products.logs.backend.column_expressions",
"products.logs.backend.presentation.views.api -> products.logs.backend.count_query_runner",
"products.logs.backend.presentation.views.api -> products.logs.backend.count_ranges_query_runner",
"products.logs.backend.presentation.views.api -> products.logs.backend.group_by_query_runner",
"products.logs.backend.presentation.views.api -> products.logs.backend.has_logs_query_runner",
"products.logs.backend.presentation.views.api -> products.logs.backend.impact_query_runner",
"products.logs.backend.presentation.views.api -> products.logs.backend.log_attributes_query_runner",
"products.logs.backend.presentation.views.api -> products.logs.backend.log_facet_values_query_runner",
"products.logs.backend.presentation.views.api -> products.logs.backend.log_values_query_runner",
"products.logs.backend.presentation.views.api -> products.logs.backend.logs_query_runner",
"products.logs.backend.presentation.views.api -> products.logs.backend.pattern_diff",
"products.logs.backend.presentation.views.api -> products.logs.backend.patterns_query_runner",
"products.logs.backend.presentation.views.api -> products.logs.backend.services_query_runner",
"products.logs.backend.presentation.views.api -> products.logs.backend.sparkline_query_runner",
"products.logs.backend.presentation.views.alerts_api -> products.logs.backend.alert_check_query",
"products.logs.backend.presentation.views.alerts_api -> products.logs.backend.alert_destinations",
"products.logs.backend.presentation.views.alerts_api -> products.logs.backend.alert_state_machine",
"products.logs.backend.presentation.views.alerts_api -> products.logs.backend.models",
"products.logs.backend.presentation.views.metric_rules_api -> products.logs.backend.models",
"products.logs.backend.presentation.views.retention_api -> products.logs.backend.models",
"products.logs.backend.presentation.views.sampling_api -> products.logs.backend.models",
"products.logs.backend.presentation.views.views_api -> products.logs.backend.models",
"products.logs.backend.presentation.views.anomalies_api -> products.logs.backend.anomaly_scan",
"products.logs.backend.presentation.views.anomalies_api -> products.logs.backend.series_bands",
# logs presentation isolation (temporary): cross-product imports the moved viewsets
# still make directly. These resolve once cdp/exports expose the needed facades, so
# they outlive the in-product cleanup above.
"products.logs.backend.presentation.views.api -> products.exports.backend.models.exported_asset",
# TODO: notebooks presentation wave — the moved NotebookViewSet still reaches in-product
# internals (collab, kernel runtime, query validation, models) and the tasks sandbox
# directly. Thin it to parse -> facade -> serialize and delete each entry.
# backend:contract-check stays off until this list is empty.
"products.notebooks.backend.presentation.views.notebook -> products.notebooks.backend.activity_logging",
"products.notebooks.backend.presentation.views.notebook -> products.notebooks.backend.analytics",
"products.notebooks.backend.presentation.views.notebook -> products.notebooks.backend.collab",
"products.notebooks.backend.presentation.views.notebook -> products.notebooks.backend.collab_stream",
"products.notebooks.backend.presentation.views.notebook -> products.notebooks.backend.kernel_runtime",
"products.notebooks.backend.presentation.views.notebook -> products.notebooks.backend.markdown_collab",
"products.notebooks.backend.presentation.views.notebook -> products.notebooks.backend.models",
"products.notebooks.backend.presentation.views.notebook -> products.notebooks.backend.presence",
"products.notebooks.backend.presentation.views.notebook -> products.notebooks.backend.python_analysis",
"products.notebooks.backend.presentation.views.notebook -> products.notebooks.backend.query_validation",
"products.notebooks.backend.presentation.views.notebook -> products.notebooks.backend.sql_v2",
"products.notebooks.backend.presentation.views.notebook -> products.notebooks.backend.sql_v2_direct",
"products.notebooks.backend.presentation.views.notebook -> products.notebooks.backend.sql_v2_runs",
"products.notebooks.backend.presentation.views.notebook -> products.notebooks.backend.sql_v2_serializers",
"products.notebooks.backend.presentation.views.notebook -> products.notebooks.backend.sql_v2_state",
"products.notebooks.backend.presentation.views.notebook -> products.notebooks.backend.sql_v2_variables",
# TODO: experiments presentation wave — these were invisible to import-linter until
# backend/presentation/ got its package marker, so they are newly surfaced debt, not new
# coupling. experiments is not isolated, so nothing was being skipped on their account.
"products.experiments.backend.presentation.serializers -> products.ai_observability.backend.models.llm_prompt",
"products.experiments.backend.presentation.serializers -> products.experiments.backend.experiment_service",
"products.experiments.backend.presentation.serializers -> products.experiments.backend.facade",
"products.experiments.backend.presentation.serializers -> products.experiments.backend.hogql_queries.exposure_query_logic",
"products.experiments.backend.presentation.serializers -> products.experiments.backend.llm_metric_templates",
"products.experiments.backend.presentation.serializers -> products.experiments.backend.metric_events",
"products.experiments.backend.presentation.serializers -> products.experiments.backend.metric_utils",
"products.experiments.backend.presentation.serializers -> products.experiments.backend.models.experiment",
"products.experiments.backend.presentation.serializers -> products.experiments.backend.running_time_calculator",
"products.experiments.backend.presentation.serializers -> products.experiments.backend.session_buckets",
"products.experiments.backend.presentation.serializers -> products.experiments.backend.session_context",
"products.experiments.backend.presentation.serializers -> products.experiments.backend.session_event_deltas",
"products.experiments.backend.presentation.serializers -> products.experiments.backend.setup_context",
"products.experiments.backend.presentation.serializers -> products.feature_flags.backend.api.feature_flag",
"products.experiments.backend.presentation.serializers -> products.feature_flags.backend.models.feature_flag",
"products.experiments.backend.presentation.views -> products.approvals.backend.mixins",
"products.experiments.backend.presentation.views -> products.experiments.backend.experiment_service",
"products.experiments.backend.presentation.views -> products.experiments.backend.llm_metric_templates",
"products.experiments.backend.presentation.views -> products.experiments.backend.models.experiment",
"products.experiments.backend.presentation.views -> products.experiments.backend.recalculation",
"products.experiments.backend.presentation.views -> products.experiments.backend.running_time_calculator",
"products.experiments.backend.presentation.views -> products.experiments.backend.session_buckets",
"products.experiments.backend.presentation.views -> products.experiments.backend.session_context",
"products.experiments.backend.presentation.views -> products.experiments.backend.session_event_deltas",
"products.experiments.backend.presentation.views -> products.experiments.backend.setup_context",
"products.experiments.backend.presentation.views -> products.feature_flags.backend.models.evaluation_context",
"products.experiments.backend.presentation.views -> products.feature_flags.backend.models.feature_flag",
# TODO: data_warehouse presentation wave — the structural move landed the viewsets under
# presentation/views/ but they still reach in-product internals and sibling products
# (warehouse_sources, data_modeling, data_tools, cdp, revenue_analytics, batch_exports)
# directly. Thin each viewset to parse -> facade -> serialize and delete each entry.
# backend:contract-check stays off until this list is empty.
# TODO: access-control presentation wave — the consolidated RBAC viewsets carry their ORM
# access with them. Thin them to parse -> facade -> serialize and delete each entry.
"products.access_control.backend.presentation.access_control -> products.access_control.backend.models.access_control",
"products.access_control.backend.presentation.access_control_settings -> products.access_control.backend.models.access_control",
"products.access_control.backend.presentation.access_control_settings -> products.access_control.backend.models.role",
"products.access_control.backend.presentation.role -> products.access_control.backend.models.role",
# TODO: batch_exports presentation wave — the moved viewsets, serializers and destination
# test helpers still reach in-product internals directly. Thin each one to parse -> facade
# -> serialize and delete each entry. backend:contract-check stays off until this list is
# empty. Only delete entries here: a new import from these views goes through the facade.
"products.batch_exports.backend.presentation.views.batch_export.destinations -> products.batch_exports.backend.models.batch_export",
"products.batch_exports.backend.presentation.views.batch_export.exports -> products.batch_exports.backend.hogql_source",
"products.batch_exports.backend.presentation.views.batch_export.exports -> products.batch_exports.backend.models.batch_export",
"products.batch_exports.backend.presentation.views.batch_export.exports -> products.batch_exports.backend.service",
]
# routes.py is the one module core reads to assemble a product's API router, so it is the
# only way a view becomes reachable over HTTP. Restricting what it may import gives the chain
# core -> routes -> presentation -> facade as three import edges: a ViewSet outside
# presentation/ cannot be routed, and the contract above already keeps presentation/ from
# reaching past the facade. Grandfathered edges below are products whose views still live in
# backend/api/; `hogli product:isolate:move` relocates them and the line comes out.
[[tool.importlinter.contracts]]
name = "routes must only import presentation"
type = "forbidden"
source_modules = ["products.*.backend.routes"]
forbidden_modules = [
"products.*.backend",
]
allow_indirect_imports = true
ignore_imports = [
"products.**.backend.routes -> products.**.backend.presentation.**",
# TODO: views still registered from backend/api/ — move them into presentation/ and drop the line
"products.actions.backend.routes -> products.actions.backend.api.action",
"products.ai_observability.backend.routes -> products.ai_observability.backend.api",
"products.annotations.backend.routes -> products.annotations.backend.api.annotation",
"products.approvals.backend.routes -> products.approvals.backend.api",
"products.business_knowledge.backend.routes -> products.business_knowledge.backend.api",
"products.cdp.backend.routes -> products.cdp.backend.api.hog_function",
"products.cdp.backend.routes -> products.cdp.backend.api.hog_function_template",
"products.cdp.backend.routes -> products.cdp.backend.api.plugin",
"products.cdp.backend.routes -> products.cdp.backend.api.plugin_log_entry",
"products.cohorts.backend.routes -> products.cohorts.backend.api.staff_tools",
"products.conversations.backend.routes -> products.conversations.backend.api",
"products.dashboards.backend.routes -> products.dashboards.backend.api.dashboard",
"products.dashboards.backend.routes -> products.dashboards.backend.api.dashboard_templates",
"products.early_access_features.backend.routes -> products.early_access_features.backend.api",
"products.exports.backend.routes -> products.exports.backend.api.exports",
"products.feature_flags.backend.routes -> products.feature_flags.backend.api.feature_flag",
"products.feature_flags.backend.routes -> products.feature_flags.backend.api.flag_value",
"products.feature_flags.backend.routes -> products.feature_flags.backend.api.organization_feature_flag",
"products.feature_flags.backend.routes -> products.feature_flags.backend.api.scheduled_change",
"products.feature_flags.backend.routes -> products.feature_flags.backend.api.staff_cache",
"products.feature_flags.backend.routes -> products.feature_flags.backend.api.staff_team_config",
"products.feature_flags.backend.routes -> products.feature_flags.backend.api.staff_teams",
"products.field_notes.backend.routes -> products.field_notes.backend.api",
"products.growth.backend.routes -> products.growth.backend.api.ai_enrichment",
"products.growth.backend.routes -> products.growth.backend.api.identity_matching",
"products.growth.backend.routes -> products.growth.backend.api.product_push",
"products.managed_migrations.backend.routes -> products.managed_migrations.backend.api.batch_imports",
"products.managed_migrations.backend.routes -> products.managed_migrations.backend.api.support_batch_imports",
"products.marketing_analytics.backend.routes -> products.marketing_analytics.backend.api",
"products.posthog_ai.backend.routes -> products.posthog_ai.backend.api",
"products.product_tours.backend.routes -> products.product_tours.backend.api",
"products.pulse.backend.routes -> products.pulse.backend.api.brief",
"products.reminders.backend.routes -> products.reminders.backend.api.reminder",
"products.replay_vision.backend.routes -> products.replay_vision.backend.api",
"products.revenue_analytics.backend.routes -> products.revenue_analytics.backend.api",
"products.signals.backend.routes -> products.signals.backend.views",
"products.signals.backend.routes -> products.signals.backend.scout_chat",
"products.signals.backend.routes -> products.signals.backend.scout_harness.views",
"products.signals.backend.routes -> products.signals.backend.scout_suggestions_api",
"products.skills.backend.routes -> products.skills.backend.api.community_skills",
"products.skills.backend.routes -> products.skills.backend.api.marketplace_views",
"products.skills.backend.routes -> products.skills.backend.api.skills",
"products.surveys.backend.routes -> products.surveys.backend.api.survey",
"products.web_analytics.backend.routes -> products.web_analytics.backend.api",
"products.web_analytics.backend.routes -> products.web_analytics.backend.api.custom_bot_rules",
"products.web_analytics.backend.routes -> products.web_analytics.backend.api.heatmaps_api",
"products.web_analytics.backend.routes -> products.web_analytics.backend.api.web_analytics_achievements",
"products.web_analytics.backend.routes -> products.web_analytics.backend.api.web_analytics_filter_preset",
"products.web_analytics.backend.routes -> products.web_analytics.backend.api.web_analytics_path_cleaning_suggestions",
]
# webhook_consumers.py is the one module core reads (through posthog.ingress.dispatch.loading)
# to register a product's inbound webhook consumers. Restricting what it may import gives the
# chain core -> webhook_consumers -> facade as two import edges, so a handler cannot reach
# product internals.
[[tool.importlinter.contracts]]
name = "webhook consumers must only import facade"
type = "forbidden"
source_modules = ["products.*.backend.webhook_consumers"]
forbidden_modules = [
"products.*.backend",
]
allow_indirect_imports = true
ignore_imports = [
"products.**.backend.webhook_consumers -> products.**.backend.facade.**",
]
# The facade is the layer below presentation. A facade that imports its product's presentation
# inverts the layering (and drags DRF onto every consumer's import path); a facade that imports
# DRF is presentation in disguise. Only direct imports are checked (allow_indirect_imports is
# forced — without it import-linter rejects the wildcard modules as having shared descendants),
# so DRF reached through an internal module stays green: this enforces the layering norm, not a
# transitive guarantee. See products/architecture.md § Wiring couplings.
[[tool.importlinter.contracts]]
name = "facade must not import presentation or DRF"
type = "forbidden"
source_modules = ["products.*.backend.facade"]
forbidden_modules = [
"products.*.backend.presentation",
"rest_framework",
]
allow_indirect_imports = true
ignore_imports = [
# TODO: existing violations — move the presentation-shaped code out of the facade, or the
# facade-shaped code out of presentation, and drop the line
"products.logs.backend.facade.retention_views -> products.logs.backend.presentation.views.retention_api",
"products.managed_warehouse.backend.facade.api -> products.managed_warehouse.backend.presentation.views",
"products.tasks.backend.facade.api -> products.tasks.backend.presentation.serializers",
"products.billing_alerts.backend.facade.api -> rest_framework",
"products.dashboards.backend.facade.api -> rest_framework",
"products.experiments.backend.facade.api -> rest_framework",
"products.feature_flags.backend.facade.api -> rest_framework",
"products.tasks.backend.facade.api -> rest_framework",
"products.tasks.backend.facade.client_provenance -> rest_framework",
]
# Contract files hold the data types other products read, so they stay free of Django, directly
# and through other product modules. Define a choices enum with posthog.enums.LabeledStrEnum or
# LabeledIntEnum instead of models.TextChoices. import-linter treats posthog as an external
# package, so Django reached through posthog.* is not visible here. See products/architecture.md
# § Contracts.
[[tool.importlinter.contracts]]
name = "facade contracts and enums must not import Django (use posthog.enums for choices)"
type = "forbidden"
source_modules = [
"products.*.backend.facade.contracts",
"products.*.backend.facade.enums",
"products.*.backend.facade.types",
]
forbidden_modules = ["django"]
allow_indirect_imports = false
[tool.ty.rules]
# Import resolution - ty can't always find all modules/stubs
unresolved-import = "ignore"
# Django-related rules - ty doesn't support Django's metaprogramming yet
# See: https://github.com/astral-sh/ty/issues/291
unresolved-attribute = "ignore" # Django model fields, relationships, auto-generated attributes
invalid-argument-type = "ignore" # Django querysets, managers, form fields
unknown-argument = "ignore" # Django kwargs, queryset filters, manager methods
invalid-base = "ignore" # Django model metaclasses (e.g., models.Model)
missing-argument = "ignore" # Django auto-generated __init__, managers
too-many-positional-arguments = "ignore" # Django metaprogramming signatures
# Dual mypy+ty — mypy still runs, so its artifacts are legitimate
redundant-cast = "ignore" # casts needed by mypy that ty resolves
unused-type-ignore-comment = "ignore" # type: ignore comments required by mypy
unused-ignore-comment = "error" # default is warn, which does not fail CI; error keeps stale ty: ignore directives out
# Flags guards on stub and TypedDict types that runtime data does not honor
# (billing JSON, posthoganalytics.api_key, untyped access-control callers)
redundant-condition = "ignore"
# Django lazy imports and settings indirection
possibly-missing-submodule = "ignore"
# DRF serializer pattern — validate/update/create overrides are idiomatic
invalid-method-override = "ignore"
invalid-attribute-override = "ignore" # Django manager/field overrides (e.g. `objects`)
# Conditional base classes (e.g. EnterpriseEventQuery | EventQuery pattern)
unsupported-base = "ignore"
# Protobuf generated code + Django lazy references
unresolved-reference = "ignore"
# Third-party library overload stubs (Anthropic SDK, Django, re, etc.)
no-matching-overload = "ignore"
# Framework metaclass magic — ty can't resolve dynamic callables
call-top-callable = "ignore"
call-non-callable = "ignore"
# Django querysets typed as `object` — ty can't narrow through managers
not-iterable = "ignore"
# `in` operator on Django/framework `object` types
unsupported-operator = "ignore"
# Subscript on `object`/`None` from unresolved Django types
not-subscriptable = "ignore"
# mypy already catches deprecations; ty is a fast preflight, not the authority
deprecated = "ignore"
# ty disagrees with mypy on some existing code — baselined, not suppressed.
# These rules catch real bugs in new code, so they stay enabled.
# invalid-type-form is the exception: generated code + ty limitations on valid patterns.
invalid-type-form = "ignore"
[[tool.ty.overrides]]
# Scanner schemas are built with create_model at runtime, so every kwarg looks extra to ty
include = ["products/replay_vision/backend/tests/test_scanners.py"]
[tool.ty.overrides.rules]
pydantic-discarded-extra-argument = "ignore"