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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@ Examples:
- Follow PEP 8 conventions.
- Use `autopep8` for Python formatting.

## Python Modernization

- Use modern Python syntax, typing, and standard-library APIs compatible with
each package's minimum supported Python version.
- Do not introduce APIs deprecated in any supported Python version. Nearby
legacy code is not precedent for new code.
- Preserve public API compatibility and runtime annotation behavior when
modernizing.
- Resolve deprecation warnings introduced by your changes. Any suppression
must be narrowly scoped and justified.
- Ruff's `UP` rules complement Flake8 and `autopep8`; they do not detect every
deprecated API. Temporary rule exclusions in `pyproject.toml` track existing
cleanup work, not preferred patterns for new code.

## Public API Compatibility

- Treat adding an abstract method or property to a public ABC as a breaking
Expand Down Expand Up @@ -91,7 +105,9 @@ priority over style.
## Python Linting

This repository uses [flake8](https://flake8.pycqa.org/) for Python
linting. Run it after making changes to verify there are no issues. Lint
linting and Ruff's `UP` rules for modernization checks. Run `nox -s lint`
for both checks. Ruff targets Python 3.11 for core/Managed and Python 3.13
for Functions, including their tests and examples. Lint
package source and its tests separately, matching CI:

```bash
Expand All @@ -103,6 +119,9 @@ python -m flake8 azure-functions-durable
python -m flake8 tests/azure-functions-durable
```

For a focused modernization check, run `python -m ruff check` with the changed
paths. Run from the repository root to use the shared configuration.

## Markdown Style

Use GitHub-style callouts for notes, warnings, and tips in Markdown files:
Expand Down Expand Up @@ -172,6 +191,15 @@ python -m pytest tests/azure-functions-durable \
-m "not dts and not azurite and not functions_e2e"
```

The root pytest configuration treats `DeprecationWarning` and
`PendingDeprecationWarning` as errors. Nox keeps Python 3.13 as the strict
deprecation gate for every package and reports these warnings without failing
on the other supported versions. Keep the full compatibility matrix; do not
raise package minimum versions just to satisfy modernization suggestions.
Tests deliberately exercising deprecated APIs should assert their warnings
with `pytest.warns`. Known third-party exceptions must be scoped by warning
category, message, and originating module where practical, with a reason.

Run Azure Functions Durable E2E tests through Nox, not directly from the root
virtual environment. Nox creates an isolated Python 3.13 session environment,
installs the local packages editable, and links it into each sample Function
Expand Down
1 change: 1 addition & 0 deletions dev-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ grpcio-tools
nox
pymarkdownlnt
pyright
ruff
45 changes: 45 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,51 @@ Azure Functions unit tests cover Python 3.13--3.15; use
Nox fails rather than silently skipping a session when its required Python
interpreter is unavailable.

### Modernization and deprecation checks

`nox -s lint` runs Flake8 and Ruff's `UP` modernization rules; `autopep8`
remains the formatter. Ruff uses Python 3.11 for core/Managed code and Python
3.13 for Functions code, including their respective tests and examples.
Generated protobuf files are excluded, including when passed explicitly.
For a focused check, run from the repository root:

```sh
python -m ruff check durabletask/client.py
```

The temporary typing and UTC rule exclusions in `pyproject.toml` are tracked
in [#322](https://github.com/microsoft/durabletask-python/issues/322) and
[#323](https://github.com/microsoft/durabletask-python/issues/323).
Behavior-sensitive generator and enum suggestions have file-specific
exceptions tracked in
[#324](https://github.com/microsoft/durabletask-python/issues/324).
Excluded rules do not reject new occurrences until their cleanup is complete;
they are not recommendations for new code.

The root pytest configuration treats `DeprecationWarning` and
`PendingDeprecationWarning` as errors and explicitly uses function-scoped event
loops for async fixtures. Nox uses Python 3.13 as the representative
strict deprecation gate for all three packages. On other supported versions,
Nox reports these warnings without failing, preserving compatibility coverage
without requiring APIs unavailable at the package's minimum version.
Run the strict sessions with:

```sh
nox -s core_tests-3.13 azuremanaged_tests-3.13 functions_unit-3.13
```

Direct pytest runs from the repository root are strict on every interpreter.
Tests intentionally exercising deprecated APIs should use `pytest.warns`.
Any known third-party warning exception must be narrowly scoped by category,
message, and originating module where practical, and include a reason.
Two upstream Functions loose-codec warnings remain visible but non-fatal
while legacy payload support is required; their removal is tracked in
[#325](https://github.com/microsoft/durabletask-python/issues/325).
Warnings raised in separately launched Functions workers are not governed by
the parent pytest process's filters.

### Services and focused validation

Nox starts Azurite automatically for the core and Azure Functions tests. The
Azure Managed tests start a disposable DTS emulator Docker container. Start
Docker before running that session. Azure Functions end-to-end tests also
Expand Down
26 changes: 21 additions & 5 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,16 @@ def _pytest_arguments(
return tuple(posargs) if has_target else (default_target, *posargs)


def _pytest_warning_arguments(session: nox.Session) -> tuple[str, ...]:
"""Keep Python 3.13 as the strict deprecation gate across packages."""
if session.python == "3.13":
return ()
return (
"-W", "default::DeprecationWarning",
"-W", "default::PendingDeprecationWarning",
)


def _start_azurite(
session: nox.Session,
ports: Sequence[int],
Expand Down Expand Up @@ -280,8 +290,8 @@ def _stop_dts_emulator(container_name: str) -> None:

@nox.session
def lint(session: nox.Session) -> None:
"""Run the Flake8 checks required by the GitHub Actions workflows."""
session.install("flake8")
"""Run Flake8 and Ruff modernization checks without replacing formatting."""
session.install("flake8", "ruff")
targets = session.posargs or (
"durabletask",
"durabletask-azuremanaged",
Expand All @@ -290,8 +300,10 @@ def lint(session: nox.Session) -> None:
"tests/durabletask-azuremanaged",
"tests/azure-functions-durable",
"examples",
"noxfile.py",
)
session.run("flake8", *targets)
session.run("ruff", "check", *targets)


@nox.session(python=["3.11"])
Expand Down Expand Up @@ -413,6 +425,7 @@ def core_tests(session: nox.Session) -> None:
arguments = _pytest_arguments("tests/durabletask", session.posargs)
session.run(
"pytest",
*_pytest_warning_arguments(session),
*arguments,
"-m",
"not dts",
Expand Down Expand Up @@ -441,7 +454,10 @@ def azuremanaged_tests(session: nox.Session) -> None:
"tests/durabletask-azuremanaged",
session.posargs,
)
session.run("pytest", *arguments, "-m", "dts", "--verbose")
session.run(
"pytest", *_pytest_warning_arguments(session),
*arguments, "-m", "dts", "--verbose",
)
finally:
_stop_dts_emulator(container_name)

Expand All @@ -454,7 +470,7 @@ def functions_unit(session: nox.Session) -> None:
session.install("pytest")
arguments = _pytest_arguments("tests/azure-functions-durable", session.posargs)
session.run(
"pytest", *arguments,
"pytest", *_pytest_warning_arguments(session), *arguments,
"-m", "not dts and not azurite and not functions_e2e",
"--verbose",
)
Expand Down Expand Up @@ -508,7 +524,7 @@ def functions_e2e(session: nox.Session) -> None:
session.posargs,
)
session.run(
"pytest", *arguments,
"pytest", *_pytest_warning_arguments(session), *arguments,
"-m", "functions_e2e",
)
finally:
Expand Down
35 changes: 35 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,42 @@ include = ["durabletask", "durabletask.*"]
minversion = "6.0"
testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = [
"error::DeprecationWarning",
"error::PendingDeprecationWarning",
# Legacy Functions payloads still require the upstream loose codec.
# Report only these known warnings without failing; see issue #325.
'default:azure\.functions df_loads was called without expected_type\.:DeprecationWarning:azure\.functions\._durable_functions$',
'default:azure\.functions Durable JSON codec reconstructed a custom object via the loose-mode object_hook path\.:DeprecationWarning:azure\.functions\._durable_functions$',
]
markers = [
"azurite: tests that require Azurite (local Azure Storage emulator)",
"functions_e2e: end-to-end tests that require the Azure Functions Core Tools (func) and Azurite",
]

[tool.ruff]
target-version = "py311"
extend-exclude = ["*_pb2.py", "*_pb2_grpc.py", "*_pb2.pyi", "*_pb2_grpc.pyi", ".python_packages"]
force-exclude = true

[tool.ruff.per-file-target-version]
"azure-functions-durable/**/*.py" = "py313"
"tests/azure-functions-durable/**/*.py" = "py313"

[tool.ruff.lint]
select = ["UP"]
ignore = [
# Typing migration requires runtime annotation compatibility review:
# https://github.com/microsoft/durabletask-python/issues/322
"UP006", "UP007", "UP035", "UP037", "UP040", "UP043", "UP045", "UP047",
# Repository-wide UTC alias cleanup:
# https://github.com/microsoft/durabletask-python/issues/323
"UP017",
]

[tool.ruff.lint.per-file-ignores]
# These suggestions can change generator protocol or public enum behavior:
# https://github.com/microsoft/durabletask-python/issues/324
"durabletask/internal/orchestration_entity_context.py" = ["UP028"]
"durabletask/scheduled/schedule_status.py" = ["UP042"]
3 changes: 2 additions & 1 deletion tests/azure-functions-durable/test_client_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -932,7 +932,8 @@ def test_entity_id_pickle_round_trip():
with pytest.warns(DeprecationWarning):
entity_id = df.EntityId("Counter", "one")

restored = pickle.loads(pickle.dumps(entity_id))
with pytest.warns(DeprecationWarning, match="EntityId is deprecated"):
restored = pickle.loads(pickle.dumps(entity_id))
assert type(restored) is df.EntityId
assert isinstance(restored, df.EntityId)
assert restored.name == "counter"
Expand Down
9 changes: 5 additions & 4 deletions tests/azure-functions-durable/test_worker_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -629,10 +629,11 @@ def orchestrator(ctx: task.OrchestrationContext, _):
def test_activity_retry_then_fan_out_uses_distinct_task_ids():
"""Regression test for Azure/azure-functions-durable-python#603."""
def orchestrator(context):
options = df.RetryOptions(
first_retry_interval_in_milliseconds=100,
max_number_of_attempts=3,
)
with pytest.warns(DeprecationWarning, match="RetryOptions is deprecated"):
options = df.RetryOptions(
first_retry_interval_in_milliseconds=100,
max_number_of_attempts=3,
)
yield context.call_activity_with_retry("flaky", options)
tasks = [context.call_activity("square", value) for value in range(13)]
return (yield context.task_all(tasks))
Expand Down
4 changes: 3 additions & 1 deletion tests/durabletask-azuremanaged/test_dts_batch_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ def emit(self, record):
log_records.append(record)

handler = ListHandler()
logger = logging.Logger("test.azuremanaged.client.pagination")
logger.addHandler(handler)

with DurableTaskSchedulerWorker(host_address=endpoint, secure_channel=True,
taskhub=taskhub_name, token_credential=None) as w:
Expand All @@ -165,7 +167,7 @@ def emit(self, record):

c = DurableTaskSchedulerClient(host_address=endpoint, secure_channel=True,
taskhub=taskhub_name, token_credential=None,
log_handler=handler)
logger=logger)

# Create at least 3 orchestrations to test the limit
ids = []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ def test_blob_name_matches_dotnet_hash_scheme():
assert _dotnet_o_format(last_updated) == "2026-06-04T17:09:09.4209900+00:00"

expected_hash = hashlib.sha256(
f"{_dotnet_o_format(last_updated)}|{instance_id}".encode("utf-8")
f"{_dotnet_o_format(last_updated)}|{instance_id}".encode()
).hexdigest()

assert _blob_name_for(
Expand Down
4 changes: 3 additions & 1 deletion tests/durabletask/test_batch_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,14 +238,16 @@ def emit(self, record):
log_records.append(record)

handler = ListHandler()
logger = logging.Logger("test.client.pagination")
logger.addHandler(handler)

worker = TaskHubGrpcWorker(host_address=HOST)

worker.add_orchestrator(empty_orchestrator)
worker.start()

try:
with TaskHubGrpcClient(host_address=HOST, log_handler=handler) as c:
with TaskHubGrpcClient(host_address=HOST, logger=logger) as c:
# Create at least 3 orchestrations to test the limit
ids = []
for i in range(3):
Expand Down
22 changes: 11 additions & 11 deletions tests/durabletask/test_orchestration_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1044,7 +1044,7 @@ def orchestrator(ctx: task.OrchestrationContext, orchestrator_input):
registry = worker._Registry()
name = registry.add_orchestrator(orchestrator)

current_timestamp = datetime.utcnow()
current_timestamp = datetime.now(UTC).replace(tzinfo=None)
# Simulate the task failing for the first time and confirm that a timer is scheduled for 1 second in the future
old_events = [
helpers.new_orchestrator_started_event(timestamp=current_timestamp),
Expand Down Expand Up @@ -1222,7 +1222,7 @@ def orchestrator(ctx: task.OrchestrationContext, orchestrator_input):

registry = worker._Registry()
name = registry.add_orchestrator(orchestrator)
current_timestamp = datetime.utcnow()
current_timestamp = datetime.now(UTC).replace(tzinfo=None)
old_events = [
helpers.new_orchestrator_started_event(timestamp=current_timestamp),
helpers.new_execution_started_event(name, TEST_INSTANCE_ID, encoded_input=None),
Expand Down Expand Up @@ -1281,7 +1281,7 @@ def orchestrator(ctx: task.OrchestrationContext, orchestrator_input):
registry = worker._Registry()
name = registry.add_orchestrator(orchestrator)

current_timestamp = datetime.utcnow()
current_timestamp = datetime.now(UTC).replace(tzinfo=None)

# Simulate the task failing for the first time — retry timer should be created at 1 second
old_events = [
Expand Down Expand Up @@ -1370,7 +1370,7 @@ def orchestrator(ctx: task.OrchestrationContext, _):
registry = worker._Registry()
name = registry.add_orchestrator(orchestrator)

current_timestamp = datetime.utcnow()
current_timestamp = datetime.now(UTC).replace(tzinfo=None)

# First failure — retry timer at 5 seconds (default backoff=1.0, so 5 * 1^0 = 5)
old_events = [
Expand Down Expand Up @@ -1433,7 +1433,7 @@ def orchestrator(ctx: task.OrchestrationContext, orchestrator_input):
name = registry.add_orchestrator(orchestrator)
executor = worker._OrchestrationExecutor(registry, TEST_LOGGER, JsonDataConverter())

start = datetime.utcnow()
start = datetime.now(UTC).replace(tzinfo=None)
t1 = start + timedelta(days=3)
t2 = start + timedelta(days=6)
t3 = start + timedelta(days=9)
Expand Down Expand Up @@ -1804,7 +1804,7 @@ def test_nondeterminism_expected_sub_orchestration_task_completion_wrong_task_ty
This variation tests the case where the expected task type is wrong (e.g. the code schedules a timer task
but the history contains a sub-orchestration completed task)."""
def orchestrator(ctx: task.OrchestrationContext, _):
result = yield ctx.create_timer(datetime.utcnow()) # created timer but history expects sub-orchestration
result = yield ctx.create_timer(datetime.now(UTC).replace(tzinfo=None)) # created timer but history expects sub-orchestration
return result

registry = worker._Registry()
Expand Down Expand Up @@ -2084,7 +2084,7 @@ def orchestrator(ctx: task.OrchestrationContext, _):

# Complete the timer task. The orchestration should move to the wait_for_external_event step, which
# should then complete immediately because the event was buffered in the old event history.
timer_due_time = datetime.utcnow() + timedelta(days=1)
timer_due_time = datetime.now(UTC).replace(tzinfo=None) + timedelta(days=1)
old_events = new_events + [helpers.new_timer_created_event(1, timer_due_time)]
new_events = [helpers.new_timer_fired_event(1, timer_due_time)]
executor = worker._OrchestrationExecutor(registry, TEST_LOGGER, JsonDataConverter())
Expand Down Expand Up @@ -2173,9 +2173,9 @@ def orchestrator(ctx: task.OrchestrationContext, input: int):
helpers.new_event_raised_event("my_event", encoded_input="42"),
helpers.new_event_raised_event("my_event", encoded_input="43"),
helpers.new_event_raised_event("my_event", encoded_input="44"),
helpers.new_timer_created_event(1, datetime.utcnow() + timedelta(days=1))]
helpers.new_timer_created_event(1, datetime.now(UTC).replace(tzinfo=None) + timedelta(days=1))]
new_events = [
helpers.new_timer_fired_event(1, datetime.utcnow() + timedelta(days=1))]
helpers.new_timer_fired_event(1, datetime.now(UTC).replace(tzinfo=None) + timedelta(days=1))]

# Execute the orchestration. It should be in a running state waiting for the timer to fire
executor = worker._OrchestrationExecutor(registry, TEST_LOGGER, JsonDataConverter())
Expand Down Expand Up @@ -3180,7 +3180,7 @@ def orchestrator(ctx: task.OrchestrationContext, _):
orchestrator_name = registry.add_orchestrator(orchestrator)
registry.add_activity(dummy_activity)

current_timestamp = datetime.utcnow()
current_timestamp = datetime.now(UTC).replace(tzinfo=None)
# Simulate the task failing for the first time and confirm that a timer is scheduled for 1 second in the future
old_events = [
helpers.new_orchestrator_started_event(timestamp=current_timestamp),
Expand Down Expand Up @@ -3268,7 +3268,7 @@ def orchestrator(ctx: task.OrchestrationContext, _):
orchestrator_name = registry.add_orchestrator(orchestrator)
registry.add_activity(dummy_activity)

current_timestamp = datetime.utcnow()
current_timestamp = datetime.now(UTC).replace(tzinfo=None)
# Simulate the task failing for the first time and confirm that a timer is scheduled for 2 seconds in the future
old_events = [
helpers.new_orchestrator_started_event(timestamp=current_timestamp),
Expand Down
Loading
Loading