[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.76.0", "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.7.5", "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 , 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 --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.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"