Skip to content
Draft
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
46 changes: 36 additions & 10 deletions bin/pyactivate
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import logging
import os
import platform
import shutil
import subprocess
import sys
import venv
Expand All @@ -22,14 +23,20 @@ from typing import Optional

logger = logging.getLogger("bootstrap")

# Minimum supported Python. Keep in sync with `PYTHON_MIN_VERSION` in
# `ci/test/lint-main/checks/check-python-version.sh` and with the version named
# in `doc/developer/guide.md`.
MIN_HEXVERSION = 0x030D0000
MIN_VERSION = "3.13.0"


def main(args: list[str]) -> int:
logging.basicConfig(level=os.environ.get("MZ_DEV_LOG", "WARNING").upper())
logger.debug("args=%s", args)

# Validate Python version.
if sys.hexversion < 0x030a0000:
print("fatal: python v3.10.0+ required", file=sys.stderr)
if sys.hexversion < MIN_HEXVERSION:
print(f"fatal: python v{MIN_VERSION}+ required", file=sys.stderr)
print(
" hint: you have v{}.{}.{}".format(
sys.version_info.major, sys.version_info.minor, sys.version_info.micro
Expand Down Expand Up @@ -71,26 +78,45 @@ def activate_venv(py_dir: Path) -> Path:
# have a working virtualenv. Instead we use the presence of the
# `stamp_path`, as that indicates the virtualenv was once working enough to
# have dependencies installed into it.
# The version check in `main` covers the bootstrap interpreter, not this one,
# and the two need not agree: the virtualenv outlives the interpreter it was
# built from. Probe the version here as well, so raising the minimum rebuilds
# existing virtualenvs instead of silently reusing one that is too old and
# failing later on an import the old version does not have.
try:
os.stat(stamp_path)
subprocess.check_call([python, "-c", ""])
subprocess.check_call(
[python, "-c", f"import sys; sys.exit(sys.hexversion < {MIN_HEXVERSION})"]
)
except Exception as e:
print("==> Checking for existing virtualenv", file=sys.stderr)
if isinstance(e, FileNotFoundError):
print("no existing virtualenv detected", file=sys.stderr)
else:
# Usually just an indication that the user has upgraded the system
# Python that the virtualenv is referring to. Not important to
# bubble up the error here. If it's persistent, it'll occur in the
# new virtual env and bubble up when we exec later.
# Either the interpreter is gone, usually because the system Python
# it referred to was upgraded, or it predates the minimum version.
# Not important to tell the two apart or to bubble the error up: a
# problem that survives the rebuild surfaces when we exec later.
print(
"warning: existing virtualenv is unable to execute python; will recreate",
f"warning: existing virtualenv is missing python or older than v{MIN_VERSION}; will recreate",
file=sys.stderr
)
logger.info("python exec error: %s", e)
logger.info("python probe error: %s", e)
print(f"==> Initializing virtualenv in {venv_dir}", file=sys.stderr)
try:
subprocess.check_call(["uv", "venv", venv_dir])
# Clear the path first. Reaching this point means the virtualenv is
# absent or unusable, so whatever occupies it should go, and removing
# it here rather than asking `uv` to keeps the behavior the same on
# every `uv` version. The flags that would express this, `--clear` and
# `--force`, disagree about a path that is not a virtualenv, and
# `--force` does not exist before uv 0.11.17.
shutil.rmtree(venv_dir, ignore_errors=True)
# Build from the interpreter running this script, the one the version
# check above validated. Left to itself, `uv` resolves an interpreter
# by its own preference order, which favors uv-managed installs and so
# can pick a Python older than the system one and older than the check
# accepts.
subprocess.check_call(["uv", "venv", "--python", sys.executable, venv_dir])
except FileNotFoundError:
# Install Python into the virtualenv via a symlink rather than copying,
# except on Windows. This matches the behavior of the `python -m venv`
Expand Down
2 changes: 1 addition & 1 deletion ci/builder/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ pytest-split==0.11.0
pyyaml==6.0.3
requests==2.34.2
ruamel.yaml==0.18.17
ruff==0.0.292
ruff==0.16.5
# NOTE scipy 1.15 is required for Python 3.10 compatibility
scipy==1.15.3; python_version < "3.11"
scipy==1.17.1; python_version >= "3.11"
Expand Down
10 changes: 4 additions & 6 deletions ci/cleanup/aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
# the Business Source License, use of this software will be governed
# by the Apache License, Version 2.0.

from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from pathlib import PurePosixPath
from typing import Any
from urllib.parse import unquote, urlparse
Expand All @@ -29,7 +29,7 @@ def clean_up_kinesis() -> None:
continue
desc = client.describe_stream(StreamName=stream)
created_at = desc["StreamDescription"]["StreamCreationTimestamp"]
age = datetime.now(timezone.utc) - created_at
age = datetime.now(UTC) - created_at
if age <= MAX_AGE:
print(f"Skipping stream {stream} whose age is beneath threshold")
continue
Expand All @@ -45,7 +45,7 @@ def clean_up_s3() -> None:
if not desc["Name"].startswith("testdrive"):
print("Skipping non-testdrive bucket {}".format(desc["Name"]))
continue
age = datetime.now(timezone.utc) - desc["CreationDate"]
age = datetime.now(UTC) - desc["CreationDate"]
if age <= MAX_AGE:
print(
"Skipping bucket {} whose age is beneath threshold".format(desc["Name"])
Expand Down Expand Up @@ -76,9 +76,7 @@ def clean_up_sqs() -> None:
QueueUrl=queue, AttributeNames=["All"]
)
created_at = int(attributes["Attributes"]["CreatedTimestamp"])
age = datetime.now(timezone.utc) - datetime.fromtimestamp(
created_at, timezone.utc
)
age = datetime.now(UTC) - datetime.fromtimestamp(created_at, UTC)
if age <= MAX_AGE:
print(f"Skipping queue {name} whose age is beneath threshold")
continue
Expand Down
2 changes: 1 addition & 1 deletion ci/deploy/pypi.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
# the Business Source License, use of this software will be governed
# by the Apache License, Version 2.0.

import distutils.core # pyright: ignore
import os
import sys
import tarfile
Expand All @@ -16,6 +15,7 @@
from pathlib import Path
from typing import Literal

import distutils.core # pyright: ignore
import requests

from materialize import spawn
Expand Down
2 changes: 1 addition & 1 deletion ci/load/periodic.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def main() -> None:
now = datetime.datetime.utcnow()
scratch.launch_cluster(
[desc],
nonce=now.replace(tzinfo=datetime.timezone.utc).isoformat(),
nonce=now.replace(tzinfo=datetime.UTC).isoformat(),
# Keep alive for at least a day.
delete_after=datetime.datetime.utcnow() + datetime.timedelta(days=1),
)
Expand Down
8 changes: 4 additions & 4 deletions ci/mkpipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from collections import OrderedDict
from collections.abc import Iterable, Iterator
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -669,9 +669,9 @@ def switch_jobs_to_aws(pipeline: Any, priority: int) -> None:
runnable = job.get("runnable_at")
if not runnable or job.get("started_at"):
continue
if datetime.now(timezone.utc) - datetime.fromisoformat(
runnable
) < timedelta(minutes=20):
if datetime.now(UTC) - datetime.fromisoformat(runnable) < timedelta(
minutes=20
):
continue

print(
Expand Down
4 changes: 2 additions & 2 deletions ci/test/lint-main/checks/check-python-files.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ if [[ ! "${MZDEV_NO_PYTHON:-}" ]]; then

try xargs npx --yes "pyright@$pyright_version" --warnings --threads 4 < "$python_files_list"

try xargs bin/pyactivate -m ruff < "$python_files_list"
try xargs bin/pyactivate -m ruff check < "$python_files_list"
# We need to maintain compatibility with older Python versions for this
try xargs bin/pyactivate -m ruff --target-version=py38 < "$dbt_files_list"
try xargs bin/pyactivate -m ruff check --target-version=py38 < "$dbt_files_list"
fi

try_status_report
17 changes: 10 additions & 7 deletions ci/test/lint-main/checks/check-python-version.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,34 +9,37 @@
# the Business Source License, use of this software will be governed
# by the Apache License, Version 2.0.
#
# check-python-version.sh — make sure Python 3.10 keeps working
# check-python-version.sh — make sure the minimum supported Python keeps working

set -euo pipefail

cd "$(dirname "$0")/../../../.."

. misc/shlib/shlib.bash

# Keep in sync with the version check in `bin/pyactivate`.
PYTHON_MIN_VERSION=3.13

if [[ ! "${MZDEV_NO_PYTHON:-}" ]]; then
if ! uv --version >/dev/null 2>/dev/null; then
echo "lint: uv is not installed"
echo "hint: refer to https://docs.astral.sh/uv/getting-started/installation/ for install instructions"
exit 1
fi

py310_venv="$(mktemp -d)/venv-py310"
trap 'rm -rf "$py310_venv"' EXIT
py_min_venv="$(mktemp -d)/venv-py-min"
trap 'rm -rf "$py_min_venv"' EXIT

try uv venv --python 3.10 "$py310_venv"
try uv pip compile --python-version 3.10 ci/builder/requirements.txt
try uv pip install --python "$py310_venv/bin/python" --requirement ci/builder/requirements.txt
try uv venv --python "$PYTHON_MIN_VERSION" "$py_min_venv"
try uv pip compile --python-version "$PYTHON_MIN_VERSION" ci/builder/requirements.txt
try uv pip install --python "$py_min_venv/bin/python" --requirement ci/builder/requirements.txt
# Wrap the compileall (the actual work) in `try`, not git_files, and keep it
# out of a pipeline: `try` in a pipeline runs in a subshell and loses its
# accounting. Guard against empty input too, otherwise compileall with no
# file arguments compiles all of sys.path instead of the repo.
py_files=$(git_files '*.py')
if [[ -n "$py_files" ]]; then
try xargs "$py310_venv/bin/python" -m compileall -q <<< "$py_files"
try xargs "$py_min_venv/bin/python" -m compileall -q <<< "$py_files"
fi
fi

Expand Down
10 changes: 6 additions & 4 deletions doc/developer/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,12 @@ environment. Most of this should be taken care of by the `bin/pyactivate`
script, which constructs a local virtual environment and keeps necessary
dependencies up to date.

We support, as a minimum version, the default Python provided in the [most
recent Ubuntu LTS release](https://wiki.ubuntu.com/Releases). As of January 2026
this is Python 3.12, provided in Ubuntu 24.04 "Noble Numbat". Earlier versions may
work but are not supported. Our recommended installation methods are:
We support Python 3.13 as a minimum version, which is what our CI builder image
provides and therefore the oldest version anything is tested against. It is also
no newer than the default Python in the [most recent Ubuntu LTS
release](https://wiki.ubuntu.com/Releases), which ships 3.14 as of Ubuntu 26.04
"Resolute Raccoon", so a current LTS satisfies it. Earlier versions may work but
are not supported. Our recommended installation methods are:

- macOS: [Homebrew](https://brew.sh) + [uv](https://docs.astral.sh/uv/)
1. `brew install uv`
Expand Down
2 changes: 1 addition & 1 deletion misc/mcp-materialize/mcp_materialize/mz_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ async def __aexit__(self, exc_type, exc, tb) -> None:
self._bg_task.cancel()
try:
await asyncio.wait_for(self._bg_task, timeout=5.0)
except (asyncio.CancelledError, asyncio.TimeoutError):
except (TimeoutError, asyncio.CancelledError):
pass

async def _subscribe(self) -> None:
Expand Down
6 changes: 2 additions & 4 deletions misc/python/materialize/buildkite.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,12 @@
from collections.abc import Callable
from enum import Enum, auto
from pathlib import Path
from typing import Any, TypeVar
from typing import Any

import yaml

from materialize import git, spawn, ui

T = TypeVar("T")


class BuildkiteEnvVar(Enum):
# environment
Expand Down Expand Up @@ -229,7 +227,7 @@ def notify_qa_team_about_failure(failure: str) -> None:
)


def shard_list(items: list[T], to_identifier: Callable[[T], str]) -> list[T]:
def shard_list[T](items: list[T], to_identifier: Callable[[T], str]) -> list[T]:
if len(items) == 0:
return []

Expand Down
4 changes: 2 additions & 2 deletions misc/python/materialize/cli/fmt.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ def _ruff_cmd(*, check: bool) -> list[str]:
return [
"bash",
"-c",
f'. misc/shlib/shlib.bash && git_files "*.py" | grep -v "^misc/dbt-materialize/" | xargs bin/pyactivate -m ruff{fix}',
f'. misc/shlib/shlib.bash && git_files "*.py" | grep -v "^misc/dbt-materialize/" | xargs bin/pyactivate -m ruff check{fix}',
]


Expand All @@ -146,7 +146,7 @@ def _ruff_dbt_cmd(*, check: bool) -> list[str]:
return [
"bash",
"-c",
f'. misc/shlib/shlib.bash && git_files "misc/dbt-materialize/*.py" | xargs bin/pyactivate -m ruff --target-version=py38{fix}',
f'. misc/shlib/shlib.bash && git_files "misc/dbt-materialize/*.py" | xargs bin/pyactivate -m ruff check --target-version=py38{fix}',
]


Expand Down
2 changes: 1 addition & 1 deletion misc/python/materialize/cli/mz_workload_anonymize_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ def cross_schema_cdc_workload() -> dict[str, Any]:
schema. The child key is built in pass 1 while processing the source's
schema (`aaa_src`), before the child's schema (`zzz_upstream`) is mapped, so
a stale key leaks the original schema name unless rebuilt in pass 2."""
empty = lambda: { # noqa: E731
empty = lambda: {
"tables": {},
"views": {},
"materialized_views": {},
Expand Down
15 changes: 7 additions & 8 deletions misc/python/materialize/cli/mz_workload_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,12 @@
import threading
import time
from contextlib import contextmanager
from datetime import datetime, timezone
from typing import Any
from datetime import UTC, datetime
from typing import Any, LiteralString

import psycopg
import yaml
from psycopg.sql import SQL, Composable, Composed, Identifier, Literal
from typing_extensions import LiteralString


@contextmanager
Expand Down Expand Up @@ -195,7 +194,7 @@ def attach_source_statistics_internal(
)
WITH (PROGRESS) AS OF AT LEAST TIMESTAMP {}""").format(
Literal(source_id),
Literal(datetime.fromtimestamp(start_time, tz=timezone.utc)),
Literal(datetime.fromtimestamp(start_time, tz=UTC)),
)
first_timestamp = None
for (
Expand All @@ -213,15 +212,15 @@ def attach_source_statistics_internal(
continue
if "bytes_total" not in source:
first_timestamp = datetime.fromtimestamp(
int(mz_timestamp / 1000), tz=timezone.utc
int(mz_timestamp / 1000), tz=UTC
)
source["bytes_total"] = int(bytes_received)
source["messages_total"] = int(messages_received)
if mz_timestamp / 1000 > end_time:
assert "bytes_total" in source and "messages_total" in source
assert first_timestamp
duration = (
datetime.fromtimestamp(int(mz_timestamp / 1000), tz=timezone.utc)
datetime.fromtimestamp(int(mz_timestamp / 1000), tz=UTC)
- first_timestamp
).total_seconds()
if duration:
Expand Down Expand Up @@ -287,7 +286,7 @@ def main() -> int:
"--output",
type=str,
help="Path to write the workload.yml, - for stdout",
default=f"workload_{datetime.now(timezone.utc).strftime('%Y-%m-%dT%H-%M-%S')}.yml",
default=f"workload_{datetime.now(UTC).strftime('%Y-%m-%dT%H-%M-%S')}.yml",
)
parser.add_argument(
"--time",
Expand Down Expand Up @@ -644,7 +643,7 @@ def main() -> int:
conn,
SQL(
"SELECT sql, cluster_name, database_name, search_path, statement_type, finished_status, params, transaction_isolation, session_id, transaction_id, began_at, finished_at - began_at, result_size FROM mz_internal.mz_recent_activity_log WHERE began_at > {} ORDER BY began_at ASC"
).format(Literal(datetime.fromtimestamp(start_time, tz=timezone.utc))),
).format(Literal(datetime.fromtimestamp(start_time, tz=UTC))),
):
workload["queries"].append(
{
Expand Down
2 changes: 1 addition & 1 deletion misc/python/materialize/cli/mzcompose.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ def main(argv: list[str]) -> None:
# Convert to a list of the members: shtab renders them via str(), and
# argparse still accepts the type-converted member, which converting to
# name strings would not (the member never compares equal to its name).
for action in parser._actions: # noqa: SLF001
for action in parser._actions:
if isinstance(action.choices, type) and issubclass(action.choices, enum.Enum):
action.choices = list(action.choices)

Expand Down
2 changes: 1 addition & 1 deletion misc/python/materialize/cli/orchestratord.py
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,7 @@ def kubectl(*args: str, cluster: str, **subprocess_args):
T = TypeVar("T")


def retry(
def retry[T](
f: Callable[[], T],
max_attempts: int = 60,
sleep_secs: int = 1,
Expand Down
Loading
Loading