prefect-client 3.2.15.dev9__py3-none-any.whl → 3.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. prefect/_build_info.py +3 -3
  2. prefect/_internal/compatibility/deprecated.py +28 -23
  3. prefect/_internal/pydantic/v2_schema.py +0 -14
  4. prefect/_internal/schemas/bases.py +6 -3
  5. prefect/_internal/schemas/validators.py +9 -2
  6. prefect/blocks/system.py +7 -6
  7. prefect/client/cloud.py +0 -1
  8. prefect/client/orchestration/__init__.py +0 -1
  9. prefect/client/orchestration/_concurrency_limits/client.py +0 -4
  10. prefect/client/schemas/objects.py +54 -25
  11. prefect/client/schemas/schedules.py +6 -5
  12. prefect/concurrency/_asyncio.py +1 -12
  13. prefect/concurrency/asyncio.py +0 -4
  14. prefect/concurrency/services.py +1 -3
  15. prefect/concurrency/sync.py +1 -6
  16. prefect/context.py +4 -1
  17. prefect/events/clients.py +3 -3
  18. prefect/events/filters.py +7 -2
  19. prefect/events/related.py +5 -3
  20. prefect/events/schemas/events.py +4 -4
  21. prefect/events/utilities.py +43 -32
  22. prefect/exceptions.py +1 -1
  23. prefect/flow_engine.py +2 -11
  24. prefect/futures.py +3 -12
  25. prefect/locking/filesystem.py +3 -2
  26. prefect/logging/formatters.py +1 -1
  27. prefect/logging/handlers.py +2 -2
  28. prefect/main.py +5 -5
  29. prefect/results.py +2 -1
  30. prefect/runner/runner.py +5 -3
  31. prefect/runner/server.py +2 -2
  32. prefect/runtime/flow_run.py +11 -6
  33. prefect/server/api/concurrency_limits_v2.py +12 -8
  34. prefect/server/api/deployments.py +4 -2
  35. prefect/server/api/server.py +2 -2
  36. prefect/server/api/ui/flows.py +7 -2
  37. prefect/server/api/ui/task_runs.py +3 -3
  38. prefect/states.py +10 -35
  39. prefect/task_engine.py +16 -9
  40. prefect/task_worker.py +6 -3
  41. prefect/tasks.py +5 -0
  42. prefect/telemetry/bootstrap.py +3 -1
  43. prefect/telemetry/instrumentation.py +13 -4
  44. prefect/telemetry/logging.py +3 -1
  45. prefect/types/_datetime.py +190 -77
  46. prefect/utilities/collections.py +6 -12
  47. prefect/utilities/dockerutils.py +14 -5
  48. prefect/utilities/engine.py +3 -8
  49. prefect/workers/base.py +15 -10
  50. prefect/workers/server.py +0 -1
  51. {prefect_client-3.2.15.dev9.dist-info → prefect_client-3.3.0.dist-info}/METADATA +7 -4
  52. {prefect_client-3.2.15.dev9.dist-info → prefect_client-3.3.0.dist-info}/RECORD +54 -56
  53. prefect/_internal/pydantic/annotations/__init__.py +0 -0
  54. prefect/_internal/pydantic/annotations/pendulum.py +0 -78
  55. {prefect_client-3.2.15.dev9.dist-info → prefect_client-3.3.0.dist-info}/WHEEL +0 -0
  56. {prefect_client-3.2.15.dev9.dist-info → prefect_client-3.3.0.dist-info}/licenses/LICENSE +0 -0
prefect/states.py CHANGED
@@ -5,7 +5,6 @@ import datetime
5
5
  import sys
6
6
  import traceback
7
7
  import uuid
8
- import warnings
9
8
  from collections import Counter
10
9
  from types import GeneratorType, TracebackType
11
10
  from typing import TYPE_CHECKING, Any, Dict, Iterable, Optional, Type
@@ -15,7 +14,6 @@ import httpx
15
14
  from opentelemetry import propagate
16
15
  from typing_extensions import TypeGuard
17
16
 
18
- from prefect._internal.compatibility import deprecated
19
17
  from prefect.client.schemas.objects import State, StateDetails, StateType
20
18
  from prefect.exceptions import (
21
19
  CancelledRun,
@@ -28,9 +26,9 @@ from prefect.exceptions import (
28
26
  UnfinishedRun,
29
27
  )
30
28
  from prefect.logging.loggers import get_logger, get_run_logger
31
- from prefect.types._datetime import DateTime, Duration, now
29
+ from prefect.types._datetime import now
32
30
  from prefect.utilities.annotations import BaseAnnotation
33
- from prefect.utilities.asyncutils import in_async_main_thread, sync_compatible
31
+ from prefect.utilities.asyncutils import sync_compatible
34
32
  from prefect.utilities.collections import ensure_iterable
35
33
 
36
34
  if TYPE_CHECKING:
@@ -73,17 +71,9 @@ def to_state_create(state: State) -> "StateCreate":
73
71
  )
74
72
 
75
73
 
76
- @deprecated.deprecated_parameter(
77
- "fetch",
78
- when=lambda fetch: fetch is not True,
79
- start_date="Oct 2024",
80
- end_date="Jan 2025",
81
- help="Please ensure you are awaiting the call to `result()` when calling in an async context.",
82
- )
83
- def get_state_result(
74
+ async def get_state_result(
84
75
  state: "State[R]",
85
76
  raise_on_failure: bool = True,
86
- fetch: bool = True,
87
77
  retry_result_failure: bool = True,
88
78
  ) -> "R":
89
79
  """
@@ -92,25 +82,11 @@ def get_state_result(
92
82
  See `State.result()`
93
83
  """
94
84
 
95
- if not fetch and in_async_main_thread():
96
- warnings.warn(
97
- (
98
- "State.result() was called from an async context but not awaited. "
99
- "This method will be updated to return a coroutine by default in "
100
- "the future. Pass `fetch=True` and `await` the call to get rid of "
101
- "this warning."
102
- ),
103
- DeprecationWarning,
104
- stacklevel=2,
105
- )
106
-
107
- return state.data
108
- else:
109
- return _get_state_result(
110
- state,
111
- raise_on_failure=raise_on_failure,
112
- retry_result_failure=retry_result_failure,
113
- )
85
+ return await _get_state_result(
86
+ state,
87
+ raise_on_failure=raise_on_failure,
88
+ retry_result_failure=retry_result_failure,
89
+ )
114
90
 
115
91
 
116
92
  RESULT_READ_MAXIMUM_ATTEMPTS = 10
@@ -155,7 +131,6 @@ async def _get_state_result_data_with_retries(
155
131
  await asyncio.sleep(RESULT_READ_RETRY_DELAY)
156
132
 
157
133
 
158
- @sync_compatible
159
134
  async def _get_state_result(
160
135
  state: "State[R]", raise_on_failure: bool, retry_result_failure: bool = True
161
136
  ) -> "R":
@@ -759,9 +734,9 @@ def Paused(
759
734
  pass
760
735
  else:
761
736
  state_details.pause_timeout = (
762
- DateTime.instance(pause_expiration_time)
737
+ pause_expiration_time
763
738
  if pause_expiration_time
764
- else now() + Duration(seconds=timeout_seconds or 0)
739
+ else now() + datetime.timedelta(seconds=timeout_seconds or 0)
765
740
  )
766
741
 
767
742
  state_details.pause_reschedule = reschedule
prefect/task_engine.py CHANGED
@@ -8,6 +8,7 @@ import time
8
8
  from asyncio import CancelledError
9
9
  from contextlib import ExitStack, asynccontextmanager, contextmanager, nullcontext
10
10
  from dataclasses import dataclass, field
11
+ from datetime import timedelta
11
12
  from functools import partial
12
13
  from textwrap import dedent
13
14
  from typing import (
@@ -32,6 +33,7 @@ import anyio
32
33
  from opentelemetry import trace
33
34
  from typing_extensions import ParamSpec, Self
34
35
 
36
+ import prefect.types._datetime
35
37
  from prefect.cache_policies import CachePolicy
36
38
  from prefect.client.orchestration import PrefectClient, SyncPrefectClient, get_client
37
39
  from prefect.client.schemas import TaskRun
@@ -80,7 +82,6 @@ from prefect.states import (
80
82
  )
81
83
  from prefect.telemetry.run_telemetry import RunTelemetry
82
84
  from prefect.transactions import IsolationLevel, Transaction, transaction
83
- from prefect.types._datetime import DateTime, Duration
84
85
  from prefect.utilities._engine import get_hook_name
85
86
  from prefect.utilities.annotations import NotSet
86
87
  from prefect.utilities.asyncutils import run_coro_as_sync
@@ -438,7 +439,7 @@ class SyncTaskRunEngine(BaseTaskRunEngine[P, R]):
438
439
  if last_state.timestamp == new_state.timestamp:
439
440
  # Ensure that the state timestamp is unique, or at least not equal to the last state.
440
441
  # This might occur especially on Windows where the timestamp resolution is limited.
441
- new_state.timestamp += Duration(microseconds=1)
442
+ new_state.timestamp += timedelta(microseconds=1)
442
443
 
443
444
  # Ensure that the state_details are populated with the current run IDs
444
445
  new_state.state_details.task_run_id = self.task_run.id
@@ -487,7 +488,7 @@ class SyncTaskRunEngine(BaseTaskRunEngine[P, R]):
487
488
 
488
489
  def handle_success(self, result: R, transaction: Transaction) -> R:
489
490
  if self.task.cache_expiration is not None:
490
- expiration = DateTime.now("utc") + self.task.cache_expiration
491
+ expiration = prefect.types._datetime.now("UTC") + self.task.cache_expiration
491
492
  else:
492
493
  expiration = None
493
494
 
@@ -536,7 +537,8 @@ class SyncTaskRunEngine(BaseTaskRunEngine[P, R]):
536
537
  else self.task.retry_delay_seconds
537
538
  )
538
539
  new_state = AwaitingRetry(
539
- scheduled_time=DateTime.now("utc").add(seconds=delay)
540
+ scheduled_time=prefect.types._datetime.now("UTC")
541
+ + timedelta(seconds=delay)
540
542
  )
541
543
  else:
542
544
  delay = None
@@ -729,7 +731,9 @@ class SyncTaskRunEngine(BaseTaskRunEngine[P, R]):
729
731
  async def wait_until_ready(self) -> None:
730
732
  """Waits until the scheduled time (if its the future), then enters Running."""
731
733
  if scheduled_time := self.state.state_details.scheduled_time:
732
- sleep_time = (scheduled_time - DateTime.now("utc")).total_seconds()
734
+ sleep_time = (
735
+ scheduled_time - prefect.types._datetime.now("UTC")
736
+ ).total_seconds()
733
737
  await anyio.sleep(sleep_time if sleep_time > 0 else 0)
734
738
  new_state = Retrying() if self.state.name == "AwaitingRetry" else Running()
735
739
  self.set_state(
@@ -968,7 +972,7 @@ class AsyncTaskRunEngine(BaseTaskRunEngine[P, R]):
968
972
  if last_state.timestamp == new_state.timestamp:
969
973
  # Ensure that the state timestamp is unique, or at least not equal to the last state.
970
974
  # This might occur especially on Windows where the timestamp resolution is limited.
971
- new_state.timestamp += Duration(microseconds=1)
975
+ new_state.timestamp += timedelta(microseconds=1)
972
976
 
973
977
  # Ensure that the state_details are populated with the current run IDs
974
978
  new_state.state_details.task_run_id = self.task_run.id
@@ -1018,7 +1022,7 @@ class AsyncTaskRunEngine(BaseTaskRunEngine[P, R]):
1018
1022
 
1019
1023
  async def handle_success(self, result: R, transaction: Transaction) -> R:
1020
1024
  if self.task.cache_expiration is not None:
1021
- expiration = DateTime.now("utc") + self.task.cache_expiration
1025
+ expiration = prefect.types._datetime.now("UTC") + self.task.cache_expiration
1022
1026
  else:
1023
1027
  expiration = None
1024
1028
 
@@ -1066,7 +1070,8 @@ class AsyncTaskRunEngine(BaseTaskRunEngine[P, R]):
1066
1070
  else self.task.retry_delay_seconds
1067
1071
  )
1068
1072
  new_state = AwaitingRetry(
1069
- scheduled_time=DateTime.now("utc").add(seconds=delay)
1073
+ scheduled_time=prefect.types._datetime.now("UTC")
1074
+ + timedelta(seconds=delay)
1070
1075
  )
1071
1076
  else:
1072
1077
  delay = None
@@ -1257,7 +1262,9 @@ class AsyncTaskRunEngine(BaseTaskRunEngine[P, R]):
1257
1262
  async def wait_until_ready(self) -> None:
1258
1263
  """Waits until the scheduled time (if its the future), then enters Running."""
1259
1264
  if scheduled_time := self.state.state_details.scheduled_time:
1260
- sleep_time = (scheduled_time - DateTime.now("utc")).total_seconds()
1265
+ sleep_time = (
1266
+ scheduled_time - prefect.types._datetime.now("UTC")
1267
+ ).total_seconds()
1261
1268
  await anyio.sleep(sleep_time if sleep_time > 0 else 0)
1262
1269
  new_state = Retrying() if self.state.name == "AwaitingRetry" else Running()
1263
1270
  await self.set_state(
prefect/task_worker.py CHANGED
@@ -20,6 +20,7 @@ from fastapi import FastAPI
20
20
  from typing_extensions import ParamSpec, Self, TypeVar
21
21
  from websockets.exceptions import InvalidStatus
22
22
 
23
+ import prefect.types._datetime
23
24
  from prefect import Task
24
25
  from prefect._internal.concurrency.api import create_call, from_sync
25
26
  from prefect.cache_policies import DEFAULT, NO_CACHE
@@ -34,7 +35,7 @@ from prefect.settings import (
34
35
  )
35
36
  from prefect.states import Pending
36
37
  from prefect.task_engine import run_task_async, run_task_sync
37
- from prefect.types._datetime import DateTime
38
+ from prefect.types import DateTime
38
39
  from prefect.utilities.annotations import NotSet
39
40
  from prefect.utilities.asyncutils import asyncnullcontext, sync_compatible
40
41
  from prefect.utilities.engine import emit_task_run_state_change_event
@@ -256,7 +257,9 @@ class TaskWorker:
256
257
  )
257
258
 
258
259
  async def _safe_submit_scheduled_task_run(self, task_run: TaskRun):
259
- self.in_flight_task_runs[task_run.task_key][task_run.id] = DateTime.now()
260
+ self.in_flight_task_runs[task_run.task_key][task_run.id] = (
261
+ prefect.types._datetime.now("UTC")
262
+ )
260
263
  try:
261
264
  await self._submit_scheduled_task_run(task_run)
262
265
  except BaseException as exc:
@@ -379,7 +382,7 @@ class TaskWorker:
379
382
  await self._exit_stack.enter_async_context(self._runs_task_group)
380
383
  self._exit_stack.enter_context(self._executor)
381
384
 
382
- self._started_at = DateTime.now()
385
+ self._started_at = prefect.types._datetime.now("UTC")
383
386
  return self
384
387
 
385
388
  async def __aexit__(self, *exc_info: Any) -> None:
prefect/tasks.py CHANGED
@@ -765,6 +765,11 @@ class Task(Generic[P, R]):
765
765
  def on_rollback(
766
766
  self, fn: Callable[["Transaction"], None]
767
767
  ) -> Callable[["Transaction"], None]:
768
+ if asyncio.iscoroutinefunction(fn):
769
+ raise ValueError(
770
+ "Asynchronous rollback hooks are not yet supported. Rollback hooks must be synchronous functions."
771
+ )
772
+
768
773
  self.on_rollback_hooks.append(fn)
769
774
  return fn
770
775
 
@@ -10,7 +10,9 @@ if TYPE_CHECKING:
10
10
  logger: "logging.Logger" = get_logger(__name__)
11
11
 
12
12
  if TYPE_CHECKING:
13
- from opentelemetry.sdk._logs import LoggerProvider
13
+ from opentelemetry.sdk._logs import (
14
+ LoggerProvider, # pyright: ignore[reportPrivateImportUsage]
15
+ )
14
16
  from opentelemetry.sdk.metrics import MeterProvider
15
17
  from opentelemetry.sdk.trace import TracerProvider
16
18
 
@@ -6,10 +6,17 @@ from urllib.parse import urljoin
6
6
  from uuid import UUID
7
7
 
8
8
  from opentelemetry import metrics, trace
9
- from opentelemetry._logs import set_logger_provider
9
+ from opentelemetry._logs import (
10
+ set_logger_provider, # pyright: ignore[reportPrivateImportUsage]
11
+ )
10
12
  from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
11
- from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
12
- from opentelemetry.sdk._logs.export import SimpleLogRecordProcessor
13
+ from opentelemetry.sdk._logs import (
14
+ LoggerProvider, # pyright: ignore[reportPrivateImportUsage]
15
+ LoggingHandler, # pyright: ignore[reportPrivateImportUsage]
16
+ )
17
+ from opentelemetry.sdk._logs.export import (
18
+ SimpleLogRecordProcessor, # pyright: ignore[reportPrivateImportUsage]
19
+ )
13
20
  from opentelemetry.sdk.metrics import MeterProvider
14
21
  from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
15
22
  from opentelemetry.sdk.resources import Resource
@@ -20,7 +27,9 @@ from .processors import InFlightSpanProcessor
20
27
  from .services import QueueingLogExporter, QueueingSpanExporter
21
28
 
22
29
  if TYPE_CHECKING:
23
- from opentelemetry.sdk._logs import LoggerProvider
30
+ from opentelemetry.sdk._logs import (
31
+ LoggerProvider, # pyright: ignore[reportPrivateImportUsage]
32
+ )
24
33
 
25
34
  UUID_REGEX = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
26
35
 
@@ -2,7 +2,9 @@ import logging
2
2
  from typing import TYPE_CHECKING, Optional
3
3
 
4
4
  if TYPE_CHECKING:
5
- from opentelemetry.sdk._logs import LoggingHandler
5
+ from opentelemetry.sdk._logs import (
6
+ LoggingHandler, # pyright: ignore[reportPrivateImportUsage]
7
+ )
6
8
 
7
9
  _log_handler: Optional["LoggingHandler"] = None
8
10
 
@@ -1,89 +1,119 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import datetime
4
- from typing import Any
5
-
6
- import pendulum
7
- import pendulum.tz
8
- from pendulum.date import Date as PendulumDate
9
- from pendulum.datetime import DateTime as PendulumDateTime
10
- from pendulum.duration import Duration as PendulumDuration
11
- from pendulum.time import Time as PendulumTime
12
- from pendulum.tz.timezone import FixedTimezone, Timezone
13
- from pydantic_extra_types.pendulum_dt import (
14
- Date as PydanticDate,
15
- )
16
- from pydantic_extra_types.pendulum_dt import (
17
- DateTime as PydanticDateTime,
18
- )
19
- from pydantic_extra_types.pendulum_dt import (
20
- Duration as PydanticDuration,
21
- )
4
+ import sys
5
+ from contextlib import contextmanager
6
+ from typing import Any, cast
7
+ from unittest import mock
8
+ from zoneinfo import ZoneInfo, available_timezones
9
+
10
+ import humanize
11
+ from dateutil.parser import parse
22
12
  from typing_extensions import TypeAlias
23
13
 
24
- DateTime: TypeAlias = PydanticDateTime
25
- Date: TypeAlias = PydanticDate
26
- Duration: TypeAlias = PydanticDuration
27
- UTC: pendulum.tz.Timezone = pendulum.tz.UTC
28
-
29
-
30
- def parse_datetime(
31
- value: str,
32
- **options: Any,
33
- ) -> PendulumDateTime | PendulumDate | PendulumTime | PendulumDuration:
34
- return pendulum.parse(value, **options)
35
-
36
-
37
- def format_diff(
38
- diff: PendulumDuration,
39
- is_now: bool = True,
40
- absolute: bool = False,
41
- locale: str | None = None,
42
- ) -> str:
43
- return pendulum.format_diff(diff, is_now, absolute, locale)
44
-
45
-
46
- def local_timezone() -> Timezone | FixedTimezone:
47
- return pendulum.tz.local_timezone()
14
+ if sys.version_info >= (3, 13):
15
+ DateTime: TypeAlias = datetime.datetime
16
+ Date: TypeAlias = datetime.date
17
+ Duration: TypeAlias = datetime.timedelta
18
+ else:
19
+ import pendulum
20
+ import pendulum.tz
21
+ from pydantic_extra_types.pendulum_dt import (
22
+ Date as PydanticDate,
23
+ )
24
+ from pydantic_extra_types.pendulum_dt import (
25
+ DateTime as PydanticDateTime,
26
+ )
27
+ from pydantic_extra_types.pendulum_dt import (
28
+ Duration as PydanticDuration,
29
+ )
30
+
31
+ DateTime: TypeAlias = PydanticDateTime
32
+ Date: TypeAlias = PydanticDate
33
+ Duration: TypeAlias = PydanticDuration
34
+
35
+
36
+ def parse_datetime(dt: str) -> datetime.datetime:
37
+ if sys.version_info >= (3, 13):
38
+ parsed_dt = parse(dt)
39
+ if parsed_dt.tzinfo is None:
40
+ # Assume UTC if no timezone is provided
41
+ return parsed_dt.replace(tzinfo=ZoneInfo("UTC"))
42
+ else:
43
+ return parsed_dt
44
+ else:
45
+ return cast(datetime.datetime, pendulum.parse(dt))
48
46
 
49
47
 
50
48
  def get_timezones() -> tuple[str, ...]:
51
- return pendulum.tz.timezones()
49
+ return tuple(available_timezones())
52
50
 
53
51
 
54
- def create_datetime_instance(v: datetime.datetime) -> DateTime:
55
- return DateTime.instance(v)
52
+ def create_datetime_instance(v: datetime.datetime) -> datetime.datetime:
53
+ if sys.version_info >= (3, 13):
54
+ if v.tzinfo is None:
55
+ # Assume UTC if no timezone is provided
56
+ return v.replace(tzinfo=ZoneInfo("UTC"))
57
+ else:
58
+ return v
56
59
 
60
+ return DateTime.instance(v)
57
61
 
58
- def from_format(
59
- value: str,
60
- fmt: str,
61
- tz: str | Timezone = UTC,
62
- locale: str | None = None,
63
- ) -> DateTime:
64
- return DateTime.instance(pendulum.from_format(value, fmt, tz, locale))
65
62
 
63
+ def from_timestamp(ts: float, tz: str | Any = "UTC") -> datetime.datetime:
64
+ if sys.version_info >= (3, 13):
65
+ if not isinstance(tz, str):
66
+ # Handle pendulum edge case
67
+ tz = tz.name
68
+ return datetime.datetime.fromtimestamp(ts, ZoneInfo(tz))
66
69
 
67
- def from_timestamp(ts: float, tz: str | pendulum.tz.Timezone = UTC) -> DateTime:
68
- return DateTime.instance(pendulum.from_timestamp(ts, tz))
70
+ return pendulum.from_timestamp(ts, tz)
69
71
 
70
72
 
71
- def human_friendly_diff(dt: DateTime | datetime.datetime) -> str:
72
- if isinstance(dt, DateTime):
73
- return dt.diff_for_humans()
73
+ def human_friendly_diff(
74
+ dt: datetime.datetime | None, other: datetime.datetime | None = None
75
+ ) -> str:
76
+ if dt is None:
77
+ return ""
78
+
79
+ # Handle naive datetimes consistently across Python versions
80
+ if dt.tzinfo is None:
81
+ local_tz = datetime.datetime.now().astimezone().tzinfo
82
+ dt = dt.replace(tzinfo=local_tz).astimezone(ZoneInfo("UTC"))
83
+ elif hasattr(dt.tzinfo, "name"):
84
+ dt = dt.replace(tzinfo=ZoneInfo(getattr(dt.tzinfo, "name")))
85
+
86
+ # Handle other parameter if provided
87
+ if other is not None:
88
+ if other.tzinfo is None:
89
+ local_tz = datetime.datetime.now().astimezone().tzinfo
90
+ other = other.replace(tzinfo=local_tz).astimezone(ZoneInfo("UTC"))
91
+ elif hasattr(other.tzinfo, "name"):
92
+ other = other.replace(tzinfo=ZoneInfo(getattr(other.tzinfo, "name")))
93
+
94
+ if sys.version_info >= (3, 13):
95
+ return humanize.naturaltime(dt, when=other)
96
+
97
+ return DateTime.instance(dt).diff_for_humans(
98
+ other=DateTime.instance(other) if other else None
99
+ )
100
+
101
+
102
+ def now(
103
+ tz: str | Any = "UTC",
104
+ ) -> datetime.datetime:
105
+ if sys.version_info >= (3, 13):
106
+ from whenever import ZonedDateTime
107
+
108
+ if isinstance(getattr(tz, "name", None), str):
109
+ tz = tz.name
110
+
111
+ return ZonedDateTime.now(tz).py_datetime()
74
112
  else:
75
- return DateTime.instance(dt).diff_for_humans()
76
-
77
-
78
- def now(tz: str | Timezone = UTC) -> DateTime:
79
- return DateTime.now(tz)
113
+ return pendulum.now(tz)
80
114
 
81
115
 
82
- def add_years(dt: DateTime, years: int) -> DateTime:
83
- return dt.add(years=years)
84
-
85
-
86
- def end_of_period(dt: DateTime, period: str) -> DateTime:
116
+ def end_of_period(dt: datetime.datetime, period: str) -> datetime.datetime:
87
117
  """
88
118
  Returns the end of the specified unit of time.
89
119
 
@@ -91,7 +121,7 @@ def end_of_period(dt: DateTime, period: str) -> DateTime:
91
121
  dt: The datetime to get the end of.
92
122
  period: The period to get the end of.
93
123
  Valid values: 'second', 'minute', 'hour', 'day',
94
- 'week', 'month', 'quarter', 'year'
124
+ 'week'
95
125
 
96
126
  Returns:
97
127
  DateTime: A new DateTime representing the end of the specified unit.
@@ -99,27 +129,110 @@ def end_of_period(dt: DateTime, period: str) -> DateTime:
99
129
  Raises:
100
130
  ValueError: If an invalid unit is specified.
101
131
  """
102
- return dt.end_of(period)
132
+ if sys.version_info >= (3, 13):
133
+ from whenever import Weekday, ZonedDateTime
134
+
135
+ if not isinstance(dt.tzinfo, ZoneInfo):
136
+ zdt = ZonedDateTime.from_py_datetime(
137
+ dt.replace(tzinfo=ZoneInfo(dt.tzname() or "UTC"))
138
+ )
139
+ else:
140
+ zdt = ZonedDateTime.from_py_datetime(dt)
141
+ if period == "second":
142
+ zdt = zdt.replace(nanosecond=999999999)
143
+ elif period == "minute":
144
+ zdt = zdt.replace(second=59, nanosecond=999999999)
145
+ elif period == "hour":
146
+ zdt = zdt.replace(minute=59, second=59, nanosecond=999999999)
147
+ elif period == "day":
148
+ zdt = zdt.replace(hour=23, minute=59, second=59, nanosecond=999999999)
149
+ elif period == "week":
150
+ days_till_end_of_week: int = (
151
+ Weekday.SUNDAY.value - zdt.date().day_of_week().value
152
+ )
153
+ zdt = zdt.replace(
154
+ day=zdt.day + days_till_end_of_week,
155
+ hour=23,
156
+ minute=59,
157
+ second=59,
158
+ nanosecond=999999999,
159
+ )
160
+ else:
161
+ raise ValueError(f"Invalid period: {period}")
162
+
163
+ return zdt.py_datetime()
164
+ else:
165
+ return DateTime.instance(dt).end_of(period)
103
166
 
104
167
 
105
- def start_of_period(dt: DateTime, period: str) -> DateTime:
168
+ def start_of_day(dt: datetime.datetime | DateTime) -> datetime.datetime:
106
169
  """
107
170
  Returns the start of the specified unit of time.
108
171
 
109
172
  Args:
110
173
  dt: The datetime to get the start of.
111
- period: The period to get the start of.
112
- Valid values: 'second', 'minute', 'hour', 'day',
113
- 'week', 'month', 'quarter', 'year'
114
174
 
115
175
  Returns:
116
- DateTime: A new DateTime representing the start of the specified unit.
176
+ datetime.datetime: A new datetime.datetime representing the start of the specified unit.
117
177
 
118
178
  Raises:
119
179
  ValueError: If an invalid unit is specified.
120
180
  """
121
- return dt.start_of(period)
181
+ if sys.version_info >= (3, 13):
182
+ from whenever import ZonedDateTime
122
183
 
184
+ if hasattr(dt, "tz"):
185
+ zdt = ZonedDateTime.from_timestamp(
186
+ dt.timestamp(), tz=dt.tz.name if dt.tz else "UTC"
187
+ )
188
+ else:
189
+ zdt = ZonedDateTime.from_py_datetime(dt)
123
190
 
124
- def earliest_possible_datetime() -> DateTime:
125
- return DateTime.instance(datetime.datetime.min)
191
+ return zdt.start_of_day().py_datetime()
192
+ else:
193
+ return DateTime.instance(dt).start_of("day")
194
+
195
+
196
+ def earliest_possible_datetime() -> datetime.datetime:
197
+ return datetime.datetime.min.replace(tzinfo=ZoneInfo("UTC"))
198
+
199
+
200
+ @contextmanager
201
+ def travel_to(dt: Any):
202
+ if sys.version_info >= (3, 13):
203
+ with mock.patch("prefect.types._datetime.now", return_value=dt):
204
+ yield
205
+
206
+ else:
207
+ from pendulum import travel_to
208
+
209
+ with travel_to(dt, freeze=True):
210
+ yield
211
+
212
+
213
+ def in_local_tz(dt: datetime.datetime) -> datetime.datetime:
214
+ if sys.version_info >= (3, 13):
215
+ from whenever import LocalDateTime, ZonedDateTime
216
+
217
+ if dt.tzinfo is None:
218
+ wdt = LocalDateTime.from_py_datetime(dt)
219
+ else:
220
+ if not isinstance(dt.tzinfo, ZoneInfo):
221
+ if key := getattr(dt.tzinfo, "key", None):
222
+ dt = dt.replace(tzinfo=ZoneInfo(key))
223
+ else:
224
+ utc_dt = dt.astimezone(datetime.timezone.utc)
225
+ dt = utc_dt.replace(tzinfo=ZoneInfo("UTC"))
226
+
227
+ wdt = ZonedDateTime.from_py_datetime(dt).to_system_tz()
228
+
229
+ return wdt.py_datetime()
230
+
231
+ return DateTime.instance(dt).in_tz(pendulum.tz.local_timezone())
232
+
233
+
234
+ def to_datetime_string(dt: datetime.datetime, include_tz: bool = True) -> str:
235
+ if include_tz:
236
+ return dt.strftime("%Y-%m-%d %H:%M:%S %Z")
237
+ else:
238
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
@@ -5,7 +5,6 @@ Utilities for extensions of and operations on Python collections.
5
5
  import io
6
6
  import itertools
7
7
  import types
8
- import warnings
9
8
  from collections import OrderedDict
10
9
  from collections.abc import (
11
10
  Callable,
@@ -509,20 +508,15 @@ def visit_collection(
509
508
 
510
509
  elif isinstance(expr, pydantic.BaseModel):
511
510
  # when extra=allow, fields not in model_fields may be in model_fields_set
512
- model_fields = expr.model_fields_set.union(expr.model_fields.keys())
513
-
514
- # We may encounter a deprecated field here, but this isn't the caller's fault
515
- with warnings.catch_warnings():
516
- warnings.simplefilter("ignore", category=DeprecationWarning)
517
-
518
- updated_data = {
519
- field: visit_nested(getattr(expr, field)) for field in model_fields
520
- }
511
+ original_data = dict(expr)
512
+ updated_data = {
513
+ field: visit_nested(value) for field, value in original_data.items()
514
+ }
521
515
 
522
516
  if return_data:
523
517
  modified = any(
524
- getattr(expr, field) is not updated_data[field]
525
- for field in model_fields
518
+ original_data[field] is not updated_data[field]
519
+ for field in updated_data
526
520
  )
527
521
  if modified:
528
522
  # Use construct to avoid validation and handle immutability