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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ jobs:
run: |
uv pip uninstall typing-extensions
uv run --no-sync python examples/basic.py
- name: Test No ciso8601
run: |
uv pip uninstall ciso8601
uv run --no-sync make test_sqlite
- name: Upload Coverage
run: |
uvx coveralls --service=github
Expand All @@ -90,6 +94,24 @@ jobs:
COVERALLS_FLAG_NAME: ${{ matrix.python-version }}
COVERALLS_PARALLEL: true

windowsSupport:
name: Check Windows Support for accel extra dependency
needs: test
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: 3.9
- uses: astral-sh/setup-uv@v7
- name: Install accel extra
run: |
uv venv --python 3.9
uv pip install -e ".[accel]"
- name: Check ciso8601
run: |
uv run --no-sync python examples/basic.py

coveralls:
name: Finish Coveralls
needs: test
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ Changed
^^^^^
- feat: foreignkey to model type (#2027)

Removed
^^^^^^^
- Remove `iso8601` because it's not active for a long time. (#2024)

0.25
====

Expand Down
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ dynamic = [ "version" ]
requires-python = ">=3.9"
dependencies = [
"pypika-tortoise (>=0.6.3,<1.0.0)",
"iso8601 (>=2.1.0,<3.0.0); python_version < '4.0'",
"aiosqlite (>=0.16.0,<1.0.0)",
"anyio",
"pytz",
Expand Down Expand Up @@ -40,7 +39,7 @@ classifiers = [

[project.optional-dependencies]
accel = [
"ciso8601; sys_platform != 'win32' and implementation_name == 'cpython'",
"ciso8601; implementation_name == 'cpython'",
"uvloop; sys_platform != 'win32' and implementation_name == 'cpython'",
"orjson",
]
Expand Down
3 changes: 1 addition & 2 deletions tests/fields/test_time.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from unittest.mock import patch

import pytz
from iso8601 import ParseError

from tests import testmodels
from tortoise import Model, fields, timezone
Expand Down Expand Up @@ -316,7 +315,7 @@ async def test_date_str(self):
obj0 = await self.model.create(date="2020-08-17")
obj1 = await self.model.get(date="2020-08-17")
self.assertEqual(obj0.date, obj1.date)
with self.assertRaises((ParseError, ValueError)):
with self.assertRaises(ValueError):
await self.model.create(date="2020-08-xx")
await self.model.filter(date="2020-08-17").update(date="2020-08-18")
obj2 = await self.model.get(date="2020-08-18")
Expand Down
49 changes: 49 additions & 0 deletions tests/utils/test_parse_datetime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from unittest import skipIf

import pytest

from tortoise.contrib import test
from tortoise.utils import parse_datetime

try:
import ciso8601
except ImportError:
ciso8601 = None # type: ignore[assignment]


@skipIf(not ciso8601, "Library 'ciso8601' is required")
class TestParseDatetime(test.TestCase):
def test_no_timezone(self):
cases = [
"20260101",
"2026-01-13",
"20260113 01",
"20260113 0102",
"20260113 010203",
"2026-01-14 00:00:01",
"2026-01-15 01:23:45.12345",
"2026-01-15 01:23:45.123456",
]
for time_str in cases:
assert ciso8601.parse_datetime(time_str) == parse_datetime(time_str)
for invalid in ["2026-00-00", "26-01-02"]:
with pytest.raises(ValueError):
ciso8601.parse_datetime(invalid)
with pytest.raises(ValueError):
parse_datetime(invalid)

def test_with_timezone(self):
cases = [
"2026-01-14T00:00:01z",
"2026-01-14T00:00:01Z",
"2026-01-15T01:23:45.123456+08:00",
"2026-01-15T01:23:45-05:30",
]
for time_str in cases:
assert ciso8601.parse_datetime(time_str) == parse_datetime(time_str)
for invalid_zone in ["+00:001", "+25:00"]:
invalid = "2026-01-14T00:00:00" + invalid_zone
with pytest.raises(ValueError):
ciso8601.parse_datetime(invalid)
with pytest.raises(ValueError):
parse_datetime(invalid)
5 changes: 1 addition & 4 deletions tortoise/backends/oracle/client.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

import datetime
import functools
from typing import TYPE_CHECKING, Any, SupportsInt, cast

import pyodbc
Expand All @@ -10,9 +9,7 @@
try:
from ciso8601 import parse_datetime
except ImportError: # pragma: nocoverage
from iso8601 import parse_date

parse_datetime = functools.partial(parse_date, default_timezone=None)
from tortoise.utils import parse_datetime
from pypika_tortoise import OracleQuery

from tortoise.backends.base.client import (
Expand Down
3 changes: 1 addition & 2 deletions tortoise/fields/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,8 @@
try:
from ciso8601 import parse_datetime
except ImportError: # pragma: nocoverage
from iso8601 import parse_date
from tortoise.utils import parse_datetime

parse_datetime = functools.partial(parse_date, default_timezone=None)

if TYPE_CHECKING: # pragma: nocoverage
from tortoise.models import Model
Expand Down
84 changes: 84 additions & 0 deletions tortoise/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from __future__ import annotations

import contextlib
import datetime
import re
import sys
from collections.abc import Iterable
from decimal import Decimal
from typing import TYPE_CHECKING, Any

from tortoise.log import logger
Expand Down Expand Up @@ -55,3 +59,83 @@ def chunk(instances: Iterable[Any], batch_size: int | None = None) -> Iterable[I
yield instances
else:
yield from batched(instances, batch_size)


# Copied from https://github.com/micktwomey/pyiso8601/blob/main/iso8601/iso8601.py
ISO8601_REGEX = re.compile(
r"""
(?P<year>[0-9]{4})
(
(
(-(?P<monthdash>[0-9]{1,2}))
|
(?P<month>[0-9]{2})
(?!$) # Don't allow YYYYMM
)
(
(
(-(?P<daydash>[0-9]{1,2}))
|
(?P<day>[0-9]{2})
)
(
(
(?P<separator>[ T])
(?P<hour>[0-9]{2})
(:{0,1}(?P<minute>[0-9]{2})){0,1}
(
:{0,1}(?P<second>[0-9]{1,2})
([.,](?P<second_fraction>[0-9]+)){0,1}
){0,1}
(?P<timezone>
Z
|
(
(?P<tz_sign>[-+])
(?P<tz_hour>[0-9]{2})
:{0,1}
(?P<tz_minute>[0-9]{2}){0,1}
)
){0,1}
){0,1}
)
){0,1} # YYYY-MM
){0,1} # YYYY only
$
""",
re.VERBOSE,
)


def parse_datetime(datetime_string: str) -> datetime.datetime:
datetime_string = datetime_string.upper()
with contextlib.suppress(ValueError):
return datetime.datetime.fromisoformat(datetime_string)
if not (m := ISO8601_REGEX.match(datetime_string)):
raise ValueError(f"Unable to parse date string {datetime_string!r}")
# Drop any Nones from the regex matches
# TODO: check if there's a way to omit results in regexes
groups: dict[str, str] = {k: v for k, v in m.groupdict().items() if v is not None}
zone = None
if tz := groups.get("timezone", None):
if tz == "Z":
zone = datetime.timezone.utc
else:
sign = groups.get("tz_sign", None)
hours = int(groups.get("tz_hour", 0))
minutes = int(groups.get("tz_minute", 0))
description = f"{sign}{hours:02d}:{minutes:02d}"
if sign == "-":
hours = -hours
minutes = -minutes
zone = datetime.timezone(datetime.timedelta(hours=hours, minutes=minutes), description)
return datetime.datetime(
year=int(groups.get("year", 0)),
month=int(groups.get("month", groups.get("monthdash", 1))),
day=int(groups.get("day", groups.get("daydash", 1))),
hour=int(groups.get("hour", 0)),
minute=int(groups.get("minute", 0)),
second=int(groups.get("second", 0)),
microsecond=int(Decimal(f"0.{groups.get('second_fraction', 0)}") * Decimal("1000000.0")),
tzinfo=zone,
)
Loading