mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-09 23:28:07 -05:00
ref(backend): time/date blocks to support ISO 8601 and custom formats (#10576)
Introduces discriminated unions for time, date, and date-time format selection, supporting both strftime and ISO 8601 (with timezone and microsecond options). Updates schemas, test cases, and block logic to handle the new format types, improving flexibility and standards compliance for time and date outputs. <!-- Clearly explain the need for these changes: --> ### Why these changes are needed Users need to output timestamps in ISO 8601/RFC 3339 format for API integrations and standardized data exchange. The previous implementation only supported strftime formatting, which made it difficult to generate properly formatted timestamps with timezone information. This change enables: - **Standards compliance**: ISO 8601 and RFC 3339 compliant timestamps - **Timezone support**: 38 timezone options covering all UTC offsets globally - **API compatibility**: Many APIs require RFC 3339 timestamps (e.g., "2011-06-03T10:00:00-07:00") - **Backward compatibility**: Existing workflows continue to work with default strftime format ### Changes 🏗️ <!-- Concisely describe all of the changes made in this pull request: --> - **Added discriminated union format types** for all time/date blocks: - `GetCurrentTimeBlock`: Now supports `TimeStrftimeFormat` and `TimeISO8601Format` - `GetCurrentDateBlock`: Now supports `DateStrftimeFormat` and `DateISO8601Format` - `GetCurrentDateAndTimeBlock`: Now supports `StrftimeFormat` and `ISO8601Format` - **Implemented shared timezone support**: - Created `TimezoneLiteral` type with 38 timezone options (all UTC offsets) - Supports fractional offsets (e.g., India UTC+05:30, Nepal UTC+05:45) - Deduplicated timezone lists across all format classes - **Added ISO 8601 format features**: - Timezone-aware timestamps with proper offset formatting - Optional microseconds inclusion - RFC 3339 compliance (subset of ISO 8601 with mandatory timezone) - **Updated test cases** for all three blocks to verify: - Default behavior unchanged (backward compatibility) - Custom strftime formats still work - ISO 8601 format produces correct output ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: <!-- Put your test plan here: --> - [x] Verified backward compatibility - default strftime format unchanged - [x] Tested ISO 8601 format with UTC timezone - [x] Tested ISO 8601 format with various timezones (India, New York, etc.) - [x] Tested microseconds option for ISO formats - [x] Verified all existing tests pass for GetCurrentTimeBlock - [x] Verified all existing tests pass for GetCurrentDateBlock - [x] Verified all existing tests pass for GetCurrentDateAndTimeBlock - [x] Manually tested each block with different format configurations - [x] Confirmed RFC 3339 compliance for timestamps with mandatory timezone --------- Co-authored-by: Claude <claude@users.noreply.github.com>
This commit is contained in:
@@ -30,6 +30,12 @@ poetry run test
|
||||
# Run specific test
|
||||
poetry run pytest path/to/test_file.py::test_function_name
|
||||
|
||||
# Run block tests (tests that validate all blocks work correctly)
|
||||
poetry run pytest backend/blocks/test/test_block.py -xvs
|
||||
|
||||
# Run tests for a specific block (e.g., GetCurrentTimeBlock)
|
||||
poetry run pytest 'backend/blocks/test/test_block.py::test_available_blocks[GetCurrentTimeBlock]' -xvs
|
||||
|
||||
# Lint and format
|
||||
# prefer format if you want to just "fix" it and only get the errors that can't be autofixed
|
||||
poetry run format # Black + isort
|
||||
|
||||
@@ -1,19 +1,78 @@
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Union
|
||||
from typing import Any, Literal, Union
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema
|
||||
from backend.data.model import SchemaField
|
||||
|
||||
# Shared timezone literal type for all time/date blocks
|
||||
TimezoneLiteral = Literal[
|
||||
"UTC", # UTC±00:00
|
||||
"Pacific/Honolulu", # UTC-10:00
|
||||
"America/Anchorage", # UTC-09:00 (Alaska)
|
||||
"America/Los_Angeles", # UTC-08:00 (Pacific)
|
||||
"America/Denver", # UTC-07:00 (Mountain)
|
||||
"America/Chicago", # UTC-06:00 (Central)
|
||||
"America/New_York", # UTC-05:00 (Eastern)
|
||||
"America/Caracas", # UTC-04:00
|
||||
"America/Sao_Paulo", # UTC-03:00
|
||||
"America/St_Johns", # UTC-02:30 (Newfoundland)
|
||||
"Atlantic/South_Georgia", # UTC-02:00
|
||||
"Atlantic/Azores", # UTC-01:00
|
||||
"Europe/London", # UTC+00:00 (GMT/BST)
|
||||
"Europe/Paris", # UTC+01:00 (CET)
|
||||
"Europe/Athens", # UTC+02:00 (EET)
|
||||
"Europe/Moscow", # UTC+03:00
|
||||
"Asia/Tehran", # UTC+03:30 (Iran)
|
||||
"Asia/Dubai", # UTC+04:00
|
||||
"Asia/Kabul", # UTC+04:30 (Afghanistan)
|
||||
"Asia/Karachi", # UTC+05:00 (Pakistan)
|
||||
"Asia/Kolkata", # UTC+05:30 (India)
|
||||
"Asia/Kathmandu", # UTC+05:45 (Nepal)
|
||||
"Asia/Dhaka", # UTC+06:00 (Bangladesh)
|
||||
"Asia/Yangon", # UTC+06:30 (Myanmar)
|
||||
"Asia/Bangkok", # UTC+07:00
|
||||
"Asia/Shanghai", # UTC+08:00 (China)
|
||||
"Australia/Eucla", # UTC+08:45
|
||||
"Asia/Tokyo", # UTC+09:00 (Japan)
|
||||
"Australia/Adelaide", # UTC+09:30
|
||||
"Australia/Sydney", # UTC+10:00
|
||||
"Australia/Lord_Howe", # UTC+10:30
|
||||
"Pacific/Noumea", # UTC+11:00
|
||||
"Pacific/Auckland", # UTC+12:00 (New Zealand)
|
||||
"Pacific/Chatham", # UTC+12:45
|
||||
"Pacific/Tongatapu", # UTC+13:00
|
||||
"Pacific/Kiritimati", # UTC+14:00
|
||||
"Etc/GMT-12", # UTC+12:00
|
||||
"Etc/GMT+12", # UTC-12:00
|
||||
]
|
||||
|
||||
|
||||
class TimeStrftimeFormat(BaseModel):
|
||||
discriminator: Literal["strftime"]
|
||||
format: str = "%H:%M:%S"
|
||||
timezone: TimezoneLiteral = "UTC"
|
||||
|
||||
|
||||
class TimeISO8601Format(BaseModel):
|
||||
discriminator: Literal["iso8601"]
|
||||
timezone: TimezoneLiteral = "UTC"
|
||||
include_microseconds: bool = False
|
||||
|
||||
|
||||
class GetCurrentTimeBlock(Block):
|
||||
class Input(BlockSchema):
|
||||
trigger: str = SchemaField(
|
||||
description="Trigger any data to output the current time"
|
||||
)
|
||||
format: str = SchemaField(
|
||||
description="Format of the time to output", default="%H:%M:%S"
|
||||
format_type: Union[TimeStrftimeFormat, TimeISO8601Format] = SchemaField(
|
||||
discriminator="discriminator",
|
||||
description="Format type for time output (strftime with custom format or ISO 8601)",
|
||||
default=TimeStrftimeFormat(discriminator="strftime"),
|
||||
)
|
||||
|
||||
class Output(BlockSchema):
|
||||
@@ -30,19 +89,65 @@ class GetCurrentTimeBlock(Block):
|
||||
output_schema=GetCurrentTimeBlock.Output,
|
||||
test_input=[
|
||||
{"trigger": "Hello"},
|
||||
{"trigger": "Hello", "format": "%H:%M"},
|
||||
{
|
||||
"trigger": "Hello",
|
||||
"format_type": {
|
||||
"discriminator": "strftime",
|
||||
"format": "%H:%M",
|
||||
},
|
||||
},
|
||||
{
|
||||
"trigger": "Hello",
|
||||
"format_type": {
|
||||
"discriminator": "iso8601",
|
||||
"timezone": "UTC",
|
||||
"include_microseconds": False,
|
||||
},
|
||||
},
|
||||
],
|
||||
test_output=[
|
||||
("time", lambda _: time.strftime("%H:%M:%S")),
|
||||
("time", lambda _: time.strftime("%H:%M")),
|
||||
(
|
||||
"time",
|
||||
lambda t: "T" in t and ("+" in t or "Z" in t),
|
||||
), # Check for ISO format with timezone
|
||||
],
|
||||
)
|
||||
|
||||
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
|
||||
current_time = time.strftime(input_data.format)
|
||||
if isinstance(input_data.format_type, TimeISO8601Format):
|
||||
# ISO 8601 format for time only (extract time portion from full ISO datetime)
|
||||
tz = ZoneInfo(input_data.format_type.timezone)
|
||||
dt = datetime.now(tz=tz)
|
||||
|
||||
# Get the full ISO format and extract just the time portion with timezone
|
||||
if input_data.format_type.include_microseconds:
|
||||
full_iso = dt.isoformat()
|
||||
else:
|
||||
full_iso = dt.isoformat(timespec="seconds")
|
||||
|
||||
# Extract time portion (everything after 'T')
|
||||
current_time = full_iso.split("T")[1] if "T" in full_iso else full_iso
|
||||
current_time = f"T{current_time}" # Add T prefix for ISO 8601 time format
|
||||
else: # TimeStrftimeFormat
|
||||
tz = ZoneInfo(input_data.format_type.timezone)
|
||||
dt = datetime.now(tz=tz)
|
||||
current_time = dt.strftime(input_data.format_type.format)
|
||||
yield "time", current_time
|
||||
|
||||
|
||||
class DateStrftimeFormat(BaseModel):
|
||||
discriminator: Literal["strftime"]
|
||||
format: str = "%Y-%m-%d"
|
||||
timezone: TimezoneLiteral = "UTC"
|
||||
|
||||
|
||||
class DateISO8601Format(BaseModel):
|
||||
discriminator: Literal["iso8601"]
|
||||
timezone: TimezoneLiteral = "UTC"
|
||||
|
||||
|
||||
class GetCurrentDateBlock(Block):
|
||||
class Input(BlockSchema):
|
||||
trigger: str = SchemaField(
|
||||
@@ -53,8 +158,10 @@ class GetCurrentDateBlock(Block):
|
||||
description="Offset in days from the current date",
|
||||
default=0,
|
||||
)
|
||||
format: str = SchemaField(
|
||||
description="Format of the date to output", default="%Y-%m-%d"
|
||||
format_type: Union[DateStrftimeFormat, DateISO8601Format] = SchemaField(
|
||||
discriminator="discriminator",
|
||||
description="Format type for date output (strftime with custom format or ISO 8601)",
|
||||
default=DateStrftimeFormat(discriminator="strftime"),
|
||||
)
|
||||
|
||||
class Output(BlockSchema):
|
||||
@@ -71,7 +178,22 @@ class GetCurrentDateBlock(Block):
|
||||
output_schema=GetCurrentDateBlock.Output,
|
||||
test_input=[
|
||||
{"trigger": "Hello", "offset": "7"},
|
||||
{"trigger": "Hello", "offset": "7", "format": "%m/%d/%Y"},
|
||||
{
|
||||
"trigger": "Hello",
|
||||
"offset": "7",
|
||||
"format_type": {
|
||||
"discriminator": "strftime",
|
||||
"format": "%m/%d/%Y",
|
||||
},
|
||||
},
|
||||
{
|
||||
"trigger": "Hello",
|
||||
"offset": "0",
|
||||
"format_type": {
|
||||
"discriminator": "iso8601",
|
||||
"timezone": "UTC",
|
||||
},
|
||||
},
|
||||
],
|
||||
test_output=[
|
||||
(
|
||||
@@ -85,6 +207,12 @@ class GetCurrentDateBlock(Block):
|
||||
< timedelta(days=8),
|
||||
# 7 days difference + 1 day error margin.
|
||||
),
|
||||
(
|
||||
"date",
|
||||
lambda t: len(t) == 10
|
||||
and t[4] == "-"
|
||||
and t[7] == "-", # ISO date format YYYY-MM-DD
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -93,8 +221,31 @@ class GetCurrentDateBlock(Block):
|
||||
offset = int(input_data.offset)
|
||||
except ValueError:
|
||||
offset = 0
|
||||
current_date = datetime.now() - timedelta(days=offset)
|
||||
yield "date", current_date.strftime(input_data.format)
|
||||
|
||||
if isinstance(input_data.format_type, DateISO8601Format):
|
||||
# ISO 8601 format for date only (YYYY-MM-DD)
|
||||
tz = ZoneInfo(input_data.format_type.timezone)
|
||||
current_date = datetime.now(tz=tz) - timedelta(days=offset)
|
||||
# ISO 8601 date format is YYYY-MM-DD
|
||||
date_str = current_date.date().isoformat()
|
||||
else: # DateStrftimeFormat
|
||||
tz = ZoneInfo(input_data.format_type.timezone)
|
||||
current_date = datetime.now(tz=tz) - timedelta(days=offset)
|
||||
date_str = current_date.strftime(input_data.format_type.format)
|
||||
|
||||
yield "date", date_str
|
||||
|
||||
|
||||
class StrftimeFormat(BaseModel):
|
||||
discriminator: Literal["strftime"]
|
||||
format: str = "%Y-%m-%d %H:%M:%S"
|
||||
timezone: TimezoneLiteral = "UTC"
|
||||
|
||||
|
||||
class ISO8601Format(BaseModel):
|
||||
discriminator: Literal["iso8601"]
|
||||
timezone: TimezoneLiteral = "UTC"
|
||||
include_microseconds: bool = False
|
||||
|
||||
|
||||
class GetCurrentDateAndTimeBlock(Block):
|
||||
@@ -102,9 +253,10 @@ class GetCurrentDateAndTimeBlock(Block):
|
||||
trigger: str = SchemaField(
|
||||
description="Trigger any data to output the current date and time"
|
||||
)
|
||||
format: str = SchemaField(
|
||||
description="Format of the date and time to output",
|
||||
default="%Y-%m-%d %H:%M:%S",
|
||||
format_type: Union[StrftimeFormat, ISO8601Format] = SchemaField(
|
||||
discriminator="discriminator",
|
||||
description="Format type for date and time output (strftime with custom format or ISO 8601/RFC 3339)",
|
||||
default=StrftimeFormat(discriminator="strftime"),
|
||||
)
|
||||
|
||||
class Output(BlockSchema):
|
||||
@@ -121,20 +273,63 @@ class GetCurrentDateAndTimeBlock(Block):
|
||||
output_schema=GetCurrentDateAndTimeBlock.Output,
|
||||
test_input=[
|
||||
{"trigger": "Hello"},
|
||||
{
|
||||
"trigger": "Hello",
|
||||
"format_type": {
|
||||
"discriminator": "strftime",
|
||||
"format": "%Y/%m/%d",
|
||||
},
|
||||
},
|
||||
{
|
||||
"trigger": "Hello",
|
||||
"format_type": {
|
||||
"discriminator": "iso8601",
|
||||
"timezone": "UTC",
|
||||
"include_microseconds": False,
|
||||
},
|
||||
},
|
||||
],
|
||||
test_output=[
|
||||
(
|
||||
"date_time",
|
||||
lambda t: abs(
|
||||
datetime.now() - datetime.strptime(t, "%Y-%m-%d %H:%M:%S")
|
||||
datetime.now(tz=ZoneInfo("UTC"))
|
||||
- datetime.strptime(t + "+00:00", "%Y-%m-%d %H:%M:%S%z")
|
||||
)
|
||||
< timedelta(seconds=10), # 10 seconds error margin.
|
||||
),
|
||||
(
|
||||
"date_time",
|
||||
lambda t: abs(
|
||||
datetime.now().date() - datetime.strptime(t, "%Y/%m/%d").date()
|
||||
)
|
||||
< timedelta(days=1), # Date format only, no time component
|
||||
),
|
||||
(
|
||||
"date_time",
|
||||
lambda t: abs(
|
||||
datetime.now(tz=ZoneInfo("UTC")) - datetime.fromisoformat(t)
|
||||
)
|
||||
< timedelta(seconds=10), # 10 seconds error margin for ISO format.
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
|
||||
current_date_time = time.strftime(input_data.format)
|
||||
if isinstance(input_data.format_type, ISO8601Format):
|
||||
# ISO 8601 format with specified timezone (also RFC3339-compliant)
|
||||
tz = ZoneInfo(input_data.format_type.timezone)
|
||||
dt = datetime.now(tz=tz)
|
||||
|
||||
# Format with or without microseconds
|
||||
if input_data.format_type.include_microseconds:
|
||||
current_date_time = dt.isoformat()
|
||||
else:
|
||||
current_date_time = dt.isoformat(timespec="seconds")
|
||||
else: # StrftimeFormat
|
||||
tz = ZoneInfo(input_data.format_type.timezone)
|
||||
dt = datetime.now(tz=tz)
|
||||
current_date_time = dt.strftime(input_data.format_type.format)
|
||||
yield "date_time", current_date_time
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user