blob: e88003ba2c27d9b3a7b661e2ef636da068b9c3a5 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import calendar
from datetime import datetime, timedelta
from typing import List
def parse_duration(text: str) -> int:
if text is None:
raise ValueError("text cannot be None")
trimmed = text.strip().lower()
if not trimmed:
raise ValueError("argument is an empty- or whitespace-only string")
pos = 0
while pos < len(trimmed) and trimmed[pos].isdigit():
pos += 1
number_str = trimmed[:pos]
unit_str = trimmed[pos:].strip()
if not number_str:
raise ValueError("text does not start with a number")
try:
value = int(number_str)
except ValueError:
raise ValueError(
f"The value '{number_str}' cannot be re represented as 64bit number (numeric overflow)."
)
if not unit_str:
result_ms = value
elif unit_str in ('ns', 'nano', 'nanosecond', 'nanoseconds'):
result_ms = value / 1_000_000
elif unit_str in ('µs', 'micro', 'microsecond', 'microseconds'):
result_ms = value / 1_000
elif unit_str in ('ms', 'milli', 'millisecond', 'milliseconds'):
result_ms = value
elif unit_str in ('s', 'sec', 'second', 'seconds'):
result_ms = value * 1000
elif unit_str in ('m', 'min', 'minute', 'minutes'):
result_ms = value * 60 * 1000
elif unit_str in ('h', 'hour', 'hours'):
result_ms = value * 60 * 60 * 1000
elif unit_str in ('d', 'day', 'days'):
result_ms = value * 24 * 60 * 60 * 1000
else:
supported_units = (
'DAYS: (d | day | days), '
'HOURS: (h | hour | hours), '
'MINUTES: (m | min | minute | minutes), '
'SECONDS: (s | sec | second | seconds), '
'MILLISECONDS: (ms | milli | millisecond | milliseconds), '
'MICROSECONDS: (µs | micro | microsecond | microseconds), '
'NANOSECONDS: (ns | nano | nanosecond | nanoseconds)'
)
raise ValueError(
f"Time interval unit label '{unit_str}' does not match any of the recognized units: "
f"{supported_units}"
)
result_ms_int = int(round(result_ms))
if result_ms_int < 0:
raise ValueError(f"Duration cannot be negative: {text}")
return result_ms_int
# Unit label -> nanoseconds. Mirrors the aliases accepted by ``parse_duration``
# (and Java ``TimeUtils.parseDuration``); a bare number with no unit is treated
# as milliseconds, matching ``parse_duration``.
_UNIT_TO_NANOS = {
'ns': 1, 'nano': 1, 'nanosecond': 1, 'nanoseconds': 1,
'µs': 1_000, 'micro': 1_000, 'microsecond': 1_000, 'microseconds': 1_000,
'ms': 1_000_000, 'milli': 1_000_000, 'millisecond': 1_000_000, 'milliseconds': 1_000_000,
's': 1_000_000_000, 'sec': 1_000_000_000, 'second': 1_000_000_000, 'seconds': 1_000_000_000,
'm': 60_000_000_000, 'min': 60_000_000_000, 'minute': 60_000_000_000, 'minutes': 60_000_000_000,
'h': 3_600_000_000_000, 'hour': 3_600_000_000_000, 'hours': 3_600_000_000_000,
'd': 86_400_000_000_000, 'day': 86_400_000_000_000, 'days': 86_400_000_000_000,
}
def parse_duration_nanos(text: str) -> int:
"""Parse a duration string to an integer nanosecond count.
This is the full-precision integer counterpart of :func:`parse_duration`
(which returns rounded milliseconds): it accepts the same unit aliases and
the same "bare number means milliseconds" convention, but keeps sub-millisecond
units exactly (``"1ns"`` -> ``1``, ``"500micro"`` -> ``500_000``) instead of
rounding them to zero. Use it where sub-millisecond precision must not be
silently dropped. ``parse_duration`` is intentionally left untouched so its
millisecond contract (relied on by option parsing) does not change.
"""
if text is None:
raise ValueError("text cannot be None")
trimmed = text.strip().lower()
if not trimmed:
raise ValueError("argument is an empty- or whitespace-only string")
pos = 0
while pos < len(trimmed) and trimmed[pos].isdigit():
pos += 1
number_str = trimmed[:pos]
unit_str = trimmed[pos:].strip()
if not number_str:
raise ValueError("text does not start with a number")
try:
value = int(number_str)
except ValueError:
raise ValueError(
f"The value '{number_str}' cannot be re represented as 64bit number (numeric overflow)."
)
if not unit_str:
nanos_per_unit = 1_000_000 # bare number is milliseconds
elif unit_str in _UNIT_TO_NANOS:
nanos_per_unit = _UNIT_TO_NANOS[unit_str]
else:
supported_units = (
'DAYS: (d | day | days), '
'HOURS: (h | hour | hours), '
'MINUTES: (m | min | minute | minutes), '
'SECONDS: (s | sec | second | seconds), '
'MILLISECONDS: (ms | milli | millisecond | milliseconds), '
'MICROSECONDS: (µs | micro | microsecond | microseconds), '
'NANOSECONDS: (ns | nano | nanosecond | nanoseconds)'
)
raise ValueError(
f"Time interval unit label '{unit_str}' does not match any of the recognized units: "
f"{supported_units}"
)
return value * nanos_per_unit
# ---------------------------------------------------------------------------
# Codecs for Java temporal types as serialized by Jackson's JavaTimeModule.
#
# These mirror the on-disk JSON shapes that Paimon's Java side reads/writes so
# that a tag file written by pypaimon is interoperable with Java and vice versa
# (see ``org.apache.paimon.tag.Tag`` and ``TagTest``):
# - ``LocalDateTime`` -> JSON array ``[year, month, day, hour, minute,
# second, nanoOfSecond]``
# - ``Duration`` -> JSON number of (fractional) seconds, e.g. ``86400.0``
#
# Resolution note: Python's ``datetime`` / ``timedelta`` are microsecond-based,
# so a Java create-time or duration finer than a microsecond is truncated to
# microseconds on read. Tag granularity is coarse, so this is not a concern in
# practice.
# ---------------------------------------------------------------------------
_NANOS_PER_MICRO = 1000
def local_datetime_to_json_array(dt: datetime) -> List[int]:
"""Encode a naive ``datetime`` as Java ``LocalDateTime`` array form.
Matches Jackson's ``LocalDateTimeSerializer`` byte-for-byte: it always emits
``[year, month, day, hour, minute]``, appends ``second`` only when second or
nanoOfSecond is non-zero, and appends ``nanoOfSecond`` only when it is
non-zero -- i.e. trailing zero components are omitted. ``json_array_to_local_datetime``
pads them back, so a shorter array round-trips. Python ``datetime`` only has
microsecond resolution, so the emitted nanoOfSecond is ``microsecond * 1000``
(never finer than microseconds).
"""
nano = dt.microsecond * _NANOS_PER_MICRO
arr = [dt.year, dt.month, dt.day, dt.hour, dt.minute]
if dt.second != 0 or nano != 0:
arr.append(dt.second)
if nano != 0:
arr.append(nano)
return arr
def json_array_to_local_datetime(arr: List[int]) -> datetime:
"""Decode a Java ``LocalDateTime`` array form into a naive ``datetime``.
Jackson omits trailing zero components, so the array may be shorter than 7;
missing components default to 0. A Java nanoOfSecond finer than a
microsecond is truncated to microseconds (Python's resolution limit).
"""
padded = list(arr) + [0] * (7 - len(arr))
year, month, day, hour, minute, second, nano = padded[:7]
return datetime(
year, month, day, hour, minute, second, nano // _NANOS_PER_MICRO
)
def duration_to_json_seconds(td: timedelta):
"""Encode a ``timedelta`` as Java ``Duration`` decimal-seconds number."""
return td.total_seconds()
def json_seconds_to_duration(seconds) -> timedelta:
"""Decode a Java ``Duration`` decimal-seconds number into a ``timedelta``."""
return timedelta(seconds=seconds)
def duration_to_iso8601(td: timedelta) -> str:
"""Render a non-negative ``timedelta`` like ``java.time.Duration.toString()``.
Examples: 1 day -> ``PT24H`` (Duration has no day unit), 30 min ->
``PT30M``, 5 s -> ``PT5S``, zero -> ``PT0S``. Matches what Paimon's Java
``$tags`` system table surfaces for ``time_retained``. Retentions only ever
come from ``parse_duration``, which rejects negatives, so only the
non-negative form is supported.
"""
total_micros = (
td.days * 86_400_000_000 + td.seconds * 1_000_000 + td.microseconds
)
if total_micros <= 0:
return "PT0S"
total_seconds, micros = divmod(total_micros, 1_000_000)
hours, rem = divmod(total_seconds, 3600)
minutes, secs = divmod(rem, 60)
buf = "PT"
if hours != 0:
buf += "{}H".format(hours)
if minutes != 0:
buf += "{}M".format(minutes)
if secs != 0 or micros != 0 or buf == "PT":
frac = ""
if micros != 0:
frac = "." + ("%06d" % micros).rstrip("0")
buf += "{}{}S".format(secs, frac)
return buf
def local_datetime_to_millis(dt: datetime) -> int:
"""Convert a naive ``LocalDateTime`` to epoch millis, treating it as UTC.
This is the zone-less / wall-clock conversion: it mirrors Java
``Timestamp.fromLocalDateTime`` and feeds the ``$tags`` system table's
``create_time`` (a ``TIMESTAMP`` column, which is itself zone-less), so the
result does NOT depend on the host's time zone. For the REST
``GetTagResponse`` epoch-millis ``Long`` use
:func:`local_datetime_to_system_zone_millis` instead. Uses integer math with
floored sub-millisecond truncation (matching Java ``Instant.toEpochMilli``),
so it is exact and correct for pre-epoch instants too.
"""
return calendar.timegm(dt.timetuple()) * 1000 + dt.microsecond // 1000
def local_datetime_to_system_zone_millis(dt: datetime) -> int:
"""Convert a naive ``LocalDateTime`` to epoch millis in the host's default
time zone.
Mirrors Java ``LocalDateTime.atZone(ZoneId.systemDefault()).toInstant()
.toEpochMilli()`` -- the conversion the REST ``GetTagResponse`` applies to
``tagCreateTime`` (see ``RESTFileSystemCatalog#getTag``). Unlike
:func:`local_datetime_to_millis` (zone-less / UTC, used by the ``$tags``
Timestamp column), the result depends on the host's local time zone, so the
two intentionally differ on non-UTC hosts -- exactly as the two Java paths do.
"""
# A naive datetime.timestamp() interprets dt in the system local zone (via
# mktime). Take whole seconds first to avoid float rounding, then floor the
# sub-second part to millis to match Instant.toEpochMilli().
epoch_seconds = int(dt.replace(microsecond=0).timestamp())
return epoch_seconds * 1000 + dt.microsecond // 1000