custom-timezone-fdg 0.1.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.
- custom_timezone/__init__.py +40 -0
- custom_timezone/apps.py +45 -0
- custom_timezone/config.py +120 -0
- custom_timezone/core.py +224 -0
- custom_timezone/exceptions.py +22 -0
- custom_timezone/middleware.py +43 -0
- custom_timezone/py.typed +0 -0
- custom_timezone/timezone_utils.py +20 -0
- custom_timezone_fdg-0.1.0.dist-info/METADATA +280 -0
- custom_timezone_fdg-0.1.0.dist-info/RECORD +13 -0
- custom_timezone_fdg-0.1.0.dist-info/WHEEL +5 -0
- custom_timezone_fdg-0.1.0.dist-info/licenses/LICENSE +21 -0
- custom_timezone_fdg-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""One configurable business time zone for any Python project.
|
|
2
|
+
|
|
3
|
+
Plain Python, no framework required::
|
|
4
|
+
|
|
5
|
+
import custom_timezone as ct
|
|
6
|
+
|
|
7
|
+
ct.configure("America/Toronto") # or set CUSTOM_TIME_ZONE in the environment
|
|
8
|
+
start = ct.parse_local_datetime("2026-09-26T14:30") # aware, America/Toronto
|
|
9
|
+
ct.format_datetime(start) # '2026-09-26 14:30:00 EDT'
|
|
10
|
+
|
|
11
|
+
Django users additionally get an app config and a middleware (see the README); those live in
|
|
12
|
+
``custom_timezone.apps`` and ``custom_timezone.middleware`` and are only imported by Django.
|
|
13
|
+
"""
|
|
14
|
+
from .config import DEFAULT_TIMEZONE, ENV_VAR, SETTING_NAME, configure, get_timezone, override
|
|
15
|
+
from .core import (
|
|
16
|
+
format_datetime,
|
|
17
|
+
format_datetime_local,
|
|
18
|
+
localize_datetime,
|
|
19
|
+
now,
|
|
20
|
+
parse_local_datetime,
|
|
21
|
+
to_custom_timezone,
|
|
22
|
+
)
|
|
23
|
+
from .exceptions import (
|
|
24
|
+
AmbiguousTimeError,
|
|
25
|
+
CustomTimezoneError,
|
|
26
|
+
NonExistentTimeError,
|
|
27
|
+
TimezoneConfigurationError,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__version__ = "0.1.0"
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"__version__",
|
|
34
|
+
"configure", "get_timezone", "override",
|
|
35
|
+
"localize_datetime", "parse_local_datetime", "to_custom_timezone",
|
|
36
|
+
"format_datetime", "format_datetime_local", "now",
|
|
37
|
+
"CustomTimezoneError", "TimezoneConfigurationError",
|
|
38
|
+
"NonExistentTimeError", "AmbiguousTimeError",
|
|
39
|
+
"DEFAULT_TIMEZONE", "ENV_VAR", "SETTING_NAME",
|
|
40
|
+
]
|
custom_timezone/apps.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Django app configuration (optional; only needed for the system checks)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, List
|
|
5
|
+
|
|
6
|
+
from django.apps import AppConfig
|
|
7
|
+
from django.core import checks
|
|
8
|
+
|
|
9
|
+
from .config import get_timezone
|
|
10
|
+
from .exceptions import TimezoneConfigurationError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def check_custom_timezone(app_configs: Any = None, **kwargs: Any) -> List[checks.CheckMessage]:
|
|
14
|
+
"""``manage.py check`` support: report a bad CUSTOM_TIME_ZONE instead of failing later."""
|
|
15
|
+
from django.conf import settings
|
|
16
|
+
|
|
17
|
+
problems: List[checks.CheckMessage] = []
|
|
18
|
+
try:
|
|
19
|
+
get_timezone()
|
|
20
|
+
except TimezoneConfigurationError as exc:
|
|
21
|
+
problems.append(
|
|
22
|
+
checks.Error(
|
|
23
|
+
str(exc),
|
|
24
|
+
hint="Set CUSTOM_TIME_ZONE to an IANA name such as 'America/Toronto'.",
|
|
25
|
+
id="custom_timezone.E001",
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
if not settings.USE_TZ:
|
|
29
|
+
problems.append(
|
|
30
|
+
checks.Warning(
|
|
31
|
+
"USE_TZ is False, so datetimes are stored without a time zone.",
|
|
32
|
+
hint="Set USE_TZ = True; custom_timezone is designed for aware datetimes.",
|
|
33
|
+
id="custom_timezone.W001",
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
return problems
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CustomTimezoneConfig(AppConfig):
|
|
40
|
+
name = "custom_timezone"
|
|
41
|
+
label = "custom_timezone"
|
|
42
|
+
verbose_name = "Custom Time Zone"
|
|
43
|
+
|
|
44
|
+
def ready(self) -> None:
|
|
45
|
+
checks.register(check_custom_timezone)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Which time zone is "the custom time zone"?
|
|
2
|
+
|
|
3
|
+
Resolution order (first match wins):
|
|
4
|
+
|
|
5
|
+
1. ``with override("Asia/Shanghai"):`` - scoped to the current thread / asyncio task
|
|
6
|
+
2. ``configure("America/Toronto")`` - process-wide, set explicitly in code
|
|
7
|
+
3. Django's ``settings.CUSTOM_TIME_ZONE`` - only if Django is already loaded in this process
|
|
8
|
+
4. the ``CUSTOM_TIME_ZONE`` environment variable
|
|
9
|
+
5. ``"UTC"``
|
|
10
|
+
|
|
11
|
+
Nothing here imports Django. Step 3 only looks at Django if *your program has already
|
|
12
|
+
imported it*, so plain Python projects never pay for it (or need it installed).
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import contextvars
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from typing import Iterator, Optional, Union
|
|
21
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
22
|
+
|
|
23
|
+
from .exceptions import TimezoneConfigurationError
|
|
24
|
+
|
|
25
|
+
SETTING_NAME = "CUSTOM_TIME_ZONE"
|
|
26
|
+
ENV_VAR = "CUSTOM_TIME_ZONE"
|
|
27
|
+
DEFAULT_TIMEZONE = "UTC"
|
|
28
|
+
|
|
29
|
+
TimezoneLike = Union[str, ZoneInfo]
|
|
30
|
+
|
|
31
|
+
_configured: Optional[ZoneInfo] = None
|
|
32
|
+
_override: "contextvars.ContextVar[Optional[ZoneInfo]]" = contextvars.ContextVar(
|
|
33
|
+
"custom_timezone_override", default=None
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_blank(value: object) -> bool:
|
|
38
|
+
return value is None or (isinstance(value, str) and not value.strip())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load(value: object, source: str) -> ZoneInfo:
|
|
42
|
+
"""Turn an IANA name (or an existing ZoneInfo) into a ZoneInfo, or raise a clear error."""
|
|
43
|
+
if isinstance(value, ZoneInfo):
|
|
44
|
+
return value
|
|
45
|
+
if not isinstance(value, str):
|
|
46
|
+
raise TimezoneConfigurationError(
|
|
47
|
+
f"{source} must be an IANA time zone name such as 'America/Toronto' "
|
|
48
|
+
f"(or a zoneinfo.ZoneInfo), not {type(value).__name__}."
|
|
49
|
+
)
|
|
50
|
+
name = value.strip()
|
|
51
|
+
try:
|
|
52
|
+
return ZoneInfo(name)
|
|
53
|
+
except (ZoneInfoNotFoundError, ValueError, OSError) as exc:
|
|
54
|
+
raise TimezoneConfigurationError(
|
|
55
|
+
f"{source} {name!r} is not a valid IANA time zone name "
|
|
56
|
+
"(for example 'America/Toronto')."
|
|
57
|
+
) from exc
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_timezone(tz: Optional[TimezoneLike] = None) -> ZoneInfo:
|
|
61
|
+
"""Return ``tz`` as a ZoneInfo, or the configured time zone when ``tz`` is None."""
|
|
62
|
+
if tz is None:
|
|
63
|
+
return get_timezone()
|
|
64
|
+
return _load(tz, "time zone")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _django_setting() -> object:
|
|
68
|
+
"""Read ``settings.CUSTOM_TIME_ZONE`` if (and only if) Django is already loaded."""
|
|
69
|
+
conf = sys.modules.get("django.conf")
|
|
70
|
+
if conf is None: # Django not imported (or not installed): never import it ourselves.
|
|
71
|
+
return None
|
|
72
|
+
from django.core.exceptions import ImproperlyConfigured # safe: django.conf is loaded
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
return getattr(conf.settings, SETTING_NAME)
|
|
76
|
+
except (ImproperlyConfigured, AttributeError):
|
|
77
|
+
return None # settings not configured yet, or the project doesn't define the setting
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_timezone() -> ZoneInfo:
|
|
81
|
+
"""Return the custom time zone as a :class:`zoneinfo.ZoneInfo` (see module docstring)."""
|
|
82
|
+
active = _override.get()
|
|
83
|
+
if active is not None:
|
|
84
|
+
return active
|
|
85
|
+
if _configured is not None:
|
|
86
|
+
return _configured
|
|
87
|
+
|
|
88
|
+
value = _django_setting()
|
|
89
|
+
source = f"Django setting {SETTING_NAME}"
|
|
90
|
+
if _is_blank(value):
|
|
91
|
+
value = os.environ.get(ENV_VAR)
|
|
92
|
+
source = f"Environment variable {ENV_VAR}"
|
|
93
|
+
if _is_blank(value):
|
|
94
|
+
return _load(DEFAULT_TIMEZONE, "default time zone")
|
|
95
|
+
return _load(value, source)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def configure(tz: Optional[TimezoneLike]) -> None:
|
|
99
|
+
"""Set the process-wide custom time zone. ``configure(None)`` clears it again.
|
|
100
|
+
|
|
101
|
+
The name is validated immediately, so a typo fails at start-up instead of on the first
|
|
102
|
+
request. Raises :class:`~custom_timezone.TimezoneConfigurationError` if it is invalid.
|
|
103
|
+
"""
|
|
104
|
+
global _configured
|
|
105
|
+
_configured = None if tz is None else _load(tz, "configure() argument")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@contextlib.contextmanager
|
|
109
|
+
def override(tz: TimezoneLike) -> Iterator[ZoneInfo]:
|
|
110
|
+
"""Temporarily use another custom time zone in the current thread / asyncio task.
|
|
111
|
+
|
|
112
|
+
Other threads and tasks are not affected, which makes it safe for per-request or
|
|
113
|
+
per-user time zones in web servers.
|
|
114
|
+
"""
|
|
115
|
+
zone = _load(tz, "override() argument")
|
|
116
|
+
token = _override.set(zone)
|
|
117
|
+
try:
|
|
118
|
+
yield zone
|
|
119
|
+
finally:
|
|
120
|
+
_override.reset(token)
|
custom_timezone/core.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Date-time helpers built on the standard library only (``datetime`` + ``zoneinfo``)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from typing import Any, Literal, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from .config import TimezoneLike, resolve_timezone
|
|
9
|
+
from .exceptions import AmbiguousTimeError, CustomTimezoneError, NonExistentTimeError
|
|
10
|
+
|
|
11
|
+
# How to treat a wall-clock time that happens twice (clocks go back) ...
|
|
12
|
+
Ambiguous = Literal["earliest", "latest", "raise"]
|
|
13
|
+
# ... or that never happens (clocks jump forward) ...
|
|
14
|
+
NonExistent = Literal["raise", "shift_forward", "shift_backward"]
|
|
15
|
+
# ... and how to treat naive datetimes that are passed in for conversion.
|
|
16
|
+
NaiveMode = Literal["local", "utc", "raise"]
|
|
17
|
+
|
|
18
|
+
_UTC = timezone.utc
|
|
19
|
+
DEFAULT_FORMAT = "%Y-%m-%d %H:%M:%S %Z"
|
|
20
|
+
DATETIME_LOCAL_FORMAT = "%Y-%m-%dT%H:%M"
|
|
21
|
+
|
|
22
|
+
_INVALID = "Enter a valid ISO 8601 date and time, for example 2026-09-26T14:30."
|
|
23
|
+
_DATE_ONLY = "Enter a date and a time (for example 2026-09-26T14:30), not just a date."
|
|
24
|
+
|
|
25
|
+
# Parsing is done with an explicit grammar rather than datetime.fromisoformat() so the
|
|
26
|
+
# accepted input is identical on every supported Python version (3.9 .. 3.13).
|
|
27
|
+
_DATETIME_RE = re.compile(
|
|
28
|
+
r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})"
|
|
29
|
+
r"[T ](?P<hour>\d{2}):(?P<minute>\d{2})"
|
|
30
|
+
r"(?::(?P<second>\d{2})(?:[.,](?P<fraction>\d{1,9}))?)?"
|
|
31
|
+
r"\s*(?P<offset>[Zz]|[+-]\d{2}(?::?\d{2})?)?",
|
|
32
|
+
re.ASCII,
|
|
33
|
+
)
|
|
34
|
+
_DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}", re.ASCII)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _check_choice(name: str, value: str, allowed: Tuple[str, ...]) -> None:
|
|
38
|
+
if value not in allowed:
|
|
39
|
+
raise ValueError(f"{name} must be one of {', '.join(map(repr, allowed))}; got {value!r}.")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _is_naive(value: datetime) -> bool:
|
|
43
|
+
return value.tzinfo is None or value.utcoffset() is None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _attach_zone(wall: datetime, tz: Any, ambiguous: str, nonexistent: str) -> datetime:
|
|
47
|
+
"""Give a naive wall-clock time the zone ``tz``, applying the DST policies."""
|
|
48
|
+
wall = wall.replace(tzinfo=None, fold=0)
|
|
49
|
+
first = wall.replace(tzinfo=tz, fold=0)
|
|
50
|
+
second = wall.replace(tzinfo=tz, fold=1)
|
|
51
|
+
if first.utcoffset() == second.utcoffset():
|
|
52
|
+
return first # the normal case: exists exactly once
|
|
53
|
+
|
|
54
|
+
# Offsets differ, so the time is either repeated (clocks go back) or skipped (clocks
|
|
55
|
+
# jump forward). A repeated time survives a round trip through UTC; a skipped one doesn't.
|
|
56
|
+
if first.astimezone(_UTC).astimezone(tz).replace(tzinfo=None) == wall:
|
|
57
|
+
if ambiguous == "raise":
|
|
58
|
+
raise AmbiguousTimeError(
|
|
59
|
+
f"{wall:%Y-%m-%d %H:%M} happens twice in {tz} (clocks go back); "
|
|
60
|
+
"choose ambiguous='earliest' or 'latest'."
|
|
61
|
+
)
|
|
62
|
+
return first if ambiguous == "earliest" else second
|
|
63
|
+
|
|
64
|
+
if nonexistent == "raise":
|
|
65
|
+
raise NonExistentTimeError(
|
|
66
|
+
f"{wall:%Y-%m-%d %H:%M} does not exist in {tz} (clocks jump forward)."
|
|
67
|
+
)
|
|
68
|
+
skipped = first if nonexistent == "shift_forward" else second
|
|
69
|
+
return skipped.astimezone(_UTC).astimezone(tz)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def localize_datetime(
|
|
73
|
+
value: Optional[datetime],
|
|
74
|
+
*,
|
|
75
|
+
tz: Optional[TimezoneLike] = None,
|
|
76
|
+
ambiguous: Ambiguous = "earliest",
|
|
77
|
+
nonexistent: NonExistent = "raise",
|
|
78
|
+
) -> Optional[datetime]:
|
|
79
|
+
"""Make a naive datetime aware in the custom time zone; aware values are returned as-is.
|
|
80
|
+
|
|
81
|
+
``ambiguous`` / ``nonexistent`` control what happens around daylight-saving changes
|
|
82
|
+
(see the README). By default the first occurrence of a repeated time is used and a
|
|
83
|
+
skipped time raises :class:`~custom_timezone.NonExistentTimeError`.
|
|
84
|
+
"""
|
|
85
|
+
if value is None:
|
|
86
|
+
return None
|
|
87
|
+
if not isinstance(value, datetime):
|
|
88
|
+
raise TypeError(f"Expected a datetime, got {type(value).__name__}.")
|
|
89
|
+
_check_choice("ambiguous", ambiguous, ("earliest", "latest", "raise"))
|
|
90
|
+
_check_choice("nonexistent", nonexistent, ("raise", "shift_forward", "shift_backward"))
|
|
91
|
+
if not _is_naive(value):
|
|
92
|
+
return value
|
|
93
|
+
return _attach_zone(value, resolve_timezone(tz), ambiguous, nonexistent)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _parse_offset(text: str) -> timezone:
|
|
97
|
+
if text in ("Z", "z"):
|
|
98
|
+
return _UTC
|
|
99
|
+
sign = -1 if text[0] == "-" else 1
|
|
100
|
+
digits = text[1:].replace(":", "")
|
|
101
|
+
hours = int(digits[:2])
|
|
102
|
+
minutes = int(digits[2:4]) if len(digits) > 2 else 0
|
|
103
|
+
if minutes > 59:
|
|
104
|
+
raise ValueError("minute offset out of range")
|
|
105
|
+
return timezone(sign * timedelta(hours=hours, minutes=minutes)) # ValueError if >= 24h
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_local_datetime(
|
|
109
|
+
value: Any,
|
|
110
|
+
*,
|
|
111
|
+
tz: Optional[TimezoneLike] = None,
|
|
112
|
+
ambiguous: Ambiguous = "earliest",
|
|
113
|
+
nonexistent: NonExistent = "raise",
|
|
114
|
+
allow_date_only: bool = False,
|
|
115
|
+
) -> Optional[datetime]:
|
|
116
|
+
"""Parse an ISO 8601 date-time string; apply the custom time zone if it has no offset.
|
|
117
|
+
|
|
118
|
+
* ``None`` and blank strings return ``None``.
|
|
119
|
+
* ``"2026-09-26T14:30"`` (HTML ``datetime-local``), with optional seconds, fraction,
|
|
120
|
+
``T`` or space separator, and an optional ``Z`` / ``+05:30`` / ``-0400`` offset.
|
|
121
|
+
An explicit offset is kept; otherwise the custom time zone is applied.
|
|
122
|
+
* Date-only strings (``"2026-09-26"``) are rejected unless ``allow_date_only=True``
|
|
123
|
+
(then they mean local midnight).
|
|
124
|
+
* Anything else - including non-string values such as numbers or dicts - raises
|
|
125
|
+
:class:`ValueError` (never ``TypeError``), so one ``except ValueError`` is enough.
|
|
126
|
+
"""
|
|
127
|
+
if value is None:
|
|
128
|
+
return None
|
|
129
|
+
if isinstance(value, datetime):
|
|
130
|
+
return localize_datetime(value, tz=tz, ambiguous=ambiguous, nonexistent=nonexistent)
|
|
131
|
+
if not isinstance(value, str):
|
|
132
|
+
raise ValueError(_INVALID)
|
|
133
|
+
|
|
134
|
+
text = value.strip()
|
|
135
|
+
if not text:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
match = _DATETIME_RE.fullmatch(text)
|
|
139
|
+
try:
|
|
140
|
+
if match is None:
|
|
141
|
+
if _DATE_RE.fullmatch(text):
|
|
142
|
+
if not allow_date_only:
|
|
143
|
+
raise ValueError(_DATE_ONLY)
|
|
144
|
+
wall = datetime(int(text[0:4]), int(text[5:7]), int(text[8:10]))
|
|
145
|
+
return localize_datetime(wall, tz=tz, ambiguous=ambiguous, nonexistent=nonexistent)
|
|
146
|
+
raise ValueError(_INVALID)
|
|
147
|
+
|
|
148
|
+
parts = match.groupdict()
|
|
149
|
+
wall = datetime(
|
|
150
|
+
int(parts["year"]), int(parts["month"]), int(parts["day"]),
|
|
151
|
+
int(parts["hour"]), int(parts["minute"]), int(parts["second"] or 0),
|
|
152
|
+
int((parts["fraction"] or "").ljust(6, "0")[:6] or 0),
|
|
153
|
+
)
|
|
154
|
+
if parts["offset"]:
|
|
155
|
+
return wall.replace(tzinfo=_parse_offset(parts["offset"]))
|
|
156
|
+
except CustomTimezoneError:
|
|
157
|
+
raise # daylight-saving errors keep their own, more specific message
|
|
158
|
+
except ValueError as exc:
|
|
159
|
+
if str(exc) in (_INVALID, _DATE_ONLY):
|
|
160
|
+
raise
|
|
161
|
+
raise ValueError(_INVALID) from exc # e.g. month 13, Feb 30, hour 24, offset +25:00
|
|
162
|
+
|
|
163
|
+
return localize_datetime(wall, tz=tz, ambiguous=ambiguous, nonexistent=nonexistent)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def to_custom_timezone(
|
|
167
|
+
value: Optional[datetime],
|
|
168
|
+
*,
|
|
169
|
+
tz: Optional[TimezoneLike] = None,
|
|
170
|
+
naive: NaiveMode = "local",
|
|
171
|
+
) -> Optional[datetime]:
|
|
172
|
+
"""Convert a datetime to the custom time zone (same instant, local wall-clock time).
|
|
173
|
+
|
|
174
|
+
Naive input is ambiguous by nature, so ``naive`` says what it means:
|
|
175
|
+
``"local"`` (default) = already in the custom time zone, ``"utc"`` = it is UTC,
|
|
176
|
+
``"raise"`` = refuse with :class:`ValueError`.
|
|
177
|
+
"""
|
|
178
|
+
if value is None:
|
|
179
|
+
return None
|
|
180
|
+
if not isinstance(value, datetime):
|
|
181
|
+
raise TypeError(f"Expected a datetime, got {type(value).__name__}.")
|
|
182
|
+
_check_choice("naive", naive, ("local", "utc", "raise"))
|
|
183
|
+
target = resolve_timezone(tz)
|
|
184
|
+
if _is_naive(value):
|
|
185
|
+
if naive == "raise":
|
|
186
|
+
raise ValueError("Refusing to convert a naive datetime (naive='raise').")
|
|
187
|
+
if naive == "utc":
|
|
188
|
+
value = value.replace(tzinfo=_UTC)
|
|
189
|
+
else:
|
|
190
|
+
value = _attach_zone(value, target, "earliest", "raise")
|
|
191
|
+
return value.astimezone(target)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def format_datetime(
|
|
195
|
+
value: Optional[datetime],
|
|
196
|
+
fmt: str = DEFAULT_FORMAT,
|
|
197
|
+
*,
|
|
198
|
+
tz: Optional[TimezoneLike] = None,
|
|
199
|
+
naive: NaiveMode = "local",
|
|
200
|
+
) -> str:
|
|
201
|
+
"""Format a datetime after converting it to the custom time zone ('' for None)."""
|
|
202
|
+
local = to_custom_timezone(value, tz=tz, naive=naive)
|
|
203
|
+
return local.strftime(fmt) if local is not None else ""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def format_datetime_local(
|
|
207
|
+
value: Optional[datetime],
|
|
208
|
+
*,
|
|
209
|
+
tz: Optional[TimezoneLike] = None,
|
|
210
|
+
naive: NaiveMode = "local",
|
|
211
|
+
) -> str:
|
|
212
|
+
"""Format for an HTML ``<input type="datetime-local">`` value ('' for None)."""
|
|
213
|
+
return format_datetime(value, DATETIME_LOCAL_FORMAT, tz=tz, naive=naive)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def now(*, tz: Optional[TimezoneLike] = None) -> datetime:
|
|
217
|
+
"""The current moment as an aware datetime in the custom time zone."""
|
|
218
|
+
return datetime.now(resolve_timezone(tz))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
__all__ = [
|
|
222
|
+
"localize_datetime", "parse_local_datetime", "to_custom_timezone",
|
|
223
|
+
"format_datetime", "format_datetime_local", "now",
|
|
224
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Exceptions raised by custom_timezone.
|
|
2
|
+
|
|
3
|
+
All of them subclass :class:`ValueError`, so existing ``except ValueError`` blocks (for
|
|
4
|
+
example around user-supplied date-time strings) keep working.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CustomTimezoneError(ValueError):
|
|
10
|
+
"""Base class for every error raised by this package."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TimezoneConfigurationError(CustomTimezoneError):
|
|
14
|
+
"""The configured time zone name is missing from the IANA database or malformed."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NonExistentTimeError(CustomTimezoneError):
|
|
18
|
+
"""A wall-clock time that was skipped by a daylight-saving 'spring forward'."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AmbiguousTimeError(CustomTimezoneError):
|
|
22
|
+
"""A wall-clock time that happens twice because of a daylight-saving 'fall back'."""
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Django middleware: activate the custom time zone for every request.
|
|
2
|
+
|
|
3
|
+
Only imported by Django (via ``MIDDLEWARE``); the rest of the package never imports it.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any, Awaitable, Callable, Union
|
|
8
|
+
|
|
9
|
+
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
|
|
10
|
+
from django.http import HttpRequest, HttpResponse
|
|
11
|
+
from django.utils import timezone
|
|
12
|
+
|
|
13
|
+
from .config import get_timezone
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CustomTimezoneMiddleware:
|
|
17
|
+
"""Activate the custom time zone for forms, admin and template rendering.
|
|
18
|
+
|
|
19
|
+
Datetimes are still stored in UTC when ``USE_TZ=True``; activation only controls how naive
|
|
20
|
+
form input is interpreted and how aware values are displayed. Works under both WSGI and
|
|
21
|
+
ASGI, and restores whatever time zone was active before the request.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
sync_capable = True
|
|
25
|
+
async_capable = True
|
|
26
|
+
|
|
27
|
+
def __init__(self, get_response: Callable[[HttpRequest], Any]) -> None:
|
|
28
|
+
self.get_response = get_response
|
|
29
|
+
self._async_mode = iscoroutinefunction(get_response)
|
|
30
|
+
if self._async_mode:
|
|
31
|
+
markcoroutinefunction(self) # tells Django to await this instance
|
|
32
|
+
|
|
33
|
+
def __call__(self, request: HttpRequest) -> Union[HttpResponse, Awaitable[HttpResponse]]:
|
|
34
|
+
if self._async_mode:
|
|
35
|
+
return self.__acall__(request)
|
|
36
|
+
with timezone.override(get_timezone()):
|
|
37
|
+
response: HttpResponse = self.get_response(request)
|
|
38
|
+
return response
|
|
39
|
+
|
|
40
|
+
async def __acall__(self, request: HttpRequest) -> HttpResponse:
|
|
41
|
+
with timezone.override(get_timezone()):
|
|
42
|
+
response: HttpResponse = await self.get_response(request)
|
|
43
|
+
return response
|
custom_timezone/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Backward-compatible import path.
|
|
2
|
+
|
|
3
|
+
Older code imported the helpers from ``custom_timezone.timezone_utils``. That still works;
|
|
4
|
+
new code can simply ``from custom_timezone import ...``.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .config import get_timezone
|
|
9
|
+
from .core import (
|
|
10
|
+
format_datetime,
|
|
11
|
+
format_datetime_local,
|
|
12
|
+
localize_datetime,
|
|
13
|
+
parse_local_datetime,
|
|
14
|
+
to_custom_timezone,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"get_timezone", "localize_datetime", "parse_local_datetime",
|
|
19
|
+
"to_custom_timezone", "format_datetime", "format_datetime_local",
|
|
20
|
+
]
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: custom_timezone_fdg
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Configurable business time zones and DST-aware datetime utilities, with optional Django integration
|
|
5
|
+
Author: Hua Chen
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/huachenlihx/custom_timezone_fdg
|
|
8
|
+
Project-URL: Repository, https://github.com/huachenlihx/custom_timezone_fdg
|
|
9
|
+
Project-URL: Issues, https://github.com/huachenlihx/custom_timezone_fdg/issues
|
|
10
|
+
Keywords: timezone,zoneinfo,datetime,dst,django
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Framework :: Django
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: tzdata
|
|
27
|
+
Provides-Extra: django
|
|
28
|
+
Requires-Dist: Django<7.0,>=4.2; extra == "django"
|
|
29
|
+
Provides-Extra: test
|
|
30
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# Custom Timezone FDG
|
|
34
|
+
|
|
35
|
+
A reusable Python package for working with a **configurable business time zone**, including daylight-saving-time (DST) edge cases. It can be used in regular Python programs, background workers and web applications, with **optional Django app and middleware integration**.
|
|
36
|
+
|
|
37
|
+
- **Distribution:** `custom_timezone_fdg`
|
|
38
|
+
- **Python import:** `custom_timezone`
|
|
39
|
+
- **Initial package version:** `0.1.0` (as defined in `src/custom_timezone/__init__.py`)
|
|
40
|
+
- **License:** MIT
|
|
41
|
+
|
|
42
|
+
The core uses Python's `datetime` and `zoneinfo`; **Django is not required** to use the core helpers. The distribution also depends on `tzdata`, which supplies IANA time-zone data on platforms that do not provide it themselves.
|
|
43
|
+
|
|
44
|
+
## Features
|
|
45
|
+
|
|
46
|
+
- Configurable business time zone using an explicit override, process configuration, Django setting or environment variable.
|
|
47
|
+
- Parsing and formatting datetimes with IANA time-zone names, including `America/Toronto`.
|
|
48
|
+
- Explicit handling of **nonexistent** and **ambiguous** local times around DST transitions.
|
|
49
|
+
- Thread- and asyncio-task-local temporary time-zone overrides.
|
|
50
|
+
- Optional Django middleware that activates the configured time zone per request and restores the previous one afterward.
|
|
51
|
+
- Django system checks for invalid time-zone settings and disabled timezone awareness.
|
|
52
|
+
- Type information via `py.typed` and compatibility imports through `custom_timezone.timezone_utils`.
|
|
53
|
+
|
|
54
|
+
## Requirements and installation
|
|
55
|
+
|
|
56
|
+
The package declares **Python 3.9+**. Optional Django support declares **Django 4.2 through 6.x**, subject to the Python versions supported by each Django release. Test your particular Python/Django combination; the declared ranges do not mean that every combination has been independently verified.
|
|
57
|
+
|
|
58
|
+
Install the core utilities:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
python -m pip install custom_timezone_fdg
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Install with Django integration dependencies:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
python -m pip install "custom_timezone_fdg[django]"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The **distribution name** contains `_fdg`, but imports use `custom_timezone`:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import custom_timezone as ct
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
If you're contributing from this repository, install it in editable mode:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
python -m pip install -e ".[test,django]"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Quick start (without Django)
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
import custom_timezone as ct
|
|
86
|
+
|
|
87
|
+
# Set the business time zone for this Python process.
|
|
88
|
+
ct.configure("America/Toronto")
|
|
89
|
+
|
|
90
|
+
# Interpret a wall-clock time entered without an offset.
|
|
91
|
+
appointment = ct.parse_local_datetime("2026-09-26T14:30")
|
|
92
|
+
print(appointment) # 2026-09-26 14:30:00-04:00
|
|
93
|
+
print(ct.format_datetime(appointment)) # 2026-09-26 14:30:00 EDT
|
|
94
|
+
|
|
95
|
+
# Convert a UTC instant to the configured business time zone.
|
|
96
|
+
from datetime import datetime, timezone
|
|
97
|
+
stored_utc = datetime(2026, 1, 1, 17, 0, tzinfo=timezone.utc)
|
|
98
|
+
print(ct.to_custom_timezone(stored_utc)) # 2026-01-01 12:00:00-05:00
|
|
99
|
+
print(ct.format_datetime_local(stored_utc)) # 2026-01-01T12:00
|
|
100
|
+
|
|
101
|
+
# Use another zone temporarily (also works in async tasks).
|
|
102
|
+
with ct.override("Asia/Shanghai"):
|
|
103
|
+
print(ct.now())
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Choose the business time zone
|
|
107
|
+
|
|
108
|
+
The first available value is used:
|
|
109
|
+
|
|
110
|
+
| Priority | Configuration | Scope |
|
|
111
|
+
| --- | --- | --- |
|
|
112
|
+
| 1 | `with ct.override("Asia/Shanghai")` | Current thread/asyncio task |
|
|
113
|
+
| 2 | `ct.configure("America/Toronto")` | Python process |
|
|
114
|
+
| 3 | Django `CUSTOM_TIME_ZONE` | If Django settings are already loaded |
|
|
115
|
+
| 4 | `CUSTOM_TIME_ZONE` environment variable | Python process |
|
|
116
|
+
| 5 | `UTC` | Default fallback |
|
|
117
|
+
|
|
118
|
+
Configure a non-Django application using an environment variable:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
export CUSTOM_TIME_ZONE=America/Toronto
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Or in Python:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
ct.configure("America/Toronto")
|
|
128
|
+
ct.configure(None) # Clear the process override; use other configuration sources.
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
An invalid time-zone name raises `TimezoneConfigurationError`, which subclasses `ValueError`.
|
|
132
|
+
|
|
133
|
+
## Core API
|
|
134
|
+
|
|
135
|
+
| Function | Purpose |
|
|
136
|
+
| --- | --- |
|
|
137
|
+
| `get_timezone()` | Return the active `zoneinfo.ZoneInfo` |
|
|
138
|
+
| `configure(tz)` | Set or clear the process-wide time zone |
|
|
139
|
+
| `override(tz)` | Temporarily use another time zone |
|
|
140
|
+
| `parse_local_datetime(text, ...)` | Parse ISO 8601 datetime text into an aware datetime |
|
|
141
|
+
| `localize_datetime(dt, ...)` | Attach the selected time zone to a naive datetime |
|
|
142
|
+
| `to_custom_timezone(dt, ...)` | Convert a datetime to the configured time zone |
|
|
143
|
+
| `format_datetime(dt, ...)` | Format a datetime in the configured time zone |
|
|
144
|
+
| `format_datetime_local(dt, ...)` | Format for HTML `datetime-local` input (`YYYY-MM-DDTHH:MM`) |
|
|
145
|
+
| `now()` | Return the current time in the configured time zone |
|
|
146
|
+
|
|
147
|
+
Most helpers also accept `tz="Europe/Paris"` to select a zone for a single call.
|
|
148
|
+
|
|
149
|
+
### Parsing rules
|
|
150
|
+
|
|
151
|
+
`parse_local_datetime()` accepts ISO-style date and time strings with a `T` or space separator, optionally including an offset. An explicit offset is retained. `None` or blank text returns `None`; invalid input raises `ValueError`.
|
|
152
|
+
|
|
153
|
+
A date without a time is intentionally rejected by default:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
ct.parse_local_datetime("2026-09-26") # ValueError
|
|
157
|
+
ct.parse_local_datetime("2026-09-26", allow_date_only=True) # Local midnight
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Daylight-saving transitions
|
|
161
|
+
|
|
162
|
+
For `America/Toronto` in 2026, **March 8 at 02:30** does not exist because clocks move forward. **November 1 at 01:30** occurs twice when clocks move back.
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from datetime import datetime
|
|
166
|
+
|
|
167
|
+
missing = datetime(2026, 3, 8, 2, 30)
|
|
168
|
+
ct.localize_datetime(missing, nonexistent="raise") # Default: NonExistentTimeError
|
|
169
|
+
ct.localize_datetime(missing, nonexistent="shift_forward") # 03:30 EDT
|
|
170
|
+
ct.localize_datetime(missing, nonexistent="shift_backward")# 01:30 EST
|
|
171
|
+
|
|
172
|
+
repeated = datetime(2026, 11, 1, 1, 30)
|
|
173
|
+
ct.localize_datetime(repeated, ambiguous="earliest") # First occurrence (EDT)
|
|
174
|
+
ct.localize_datetime(repeated, ambiguous="latest") # Second occurrence (EST)
|
|
175
|
+
ct.localize_datetime(repeated, ambiguous="raise") # AmbiguousTimeError
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Both exceptions subclass `ValueError`; the same options are available in `parse_local_datetime()`.
|
|
179
|
+
|
|
180
|
+
### Naive datetimes
|
|
181
|
+
|
|
182
|
+
With `to_custom_timezone()`, use the `naive=` parameter to specify how an unaware datetime should be interpreted: `"local"` (default), `"utc"`, or `"raise"`.
|
|
183
|
+
|
|
184
|
+
## Optional Django integration
|
|
185
|
+
|
|
186
|
+
Install with the `django` extra and configure your new or existing Django project:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
# settings.py
|
|
190
|
+
import os
|
|
191
|
+
|
|
192
|
+
INSTALLED_APPS = [
|
|
193
|
+
# Django's standard and project apps...
|
|
194
|
+
"custom_timezone.apps.CustomTimezoneConfig",
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
MIDDLEWARE = [
|
|
198
|
+
# Normal Django middleware...
|
|
199
|
+
"custom_timezone.middleware.CustomTimezoneMiddleware",
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
TIME_ZONE = "UTC"
|
|
203
|
+
USE_TZ = True
|
|
204
|
+
CUSTOM_TIME_ZONE = os.getenv("CUSTOM_TIME_ZONE", "America/Toronto")
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
**Add** these entries to your existing `INSTALLED_APPS` and `MIDDLEWARE` lists; do not replace the other middleware that Django requires. The shorter app entry `"custom_timezone"` is also supported.
|
|
208
|
+
|
|
209
|
+
- The middleware activates the selected business time zone for each request, including Django templates, forms and Admin; the previous active zone is restored afterward.
|
|
210
|
+
- Django can keep storing timestamps in UTC when `USE_TZ=True`.
|
|
211
|
+
- `python manage.py check` reports `custom_timezone.E001` for an invalid configured zone and `custom_timezone.W001` if `USE_TZ=False`.
|
|
212
|
+
- This Django app has no models or migrations.
|
|
213
|
+
|
|
214
|
+
Verify with:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
python manage.py check
|
|
218
|
+
python manage.py test
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Using in other frameworks
|
|
222
|
+
|
|
223
|
+
No framework-specific integration is required for regular scripts, FastAPI, Flask or task workers. To select a time zone for a single request or task, use `with ct.override("Area/City")` and ensure the context spans only the intended operation.
|
|
224
|
+
|
|
225
|
+
## Replacing an existing in-project `custom_timezone` app
|
|
226
|
+
|
|
227
|
+
The import paths `custom_timezone` and `custom_timezone.timezone_utils` are retained for common usage. However, the standalone package intentionally changes certain behaviours, so **test before replacing an existing application-specific implementation**.
|
|
228
|
+
|
|
229
|
+
1. Remove or rename the old in-project `custom_timezone/` module so it cannot shadow the installed distribution.
|
|
230
|
+
2. Install `custom_timezone_fdg[django]` in your project's environment.
|
|
231
|
+
3. Retain or adapt your existing `INSTALLED_APPS`, middleware, imports and `CUSTOM_TIME_ZONE` setting.
|
|
232
|
+
4. Run tests for datetime parsing, API validation and DST transitions before deploying.
|
|
233
|
+
|
|
234
|
+
| Input/behaviour | Older in-project app | Package behaviour |
|
|
235
|
+
| --- | --- | --- |
|
|
236
|
+
| Nonexistent local time | Could silently shift forward | Raises `NonExistentTimeError` by default |
|
|
237
|
+
| Date-only string | Could be treated as midnight | Rejected unless `allow_date_only=True` |
|
|
238
|
+
| Invalid non-string input | Could raise `TypeError` or act as empty | Raises `ValueError` |
|
|
239
|
+
| Invalid business time zone | Could raise `ImproperlyConfigured` | Raises `TimezoneConfigurationError` |
|
|
240
|
+
| Middleware cleanup | Could reset to Django default | Restores previously active time zone |
|
|
241
|
+
|
|
242
|
+
## Development, testing and publishing
|
|
243
|
+
|
|
244
|
+
This repository uses the **`src/` layout**: distributable code lives in `src/custom_timezone/`, while tests and packaging files are at the repository root. Consumers still import `custom_timezone`—never `src.custom_timezone`.
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
python3.13 -m venv .venv
|
|
248
|
+
source .venv/bin/activate
|
|
249
|
+
python -m pip install --upgrade pip
|
|
250
|
+
python -m pip install -e ".[test,django]"
|
|
251
|
+
python -m pytest
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
The `0.1.0` version is defined in `src/custom_timezone/__init__.py` through `__version__`. Update that value before creating a future release. The repository's `CHANGELOG.md` records release details.
|
|
255
|
+
|
|
256
|
+
Build and inspect the distributions:
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
python -m pip install --upgrade build twine
|
|
260
|
+
python -m build
|
|
261
|
+
python -m twine check dist/*
|
|
262
|
+
unzip -l dist/*.whl
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Before publishing, install the **built wheel** in a separate clean virtual environment and run its import and (if relevant) Django integration checks. Test on TestPyPI before publishing to PyPI; new uploads cannot overwrite an existing version's distribution filename.
|
|
266
|
+
|
|
267
|
+
## Project links
|
|
268
|
+
|
|
269
|
+
- Source repository: [GitHub](https://github.com/huachenlihx/custom_timezone_fdg)
|
|
270
|
+
- Issues and feature requests: [GitHub Issues](https://github.com/huachenlihx/custom_timezone_fdg/issues)
|
|
271
|
+
|
|
272
|
+
These links assume the repository is published under the shown name; update them if you choose a different repository name.
|
|
273
|
+
|
|
274
|
+
## Contact
|
|
275
|
+
|
|
276
|
+
**Email:** `huachenlihx@gmail.com`
|
|
277
|
+
|
|
278
|
+
## License
|
|
279
|
+
|
|
280
|
+
This project is licensed under the **MIT License**. See the [LICENSE](https://github.com/huachenlihx/custom_timezone_fdg/blob/main/LICENSE) file for the full license text.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
custom_timezone/__init__.py,sha256=sxR2WjdHIjy8_5xTGxZtBa1pxD10xzgL1EgZE9JeGq4,1357
|
|
2
|
+
custom_timezone/apps.py,sha256=wnEzQNoSKE8whqCaCKguniBs3cV33YGbvZzCXmEAngc,1436
|
|
3
|
+
custom_timezone/config.py,sha256=HkScb41JYjylhDbJYF_I8IVZpOcth4HCb6z6uUjGvkk,4253
|
|
4
|
+
custom_timezone/core.py,sha256=nfG76yosmDy9zxwP-yZ7TnvwN5MNz1aeKC5TQGkfiO8,8969
|
|
5
|
+
custom_timezone/exceptions.py,sha256=bBWamIuouGDz48bEXmAuFQ2uHNugztS0B4t0pGRdmMY,751
|
|
6
|
+
custom_timezone/middleware.py,sha256=2AaanxWgzRXRb04LlTe1Q0jtM-4Jv_KfYZO49oqsLFA,1679
|
|
7
|
+
custom_timezone/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
custom_timezone/timezone_utils.py,sha256=SxD4h2fa7ekfAZfhdgH9uAUO6qpuxVxW9lkwYHfVn0A,551
|
|
9
|
+
custom_timezone_fdg-0.1.0.dist-info/licenses/LICENSE,sha256=kDt3b_PpMAh3dKRCk_KDvjSpg5YeBqqq1CjogpvwBiY,1065
|
|
10
|
+
custom_timezone_fdg-0.1.0.dist-info/METADATA,sha256=0YlAiIEKJcDvT1fs5A96zckBU9mPkFkriaKCmx4Pmj4,11815
|
|
11
|
+
custom_timezone_fdg-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
12
|
+
custom_timezone_fdg-0.1.0.dist-info/top_level.txt,sha256=IfjzA2LaHCHGeuoTeTU18vxLgGw0f5oTXLOa70As5W0,16
|
|
13
|
+
custom_timezone_fdg-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hua Chen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
custom_timezone
|