Skip to content

Commit 4a8206a

Browse files
authored
Merge pull request #26 from leblancfg/feat/rss-generator
feat: Add RSS feed generator with versioning support
2 parents 04f43dd + abe6a27 commit 4a8206a

File tree

15 files changed

+2446
-109
lines changed

15 files changed

+2446
-109
lines changed

src/models/deprecation.py

Lines changed: 101 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,135 +1,153 @@
1-
"""Data models for deprecation entries."""
1+
"""Deprecation model for AI model deprecation tracking."""
22

3-
import hashlib
43
from datetime import UTC, datetime
5-
from typing import Any
64

7-
from pydantic import BaseModel, Field, HttpUrl, field_validator, model_validator
5+
from pydantic import BaseModel, Field, field_validator, model_validator
86

97

10-
class Deprecation(BaseModel):
11-
"""Model for AI model deprecation information."""
8+
class DeprecationEntry(BaseModel):
9+
"""Model representing an AI model deprecation entry."""
1210

13-
provider: str = Field(description="Provider name (e.g., 'OpenAI', 'Anthropic')")
14-
model: str = Field(description="Affected model name")
15-
deprecation_date: datetime = Field(description="When the deprecation was announced")
16-
retirement_date: datetime = Field(description="When the model stops working")
17-
replacement: str | None = Field(default=None, description="Suggested alternative model")
18-
notes: str | None = Field(default=None, description="Additional context")
19-
source_url: HttpUrl = Field(description="URL where the deprecation info came from")
11+
provider: str = Field(..., description="Provider name (e.g., OpenAI, Anthropic)")
12+
model: str = Field(..., description="Model name or identifier")
13+
deprecation_date: datetime = Field(..., description="Date when deprecation was announced")
14+
retirement_date: datetime = Field(..., description="Date when model stops working")
15+
replacement: str | None = Field(None, description="Suggested alternative model")
16+
notes: str | None = Field(None, description="Additional context or information")
17+
source_url: str = Field(..., description="Link to official announcement")
2018
last_updated: datetime = Field(
2119
default_factory=lambda: datetime.now(UTC),
22-
description="When we last checked this information",
23-
)
24-
# Alias for compatibility with main branch
25-
created_at: datetime = Field(
26-
default_factory=lambda: datetime.now(UTC),
27-
description="When entry was created (alias for last_updated)",
20+
description="When this entry was last updated",
2821
)
2922

30-
@field_validator("deprecation_date", "retirement_date", "last_updated")
23+
@field_validator("provider", "model")
24+
@classmethod
25+
def validate_non_empty_string(cls, v: str) -> str:
26+
"""Ensure provider and model are non-empty strings."""
27+
if not v or not v.strip():
28+
raise ValueError("Field must be a non-empty string")
29+
return v.strip()
30+
31+
@field_validator("source_url")
3132
@classmethod
32-
def ensure_utc_timezone(cls, v: datetime) -> datetime:
33-
"""Ensure datetime fields have UTC timezone."""
34-
if v.tzinfo is None:
35-
return v.replace(tzinfo=UTC)
36-
return v.astimezone(UTC)
33+
def validate_url(cls, v: str) -> str:
34+
"""Basic URL validation."""
35+
if not v.startswith(("http://", "https://")):
36+
raise ValueError("URL must start with http:// or https://")
37+
return v
3738

3839
@model_validator(mode="after")
39-
def validate_dates(self) -> "Deprecation":
40-
"""Validate that retirement_date is after deprecation_date."""
40+
def validate_dates(self) -> "DeprecationEntry":
41+
"""Ensure retirement date is after deprecation date."""
4142
if self.retirement_date <= self.deprecation_date:
42-
raise ValueError("retirement_date must be after deprecation_date")
43+
raise ValueError("Retirement date must be after deprecation date")
4344
return self
4445

46+
def to_rss_item(self) -> dict[str, str | datetime]:
47+
"""Convert to RSS item dictionary."""
48+
description_parts = [
49+
f"Provider: {self.provider}",
50+
f"Model: {self.model}",
51+
f"Deprecation Date: {self.deprecation_date.isoformat()}",
52+
f"Retirement Date: {self.retirement_date.isoformat()}",
53+
]
54+
55+
if self.replacement:
56+
description_parts.append(f"Replacement: {self.replacement}")
57+
58+
if self.notes:
59+
description_parts.append(f"Notes: {self.notes}")
60+
61+
description = "\n".join(description_parts)
62+
63+
title = f"{self.provider} - {self.model} Deprecation"
64+
65+
return {
66+
"title": title,
67+
"description": description,
68+
"link": self.source_url,
69+
"guid": f"{self.provider}-{self.model}-{self.deprecation_date.isoformat()}",
70+
"pubDate": self.deprecation_date,
71+
}
72+
73+
def to_json_dict(self) -> dict[str, str | None]:
74+
"""Convert to JSON-serializable dictionary."""
75+
return {
76+
"provider": self.provider,
77+
"model": self.model,
78+
"deprecation_date": self.deprecation_date.isoformat(),
79+
"retirement_date": self.retirement_date.isoformat(),
80+
"replacement": self.replacement,
81+
"notes": self.notes,
82+
"source_url": self.source_url,
83+
"last_updated": self.last_updated.isoformat(),
84+
}
85+
86+
model_config = {
87+
"json_encoders": {
88+
datetime: lambda v: v.isoformat(),
89+
}
90+
}
91+
92+
# Compatibility methods for main branch
93+
def is_active(self) -> bool:
94+
"""Check if deprecation is still active (not yet retired)."""
95+
now = datetime.now(UTC)
96+
return self.retirement_date > now
97+
4598
def get_hash(self) -> str:
46-
"""Generate hash of core deprecation data (excluding last_updated)."""
47-
# Include all fields that identify the unique deprecation, excluding last_updated
99+
"""Generate hash for core deprecation data."""
100+
import hashlib
101+
48102
core_data = {
49103
"provider": self.provider,
50104
"model": self.model,
51105
"deprecation_date": self.deprecation_date.isoformat(),
52106
"retirement_date": self.retirement_date.isoformat(),
53107
"replacement": self.replacement,
54108
"notes": self.notes,
55-
"source_url": str(self.source_url),
109+
"source_url": self.source_url,
56110
}
57-
58-
# Create deterministic string representation
59111
data_str = str(sorted(core_data.items()))
60112
return hashlib.sha256(data_str.encode()).hexdigest()
61113

62114
def get_identity_hash(self) -> str:
63-
"""Generate hash for identifying same deprecation (for updates)."""
64-
# Only include immutable fields that identify the unique deprecation
115+
"""Generate hash for identifying same deprecation."""
116+
import hashlib
117+
65118
identity_data = {
66119
"provider": self.provider,
67120
"model": self.model,
68121
"deprecation_date": self.deprecation_date.isoformat(),
69122
"retirement_date": self.retirement_date.isoformat(),
70-
"source_url": str(self.source_url),
123+
"source_url": self.source_url,
71124
}
72-
73-
# Create deterministic string representation
74125
data_str = str(sorted(identity_data.items()))
75126
return hashlib.sha256(data_str.encode()).hexdigest()
76127

77-
def same_deprecation(self, other: "Deprecation") -> bool:
78-
"""Check if this represents the same deprecation (for updates)."""
128+
def same_deprecation(self, other: "DeprecationEntry") -> bool:
129+
"""Check if this represents the same deprecation."""
79130
return self.get_identity_hash() == other.get_identity_hash()
80131

81132
def __eq__(self, other: object) -> bool:
82133
"""Compare deprecations based on core data (excluding last_updated)."""
83-
if not isinstance(other, Deprecation):
134+
if not isinstance(other, DeprecationEntry):
84135
return False
85136
return self.get_hash() == other.get_hash()
86137

87-
def __hash__(self) -> int:
88-
"""Hash based on core data for use in sets/dicts."""
89-
return hash(self.get_hash())
90-
91138
def __str__(self) -> str:
92139
"""String representation of deprecation."""
93140
return (
94-
f"Deprecation({self.provider} {self.model}: "
95-
f"{self.deprecation_date.date()} -> {self.retirement_date.date()})"
141+
f"DeprecationEntry(provider='{self.provider}', model='{self.model}', "
142+
f"deprecation_date='{self.deprecation_date.date()}', "
143+
f"retirement_date='{self.retirement_date.date()}')"
96144
)
97145

98-
def is_active(self) -> bool:
99-
"""Check if the deprecation is still active (not yet retired)."""
100-
now = datetime.now(UTC)
101-
return self.retirement_date > now
102-
103-
def to_rss_item(self) -> dict[str, Any]:
104-
"""Convert deprecation to RSS item format (compatibility with main)."""
105-
title = f"{self.provider}: {self.model} Deprecation"
106-
description_parts = [
107-
f"Model: {self.model}",
108-
f"Provider: {self.provider}",
109-
f"Deprecation Date: {self.deprecation_date.strftime('%Y-%m-%d')}",
110-
f"Retirement Date: {self.retirement_date.strftime('%Y-%m-%d')}",
111-
]
112-
if self.replacement:
113-
description_parts.append(f"Replacement: {self.replacement}")
114-
if self.notes:
115-
description_parts.append(f"Notes: {self.notes}")
116-
117-
return {
118-
"title": title,
119-
"description": " | ".join(description_parts),
120-
"guid": str(self.source_url),
121-
"pubDate": self.created_at,
122-
"link": str(self.source_url),
123-
}
124-
125-
def __repr__(self) -> str:
126-
"""Detailed string representation."""
127-
return (
128-
f"Deprecation(provider='{self.provider}', model='{self.model}', "
129-
f"deprecation_date={self.deprecation_date.isoformat()}, "
130-
f"retirement_date={self.retirement_date.isoformat()})"
131-
)
146+
@property
147+
def created_at(self) -> datetime:
148+
"""Alias for compatibility with main branch."""
149+
return self.deprecation_date
132150

133151

134-
# Main has DeprecationEntry, we use Deprecation, create alias for compatibility
135-
DeprecationEntry = Deprecation
152+
# Alias for compatibility with other branches that may use Deprecation
153+
Deprecation = DeprecationEntry

src/rss/config.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""RSS feed configuration."""
2+
3+
from pathlib import Path
4+
from typing import Any
5+
6+
from pydantic import BaseModel, Field
7+
8+
9+
class FeedConfig(BaseModel):
10+
"""Configuration for RSS feed generation."""
11+
12+
title: str = Field(
13+
default="AI Model Deprecations",
14+
description="Feed title",
15+
)
16+
description: str = Field(
17+
default="Daily-updated RSS feed tracking AI model deprecations across providers",
18+
description="Feed description",
19+
)
20+
link: str = Field(
21+
default="https://deprecations.example.com",
22+
description="Feed website link",
23+
)
24+
language: str = Field(
25+
default="en",
26+
description="Feed language code",
27+
)
28+
copyright: str | None = Field(
29+
default=None,
30+
description="Copyright information",
31+
)
32+
managing_editor: str | None = Field(
33+
default=None,
34+
description="Managing editor email",
35+
)
36+
webmaster: str | None = Field(
37+
default=None,
38+
description="Webmaster email",
39+
)
40+
ttl: int = Field(
41+
default=1440,
42+
description="Time to live in minutes (default 24 hours)",
43+
gt=0,
44+
)
45+
46+
model_config = {"validate_assignment": True}
47+
48+
49+
class VersionConfig(BaseModel):
50+
"""Configuration for RSS feed versioning."""
51+
52+
version: str = Field(
53+
default="v1",
54+
description="Feed version identifier",
55+
pattern=r"^v\d+$",
56+
)
57+
supported_versions: list[str] = Field(
58+
default_factory=lambda: ["v1"],
59+
description="List of supported versions",
60+
)
61+
62+
def is_version_supported(self, version: str) -> bool:
63+
"""Check if a version is supported."""
64+
return version in self.supported_versions
65+
66+
model_config = {"validate_assignment": True}
67+
68+
69+
class OutputConfig(BaseModel):
70+
"""Configuration for RSS feed output paths."""
71+
72+
base_path: Path = Field(
73+
default=Path("output/rss"),
74+
description="Base output directory for RSS feeds",
75+
)
76+
filename: str = Field(
77+
default="feed.xml",
78+
description="RSS feed filename",
79+
)
80+
81+
def get_versioned_path(self, version: str) -> Path:
82+
"""Get the full path for a versioned feed."""
83+
return self.base_path / version / self.filename
84+
85+
def ensure_directories(self, version: str) -> None:
86+
"""Ensure output directories exist for a given version."""
87+
versioned_dir = self.base_path / version
88+
versioned_dir.mkdir(parents=True, exist_ok=True)
89+
90+
model_config = {"validate_assignment": True}
91+
92+
93+
class RSSConfig(BaseModel):
94+
"""Complete RSS configuration."""
95+
96+
feed: FeedConfig = Field(
97+
default_factory=FeedConfig,
98+
description="Feed metadata configuration",
99+
)
100+
version: VersionConfig = Field(
101+
default_factory=VersionConfig,
102+
description="Version configuration",
103+
)
104+
output: OutputConfig = Field(
105+
default_factory=OutputConfig,
106+
description="Output path configuration",
107+
)
108+
109+
@classmethod
110+
def from_dict(cls, config_dict: dict[str, Any]) -> "RSSConfig":
111+
"""Create RSSConfig from dictionary."""
112+
return cls(
113+
feed=FeedConfig(**config_dict.get("feed", {})),
114+
version=VersionConfig(**config_dict.get("version", {})),
115+
output=OutputConfig(**config_dict.get("output", {})),
116+
)
117+
118+
def to_dict(self) -> dict[str, Any]:
119+
"""Convert to dictionary."""
120+
return {
121+
"feed": self.feed.model_dump(),
122+
"version": self.version.model_dump(),
123+
"output": {
124+
"base_path": str(self.output.base_path),
125+
"filename": self.output.filename,
126+
},
127+
}
128+
129+
model_config = {"validate_assignment": True}
130+
131+
132+
def get_default_config() -> RSSConfig:
133+
"""Get default RSS configuration."""
134+
return RSSConfig()

0 commit comments

Comments
 (0)