Skip to content

Commit 5eabda2

Browse files
authored
Merge pull request #55 from dhvcc/feat/pydantic-v2
Pydantic v2, move old models to legacy, py3.14
2 parents b94e933 + 43efc4c commit 5eabda2

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+1199
-474
lines changed

.github/workflows/ci.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,16 @@ on:
77
paths-ignore:
88
- ".gitignore"
99
- "README.md"
10-
pull_request:
10+
- "pre-commit-config.yaml"
11+
- "LICENSE"
1112

1213
jobs:
1314
test:
1415
strategy:
1516
max-parallel: 6
1617
matrix:
1718
os: [ "ubuntu-latest", "windows-latest", "macos-latest" ]
18-
python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13" ]
19+
python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13", "3.14" ]
1920

2021
runs-on: ${{ matrix.os }}
2122

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ repos:
3131
- run
3232
- mypy
3333
- rss_parser
34+
pass_filenames: false
3435
language: system
3536
stages: [ push ]
3637

README.md

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,31 @@ pip install dist/*.whl
3737
- Models for RSS-specific schemas have been moved from `rss_parser.models` to `rss_parser.models.rss`. Generic types remain unchanged
3838
- Date parsing has been improved and now uses pydantic's `validator` instead of `email.utils`, producing better datetime objects where it previously defaulted to `str`
3939

40+
## V2 -> V3 Migration
41+
42+
`rss-parser` 3.x upgrades the runtime models to [Pydantic v2](https://docs.pydantic.dev/latest/migration/). Highlights:
43+
44+
- **New default models** now inherit from `pydantic.BaseModel` v2 and use `model_validate`/`model_dump`. If you extend our classes, switch from `dict()`/`json()` to `model_dump()`/`model_dump_json()`.
45+
- **Legacy compatibility** lives under `rss_parser.models.legacy`. Point your custom parser at the legacy schema if you must stay on the v1 API surface.
46+
- **Collections**: list-like XML fields now use `OnlyList[...]` directly with an automatic `default_factory` so that attributes are always lists (no more `Optional[OnlyList[T]] = Field(..., default=[])`). Update custom schemas accordingly.
47+
- **Custom hooks**: if you relied on `rss_parser.pydantic_proxy`, import it from `rss_parser.models.legacy.pydantic_proxy`. The top-level module only re-exports it for backwards compatibility.
48+
49+
See the “Legacy Models” section below for sample snippets showing how to stay on the older types. Tests in this repo cover both tracks to guarantee matching output.
50+
51+
## Legacy Models
52+
53+
Pydantic v1-based models are still available under `rss_parser.models.legacy`. They retain the previous behaviour and re-export the `import_v1_pydantic` helper as `rss_parser.models.legacy.pydantic_proxy.import_v1_pydantic`. You can continue to use them by pointing your parser at the legacy schema:
54+
55+
```python
56+
from rss_parser import RSSParser
57+
from rss_parser.models.legacy.rss import RSS as LegacyRSS
58+
59+
class LegacyRSSParser(RSSParser):
60+
schema = LegacyRSS
61+
```
62+
63+
Tests in this repository run against both the v2 and legacy models to ensure parity.
64+
4065
## Usage
4166

4267
### Quickstart
@@ -163,18 +188,17 @@ If you don't want to deal with these conditions and want to parse something **al
163188
```python
164189
from typing import Optional
165190

191+
from pydantic import Field
192+
166193
from rss_parser.models.rss.item import Item
167194
from rss_parser.models.types.only_list import OnlyList
168195
from rss_parser.models.types.tag import Tag
169-
from rss_parser.pydantic_proxy import import_v1_pydantic
170-
171-
pydantic = import_v1_pydantic()
172196
...
173197

174198

175199
class OptionalChannelElementsMixin(...):
176200
...
177-
items: Optional[OnlyList[Tag[Item]]] = pydantic.Field(alias="item", default=[])
201+
items: Optional[OnlyList[Tag[Item]]] = Field(alias="item", default_factory=list)
178202
```
179203

180204
### Tag Field

poetry.lock

Lines changed: 398 additions & 322 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "rss-parser"
3-
version = "2.1.1"
3+
version = "3.0.0a2"
44
description = "Typed pythonic RSS/Atom parser"
55
authors = ["dhvcc <[email protected]>"]
66
license = "GPL-3.0"
@@ -28,6 +28,8 @@ classifiers = [
2828
"Programming Language :: Python :: 3.10",
2929
"Programming Language :: Python :: 3.11",
3030
"Programming Language :: Python :: 3.12",
31+
"Programming Language :: Python :: 3.13",
32+
"Programming Language :: Python :: 3.14",
3133
]
3234
packages = [{ include = "rss_parser" }, { include = "rss_parser/py.typed" }]
3335

@@ -39,7 +41,7 @@ packages = [{ include = "rss_parser" }, { include = "rss_parser/py.typed" }]
3941

4042
[tool.poetry.dependencies]
4143
python = "^3.9"
42-
pydantic = ">1.9"
44+
pydantic = "<3.0"
4345
xmltodict = "^0.13.0"
4446
types-xmltodict = "^0.14.0.20241009"
4547

@@ -85,6 +87,9 @@ select = [
8587
"RUF", # ruff
8688
]
8789

90+
[tool.ruff.lint.pep8-naming]
91+
ignore-names = ["LegacyRSS"]
92+
8893
[tool.ruff.per-file-ignores]
8994
"tests/**.py" = [
9095
"S101", # Use of assert detected
@@ -95,7 +100,6 @@ select = [
95100
"**/__init__.py" = ["F401"]
96101
"rss_parser/models/atom/**" = ["A003"]
97102

98-
99103
[build-system]
100104
requires = ["poetry-core"]
101105
build-backend = "poetry.core.masonry.api"

rss_parser/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
from ._parser import AtomParser, BaseParser, RSSParser
22

3-
__all__ = ("BaseParser", "AtomParser", "RSSParser")
3+
__all__ = ("AtomParser", "BaseParser", "RSSParser")

rss_parser/_parser.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ def parse(
4848
if root_key:
4949
root = root.get(root_key, root)
5050

51+
if hasattr(schema, "model_validate"):
52+
return schema.model_validate(root)
53+
54+
# Pydantic v1 only
5155
return schema.parse_obj(root)
5256

5357

rss_parser/models/__init__.py

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,25 @@
1-
"""
2-
Models created according to https://www.rssboard.org/rss-specification.
1+
from __future__ import annotations
32

4-
Some types and validation may be a bit custom to account for broken standards in some RSS feeds.
5-
"""
6-
7-
from json import loads
8-
from typing import TYPE_CHECKING
3+
from pydantic import BaseModel, ConfigDict
94

105
from rss_parser.models.utils import camel_case
11-
from rss_parser.pydantic_proxy import import_v1_pydantic
12-
13-
if TYPE_CHECKING:
14-
from pydantic import v1 as pydantic
15-
else:
16-
pydantic = import_v1_pydantic()
176

187

19-
class XMLBaseModel(pydantic.BaseModel):
20-
class Config:
21-
alias_generator = camel_case
8+
class XMLBaseModel(BaseModel):
9+
model_config = ConfigDict(alias_generator=camel_case)
2210

23-
def json_plain(self, **kw):
11+
def json_plain(self, **kwargs) -> str:
2412
"""
25-
Run pydantic's json with custom encoder to encode Tags as only content.
13+
Serialize the model while flattening Tag instances into their content.
2614
"""
2715
from rss_parser.models.types.tag import Tag # noqa: PLC0415
2816

29-
return self.json(models_as_dict=False, encoder=Tag.flatten_tag_encoder, **kw)
17+
return self.model_dump_json(fallback=Tag.flatten_tag_encoder, **kwargs)
18+
19+
def dict_plain(self, **kwargs):
20+
from rss_parser.models.types.tag import Tag # noqa: PLC0415
21+
22+
return self.model_dump(mode="json", fallback=Tag.flatten_tag_encoder, **kwargs)
23+
3024

31-
def dict_plain(self, **kw):
32-
return loads(self.json_plain(**kw))
25+
__all__ = ("XMLBaseModel",)

rss_parser/models/atom/atom.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
from typing import Optional
22

3+
from pydantic import Field
4+
35
from rss_parser.models import XMLBaseModel
46
from rss_parser.models.atom.feed import Feed
57
from rss_parser.models.types.tag import Tag
6-
from rss_parser.pydantic_proxy import import_v1_pydantic
7-
8-
pydantic = import_v1_pydantic()
98

109

1110
class Atom(XMLBaseModel):
1211
"""Atom 1.0"""
1312

14-
version: Optional[Tag[str]] = pydantic.Field(alias="@version")
13+
version: Optional[Tag[str]] = Field(alias="@version", default=None)
1514
feed: Tag[Feed]

rss_parser/models/atom/entry.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
from typing import Optional
22

3+
from pydantic import Field
4+
35
from rss_parser.models import XMLBaseModel
46
from rss_parser.models.atom.person import Person
57
from rss_parser.models.types.date import DateTimeOrStr
68
from rss_parser.models.types.only_list import OnlyList
79
from rss_parser.models.types.tag import Tag
8-
from rss_parser.pydantic_proxy import import_v1_pydantic
9-
10-
pydantic = import_v1_pydantic()
1110

1211

1312
class RequiredAtomEntryMixin(XMLBaseModel):
@@ -22,10 +21,10 @@ class RequiredAtomEntryMixin(XMLBaseModel):
2221

2322

2423
class RecommendedAtomEntryMixin(XMLBaseModel):
25-
authors: Optional[OnlyList[Tag[Person]]] = pydantic.Field(alias="author", default=[])
24+
authors: OnlyList[Tag[Person]] = Field(alias="author", default_factory=OnlyList)
2625
"Entry authors."
2726

28-
links: Optional[OnlyList[Tag[str]]] = pydantic.Field(alias="link", default=[])
27+
links: OnlyList[Tag[str]] = Field(alias="link", default_factory=OnlyList)
2928
"The URL of the entry."
3029

3130
content: Optional[Tag[str]] = None
@@ -36,10 +35,10 @@ class RecommendedAtomEntryMixin(XMLBaseModel):
3635

3736

3837
class OptionalAtomEntryMixin(XMLBaseModel):
39-
categories: Optional[OnlyList[Tag[dict]]] = pydantic.Field(alias="category", default=[])
38+
categories: OnlyList[Tag[dict]] = Field(alias="category", default_factory=OnlyList)
4039
"Specifies a categories that the entry belongs to."
4140

42-
contributors: Optional[OnlyList[Tag[Person]]] = pydantic.Field(alias="contributor", default=[])
41+
contributors: OnlyList[Tag[Person]] = Field(alias="contributor", default_factory=OnlyList)
4342
"Entry contributors."
4443

4544
rights: Optional[Tag[str]] = None

0 commit comments

Comments
 (0)