Repository: metricq/metricq-python (language: python)

Issue:
top level module "types" clashes with mypy
We cannot use either of these names
https://github.com/python/mypy/blob/099500e4b763f0819954810b04a7486888d6085d/mypy/build.py#L116

I suggest to split it, it is too large anyway. But I'd also like to move it into a package, not sure how to call that though.


Discussion / hints:

Fix patch:
diff --git a/docs/conf.py b/docs/conf.py
index 13188f2f..9f1f6454 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -54,10 +54,10 @@
     "metricq.history_client.HistoryClient": "metricq.HistoryClient",
     "metricq.drain.Drain": "metricq.Drain",
     "metricq.subscription.Subscriber": "metricq.Subscriber",
-    "metricq.types.Timedelta": "metricq.Timedelta",
-    "metricq.types.Timestamp": "metricq.Timestamp",
-    "metricq.types.TimeValue": "metricq.TimeValue",
-    "metricq.types.TimeAggregate": "metricq.TimeAggregate",
+    "metricq.timeseries.timedelta.Timedelta": "metricq.Timedelta",
+    "metricq.timeseries.timestamp.Timestamp": "metricq.Timestamp",
+    "metricq.timeseries.time_value.TimeValue": "metricq.TimeValue",
+    "metricq.timeseries.time_aggregate.TimeAggregate": "metricq.TimeAggregate",
     "aio_pika.exchange.Exchange": "aio_pika.Exchange",
 }
 
diff --git a/examples/metricq_synchronous_source.py b/examples/metricq_synchronous_source.py
index 12258c55..96c69439 100755
--- a/examples/metricq_synchronous_source.py
+++ b/examples/metricq_synchronous_source.py
@@ -37,8 +37,7 @@
 import click_completion  # type: ignore
 import click_log  # type: ignore
 
-from metricq import SynchronousSource, get_logger
-from metricq.types import Timestamp
+from metricq import SynchronousSource, Timestamp, get_logger
 
 logger = get_logger()
 
diff --git a/metricq/__init__.py b/metricq/__init__.py
index 07d7886f..329ba2e7 100644
--- a/metricq/__init__.py
+++ b/metricq/__init__.py
@@ -41,7 +41,7 @@
 from .source import Source
 from .subscription import Subscriber
 from .synchronous_source import SynchronousSource
-from .types import TimeAggregate, Timedelta, Timestamp, TimeValue
+from .timeseries import TimeAggregate, Timedelta, Timestamp, TimeValue
 from .version import __version__
 
 __all__ = [
diff --git a/metricq/agent.py b/metricq/agent.py
index 78ed9af8..a902f675 100644
--- a/metricq/agent.py
+++ b/metricq/agent.py
@@ -67,7 +67,7 @@
 )
 from .logging import get_logger
 from .rpc import RPCDispatcher
-from .types import JsonDict
+from .timeseries import JsonDict
 from .version import __version__
 
 logger = get_logger(__name__)
diff --git a/metricq/client.py b/metricq/client.py
index caf56b3e..03bfd5cc 100644
--- a/metricq/client.py
+++ b/metricq/client.py
@@ -36,7 +36,7 @@
 from .agent import Agent
 from .logging import get_logger
 from .rpc import rpc_handler
-from .types import JsonDict, Timestamp
+from .timeseries import JsonDict, Timestamp
 from .version import __version__
 
 logger = get_logger(__name__)
diff --git a/metricq/drain.py b/metricq/drain.py
index 9960e00d..7bce87f4 100644
--- a/metricq/drain.py
+++ b/metricq/drain.py
@@ -34,7 +34,7 @@
 
 from .logging import get_logger
 from .sink import Sink
-from .types import Timestamp
+from .timeseries import Timestamp
 
 logger = get_logger(__name__)
 
diff --git a/metricq/history_client.py b/metricq/history_client.py
index 65e90ea4..21447b5f 100644
--- a/metricq/history_client.py
+++ b/metricq/history_client.py
@@ -47,7 +47,7 @@
 )
 from .logging import get_logger
 from .rpc import rpc_handler
-from .types import TimeAggregate, Timedelta, Timestamp, TimeValue
+from .timeseries import TimeAggregate, Timedelta, Timestamp, TimeValue
 from .version import __version__  # noqa: F401 - shut up flake8, automatic version str
 
 logger = get_logger(__name__)
diff --git a/metricq/interval_source.py b/metricq/interval_source.py
index fc5a9ecd..da4ca3b2 100644
--- a/metricq/interval_source.py
+++ b/metricq/interval_source.py
@@ -33,7 +33,7 @@
 from .exceptions import PublishFailed
 from .logging import get_logger
 from .source import Source
-from .types import Timedelta, Timestamp
+from .timeseries import Timedelta, Timestamp
 
 logger = get_logger(__name__)
 
diff --git a/metricq/sink.py b/metricq/sink.py
index 2f188f54..dd6d7131 100644
--- a/metricq/sink.py
+++ b/metricq/sink.py
@@ -36,7 +36,7 @@
 from .data_client import DataClient
 from .datachunk_pb2 import DataChunk
 from .logging import get_logger
-from .types import JsonDict, Metric, Timestamp
+from .timeseries import JsonDict, Metric, Timestamp
 
 logger = get_logger(__name__)
 
diff --git a/metricq/source.py b/metricq/source.py
index d4fc2a6a..3f25d5c2 100644
--- a/metricq/source.py
+++ b/metricq/source.py
@@ -41,7 +41,7 @@
 from .logging import get_logger
 from .rpc import rpc_handler
 from .source_metric import ChunkSize, SourceMetric
-from .types import Metric, Timestamp
+from .timeseries import Metric, Timestamp
 
 logger = get_logger(__name__)
 
diff --git a/metricq/source_metric.py b/metricq/source_metric.py
index 26f22f16..505f4533 100644
--- a/metricq/source_metric.py
+++ b/metricq/source_metric.py
@@ -33,7 +33,7 @@
 
 from . import source
 from .datachunk_pb2 import DataChunk
-from .types import Metric, Timestamp
+from .timeseries import Metric, Timestamp
 
 
 class ChunkSize:
diff --git a/metricq/subscription.py b/metricq/subscription.py
index 8771e141..e379ca9e 100644
--- a/metricq/subscription.py
+++ b/metricq/subscription.py
@@ -32,7 +32,7 @@
 from .client import Client
 from .drain import Drain
 from .logging import get_logger
-from .types import Timedelta
+from .timeseries import Timedelta
 
 logger = get_logger(__name__)
 
diff --git a/metricq/synchronous_source.py b/metricq/synchronous_source.py
index 07a4a634..a2910fc6 100644
--- a/metricq/synchronous_source.py
+++ b/metricq/synchronous_source.py
@@ -33,7 +33,7 @@
 
 from .logging import get_logger
 from .source import MetadataDict, Source
-from .types import Timestamp
+from .timeseries import Timestamp
 
 logger = get_logger(__name__)
 
diff --git a/metricq/timeseries/__init__.py b/metricq/timeseries/__init__.py
new file mode 100644
index 00000000..ffc9d82a
--- /dev/null
+++ b/metricq/timeseries/__init__.py
@@ -0,0 +1,17 @@
+from typing import Any, Dict
+
+from .time_aggregate import TimeAggregate
+from .time_value import TimeValue
+from .timedelta import Timedelta
+from .timestamp import Timestamp
+
+Metric = str
+"""Type alias for strings that represent metric names
+"""
+
+JsonDict = Dict[str, Any]
+"""Type alias for dicts that represent JSON data
+"""
+
+
+__all__ = ["TimeAggregate", "TimeValue", "Timedelta", "Timestamp", "Metric", "JsonDict"]
diff --git a/metricq/timeseries/time_aggregate.py b/metricq/timeseries/time_aggregate.py
new file mode 100644
index 00000000..904fff94
--- /dev/null
+++ b/metricq/timeseries/time_aggregate.py
@@ -0,0 +1,124 @@
+from dataclasses import dataclass
+from typing import Any
+
+from .. import history_pb2
+from ..exceptions import NonMonotonicTimestamps
+from .timedelta import Timedelta
+from .timestamp import Timestamp
+
+
+@dataclass(frozen=True)
+class TimeAggregate:
+    """Summary of a metric's values within a certain period of time."""
+
+    __slots__ = (
+        "timestamp",
+        "minimum",
+        "maximum",
+        "sum",
+        "count",
+        "integral_ns",
+        "active_time",
+    )
+
+    timestamp: Timestamp
+    """starting time of this aggregate"""
+    minimum: float
+    """minimum value"""
+    maximum: float
+    """maximum value"""
+    sum: float
+    """sum of all values"""
+    count: int
+    """total number of values"""
+    integral_ns: float
+    """Integral of values in this aggregate over its active time, nanoseconds-based"""
+    active_time: Timedelta
+    """time spanned by this aggregate"""
+
+    @staticmethod
+    def from_proto(
+        timestamp: Timestamp, proto: history_pb2.HistoryResponse.Aggregate
+    ) -> "TimeAggregate":
+        return TimeAggregate(
+            timestamp=timestamp,
+            minimum=proto.minimum,
+            maximum=proto.maximum,
+            sum=proto.sum,
+            count=proto.count,
+            integral_ns=proto.integral,
+            active_time=Timedelta(proto.active_time),
+        )
+
+    @staticmethod
+    def from_value(timestamp: Timestamp, value: float) -> "TimeAggregate":
+        return TimeAggregate(
+            timestamp=timestamp,
+            minimum=value,
+            maximum=value,
+            sum=value,
+            count=1,
+            integral_ns=0,
+            active_time=Timedelta(0),
+        )
+
+    @staticmethod
+    def from_value_pair(
+        timestamp_before: Timestamp, timestamp: Timestamp, value: float
+    ) -> "TimeAggregate":
+        """Create a TimeAggregate from a pair of timestamps (class:`Timestamp`) and one value
+
+        Raises:
+            NonMonotonicTimestamps: if the two timestamps are not strictly monotonic
+        """
+        # Can't use (https://github.com/python/mypy/issues/4610)
+        # if timestamp_before >= timestamp:
+        if not timestamp_before < timestamp:
+            raise NonMonotonicTimestamps(
+                "Timestamps in HistoryResponse are not strictly monotonic ({} -> {})".format(
+                    timestamp_before, timestamp
+                )
+            )
+        delta = timestamp - timestamp_before
+        return TimeAggregate(
+            timestamp=timestamp_before,
+            minimum=value,
+            maximum=value,
+            sum=value,
+            count=1,
+            integral_ns=delta.ns * value,
+            active_time=delta,
+        )
+
+    @property
+    def integral_s(self) -> float:
+        """Integral of values in this aggregate over its active time, seconds-based"""
+        return self.integral_ns / 1e9
+
+    @property
+    def mean(self) -> float:
+        if self.active_time.ns > 0:
+            return self.mean_integral
+        else:
+            return self.mean_sum
+
+    @property
+    def mean_integral(self) -> float:
+        return self.integral_ns / self.active_time.ns
+
+    @property
+    def mean_sum(self) -> float:
+        return self.sum / self.count
+
+    def dict(self) -> dict[str, Any]:
+        """
+        returns a dict representing the TimeAggregate instance.
+        Keys are `timestamp`, `minimum`, `mean`, `maximum`, and `count`.
+        """
+        return {
+            "minimum": self.minimum,
+            "mean": self.mean,
+            "maximum": self.maximum,
+            "count": self.count,
+            "timestamp": self.timestamp.posix_ns,
+        }
diff --git a/metricq/timeseries/time_value.py b/metricq/timeseries/time_value.py
new file mode 100644
index 00000000..7058796c
--- /dev/null
+++ b/metricq/timeseries/time_value.py
@@ -0,0 +1,34 @@
+from dataclasses import dataclass
+from typing import Any, Iterator, Union
+
+from .timestamp import Timestamp
+
+
+@dataclass(frozen=True)
+class TimeValue:
+    """A `timestamp-value` pair.
+
+    Unpack it like so:
+
+    >>> tv = TimeValue(Timestamp.now(), 42.0)
+    >>> (timestamp, value) = tv
+
+    """
+
+    __slots__ = ("timestamp", "value")
+
+    timestamp: Timestamp
+    value: float
+
+    def __iter__(self) -> Iterator[Union["Timestamp", float]]:
+        return iter([self.timestamp, self.value])
+
+    def dict(self) -> dict[str, Any]:
+        """
+        returns a dict representing the TimeValue instance.
+        Keys are `timestamp` and `value`
+        """
+        return {
+            "timestamp": self.timestamp.posix_ns,
+            "value": self.value,
+        }
diff --git a/metricq/types.py b/metricq/timeseries/timedelta.py
similarity index 51%
rename from metricq/types.py
rename to metricq/timeseries/timedelta.py
index a4603772..d7f340a0 100644
--- a/metricq/types.py
+++ b/metricq/timeseries/timedelta.py
@@ -1,44 +1,10 @@
-# Copyright (c) 2018, ZIH,
-# Technische Universitaet Dresden,
-# Federal Republic of Germany
-#
-# All rights reserved.
-#
-# Redistribution and use in source and binary forms, with or without modification,
-# are permitted provided that the following conditions are met:
-#
-#     * Redistributions of source code must retain the above copyright notice,
-#       this list of conditions and the following disclaimer.
-#     * Redistributions in binary form must reproduce the above copyright notice,
-#       this list of conditions and the following disclaimer in the documentation
-#       and/or other materials provided with the distribution.
-#     * Neither the name of metricq nor the names of its contributors
-#       may be used to endorse or promote products derived from this software
-#       without specific prior written permission.
-#
-# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
-# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
-# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
-# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
-# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
-# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
-# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
-# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
 import datetime
 import re
-from dataclasses import dataclass
 from functools import total_ordering
-from typing import Any, Dict, Iterator, Union, overload
-
-from dateutil import tz
-from dateutil.parser import isoparse as dateutil_isoparse
+from typing import TYPE_CHECKING, Union, overload
 
-from . import history_pb2
-from .exceptions import NonMonotonicTimestamps
+if TYPE_CHECKING:
+    from .timestamp import Timestamp
 
 
 @total_ordering
@@ -361,405 +327,3 @@ def __lt__(self, other: Union["Timedelta", datetime.timedelta]) -> bool:
         if isinstance(other, datetime.timedelta):
             return self.timedelta < other
         return self._value < other._value
-
-
-@total_ordering
-class Timestamp:
-    """A MetricQ Timestamp
-
-    Args:
-        nanoseconds: number of nanoseconds elapsed since the UNIX epoch
-    """
-
-    _EPOCH = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)
-
-    def __init__(self, nanoseconds: int):
-        self._value = nanoseconds
-        """Timestamp value in nanoseconds since :const:`Timestamp._EPOCH`,
-
-        :meta private:
-        """
-
-    @classmethod
-    def from_posix_seconds(cls, seconds: float) -> "Timestamp":
-        """Create a Timestamp from a POSIX timestamp
-
-        Args:
-            seconds: number of seconds since the UNIX epoch
-
-        Returns:
-            :class:`Timestamp`
-        """
-        return Timestamp(int(seconds * 1e9))
-
-    @classmethod
-    def from_datetime(cls, dt: datetime.datetime) -> "Timestamp":
-        """
-        Create a Timestamp from an aware datetime object. If you have a naive
-        datetime object, consider using :meth:`from_local_datetime` instead.
-        """
-        if dt.tzinfo is None:
-            raise TypeError(
-                "cannot to parse naive datetime with `from_datetime`, "
-                "consider using `from_local_datetime` if applicable"
-            )
-        delta = dt - Timestamp._EPOCH
-        seconds = (delta.days * 24 * 3600) + delta.seconds
-        microseconds = seconds * 1000000 + delta.microseconds
-        return Timestamp(microseconds * 1000)
-
-    @classmethod
-    def from_local_datetime(cls, dt: datetime.datetime) -> "Timestamp":
-        """
-        Create a timestamp from a naive :class:`datetime` object that uses the
-        local timezone.
-        If you have a proper aware object, use :meth:`from_datetime` instead.
-
-        This function uses `dateutil.tz.gettz()` which uses `/etc/localtime`.
-        Using naive datetime object can be errorprone. Naive local times are
-        ambiguous during daylight savings time adjustments.
-        If you have to use this function, your workflow is probably broken.
-        """
-        if dt.tzinfo is not None:
-            raise TypeError(
-                "refusing to parse aware datetime with `from_local_datetime`, "
-                "use `from_datetime` instead."
-            )
-        return cls.from_datetime(dt.replace(tzinfo=tz.gettz()))
-
-    @classmethod
-    def from_iso8601(cls, iso_string: str) -> "Timestamp":
-        """Create a Timestamp from a `ISO 8601` date-time string.
-
-        >>> Timestamp.from_iso8601("1970-01-01T00:00:00.0Z") == Timestamp(0)
-        True
-
-        This is a convenience method that parses the date-time string into a
-        :class:`python:datetime.datetime` using
-        :meth:`dateutil:dateutil.parser.isoparse`,
-        and then calls :meth:`from_datetime` to create a :class:`Timestamp` from that.
-
-        The provided `iso_string` must include timezone information. To parse
-        local time strings, you must convert them yourself and use
-        :meth:`from_local_datetime`. Or better yet, somehow create an aware
-        :class:`python:datetime.datetime` and use :meth:`from_datetime`.
-
-        Note:
-            The parser only supports up to *6 sub-second digits*,
-            further digits are simply dropped.
-            If you need to parse timestamps with higher precision,
-            you need to convert the string to nanoseconds yourself.
-
-        Args:
-            iso_string: a date-time string in `ISO 8601` format including a
-                        timezone specifier.
-        """
-        dt = dateutil_isoparse(iso_string)
-        if dt.tzinfo is None:
-            raise TypeError("provided timestamp does not include timezone info")
-        return cls.from_datetime(dt)
-
-    @classmethod
-    def ago(cls, delta: Timedelta) -> "Timestamp":
-        """Return a timestamp `delta` in the past.
-
-        This is equivalent to::
-
-            Timestamp.now() - delta
-
-        Args:
-            delta: the time difference to now
-        """
-        return cls.now() - delta
-
-    @classmethod
-    def from_now(cls, delta: Timedelta) -> "Timestamp":
-        """Return a timestamp `delta` in the future.
-
-        This is equivalent to::
-
-            Timestamp.now() + delta
-
-        Args:
-            delta: the time difference to now
-        """
-        return cls.now() + delta
-
-    @classmethod
-    def now(cls) -> "Timestamp":
-        """Return a Timestamp corresponding to "now"
-
-        Returns:
-            :class:`Timestamp`
-        """
-        return cls.from_datetime(datetime.datetime.now(datetime.timezone.utc))
-
-    @property
-    def posix_ns(self) -> int:
-        """Number of nanoseconds since the UNIX epoch"""
-        return self._value
-
-    @property
-    def posix_us(self) -> float:
-        """Number of microseconds since the UNIX epoch"""
-        return self._value / 1000
-
-    @property
-    def posix_ms(self) -> float:
-        """Number of milliseconds since the UNIX epoch"""
-        return self._value / 1000000
-
-    @property
-    def posix(self) -> float:
-        """Number of seconds since the UNIX epoch"""
-        return self._value / 1000000000
-
-    @property
-    def datetime(self) -> datetime.datetime:
-        """Create an aware UTC datetime object
-
-        All MetricQ timestamps are POSIX timestamps, hence UTC.
-        """
-        # We use timedelta in the hope that this doesn't break
-        # on non-POSIX systems, where fromtimestamp apparently may omit leap seconds
-        # but our MetricQ timestamps are true UNIX timestamps without leap seconds
-        microseconds = self._value // 1000
-        return Timestamp._EPOCH + datetime.timedelta(microseconds=microseconds)
-
-    def __add__(self, delta: Timedelta) -> "Timestamp":
-        """Return the timestamp `delta` in the future (or in the past, if `delta` is negative), relative to a :class:`Timestamp` instance.
-
-        >>> epoch = Timestamp(0)
-        >>> a_week_later = epoch + Timedelta.from_string("7 days")
-        >>> str(a_week_later)
-        '[604800000000000] 1970-01-08 01:00:00+01:00'
-
-        Args:
-            delta: a time duration, possibly negative
-
-        Returns:
-            :class:`Timestamp`
-        """
-        return Timestamp(self._value + delta.ns)
-
-    @overload
-    def __sub__(self, other: "Timedelta") -> "Timestamp":
-        ...
-
-    @overload
-    def __sub__(self, other: "Timestamp") -> "Timedelta":
-        ...
-
-    def __sub__(
-        self, other: Union["Timedelta", "Timestamp"]
-    ) -> Union["Timestamp", "Timedelta"]:
-        if isinstance(other, Timedelta):
-            return Timestamp(self._value - other.ns)
-        if isinstance(other, Timestamp):
-            return Timedelta(self._value - other._value)
-        raise TypeError(
-            "Invalid type to subtract from Timestamp: {}".format(type(other))
-        )
-
-    def __lt__(self, other: "Timestamp") -> bool:
-        """Compare whether this timestamp describes a time before another timestamp.
-
-        >>> now = Timestamp.now()
-        >>> later = now + Timedelta.from_s(10)
-        >>> now < later
-        True
-
-        Together with :meth:`__eq__`, all relational operations (``<=``, ``>``, ``!=``, etc.) are supported.
-        Timestamps are `totally ordered` (as supplied by :func:`python:functools.total_ordering`).
-
-        Args:
-            other: another timestamp
-        """
-
-        return self._value < other._value
-
-    def __eq__(self, other: object) -> bool:
-        """Check whether two :class:`Timestamps<Timestamp>` refer to the same instance of time:
-
-        >>> now = Timestamp.now()
-        >>> later = now + Timedelta.from_s(10)
-        >>> now == later
-        False
-
-        Args:
-            other: another timestamp
-        """
-        if not isinstance(other, Timestamp):
-            return NotImplemented
-
-        return self._value == other._value
-
-    def __str__(self) -> str:
-        """Yield a human-readable date-time string in the local timezone:
-
-        >>> # in UTC+01:00, it was already 1 in the night when the UNIX epoch happened
-        >>> str(Timestamp(0))
-        '[0] 1970-01-01 01:00:00+01:00'
-        """
-
-        # Note we convert to local timezone with astimezone for printing
-        return "[{}] {}".format(self.posix_ns, str(self.datetime.astimezone()))
-
-    def __repr__(self) -> str:
-        return f"Timestamp({self.posix_ns})"
-
-
-@dataclass(frozen=True)
-class TimeValue:
-    """A `timestamp-value` pair.
-
-    Unpack it like so:
-
-    >>> tv = TimeValue(Timestamp.now(), 42.0)
-    >>> (timestamp, value) = tv
-
-    """
-
-    __slots__ = ("timestamp", "value")
-
-    timestamp: Timestamp
-    value: float
-
-    def __iter__(self) -> Iterator[Union["Timestamp", float]]:
-        return iter([self.timestamp, self.value])
-
-    def dict(self) -> dict[str, Any]:
-        """
-        returns a dict representing the TimeValue instance.
-        Keys are `timestamp` and `value`
-        """
-        return {
-            "timestamp": self.timestamp.posix_ns,
-            "value": self.value,
-        }
-
-
-@dataclass(frozen=True)
-class TimeAggregate:
-    """Summary of a metric's values within a certain period of time."""
-
-    __slots__ = (
-        "timestamp",
-        "minimum",
-        "maximum",
-        "sum",
-        "count",
-        "integral_ns",
-        "active_time",
-    )
-
-    timestamp: Timestamp
-    """starting time of this aggregate"""
-    minimum: float
-    """minimum value"""
-    maximum: float
-    """maximum value"""
-    sum: float
-    """sum of all values"""
-    count: int
-    """total number of values"""
-    integral_ns: float
-    """Integral of values in this aggregate over its active time, nanoseconds-based"""
-    active_time: Timedelta
-    """time spanned by this aggregate"""
-
-    @staticmethod
-    def from_proto(
-        timestamp: Timestamp, proto: history_pb2.HistoryResponse.Aggregate
-    ) -> "TimeAggregate":
-        return TimeAggregate(
-            timestamp=timestamp,
-            minimum=proto.minimum,
-            maximum=proto.maximum,
-            sum=proto.sum,
-            count=proto.count,
-            integral_ns=proto.integral,
-            active_time=Timedelta(proto.active_time),
-        )
-
-    @staticmethod
-    def from_value(timestamp: Timestamp, value: float) -> "TimeAggregate":
-        return TimeAggregate(
-            timestamp=timestamp,
-            minimum=value,
-            maximum=value,
-            sum=value,
-            count=1,
-            integral_ns=0,
-            active_time=Timedelta(0),
-        )
-
-    @staticmethod
-    def from_value_pair(
-        timestamp_before: Timestamp, timestamp: Timestamp, value: float
-    ) -> "TimeAggregate":
-        """Create a TimeAggregate from a pair of timestamps (class:`Timestamp`) and one value
-
-        Raises:
-            NonMonotonicTimestamps: if the two timestamps are not strictly monotonic
-        """
-        # Can't use (https://github.com/python/mypy/issues/4610)
-        # if timestamp_before >= timestamp:
-        if not timestamp_before < timestamp:
-            raise NonMonotonicTimestamps(
-                "Timestamps in HistoryResponse are not strictly monotonic ({} -> {})".format(
-                    timestamp_before, timestamp
-                )
-            )
-        delta = timestamp - timestamp_before
-        return TimeAggregate(
-            timestamp=timestamp_before,
-            minimum=value,
-            maximum=value,
-            sum=value,
-            count=1,
-            integral_ns=delta.ns * value,
-            active_time=delta,
-        )
-
-    @property
-    def integral_s(self) -> float:
-        """Integral of values in this aggregate over its active time, seconds-based"""
-        return self.integral_ns / 1e9
-
-    @property
-    def mean(self) -> float:
-        if self.active_time.ns > 0:
-            return self.mean_integral
-        else:
-            return self.mean_sum
-
-    @property
-    def mean_integral(self) -> float:
-        return self.integral_ns / self.active_time.ns
-
-    @property
-    def mean_sum(self) -> float:
-        return self.sum / self.count
-
-    def dict(self) -> dict[str, Any]:
-        """
-        returns a dict representing the TimeAggregate instance.
-        Keys are `timestamp`, `minimum`, `mean`, `maximum`, and `count`.
-        """
-        return {
-            "minimum": self.minimum,
-            "mean": self.mean,
-            "maximum": self.maximum,
-            "count": self.count,
-            "timestamp": self.timestamp.posix_ns,
-        }
-
-
-Metric = str
-"""Type alias for strings that represent metric names
-"""
-
-JsonDict = Dict[str, Any]
-"""Type alias for dicts that represent JSON data
-"""
diff --git a/metricq/timeseries/timestamp.py b/metricq/timeseries/timestamp.py
new file mode 100644
index 00000000..f1e18390
--- /dev/null
+++ b/metricq/timeseries/timestamp.py
@@ -0,0 +1,254 @@
+import datetime
+from functools import total_ordering
+from typing import Union, overload
+
+from dateutil import tz
+from dateutil.parser import isoparse as dateutil_isoparse
+
+from .timedelta import Timedelta
+
+
+@total_ordering
+class Timestamp:
+    """A MetricQ Timestamp
+
+    Args:
+        nanoseconds: number of nanoseconds elapsed since the UNIX epoch
+    """
+
+    _EPOCH = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)
+
+    def __init__(self, nanoseconds: int):
+        self._value = nanoseconds
+        """Timestamp value in nanoseconds since :const:`Timestamp._EPOCH`,
+
+        :meta private:
+        """
+
+    @classmethod
+    def from_posix_seconds(cls, seconds: float) -> "Timestamp":
+        """Create a Timestamp from a POSIX timestamp
+
+        Args:
+            seconds: number of seconds since the UNIX epoch
+
+        Returns:
+            :class:`Timestamp`
+        """
+        return Timestamp(int(seconds * 1e9))
+
+    @classmethod
+    def from_datetime(cls, dt: datetime.datetime) -> "Timestamp":
+        """
+        Create a Timestamp from an aware datetime object. If you have a naive
+        datetime object, consider using :meth:`from_local_datetime` instead.
+        """
+        if dt.tzinfo is None:
+            raise TypeError(
+                "cannot to parse naive datetime with `from_datetime`, "
+                "consider using `from_local_datetime` if applicable"
+            )
+        delta = dt - Timestamp._EPOCH
+        seconds = (delta.days * 24 * 3600) + delta.seconds
+        microseconds = seconds * 1000000 + delta.microseconds
+        return Timestamp(microseconds * 1000)
+
+    @classmethod
+    def from_local_datetime(cls, dt: datetime.datetime) -> "Timestamp":
+        """
+        Create a timestamp from a naive :class:`datetime` object that uses the
+        local timezone.
+        If you have a proper aware object, use :meth:`from_datetime` instead.
+
+        This function uses `dateutil.tz.gettz()` which uses `/etc/localtime`.
+        Using naive datetime object can be errorprone. Naive local times are
+        ambiguous during daylight savings time adjustments.
+        If you have to use this function, your workflow is probably broken.
+        """
+        if dt.tzinfo is not None:
+            raise TypeError(
+                "refusing to parse aware datetime with `from_local_datetime`, "
+                "use `from_datetime` instead."
+            )
+        return cls.from_datetime(dt.replace(tzinfo=tz.gettz()))
+
+    @classmethod
+    def from_iso8601(cls, iso_string: str) -> "Timestamp":
+        """Create a Timestamp from a `ISO 8601` date-time string.
+
+        >>> Timestamp.from_iso8601("1970-01-01T00:00:00.0Z") == Timestamp(0)
+        True
+
+        This is a convenience method that parses the date-time string into a
+        :class:`python:datetime.datetime` using
+        :meth:`dateutil:dateutil.parser.isoparse`,
+        and then calls :meth:`from_datetime` to create a :class:`Timestamp` from that.
+
+        The provided `iso_string` must include timezone information. To parse
+        local time strings, you must convert them yourself and use
+        :meth:`from_local_datetime`. Or better yet, somehow create an aware
+        :class:`python:datetime.datetime` and use :meth:`from_datetime`.
+
+        Note:
+            The parser only supports up to *6 sub-second digits*,
+            further digits are simply dropped.
+            If you need to parse timestamps with higher precision,
+            you need to convert the string to nanoseconds yourself.
+
+        Args:
+            iso_string: a date-time string in `ISO 8601` format including a
+                        timezone specifier.
+        """
+        dt = dateutil_isoparse(iso_string)
+        if dt.tzinfo is None:
+            raise TypeError("provided timestamp does not include timezone info")
+        return cls.from_datetime(dt)
+
+    @classmethod
+    def ago(cls, delta: Timedelta) -> "Timestamp":
+        """Return a timestamp `delta` in the past.
+
+        This is equivalent to::
+
+            Timestamp.now() - delta
+
+        Args:
+            delta: the time difference to now
+        """
+        return cls.now() - delta
+
+    @classmethod
+    def from_now(cls, delta: Timedelta) -> "Timestamp":
+        """Return a timestamp `delta` in the future.
+
+        This is equivalent to::
+
+            Timestamp.now() + delta
+
+        Args:
+            delta: the time difference to now
+        """
+        return cls.now() + delta
+
+    @classmethod
+    def now(cls) -> "Timestamp":
+        """Return a Timestamp corresponding to "now"
+
+        Returns:
+            :class:`Timestamp`
+        """
+        return cls.from_datetime(datetime.datetime.now(datetime.timezone.utc))
+
+    @property
+    def posix_ns(self) -> int:
+        """Number of nanoseconds since the UNIX epoch"""
+        return self._value
+
+    @property
+    def posix_us(self) -> float:
+        """Number of microseconds since the UNIX epoch"""
+        return self._value / 1000
+
+    @property
+    def posix_ms(self) -> float:
+        """Number of milliseconds since the UNIX epoch"""
+        return self._value / 1000000
+
+    @property
+    def posix(self) -> float:
+        """Number of seconds since the UNIX epoch"""
+        return self._value / 1000000000
+
+    @property
+    def datetime(self) -> datetime.datetime:
+        """Create an aware UTC datetime object
+
+        All MetricQ timestamps are POSIX timestamps, hence UTC.
+        """
+        # We use timedelta in the hope that this doesn't break
+        # on non-POSIX systems, where fromtimestamp apparently may omit leap seconds
+        # but our MetricQ timestamps are true UNIX timestamps without leap seconds
+        microseconds = self._value // 1000
+        return Timestamp._EPOCH + datetime.timedelta(microseconds=microseconds)
+
+    def __add__(self, delta: Timedelta) -> "Timestamp":
+        """Return the timestamp `delta` in the future (or in the past, if `delta` is negative), relative to a :class:`Timestamp` instance.
+
+        >>> epoch = Timestamp(0)
+        >>> a_week_later = epoch + Timedelta.from_string("7 days")
+        >>> str(a_week_later)
+        '[604800000000000] 1970-01-08 01:00:00+01:00'
+
+        Args:
+            delta: a time duration, possibly negative
+
+        Returns:
+            :class:`Timestamp`
+        """
+        return Timestamp(self._value + delta.ns)
+
+    @overload
+    def __sub__(self, other: Timedelta) -> "Timestamp":
+        ...
+
+    @overload
+    def __sub__(self, other: "Timestamp") -> Timedelta:
+        ...
+
+    def __sub__(
+        self, other: Union[Timedelta, "Timestamp"]
+    ) -> Union["Timestamp", Timedelta]:
+        if isinstance(other, Timedelta):
+            return Timestamp(self._value - other.ns)
+        if isinstance(other, Timestamp):
+            return Timedelta(self._value - other._value)
+        raise TypeError(
+            "Invalid type to subtract from Timestamp: {}".format(type(other))
+        )
+
+    def __lt__(self, other: "Timestamp") -> bool:
+        """Compare whether this timestamp describes a time before another timestamp.
+
+        >>> now = Timestamp.now()
+        >>> later = now + Timedelta.from_s(10)
+        >>> now < later
+        True
+
+        Together with :meth:`__eq__`, all relational operations (``<=``, ``>``, ``!=``, etc.) are supported.
+        Timestamps are `totally ordered` (as supplied by :func:`python:functools.total_ordering`).
+
+        Args:
+            other: another timestamp
+        """
+
+        return self._value < other._value
+
+    def __eq__(self, other: object) -> bool:
+        """Check whether two :class:`Timestamps<Timestamp>` refer to the same instance of time:
+
+        >>> now = Timestamp.now()
+        >>> later = now + Timedelta.from_s(10)
+        >>> now == later
+        False
+
+        Args:
+            other: another timestamp
+        """
+        if not isinstance(other, Timestamp):
+            return NotImplemented
+
+        return self._value == other._value
+
+    def __str__(self) -> str:
+        """Yield a human-readable date-time string in the local timezone:
+
+        >>> # in UTC+01:00, it was already 1 in the night when the UNIX epoch happened
+        >>> str(Timestamp(0))
+        '[0] 1970-01-01 01:00:00+01:00'
+        """
+
+        # Note we convert to local timezone with astimezone for printing
+        return "[{}] {}".format(self.posix_ns, str(self.datetime.astimezone()))
+
+    def __repr__(self) -> str:
+        return f"Timestamp({self.posix_ns})"
diff --git a/setup.cfg b/setup.cfg
index b05fcf87..a626acf0 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -13,11 +13,6 @@ classifiers =
     Programming Language :: Python :: 3
 
 [options]
-packages =
-    metricq
-    metricq_proto
-package_dir =
-    metricq_proto = lib/metricq-protobuf
 python_requires = >=3.10
 
 [options.package_data]
diff --git a/setup.py b/setup.py
index 85ce8542..da34c04f 100755
--- a/setup.py
+++ b/setup.py
@@ -5,7 +5,7 @@
 #
 # See https://setuptools.readthedocs.io/en/latest/deprecated/distutils-legacy.html
 # isort: off
-from setuptools import Command, setup
+from setuptools import Command, setup, find_packages
 from setuptools.command.build_py import build_py
 from setuptools.command.develop import develop
 
@@ -281,4 +281,6 @@ def run(self):
         "develop": ProtoDevelop,
     },
     include_package_data=True,
+    packages=find_packages(include=["metricq", "metricq.*", "metricq_proto"]),
+    package_dir={"metricq_proto": "lib/metricq-protobuf"},
 )


Test patch:
diff --git a/tests/test_interval_source.py b/tests/test_interval_source.py
index e6d39857..dc89db39 100644
--- a/tests/test_interval_source.py
+++ b/tests/test_interval_source.py
@@ -3,9 +3,9 @@
 
 import pytest
 
+from metricq import Timedelta
 from metricq.exceptions import AgentStopped
 from metricq.interval_source import IntervalSource
-from metricq.types import Timedelta
 
 pytestmark = pytest.mark.asyncio
 
diff --git a/tests/test_source_metric.py b/tests/test_source_metric.py
index 28b48d0a..5b6adb03 100644
--- a/tests/test_source_metric.py
+++ b/tests/test_source_metric.py
@@ -4,10 +4,10 @@
 
 import pytest
 
+from metricq import Timedelta, Timestamp
 from metricq.datachunk_pb2 import DataChunk
 from metricq.source import Source
 from metricq.source_metric import ChunkSize, SourceMetric
-from metricq.types import Timedelta, Timestamp
 
 pytestmark = pytest.mark.asyncio
 
diff --git a/tests/test_types.py b/tests/test_types.py
index b5a1f806..f9b11e35 100644
--- a/tests/test_types.py
+++ b/tests/test_types.py
@@ -7,8 +7,8 @@
 import pytest
 from dateutil import tz
 
+from metricq import TimeAggregate, Timedelta, Timestamp, TimeValue
 from metricq.exceptions import NonMonotonicTimestamps
-from metricq.types import TimeAggregate, Timedelta, Timestamp, TimeValue
 
 logger = getLogger(__name__)