| # 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 |