diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6f727f4..68d67ad 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,6 +13,25 @@ env: CARGO_TERM_COLOR: always jobs: + python-quality: + name: Ruff and Pyright + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: "3.10" + - name: Install Python quality tools + run: | + python -m pip install --upgrade pip + python -m pip install --group dev + - name: Lint Python + run: ruff check python + - name: Check Python formatting + run: ruff format --check python + - name: Check Python types + run: pyright + test: runs-on: ubuntu-latest @@ -63,3 +82,9 @@ jobs: - name: Run Python tests run: python -m pytest python/tests + + - name: Run Python examples + run: | + for example in python/examples/*.py; do + python "$example" > /dev/null + done diff --git a/pyproject.toml b/pyproject.toml index 2dbeaa4..68fbad0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,3 +34,21 @@ Documentation = "https://github.com/RustedBytes/fasttime/blob/main/python/README features = ["pyo3/extension-module", "python", "std"] python-source = "python" module-name = "fasttime" + +[dependency-groups] +dev = ["pytest>=8", "ruff==0.16.10", "pyright==1.1.414"] + +[tool.ruff] +target-version = "py310" +line-length = 88 + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I", "UP", "B"] + +[tool.pyright] +include = ["python"] +extraPaths = ["python"] +pythonVersion = "3.10" +typeCheckingMode = "standard" +# The implementation is a Rust extension; its API is declared in fasttime.pyi. +reportMissingModuleSource = "none" diff --git a/python/benchmarks/string_formatting.py b/python/benchmarks/string_formatting.py new file mode 100644 index 0000000..85fdfda --- /dev/null +++ b/python/benchmarks/string_formatting.py @@ -0,0 +1,41 @@ +"""Compare installed release wheels in separate virtual environments. + +Run with each environment's Python. Results are median nanoseconds per call, +including Python dispatch, over seven repetitions of 200,000 calls. +""" + +import json +import statistics +import timeit +from collections.abc import Callable + +import fasttime + +values = { + "date": fasttime.Date(2024, 6, 15), + "time": fasttime.Time(12, 30, 45, nanosecond=123456789), + "datetime": fasttime.DateTime.parse("2024-06-15T12:30:45.123456789Z"), + "offset": fasttime.OffsetDateTime.parse("2024-06-15T12:30:45.123456789+05:30"), + "duration": fasttime.Duration.nanoseconds(123456789), + "weekday": fasttime.Weekday.MONDAY, +} + + +def measure(op: Callable[[object], str], value: object) -> float: + return round( + statistics.median(timeit.repeat(lambda: op(value), number=200000, repeat=7)) + * 1e9 + / 200000, + 1, + ) + + +print( + json.dumps( + { + f"{name}/{op.__name__}": measure(op, value) + for name, value in values.items() + for op in [str, repr] + } + ) +) diff --git a/python/examples/basic_usage.py b/python/examples/basic_usage.py index b7a2242..debdfe4 100644 --- a/python/examples/basic_usage.py +++ b/python/examples/basic_usage.py @@ -19,7 +19,7 @@ def main(): print(f"Year: {date.year}, Month: {date.month}, Day: {date.day}") print(f"Weekday: {date.weekday()}") print(f"Day of year: {date.ordinal()}") - + # Add/subtract days tomorrow = date.add_days(1) yesterday = date.add_days(-1) @@ -45,7 +45,7 @@ def main(): print(f"DateTime: {dt}") print(f"Date component: {dt.date}") print(f"Time component: {dt.time}") - + # Get current time now = fasttime.DateTime.now_utc() print(f"Current UTC time: {now}") @@ -54,25 +54,25 @@ def main(): # 4. Working with Durations print("4. Working with Durations") print("-" * 40) - + # Create durations one_hour = fasttime.Duration.seconds(3600) one_day = fasttime.Duration.seconds(86400) half_second = fasttime.Duration.milliseconds(500) - + print(f"One hour: {one_hour.total_seconds()} seconds") print(f"One day: {one_day.total_seconds()} seconds") print(f"Half second: {half_second.total_seconds()} seconds") - + # Add durations total = one_hour + half_second print(f"One hour + 500ms: {total.total_seconds()} seconds") - + # Add duration to datetime later = dt.add_duration(one_hour) print(f"Original: {dt}") print(f"One hour later: {later}") - + # Calculate difference diff = later.difference(dt) print(f"Difference: {diff.total_seconds()} seconds") @@ -83,7 +83,7 @@ def main(): print("-" * 40) date1 = fasttime.Date(2024, 1, 1) date2 = fasttime.Date(2024, 12, 31) - + print(f"{date1} < {date2}: {date1 < date2}") print(f"{date1} == {date2}: {date1 == date2}") print(f"{date1} > {date2}: {date1 > date2}") @@ -108,10 +108,10 @@ def main(): print("-" * 40) epoch = fasttime.Date.from_days_since_unix_epoch(0) print(f"Unix epoch (day 0): {epoch}") - + days = date.days_since_unix_epoch() print(f"{date} is {days} days since epoch") - + reconstructed = fasttime.Date.from_days_since_unix_epoch(days) print(f"Reconstructed: {reconstructed}") print(f"Match: {date == reconstructed}") diff --git a/python/examples/durations.py b/python/examples/durations.py index a68a678..c9e4f23 100644 --- a/python/examples/durations.py +++ b/python/examples/durations.py @@ -14,12 +14,12 @@ def main(): # 1. Creating Durations print("1. Creating Durations") print("-" * 40) - + seconds = fasttime.Duration.seconds(60) millis = fasttime.Duration.milliseconds(500) micros = fasttime.Duration.microseconds(1000) nanos = fasttime.Duration.nanoseconds(123_456_789) - + print(f"60 seconds: {seconds.total_seconds()} s") print(f"500 milliseconds: {millis.total_seconds()} s") print(f"1000 microseconds: {micros.total_seconds()} s") @@ -29,14 +29,14 @@ def main(): # 2. Duration Arithmetic print("2. Duration Arithmetic") print("-" * 40) - + one_hour = fasttime.Duration.seconds(3600) thirty_minutes = fasttime.Duration.seconds(1800) - + total = one_hour + thirty_minutes difference = one_hour - thirty_minutes negated = -thirty_minutes - + print(f"1 hour + 30 minutes: {total.total_seconds()} seconds") print(f"1 hour - 30 minutes: {difference.total_seconds()} seconds") print(f"-(30 minutes): {negated.total_seconds()} seconds") @@ -45,15 +45,15 @@ def main(): # 3. Adding Durations to DateTimes print("3. Adding Durations to DateTimes") print("-" * 40) - + start = fasttime.DateTime.parse("2024-01-01T00:00:00Z") print(f"Start: {start}") - + # Add various durations one_day = fasttime.Duration.seconds(86400) one_week = fasttime.Duration.seconds(86400 * 7) one_year = fasttime.Duration.seconds(86400 * 365) - + print(f"+1 day: {start.add_duration(one_day)}") print(f"+1 week: {start.add_duration(one_week)}") print(f"+1 year: {start.add_duration(one_year)}") @@ -62,10 +62,10 @@ def main(): # 4. Calculating Time Differences print("4. Calculating Time Differences") print("-" * 40) - + dt1 = fasttime.DateTime.parse("2024-01-01T12:00:00Z") dt2 = fasttime.DateTime.parse("2024-01-01T18:30:45Z") - + diff = dt2.difference(dt1) print(f"Time 1: {dt1}") print(f"Time 2: {dt2}") @@ -76,10 +76,10 @@ def main(): # 5. Measuring Intervals print("5. Measuring Intervals") print("-" * 40) - + meeting_start = fasttime.DateTime.parse("2024-06-15T09:00:00Z") meeting_end = fasttime.DateTime.parse("2024-06-15T10:30:00Z") - + duration = meeting_end.difference(meeting_start) print(f"Meeting start: {meeting_start}") print(f"Meeting end: {meeting_end}") @@ -89,10 +89,10 @@ def main(): # 6. Comparing Durations print("6. Comparing Durations") print("-" * 40) - + short = fasttime.Duration.seconds(10) long = fasttime.Duration.seconds(100) - + print(f"Short duration: {short.total_seconds()} s") print(f"Long duration: {long.total_seconds()} s") print(f"short < long: {short < long}") @@ -103,11 +103,11 @@ def main(): # 7. Working with Nanoseconds print("7. Working with Nanosecond Precision") print("-" * 40) - + # High-precision timestamps dt_start = fasttime.DateTime.parse("2024-01-01T00:00:00.000000000Z") dt_end = fasttime.DateTime.parse("2024-01-01T00:00:00.123456789Z") - + nano_diff = dt_end.difference(dt_start) print(f"Start: {dt_start}") print(f"End: {dt_end}") @@ -118,15 +118,15 @@ def main(): # 8. Negative Durations print("8. Negative Durations") print("-" * 40) - + future = fasttime.DateTime.parse("2024-12-31T23:59:59Z") past = fasttime.DateTime.parse("2024-01-01T00:00:00Z") - + # Future - past = positive forward = future.difference(past) # Past - future = negative backward = past.difference(future) - + print(f"Future: {future}") print(f"Past: {past}") print(f"Future - Past: {forward.total_seconds()} seconds (positive)") @@ -136,7 +136,7 @@ def main(): # 9. Common Duration Constants print("9. Common Duration Values") print("-" * 40) - + constants = [ ("1 millisecond", fasttime.Duration.milliseconds(1)), ("1 second", fasttime.Duration.seconds(1)), @@ -145,7 +145,7 @@ def main(): ("1 day", fasttime.Duration.seconds(86400)), ("1 week", fasttime.Duration.seconds(86400 * 7)), ] - + for name, dur in constants: print(f"{name:15} = {dur.total_nanos():>20} nanoseconds") print() diff --git a/python/examples/parsing.py b/python/examples/parsing.py index c558567..d5a98cd 100644 --- a/python/examples/parsing.py +++ b/python/examples/parsing.py @@ -14,14 +14,14 @@ def main(): # 1. Parsing Dates print("1. Parsing Dates (YYYY-MM-DD)") print("-" * 40) - + date_strings = [ "2024-01-01", "2024-12-31", "2000-02-29", # Leap year "1970-01-01", # Unix epoch ] - + for s in date_strings: date = fasttime.Date.parse(s) print(f"{s:12} -> {date} (weekday: {date.weekday()})") @@ -30,7 +30,7 @@ def main(): # 2. Parsing Times print("2. Parsing Times (HH:MM:SS[.fffffffff])") print("-" * 40) - + time_strings = [ "00:00:00", "12:00:00", @@ -40,7 +40,7 @@ def main(): "14:30:45.123456", "14:30:45.123456789", ] - + for s in time_strings: time = fasttime.Time.parse(s) print(f"{s:25} -> {time}") @@ -49,7 +49,7 @@ def main(): # 3. Parsing UTC DateTimes print("3. Parsing UTC DateTimes (ISO 8601 / RFC 3339)") print("-" * 40) - + datetime_strings = [ "2024-01-01T00:00:00Z", "2024-12-31T23:59:59Z", @@ -57,7 +57,7 @@ def main(): "2024-06-15T12:30:45.5Z", "2024-06-15T12:30:45.123456789Z", ] - + for s in datetime_strings: dt = fasttime.DateTime.parse(s) print(f"{s:40} -> {dt}") @@ -67,7 +67,7 @@ def main(): # 4. Parsing OffsetDateTimes (with timezone offsets) print("4. Parsing OffsetDateTimes (RFC 3339 with offsets)") print("-" * 40) - + offset_datetime_strings = [ "2024-06-15T12:30:45Z", "2024-06-15T12:30:45+00:00", @@ -75,7 +75,7 @@ def main(): "2024-06-15T12:30:45-08:00", "2024-06-15T12:30:45.123456789+02:00", ] - + for s in offset_datetime_strings: odt = fasttime.OffsetDateTime.parse(s) print(f"{s:45} -> {odt}") @@ -87,11 +87,11 @@ def main(): # 5. Round-trip: String -> Parse -> String print("5. Round-trip Parsing") print("-" * 40) - + original = "2024-06-15T14:30:45.123456789+05:30" odt = fasttime.OffsetDateTime.parse(original) serialized = str(odt) - + print(f"Original: {original}") print(f"Parsed: {odt}") print(f"Serialized: {serialized}") @@ -101,14 +101,14 @@ def main(): # 6. Error handling print("6. Error Handling") print("-" * 40) - + invalid_strings = [ ("2024-13-01", "Invalid month"), ("2024-02-30", "Invalid day for February"), ("25:00:00", "Invalid hour"), ("2024-01-01", "Missing time/timezone for DateTime"), ] - + for s, reason in invalid_strings: try: if ":" in s and "-" not in s: @@ -121,7 +121,7 @@ def main(): # Looks like a date fasttime.Date.parse(s) print(f"✗ {s:20} - Should have failed ({reason})") - except ValueError as e: + except ValueError: print(f"✓ {s:20} - Correctly rejected: {reason}") print() diff --git a/python/examples/timezones.py b/python/examples/timezones.py index c8b09ee..49ece79 100644 --- a/python/examples/timezones.py +++ b/python/examples/timezones.py @@ -14,12 +14,12 @@ def main(): # 1. Creating UTC Offsets print("1. Creating UTC Offsets") print("-" * 40) - + utc = fasttime.UtcOffset.from_seconds(0) plus_five_thirty = fasttime.UtcOffset.from_hours_minutes(True, 5, 30) minus_eight = fasttime.UtcOffset.from_hours_minutes(False, 8, 0) plus_one = fasttime.UtcOffset.from_seconds(3600) - + print(f"UTC: {utc} ({utc.as_seconds()} seconds)") print(f"+05:30: {plus_five_thirty} ({plus_five_thirty.as_seconds()} seconds)") print(f"-08:00: {minus_eight} ({minus_eight.as_seconds()} seconds)") @@ -31,10 +31,10 @@ def main(): # 2. Creating OffsetDateTime from UTC print("2. Creating OffsetDateTime from UTC") print("-" * 40) - + utc_dt = fasttime.DateTime.parse("2024-06-15T12:00:00Z") offset = fasttime.UtcOffset.from_hours_minutes(True, 5, 30) - + odt = fasttime.OffsetDateTime.from_utc(utc_dt, offset) print(f"UTC DateTime: {utc_dt}") print(f"With offset: {odt}") @@ -44,11 +44,11 @@ def main(): # 3. Creating OffsetDateTime from Local Time print("3. Creating OffsetDateTime from Local Time") print("-" * 40) - + date = fasttime.Date(2024, 6, 15) time = fasttime.Time(18, 0, 0) # 6:00 PM local offset = fasttime.UtcOffset.from_hours_minutes(True, 5, 30) # +05:30 - + odt = fasttime.OffsetDateTime.from_local(date, time, offset) print(f"Local Date: {date}") print(f"Local Time: {time}") @@ -60,19 +60,19 @@ def main(): # 4. Common Timezone Offsets print("4. Common Timezone Offsets") print("-" * 40) - + common_offsets = [ ("UTC", 0, 0, True), - ("EST", 5, 0, False), # -05:00 - ("PST", 8, 0, False), # -08:00 - ("CET", 1, 0, True), # +01:00 - ("IST", 5, 30, True), # +05:30 (India) - ("JST", 9, 0, True), # +09:00 (Japan) + ("EST", 5, 0, False), # -05:00 + ("PST", 8, 0, False), # -08:00 + ("CET", 1, 0, True), # +01:00 + ("IST", 5, 30, True), # +05:30 (India) + ("JST", 9, 0, True), # +09:00 (Japan) ] - + date = fasttime.Date(2024, 6, 15) time = fasttime.Time(12, 0, 0) # Noon local - + for name, hours, minutes, positive in common_offsets: offset = fasttime.UtcOffset.from_hours_minutes(positive, hours, minutes) odt = fasttime.OffsetDateTime.from_local(date, time, offset) @@ -82,19 +82,19 @@ def main(): # 5. Converting Between Offsets print("5. Converting Between Offsets") print("-" * 40) - + # Start with a time in New York (EST) ny_offset = fasttime.UtcOffset.from_hours_minutes(False, 5, 0) ny_date = fasttime.Date(2024, 6, 15) ny_time = fasttime.Time(14, 30, 0) # 2:30 PM in NY - + ny_dt = fasttime.OffsetDateTime.from_local(ny_date, ny_time, ny_offset) print(f"New York time: {ny_dt}") - + # Convert to UTC utc_dt = ny_dt.utc print(f"UTC time: {utc_dt}") - + # Convert to Tokyo (JST) tokyo_offset = fasttime.UtcOffset.from_hours_minutes(True, 9, 0) tokyo_dt = fasttime.OffsetDateTime.from_utc(utc_dt, tokyo_offset) @@ -105,14 +105,14 @@ def main(): # 6. Parsing RFC 3339 with Offsets print("6. Parsing RFC 3339 with Offsets") print("-" * 40) - + rfc3339_examples = [ "2024-06-15T12:00:00Z", "2024-06-15T12:00:00+00:00", "2024-06-15T12:00:00-05:00", "2024-06-15T12:00:00+05:30", ] - + for s in rfc3339_examples: odt = fasttime.OffsetDateTime.parse(s) print(f"{s:35} -> {odt}") @@ -122,11 +122,11 @@ def main(): # 7. Duration with Offsets print("7. Duration with Offsets") print("-" * 40) - + start = fasttime.OffsetDateTime.parse("2024-06-15T10:00:00+05:30") duration = fasttime.Duration.seconds(7200) # 2 hours end = start.add_duration(duration) - + print(f"Start: {start}") print(f"Duration: {duration.total_seconds() / 3600} hours") print(f"End: {end}") @@ -136,18 +136,18 @@ def main(): # 8. Comparing Across Offsets print("8. Comparing Across Offsets") print("-" * 40) - + # 12:00 in New York ny = fasttime.OffsetDateTime.parse("2024-06-15T12:00:00-05:00") # 17:00 in UTC (same instant) utc = fasttime.OffsetDateTime.parse("2024-06-15T17:00:00Z") # 22:30 in India (same instant) india = fasttime.OffsetDateTime.parse("2024-06-15T22:30:00+05:30") - + print(f"New York: {ny}") print(f"UTC: {utc}") print(f"India: {india}") - print(f"\nAll represent the same instant:") + print("\nAll represent the same instant:") print(f"NY == UTC: {ny == utc}") print(f"UTC == India: {utc == india}") print(f"NY == India: {ny == india}") @@ -156,16 +156,16 @@ def main(): # 9. Unix Timestamps with Offsets print("9. Unix Timestamps with Offsets") print("-" * 40) - + odt = fasttime.OffsetDateTime.parse("2024-06-15T18:00:00+05:30") timestamp = odt.unix_timestamp() timestamp_nanos = odt.unix_timestamp_nanos() - + print(f"OffsetDateTime: {odt}") print(f"UTC equivalent: {odt.utc}") print(f"Unix timestamp: {timestamp}") print(f"Unix timestamp ns: {timestamp_nanos}") - + # Convert back utc_dt = fasttime.DateTime.from_unix_timestamp(timestamp) print(f"From timestamp: {utc_dt}") diff --git a/python/examples/unix_timestamps.py b/python/examples/unix_timestamps.py index fd76771..f237dab 100644 --- a/python/examples/unix_timestamps.py +++ b/python/examples/unix_timestamps.py @@ -14,12 +14,12 @@ def main(): # 1. The Unix Epoch print("1. The Unix Epoch") print("-" * 40) - + epoch = fasttime.DateTime.from_unix_timestamp(0) print(f"Unix epoch (timestamp 0): {epoch}") print(f"Date: {epoch.date}") print(f"Time: {epoch.time}") - + epoch_date = fasttime.Date.from_days_since_unix_epoch(0) print(f"Epoch date: {epoch_date}") print() @@ -27,11 +27,11 @@ def main(): # 2. Converting to Unix Timestamps print("2. Converting to Unix Timestamps") print("-" * 40) - + dt = fasttime.DateTime.parse("2024-06-15T12:30:45Z") timestamp = dt.unix_timestamp() timestamp_nanos = dt.unix_timestamp_nanos() - + print(f"DateTime: {dt}") print(f"Unix timestamp (seconds): {timestamp}") print(f"Unix timestamp (nanos): {timestamp_nanos}") @@ -40,7 +40,7 @@ def main(): # 3. Creating DateTimes from Timestamps print("3. Creating DateTimes from Timestamps") print("-" * 40) - + # Various timestamps timestamps = [ (0, "Unix epoch"), @@ -48,7 +48,7 @@ def main(): (1_700_000_000, "2023-11-14"), (2_000_000_000, "2033-05-18"), ] - + for ts, description in timestamps: dt = fasttime.DateTime.from_unix_timestamp(ts) print(f"{ts:15} -> {dt} ({description})") @@ -57,13 +57,13 @@ def main(): # 4. Nanosecond Precision print("4. Nanosecond Precision") print("-" * 40) - + # Create with nanoseconds dt1 = fasttime.DateTime.from_unix_timestamp(1_700_000_000, 0) dt2 = fasttime.DateTime.from_unix_timestamp(1_700_000_000, 123_456_789) dt3 = fasttime.DateTime.from_unix_timestamp(1_700_000_000, 999_999_999) - - print(f"Same second, different nanos:") + + print("Same second, different nanos:") print(f" {dt1} ({dt1.unix_timestamp_nanos()})") print(f" {dt2} ({dt2.unix_timestamp_nanos()})") print(f" {dt3} ({dt3.unix_timestamp_nanos()})") @@ -72,12 +72,12 @@ def main(): # 5. Negative Timestamps (Before 1970) print("5. Negative Timestamps (Before 1970)") print("-" * 40) - + negative_timestamps = [ - -86400, # 1 day before epoch + -86400, # 1 day before epoch -31_536_000, # ~1 year before epoch ] - + for ts in negative_timestamps: dt = fasttime.DateTime.from_unix_timestamp(ts) print(f"{ts:15} -> {dt}") @@ -86,13 +86,13 @@ def main(): # 6. Round-trip Conversion print("6. Round-trip Conversion") print("-" * 40) - + original = fasttime.DateTime.parse("2024-06-15T14:30:45.123456789Z") timestamp = original.unix_timestamp() nanos = original.time.nanosecond - + reconstructed = fasttime.DateTime.from_unix_timestamp(timestamp, nanos) - + print(f"Original: {original}") print(f"Timestamp: {timestamp}") print(f"Nanoseconds: {nanos}") @@ -103,14 +103,14 @@ def main(): # 7. Timestamp Arithmetic print("7. Timestamp Arithmetic") print("-" * 40) - + dt = fasttime.DateTime.from_unix_timestamp(1_700_000_000) print(f"Base datetime: {dt}") - + # Add various durations one_hour_later = dt.add_duration(fasttime.Duration.seconds(3600)) one_day_later = dt.add_duration(fasttime.Duration.seconds(86400)) - + print(f"+1 hour: {one_hour_later} (ts: {one_hour_later.unix_timestamp()})") print(f"+1 day: {one_day_later} (ts: {one_day_later.unix_timestamp()})") print() @@ -118,13 +118,13 @@ def main(): # 8. Timestamp Differences print("8. Timestamp Differences") print("-" * 40) - + dt1 = fasttime.DateTime.from_unix_timestamp(1_700_000_000) dt2 = fasttime.DateTime.from_unix_timestamp(1_700_086_400) - + diff = dt2.difference(dt1) ts_diff = dt2.unix_timestamp() - dt1.unix_timestamp() - + print(f"DateTime 1: {dt1} (ts: {dt1.unix_timestamp()})") print(f"DateTime 2: {dt2} (ts: {dt2.unix_timestamp()})") print(f"Duration diff: {diff.total_seconds()} seconds") @@ -135,22 +135,24 @@ def main(): # 9. OffsetDateTime Timestamps print("9. OffsetDateTime Timestamps") print("-" * 40) - + # Different offsets, same instant utc_odt = fasttime.OffsetDateTime.parse("2024-06-15T12:00:00Z") ny_odt = fasttime.OffsetDateTime.parse("2024-06-15T07:00:00-05:00") tokyo_odt = fasttime.OffsetDateTime.parse("2024-06-15T21:00:00+09:00") - + print(f"UTC: {utc_odt} (ts: {utc_odt.unix_timestamp()})") print(f"NY: {ny_odt} (ts: {ny_odt.unix_timestamp()})") print(f"Tokyo: {tokyo_odt} (ts: {tokyo_odt.unix_timestamp()})") - print(f"\nAll same instant: {utc_odt.unix_timestamp() == ny_odt.unix_timestamp() == tokyo_odt.unix_timestamp()}") + print( + f"\nAll same instant: {utc_odt.unix_timestamp() == ny_odt.unix_timestamp() == tokyo_odt.unix_timestamp()}" + ) print() # 10. High-resolution Timing print("10. High-resolution Timing") print("-" * 40) - + # Simulate high-precision measurements measurements = [ fasttime.DateTime.from_unix_timestamp(1_700_000_000, 0), @@ -158,23 +160,23 @@ def main(): fasttime.DateTime.from_unix_timestamp(1_700_000_000, 200_000), fasttime.DateTime.from_unix_timestamp(1_700_000_000, 300_000), ] - + print("High-resolution measurements:") for i, dt in enumerate(measurements): nanos = dt.unix_timestamp_nanos() print(f" {i}: {dt} ({nanos} ns)") - + # Calculate intervals print("\nIntervals:") for i in range(len(measurements) - 1): diff = measurements[i + 1].difference(measurements[i]) - print(f" {i} -> {i+1}: {diff.total_nanos()} ns") + print(f" {i} -> {i + 1}: {diff.total_nanos()} ns") print() # 11. Common Timestamps print("11. Common Historical Timestamps") print("-" * 40) - + historical = [ (0, "Unix epoch"), (315_532_800, "Y2K preparation era (1980-01-01)"), @@ -182,10 +184,10 @@ def main(): (1_000_000_000, "Billennium"), (1_234_567_890, "Unix billionth second + change"), ] - + for ts, desc in historical: dt = fasttime.DateTime.from_unix_timestamp(ts) - print(f"{ts:15} -> {dt:30} {desc}") + print(f"{ts:15} -> {dt!s:30} {desc}") print() diff --git a/python/fasttime/__init__.py b/python/fasttime/__init__.py index 1851aed..378863e 100644 --- a/python/fasttime/__init__.py +++ b/python/fasttime/__init__.py @@ -2,7 +2,27 @@ fasttime - Fast UTC date/time library for Python, powered by Rust. """ -from .fasttime import * # noqa: F403, F401 +from .fasttime import ( + Date as Date, +) +from .fasttime import ( + DateTime as DateTime, +) +from .fasttime import ( + Duration as Duration, +) +from .fasttime import ( + OffsetDateTime as OffsetDateTime, +) +from .fasttime import ( + Time as Time, +) +from .fasttime import ( + UtcOffset as UtcOffset, +) +from .fasttime import ( + Weekday as Weekday, +) __all__ = [ "Weekday", diff --git a/python/fasttime/__init__.pyi b/python/fasttime/__init__.pyi index b4d22dc..eeaa807 100644 --- a/python/fasttime/__init__.pyi +++ b/python/fasttime/__init__.pyi @@ -1,272 +1,21 @@ -""" -Type stubs for fasttime Python bindings. - -This file provides type hints for the fasttime module, which is -implemented in Rust via PyO3. -""" - -from typing import ClassVar - -class Weekday: - """Calendar weekday (ISO order, Monday = 1).""" - MONDAY: ClassVar[Weekday] - TUESDAY: ClassVar[Weekday] - WEDNESDAY: ClassVar[Weekday] - THURSDAY: ClassVar[Weekday] - FRIDAY: ClassVar[Weekday] - SATURDAY: ClassVar[Weekday] - SUNDAY: ClassVar[Weekday] - - def number_from_monday(self) -> int: - """Get the weekday number from Monday (1-7).""" - ... - -class Date: - """Gregorian calendar date (proleptic).""" - - def __init__(self, year: int, month: int, day: int) -> None: - """Create a new Date from year, month, and day.""" - ... - - @property - def year(self) -> int: - """Year component.""" - ... - - @property - def month(self) -> int: - """Month component (1-12).""" - ... - - @property - def day(self) -> int: - """Day component (1-31).""" - ... - - @classmethod - def from_days_since_unix_epoch(cls, days: int) -> Date: - """Create a Date from days since Unix epoch.""" - ... - - def days_since_unix_epoch(self) -> int: - """Convert to days since Unix epoch.""" - ... - - def weekday(self) -> Weekday: - """Get the weekday.""" - ... - - def ordinal(self) -> int: - """Get the day of the year (1-366).""" - ... - - def add_days(self, days: int) -> Date: - """Add days to the date.""" - ... - - @classmethod - def parse(cls, s: str) -> Date: - """Parse a date from ISO format (YYYY-MM-DD).""" - ... - -class Time: - """Time of day in nanoseconds since midnight.""" - - def __init__(self, hour: int, minute: int, second: int, nanosecond: int = 0) -> None: - """Create a new Time.""" - ... - - @property - def hour(self) -> int: - """Hour component (0-23).""" - ... - - @property - def minute(self) -> int: - """Minute component (0-59).""" - ... - - @property - def second(self) -> int: - """Second component (0-59).""" - ... - - @property - def nanosecond(self) -> int: - """Nanosecond component (0-999,999,999).""" - ... - - def seconds_since_midnight(self) -> int: - """Get total seconds since midnight.""" - ... - - def nanos_since_midnight(self) -> int: - """Get total nanoseconds since midnight.""" - ... - - @classmethod - def parse(cls, s: str) -> Time: - """Parse a time from ISO format.""" - ... - -class Duration: - """Signed duration with nanosecond precision.""" - - @classmethod - def seconds(cls, secs: int) -> Duration: - """Create a duration from seconds.""" - ... - - @classmethod - def milliseconds(cls, ms: int) -> Duration: - """Create a duration from milliseconds.""" - ... - - @classmethod - def microseconds(cls, us: int) -> Duration: - """Create a duration from microseconds.""" - ... - - @classmethod - def nanoseconds(cls, ns: int) -> Duration: - """Create a duration from nanoseconds.""" - ... - - def total_seconds(self) -> float: - """Get total seconds as a float.""" - ... - - def total_nanos(self) -> int: - """Get total nanoseconds as an integer.""" - ... - - def __add__(self, other: Duration) -> Duration: ... - def __sub__(self, other: Duration) -> Duration: ... - def __neg__(self) -> Duration: ... - -class DateTime: - """Combined UTC date and time.""" - - def __init__(self, date: Date, time: Time) -> None: - """Create a DateTime from a Date and Time.""" - ... - - @property - def date(self) -> Date: - """Date component.""" - ... - - @property - def time(self) -> Time: - """Time component.""" - ... - - @classmethod - def from_unix_timestamp(cls, secs: int, nanos: int = 0) -> DateTime: - """Create a DateTime from Unix timestamp.""" - ... - - def unix_timestamp(self) -> int: - """Get Unix timestamp (seconds since Unix epoch).""" - ... - - def unix_timestamp_nanos(self) -> int: - """Get Unix timestamp in nanoseconds.""" - ... - - def add_duration(self, dur: Duration) -> DateTime: - """Add a duration to this DateTime.""" - ... - - def difference(self, other: DateTime) -> Duration: - """Calculate the difference between two DateTimes.""" - ... - - @classmethod - def now_utc(cls) -> DateTime: - """Get the current UTC DateTime.""" - ... - - @classmethod - def parse(cls, s: str) -> DateTime: - """Parse a DateTime from ISO 8601 / RFC 3339 UTC format.""" - ... - -class UtcOffset: - """Fixed offset from UTC.""" - - @classmethod - def from_seconds(cls, seconds: int) -> UtcOffset: - """Create a UtcOffset from total seconds.""" - ... - - @classmethod - def from_hours_minutes(cls, sign_positive: bool, hours: int, minutes: int) -> UtcOffset: - """Create a UtcOffset from hours and minutes.""" - ... - - def as_seconds(self) -> int: - """Get the offset as seconds.""" - ... - - def is_utc(self) -> bool: - """Check if this is UTC (offset = 0).""" - ... - -class OffsetDateTime: - """Date-time with a fixed offset from UTC.""" - - @classmethod - def from_utc(cls, utc: DateTime, offset: UtcOffset) -> OffsetDateTime: - """Create an OffsetDateTime from a UTC DateTime and offset.""" - ... - - @classmethod - def from_local(cls, date: Date, time: Time, offset: UtcOffset) -> OffsetDateTime: - """Create an OffsetDateTime from local date, time, and offset.""" - ... - - @property - def utc(self) -> DateTime: - """UTC datetime.""" - ... - - @property - def offset(self) -> UtcOffset: - """Offset from UTC.""" - ... - - def to_local(self) -> DateTime: - """Convert to local DateTime.""" - ... - - def unix_timestamp(self) -> int: - """Get Unix timestamp (seconds since Unix epoch).""" - ... - - def unix_timestamp_nanos(self) -> int: - """Get Unix timestamp in nanoseconds.""" - ... - - def add_duration(self, dur: Duration) -> OffsetDateTime: - """Add a duration.""" - ... - - def difference(self, other: OffsetDateTime) -> Duration: - """Calculate the difference between two OffsetDateTimes.""" - ... - - @classmethod - def parse(cls, s: str) -> OffsetDateTime: - """Parse an OffsetDateTime from RFC 3339 format.""" - ... - -__all__ = [ - "Weekday", - "Date", - "Time", - "Duration", - "DateTime", - "UtcOffset", - "OffsetDateTime", -] +from .fasttime import ( + Date as Date, +) +from .fasttime import ( + DateTime as DateTime, +) +from .fasttime import ( + Duration as Duration, +) +from .fasttime import ( + OffsetDateTime as OffsetDateTime, +) +from .fasttime import ( + Time as Time, +) +from .fasttime import ( + UtcOffset as UtcOffset, +) +from .fasttime import ( + Weekday as Weekday, +) diff --git a/python/fasttime/fasttime.pyi b/python/fasttime/fasttime.pyi new file mode 100644 index 0000000..27aaffa --- /dev/null +++ b/python/fasttime/fasttime.pyi @@ -0,0 +1,307 @@ +""" +Type stubs for fasttime Python bindings. + +This file provides type hints for the fasttime module, which is +implemented in Rust via PyO3. +""" + +from typing import ClassVar + +class Weekday: + """Calendar weekday (ISO order, Monday = 1).""" + + MONDAY: ClassVar[Weekday] + TUESDAY: ClassVar[Weekday] + WEDNESDAY: ClassVar[Weekday] + THURSDAY: ClassVar[Weekday] + FRIDAY: ClassVar[Weekday] + SATURDAY: ClassVar[Weekday] + SUNDAY: ClassVar[Weekday] + + def number_from_monday(self) -> int: + """Get the weekday number from Monday (1-7).""" + ... + +class Date: + def __lt__(self, other: Date) -> bool: ... + def __le__(self, other: Date) -> bool: ... + def __gt__(self, other: Date) -> bool: ... + def __ge__(self, other: Date) -> bool: ... + + """Gregorian calendar date (proleptic).""" + + def __init__(self, year: int, month: int, day: int) -> None: + """Create a new Date from year, month, and day.""" + ... + + @property + def year(self) -> int: + """Year component.""" + ... + + @property + def month(self) -> int: + """Month component (1-12).""" + ... + + @property + def day(self) -> int: + """Day component (1-31).""" + ... + + @classmethod + def from_days_since_unix_epoch(cls, days: int) -> Date: + """Create a Date from days since Unix epoch.""" + ... + + def days_since_unix_epoch(self) -> int: + """Convert to days since Unix epoch.""" + ... + + def weekday(self) -> Weekday: + """Get the weekday.""" + ... + + def ordinal(self) -> int: + """Get the day of the year (1-366).""" + ... + + def add_days(self, days: int) -> Date: + """Add days to the date.""" + ... + + @classmethod + def parse(cls, s: str) -> Date: + """Parse a date from ISO format (YYYY-MM-DD).""" + ... + +class Time: + def __lt__(self, other: Time) -> bool: ... + def __le__(self, other: Time) -> bool: ... + def __gt__(self, other: Time) -> bool: ... + def __ge__(self, other: Time) -> bool: ... + + """Time of day in nanoseconds since midnight.""" + + def __init__( + self, hour: int, minute: int, second: int, nanosecond: int = 0 + ) -> None: + """Create a new Time.""" + ... + + @property + def hour(self) -> int: + """Hour component (0-23).""" + ... + + @property + def minute(self) -> int: + """Minute component (0-59).""" + ... + + @property + def second(self) -> int: + """Second component (0-59).""" + ... + + @property + def nanosecond(self) -> int: + """Nanosecond component (0-999,999,999).""" + ... + + def seconds_since_midnight(self) -> int: + """Get total seconds since midnight.""" + ... + + def nanos_since_midnight(self) -> int: + """Get total nanoseconds since midnight.""" + ... + + @classmethod + def parse(cls, s: str) -> Time: + """Parse a time from ISO format.""" + ... + +class Duration: + def __lt__(self, other: Duration) -> bool: ... + def __le__(self, other: Duration) -> bool: ... + def __gt__(self, other: Duration) -> bool: ... + def __ge__(self, other: Duration) -> bool: ... + + """Signed duration with nanosecond precision.""" + + @classmethod + def seconds(cls, secs: int) -> Duration: + """Create a duration from seconds.""" + ... + + @classmethod + def milliseconds(cls, ms: int) -> Duration: + """Create a duration from milliseconds.""" + ... + + @classmethod + def microseconds(cls, us: int) -> Duration: + """Create a duration from microseconds.""" + ... + + @classmethod + def nanoseconds(cls, ns: int) -> Duration: + """Create a duration from nanoseconds.""" + ... + + def total_seconds(self) -> float: + """Get total seconds as a float.""" + ... + + def total_nanos(self) -> int: + """Get total nanoseconds as an integer.""" + ... + + def __add__(self, other: Duration) -> Duration: ... + def __sub__(self, other: Duration) -> Duration: ... + def __neg__(self) -> Duration: ... + +class DateTime: + def __lt__(self, other: DateTime) -> bool: ... + def __le__(self, other: DateTime) -> bool: ... + def __gt__(self, other: DateTime) -> bool: ... + def __ge__(self, other: DateTime) -> bool: ... + + """Combined UTC date and time.""" + + def __init__(self, date: Date, time: Time) -> None: + """Create a DateTime from a Date and Time.""" + ... + + @property + def date(self) -> Date: + """Date component.""" + ... + + @property + def time(self) -> Time: + """Time component.""" + ... + + @classmethod + def from_unix_timestamp(cls, secs: int, nanos: int = 0) -> DateTime: + """Create a DateTime from Unix timestamp.""" + ... + + def unix_timestamp(self) -> int: + """Get Unix timestamp (seconds since Unix epoch).""" + ... + + def unix_timestamp_nanos(self) -> int: + """Get Unix timestamp in nanoseconds.""" + ... + + def add_duration(self, dur: Duration) -> DateTime: + """Add a duration to this DateTime.""" + ... + + def difference(self, other: DateTime) -> Duration: + """Calculate the difference between two DateTimes.""" + ... + + @classmethod + def now_utc(cls) -> DateTime: + """Get the current UTC DateTime.""" + ... + + @classmethod + def parse(cls, s: str) -> DateTime: + """Parse a DateTime from ISO 8601 / RFC 3339 UTC format.""" + ... + +class UtcOffset: + def __lt__(self, other: UtcOffset) -> bool: ... + def __le__(self, other: UtcOffset) -> bool: ... + def __gt__(self, other: UtcOffset) -> bool: ... + def __ge__(self, other: UtcOffset) -> bool: ... + + """Fixed offset from UTC.""" + + @classmethod + def from_seconds(cls, seconds: int) -> UtcOffset: + """Create a UtcOffset from total seconds.""" + ... + + @classmethod + def from_hours_minutes( + cls, sign_positive: bool, hours: int, minutes: int + ) -> UtcOffset: + """Create a UtcOffset from hours and minutes.""" + ... + + def as_seconds(self) -> int: + """Get the offset as seconds.""" + ... + + def is_utc(self) -> bool: + """Check if this is UTC (offset = 0).""" + ... + +class OffsetDateTime: + def __lt__(self, other: OffsetDateTime) -> bool: ... + def __le__(self, other: OffsetDateTime) -> bool: ... + def __gt__(self, other: OffsetDateTime) -> bool: ... + def __ge__(self, other: OffsetDateTime) -> bool: ... + + """Date-time with a fixed offset from UTC.""" + + @classmethod + def from_utc(cls, utc: DateTime, offset: UtcOffset) -> OffsetDateTime: + """Create an OffsetDateTime from a UTC DateTime and offset.""" + ... + + @classmethod + def from_local(cls, date: Date, time: Time, offset: UtcOffset) -> OffsetDateTime: + """Create an OffsetDateTime from local date, time, and offset.""" + ... + + @property + def utc(self) -> DateTime: + """UTC datetime.""" + ... + + @property + def offset(self) -> UtcOffset: + """Offset from UTC.""" + ... + + def to_local(self) -> DateTime: + """Convert to local DateTime.""" + ... + + def unix_timestamp(self) -> int: + """Get Unix timestamp (seconds since Unix epoch).""" + ... + + def unix_timestamp_nanos(self) -> int: + """Get Unix timestamp in nanoseconds.""" + ... + + def add_duration(self, dur: Duration) -> OffsetDateTime: + """Add a duration.""" + ... + + def difference(self, other: OffsetDateTime) -> Duration: + """Calculate the difference between two OffsetDateTimes.""" + ... + + @classmethod + def parse(cls, s: str) -> OffsetDateTime: + """Parse an OffsetDateTime from RFC 3339 format.""" + ... + +__all__ = [ + "Weekday", + "Date", + "Time", + "Duration", + "DateTime", + "UtcOffset", + "OffsetDateTime", +] diff --git a/python/tests/test_fasttime.py b/python/tests/test_fasttime.py index 4764f34..da7740f 100644 --- a/python/tests/test_fasttime.py +++ b/python/tests/test_fasttime.py @@ -2,8 +2,8 @@ Tests for the fasttime Python bindings. """ -import pytest import fasttime +import pytest def test_date_creation(): @@ -34,7 +34,7 @@ def test_date_invalid(): """Test that invalid dates raise errors.""" with pytest.raises(ValueError): fasttime.Date(2024, 13, 1) # Invalid month - + with pytest.raises(ValueError): fasttime.Date(2024, 2, 30) # Invalid day for February @@ -50,7 +50,7 @@ def test_date_ordinal(): """Test ordinal (day of year) calculation.""" date = fasttime.Date(2024, 1, 1) assert date.ordinal() == 1 - + date = fasttime.Date(2024, 12, 31) assert date.ordinal() == 366 # 2024 is a leap year @@ -68,7 +68,7 @@ def test_date_comparison(): """Test date comparisons.""" date1 = fasttime.Date(2024, 1, 1) date2 = fasttime.Date(2024, 12, 31) - + assert date1 < date2 assert date1 <= date2 assert date2 > date1 @@ -82,10 +82,10 @@ def test_date_hash(): date1 = fasttime.Date(2024, 1, 1) date2 = fasttime.Date(2024, 1, 1) date3 = fasttime.Date(2024, 12, 31) - + assert hash(date1) == hash(date2) assert hash(date1) != hash(date3) - + # Test in sets dates = {date1, date2, date3} assert len(dates) == 2 @@ -113,7 +113,7 @@ def test_time_invalid(): """Test that invalid times raise errors.""" with pytest.raises(ValueError): fasttime.Time(25, 0, 0) # Invalid hour - + with pytest.raises(ValueError): fasttime.Time(0, 60, 0) # Invalid minute @@ -123,7 +123,7 @@ def test_datetime_creation(): date = fasttime.Date(2024, 6, 15) time = fasttime.Time(12, 30, 45) dt = fasttime.DateTime(date, time) - + assert dt.date == date assert dt.time == time @@ -156,7 +156,7 @@ def test_datetime_add_duration(): """Test adding durations to datetimes.""" dt = fasttime.DateTime.parse("2024-01-01T00:00:00Z") one_hour = fasttime.Duration.seconds(3600) - + later = dt.add_duration(one_hour) assert later.time.hour == 1 @@ -165,7 +165,7 @@ def test_datetime_difference(): """Test calculating differences between datetimes.""" dt1 = fasttime.DateTime.parse("2024-01-01T00:00:00Z") dt2 = fasttime.DateTime.parse("2024-01-01T01:00:00Z") - + diff = dt2.difference(dt1) assert diff.total_seconds() == 3600 @@ -174,7 +174,7 @@ def test_duration_creation(): """Test creating durations.""" seconds = fasttime.Duration.seconds(60) millis = fasttime.Duration.milliseconds(1000) - + assert seconds.total_seconds() == 60 assert millis.total_seconds() == 1 @@ -183,11 +183,11 @@ def test_duration_arithmetic(): """Test duration arithmetic.""" dur1 = fasttime.Duration.seconds(10) dur2 = fasttime.Duration.seconds(5) - + total = dur1 + dur2 diff = dur1 - dur2 neg = -dur1 - + assert total.total_seconds() == 15 assert diff.total_seconds() == 5 assert neg.total_seconds() == -10 @@ -197,7 +197,7 @@ def test_duration_comparison(): """Test duration comparisons.""" short = fasttime.Duration.seconds(10) long = fasttime.Duration.seconds(100) - + assert short < long assert short <= long assert long > short @@ -210,7 +210,7 @@ def test_utc_offset_creation(): """Test creating UTC offsets.""" offset = fasttime.UtcOffset.from_hours_minutes(True, 5, 30) assert offset.as_seconds() == 5 * 3600 + 30 * 60 - + offset_neg = fasttime.UtcOffset.from_hours_minutes(False, 8, 0) assert offset_neg.as_seconds() == -8 * 3600 @@ -219,7 +219,7 @@ def test_utc_offset_is_utc(): """Test checking if offset is UTC.""" utc = fasttime.UtcOffset.from_seconds(0) not_utc = fasttime.UtcOffset.from_hours_minutes(True, 5, 0) - + assert utc.is_utc() assert not not_utc.is_utc() @@ -229,7 +229,7 @@ def test_offset_datetime_from_local(): date = fasttime.Date(2024, 6, 15) time = fasttime.Time(18, 0, 0) offset = fasttime.UtcOffset.from_hours_minutes(True, 5, 30) - + odt = fasttime.OffsetDateTime.from_local(date, time, offset) assert odt.offset == offset @@ -244,7 +244,7 @@ def test_offset_datetime_to_local(): """Test converting offset datetime to local.""" odt = fasttime.OffsetDateTime.parse("2024-06-15T12:00:00Z") local = odt.to_local() - + assert local.date.year == 2024 assert local.date.month == 6 assert local.date.day == 15 @@ -255,7 +255,7 @@ def test_round_trip_date(): original = fasttime.Date(2024, 6, 15) days = original.days_since_unix_epoch() reconstructed = fasttime.Date.from_days_since_unix_epoch(days) - + assert original == reconstructed @@ -265,7 +265,7 @@ def test_round_trip_datetime(): timestamp = original.unix_timestamp() nanos = original.time.nanosecond reconstructed = fasttime.DateTime.from_unix_timestamp(timestamp, nanos) - + assert original == reconstructed @@ -274,5 +274,56 @@ def test_round_trip_offset_datetime(): original = "2024-06-15T12:00:00+05:30" odt = fasttime.OffsetDateTime.parse(original) serialized = str(odt) - + assert original == serialized + + +@pytest.mark.parametrize( + "value, text, representation", + [ + (fasttime.Weekday.MONDAY, "Monday", "Monday"), + ( + fasttime.Date(-2147483648, 1, 1), + "-2147483648-01-01", + "Date(year=-2147483648, month=1, day=1)", + ), + ( + fasttime.Date(2147483647, 12, 31), + "2147483647-12-31", + "Date(year=2147483647, month=12, day=31)", + ), + ( + fasttime.Time(23, 59, 59, nanosecond=999999999), + "23:59:59.999999999", + "Time(hour=23, minute=59, second=59, nanosecond=999999999)", + ), + ( + fasttime.Duration.nanoseconds(-(2**127)), + f"Duration({-(2**127)} ns)", + f"Duration.nanoseconds({-(2**127)})", + ), + ( + fasttime.Duration.nanoseconds(2**127 - 1), + f"Duration({2**127 - 1} ns)", + f"Duration.nanoseconds({2**127 - 1})", + ), + ( + fasttime.DateTime.parse("2024-06-15T12:30:45.123456789Z"), + "2024-06-15T12:30:45.123456789Z", + "DateTime.parse('2024-06-15T12:30:45.123456789Z')", + ), + ( + fasttime.UtcOffset.from_seconds(-86399), + "-23:59", + "UtcOffset.from_seconds(-86399)", + ), + ( + fasttime.OffsetDateTime.parse("2024-06-15T12:30:45.123456789+05:30"), + "2024-06-15T12:30:45.123456789+05:30", + "OffsetDateTime.parse('2024-06-15T12:30:45.123456789+05:30')", + ), + ], +) +def test_string_representations(value, text, representation): + assert str(value) == text + assert repr(value) == representation diff --git a/src/lib.rs b/src/lib.rs index f9e41d5..30cfe51 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,19 @@ //! - `OffsetDateTime`: `YYYY-MM-DDTHH:MM:SS[.fffffffff][Z|±HH:MM]` (RFC 3339 subset). //! - `DateTime::now_utc()` when the `std` feature is enabled. //! +//! ## Allocation behavior +//! +//! Rust parsing borrows the input string and returns inline `Copy` values; +//! calendar arithmetic and timestamp conversion do not allocate. `Display` +//! writes directly to the supplied formatter. Use `core::fmt::Write` with a +//! caller-owned buffer to format without allocating; `to_string()` and +//! `format!()` allocate their owned output. +//! +//! Python `str()` and `repr()` format through a fixed stack buffer, avoiding +//! an intermediate Rust `String`. The resulting Python Unicode object still +//! allocates and copies the formatted bytes. Python object construction and +//! exceptions are outside the allocation-free Rust API guarantee. +//! //! ## Python Bindings //! //! When built with the `python` feature, this crate provides Python bindings via `PyO3`. diff --git a/src/python.rs b/src/python.rs index 7eab42e..f479ca7 100644 --- a/src/python.rs +++ b/src/python.rs @@ -5,12 +5,44 @@ // backticks around Python type names would leak into `help()` output. #![allow(clippy::doc_markdown)] +use core::fmt::{self, Write}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; -use pyo3::types::PyType; +use pyo3::types::{PyString, PyType}; use crate::{Date, DateTime, Duration, OffsetDateTime, Time, UtcOffset, Weekday as RustWeekday}; +// All built-in representations fit in 128 bytes, including i32 years and +// i128 durations. Keep formatting on the stack; only the returned Python +// Unicode object needs a heap allocation. +struct FormatBuffer { + bytes: [u8; 128], + len: usize, +} + +impl Write for FormatBuffer { + fn write_str(&mut self, value: &str) -> fmt::Result { + let end = self.len.checked_add(value.len()).ok_or(fmt::Error)?; + let output = self.bytes.get_mut(self.len..end).ok_or(fmt::Error)?; + output.copy_from_slice(value.as_bytes()); + self.len = end; + Ok(()) + } +} + +fn python_string<'py>(py: Python<'py>, args: fmt::Arguments<'_>) -> PyResult> { + let mut buffer = FormatBuffer { + bytes: [0; 128], + len: 0, + }; + buffer + .write_fmt(args) + .map_err(|_| PyValueError::new_err("Representation exceeds formatting buffer"))?; + let text = core::str::from_utf8(&buffer.bytes[..buffer.len]) + .map_err(|_| PyValueError::new_err("Invalid UTF-8 in representation"))?; + Ok(PyString::new(py, text)) +} + // ===== Weekday ===== #[pyclass(name = "Weekday", module = "fasttime", from_py_object)] @@ -40,12 +72,12 @@ impl PyWeekday { self.0.number_from_monday() } - fn __repr__(&self) -> String { - format!("{:?}", self.0) + fn __repr__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string(py, format_args!("{:?}", self.0)) } - fn __str__(&self) -> String { - format!("{:?}", self.0) + fn __str__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string(py, format_args!("{:?}", self.0)) } } @@ -163,14 +195,17 @@ impl PyDate { .map_err(|e| PyValueError::new_err(format!("Invalid date string: {e:?}"))) } - fn __str__(&self) -> String { - self.0.to_string() + fn __str__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string(py, format_args!("{}", self.0)) } - fn __repr__(&self) -> String { - format!( - "Date(year={}, month={}, day={})", - self.0.year, self.0.month, self.0.day + fn __repr__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string( + py, + format_args!( + "Date(year={}, month={}, day={})", + self.0.year, self.0.month, self.0.day + ), ) } @@ -277,14 +312,17 @@ impl PyTime { .map_err(|e| PyValueError::new_err(format!("Invalid time string: {e:?}"))) } - fn __str__(&self) -> String { - self.0.to_string() + fn __str__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string(py, format_args!("{}", self.0)) } - fn __repr__(&self) -> String { - format!( - "Time(hour={}, minute={}, second={}, nanosecond={})", - self.0.hour, self.0.minute, self.0.second, self.0.nanosecond + fn __repr__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string( + py, + format_args!( + "Time(hour={}, minute={}, second={}, nanosecond={})", + self.0.hour, self.0.minute, self.0.second, self.0.nanosecond + ), ) } @@ -369,12 +407,15 @@ impl PyDuration { PyDuration(-self.0) } - fn __str__(&self) -> String { - format!("Duration({} ns)", self.0.total_nanos()) + fn __str__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string(py, format_args!("Duration({} ns)", self.0.total_nanos())) } - fn __repr__(&self) -> String { - format!("Duration.nanoseconds({})", self.0.total_nanos()) + fn __repr__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string( + py, + format_args!("Duration.nanoseconds({})", self.0.total_nanos()), + ) } fn __richcmp__(&self, other: &Self, op: pyo3::basic::CompareOp) -> bool { @@ -528,12 +569,12 @@ impl PyDateTime { }) } - fn __str__(&self) -> String { - self.0.to_string() + fn __str__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string(py, format_args!("{}", self.0)) } - fn __repr__(&self) -> String { - format!("DateTime.parse('{}')", self.0) + fn __repr__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string(py, format_args!("DateTime.parse('{}')", self.0)) } fn __richcmp__(&self, other: &Self, op: pyo3::basic::CompareOp) -> bool { @@ -620,12 +661,15 @@ impl PyUtcOffset { self.0.is_utc() } - fn __str__(&self) -> String { - self.0.to_string() + fn __str__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string(py, format_args!("{}", self.0)) } - fn __repr__(&self) -> String { - format!("UtcOffset.from_seconds({})", self.0.as_seconds()) + fn __repr__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string( + py, + format_args!("UtcOffset.from_seconds({})", self.0.as_seconds()), + ) } fn __richcmp__(&self, other: &Self, op: pyo3::basic::CompareOp) -> bool { @@ -785,12 +829,12 @@ impl PyOffsetDateTime { }) } - fn __str__(&self) -> String { - self.0.to_string() + fn __str__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string(py, format_args!("{}", self.0)) } - fn __repr__(&self) -> String { - format!("OffsetDateTime.parse('{}')", self.0) + fn __repr__<'py>(&self, py: Python<'py>) -> PyResult> { + python_string(py, format_args!("OffsetDateTime.parse('{}')", self.0)) } fn __richcmp__(&self, other: &Self, op: pyo3::basic::CompareOp) -> bool { diff --git a/tests/allocations.rs b/tests/allocations.rs new file mode 100644 index 0000000..2c29d75 --- /dev/null +++ b/tests/allocations.rs @@ -0,0 +1,91 @@ +//! Guard the allocation-free Rust parsing, arithmetic and formatting paths. +use fasttime::{Date, DateTime, Duration, OffsetDateTime, Time}; +use std::alloc::{GlobalAlloc, Layout, System}; +use std::cell::Cell; +use std::fmt::{self, Write}; +use std::hint::black_box; + +thread_local! { + static COUNT: Cell> = const { Cell::new(None) }; +} + +struct CountingAllocator; + +fn record() { + let _ = COUNT.try_with(|count| { + if let Some(value) = count.get() { + count.set(Some(value + 1)); + } + }); +} + +// SAFETY: all operations delegate unchanged layouts and pointers to System. +unsafe impl GlobalAlloc for CountingAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + record(); + unsafe { System.alloc(layout) } + } + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + record(); + unsafe { System.alloc_zeroed(layout) } + } + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 { + record(); + unsafe { System.realloc(ptr, layout, size) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } +} + +#[global_allocator] +static ALLOCATOR: CountingAllocator = CountingAllocator; + +struct Buffer([u8; 128], usize); +impl Write for Buffer { + fn write_str(&mut self, text: &str) -> fmt::Result { + let end = self.1 + text.len(); + self.0 + .get_mut(self.1..end) + .ok_or(fmt::Error)? + .copy_from_slice(text.as_bytes()); + self.1 = end; + Ok(()) + } +} + +#[test] +fn rust_hot_paths_do_not_allocate() { + // Positive control proves this thread's allocator instrumentation is active. + COUNT.with(|count| count.set(Some(0))); + let allocation = black_box(Box::new(42)); + assert!(COUNT.with(|count| count.replace(None).unwrap()) > 0); + drop(allocation); + + COUNT.with(|count| count.set(Some(0))); + for input in ["2024-06-15", "-2147483648-01-01", "2147483647-12-31"] { + let date = black_box(input).parse::().unwrap(); + let mut buffer = Buffer([0; 128], 0); + write!(buffer, "{date}").unwrap(); + black_box(buffer); + } + let time = black_box("12:30:45.123456789").parse::