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.
@@ -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
+ ]
@@ -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)
@@ -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
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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