Skip to content

Commit 386f617

Browse files
committed
feat(python): add complete bindings for GeoRSS, Media RSS, Dublin Core, and Podcast 2.0
Phase 1: Python Bindings Implementation New types: - PyGeoLocation (geo_type, coordinates, srs_name) - PyMediaThumbnail (url, width, height) - PyMediaContent (url, type, filesize, width, height, duration) - PyPodcastChapters (url, type) - PyPodcastSoundbite (start_time, duration, title) - PyPodcastEntryMeta (transcript, chapters, soundbite, person) New Entry getters: - geo, dc_creator, dc_date, dc_date_parsed, dc_rights, dc_subject - media_thumbnails, media_content, podcast New FeedMeta getters: - geo All types include __repr__ and __eq__ implementations. Comprehensive test coverage with 23 new tests.
1 parent 2b973d2 commit 386f617

File tree

12 files changed

+1204
-6
lines changed

12 files changed

+1204
-6
lines changed

crates/feedparser-rs-core/src/lib.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,10 @@ pub use options::ParseOptions;
6868
pub use parser::{detect_format, parse, parse_with_limits};
6969
pub use types::{
7070
Content, Enclosure, Entry, FeedMeta, FeedVersion, Generator, Image, ItunesCategory,
71-
ItunesEntryMeta, ItunesFeedMeta, ItunesOwner, LimitedCollectionExt, Link, ParsedFeed, Person,
72-
PodcastChapters, PodcastEntryMeta, PodcastFunding, PodcastMeta, PodcastPerson,
73-
PodcastSoundbite, PodcastTranscript, PodcastValue, PodcastValueRecipient, Source, Tag,
74-
TextConstruct, TextType, parse_duration, parse_explicit,
71+
ItunesEntryMeta, ItunesFeedMeta, ItunesOwner, LimitedCollectionExt, Link, MediaContent,
72+
MediaThumbnail, ParsedFeed, Person, PodcastChapters, PodcastEntryMeta, PodcastFunding,
73+
PodcastMeta, PodcastPerson, PodcastSoundbite, PodcastTranscript, PodcastValue,
74+
PodcastValueRecipient, Source, Tag, TextConstruct, TextType, parse_duration, parse_explicit,
7575
};
7676

7777
pub use namespace::syndication::{SyndicationMeta, UpdatePeriod};

crates/feedparser-rs-py/pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,8 @@ Repository = "https://github.com/bug-ops/feedparser-rs"
3434
features = ["pyo3/extension-module"]
3535
python-source = "python"
3636
module-name = "feedparser_rs._feedparser_rs"
37+
38+
[dependency-groups]
39+
dev = [
40+
"pytest<9",
41+
]

crates/feedparser-rs-py/src/lib.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,20 @@ fn _feedparser_rs(m: &Bound<'_, PyModule>) -> PyResult<()> {
2222
m.add_function(wrap_pyfunction!(detect_format, m)?)?;
2323
m.add_class::<PyParsedFeed>()?;
2424
m.add_class::<PyParserLimits>()?;
25+
m.add_class::<types::geo::PyGeoLocation>()?;
26+
m.add_class::<types::media::PyMediaThumbnail>()?;
27+
m.add_class::<types::media::PyMediaContent>()?;
28+
m.add_class::<types::podcast::PyItunesFeedMeta>()?;
29+
m.add_class::<types::podcast::PyItunesEntryMeta>()?;
30+
m.add_class::<types::podcast::PyItunesOwner>()?;
31+
m.add_class::<types::podcast::PyItunesCategory>()?;
32+
m.add_class::<types::podcast::PyPodcastMeta>()?;
33+
m.add_class::<types::podcast::PyPodcastTranscript>()?;
34+
m.add_class::<types::podcast::PyPodcastFunding>()?;
35+
m.add_class::<types::podcast::PyPodcastPerson>()?;
36+
m.add_class::<types::podcast::PyPodcastChapters>()?;
37+
m.add_class::<types::podcast::PyPodcastSoundbite>()?;
38+
m.add_class::<types::podcast::PyPodcastEntryMeta>()?;
2539
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
2640
Ok(())
2741
}

crates/feedparser-rs-py/src/types/entry.rs

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ use pyo3::prelude::*;
33

44
use super::common::{PyContent, PyEnclosure, PyLink, PyPerson, PySource, PyTag, PyTextConstruct};
55
use super::datetime::optional_datetime_to_struct_time;
6-
use super::podcast::{PyItunesEntryMeta, PyPodcastPerson, PyPodcastTranscript};
6+
use super::geo::PyGeoLocation;
7+
use super::media::{PyMediaContent, PyMediaThumbnail};
8+
use super::podcast::{PyItunesEntryMeta, PyPodcastEntryMeta, PyPodcastPerson, PyPodcastTranscript};
79

810
#[pyclass(name = "Entry", module = "feedparser_rs")]
911
#[derive(Clone)]
@@ -196,6 +198,13 @@ impl PyEntry {
196198
.map(|i| PyItunesEntryMeta::from_core(i.clone()))
197199
}
198200

201+
/// Returns podcast transcripts for this entry.
202+
///
203+
/// Dual access pattern for feedparser compatibility:
204+
/// - `entry.podcast_transcripts` - Direct access (this method)
205+
/// - `entry.podcast.transcript` - Nested access via PodcastEntryMeta
206+
///
207+
/// Both provide the same data. Use whichever pattern matches your code style.
199208
#[getter]
200209
fn podcast_transcripts(&self) -> Vec<PyPodcastTranscript> {
201210
self.inner
@@ -205,6 +214,13 @@ impl PyEntry {
205214
.collect()
206215
}
207216

217+
/// Returns podcast persons for this entry.
218+
///
219+
/// Dual access pattern for feedparser compatibility:
220+
/// - `entry.podcast_persons` - Direct access (this method)
221+
/// - `entry.podcast.person` - Nested access via PodcastEntryMeta
222+
///
223+
/// Both provide the same data. Use whichever pattern matches your code style.
208224
#[getter]
209225
fn podcast_persons(&self) -> Vec<PyPodcastPerson> {
210226
self.inner
@@ -219,6 +235,65 @@ impl PyEntry {
219235
self.inner.license.as_deref()
220236
}
221237

238+
#[getter]
239+
fn geo(&self) -> Option<PyGeoLocation> {
240+
self.inner
241+
.geo
242+
.as_ref()
243+
.map(|g| PyGeoLocation::from_core(g.clone()))
244+
}
245+
246+
#[getter]
247+
fn dc_creator(&self) -> Option<&str> {
248+
self.inner.dc_creator.as_deref()
249+
}
250+
251+
#[getter]
252+
fn dc_date(&self) -> Option<String> {
253+
self.inner.dc_date.map(|dt| dt.to_rfc3339())
254+
}
255+
256+
#[getter]
257+
fn dc_date_parsed(&self, py: Python<'_>) -> PyResult<Option<Py<PyAny>>> {
258+
optional_datetime_to_struct_time(py, &self.inner.dc_date)
259+
}
260+
261+
#[getter]
262+
fn dc_rights(&self) -> Option<&str> {
263+
self.inner.dc_rights.as_deref()
264+
}
265+
266+
#[getter]
267+
fn dc_subject(&self) -> Vec<String> {
268+
self.inner.dc_subject.clone()
269+
}
270+
271+
#[getter]
272+
fn media_thumbnails(&self) -> Vec<PyMediaThumbnail> {
273+
self.inner
274+
.media_thumbnails
275+
.iter()
276+
.map(|t| PyMediaThumbnail::from_core(t.clone()))
277+
.collect()
278+
}
279+
280+
#[getter]
281+
fn media_content(&self) -> Vec<PyMediaContent> {
282+
self.inner
283+
.media_content
284+
.iter()
285+
.map(|c| PyMediaContent::from_core(c.clone()))
286+
.collect()
287+
}
288+
289+
#[getter]
290+
fn podcast(&self) -> Option<PyPodcastEntryMeta> {
291+
self.inner
292+
.podcast
293+
.as_ref()
294+
.map(|p| PyPodcastEntryMeta::from_core(p.clone()))
295+
}
296+
222297
fn __repr__(&self) -> String {
223298
format!(
224299
"Entry(title='{}', id='{}')",

crates/feedparser-rs-py/src/types/feed_meta.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use pyo3::prelude::*;
33

44
use super::common::{PyGenerator, PyImage, PyLink, PyPerson, PyTag, PyTextConstruct};
55
use super::datetime::optional_datetime_to_struct_time;
6+
use super::geo::PyGeoLocation;
67
use super::podcast::{PyItunesFeedMeta, PyPodcastMeta};
78
use super::syndication::PySyndicationMeta;
89

@@ -236,6 +237,14 @@ impl PyFeedMeta {
236237
self.inner.dc_rights.as_deref()
237238
}
238239

240+
#[getter]
241+
fn geo(&self) -> Option<PyGeoLocation> {
242+
self.inner
243+
.geo
244+
.as_ref()
245+
.map(|g| PyGeoLocation::from_core(g.clone()))
246+
}
247+
239248
fn __repr__(&self) -> String {
240249
format!(
241250
"FeedMeta(title='{}', link='{}')",
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
use feedparser_rs::namespace::georss::{GeoLocation as CoreGeoLocation, GeoType};
2+
use pyo3::prelude::*;
3+
4+
/// Represents a GeoRSS geographic location.
5+
///
6+
/// GeoRSS is a namespace extension that adds geographic information to RSS feeds.
7+
/// Supports points, lines, polygons, and bounding boxes with coordinate data.
8+
#[pyclass(name = "GeoLocation", module = "feedparser_rs")]
9+
#[derive(Clone)]
10+
pub struct PyGeoLocation {
11+
inner: CoreGeoLocation,
12+
}
13+
14+
impl PyGeoLocation {
15+
pub fn from_core(core: CoreGeoLocation) -> Self {
16+
Self { inner: core }
17+
}
18+
}
19+
20+
#[pymethods]
21+
impl PyGeoLocation {
22+
#[getter]
23+
fn geo_type(&self) -> &str {
24+
match self.inner.geo_type {
25+
GeoType::Point => "point",
26+
GeoType::Line => "line",
27+
GeoType::Polygon => "polygon",
28+
GeoType::Box => "box",
29+
}
30+
}
31+
32+
#[getter]
33+
fn coordinates(&self) -> Vec<(f64, f64)> {
34+
self.inner.coordinates.clone()
35+
}
36+
37+
#[getter]
38+
fn srs_name(&self) -> Option<&str> {
39+
self.inner.srs_name.as_deref()
40+
}
41+
42+
fn __repr__(&self) -> String {
43+
match self.inner.geo_type {
44+
GeoType::Point => {
45+
if let Some(coord) = self.inner.coordinates.first() {
46+
format!(
47+
"GeoLocation(geo_type='point', coordinates=[({}, {})])",
48+
coord.0, coord.1
49+
)
50+
} else {
51+
"GeoLocation(geo_type='point', coordinates=[])".to_string()
52+
}
53+
}
54+
_ => format!(
55+
"GeoLocation(geo_type='{}', coordinates={})",
56+
self.geo_type(),
57+
self.inner.coordinates.len()
58+
),
59+
}
60+
}
61+
62+
fn __eq__(&self, other: &Self) -> bool {
63+
self.inner.geo_type == other.inner.geo_type
64+
&& self.inner.coordinates == other.inner.coordinates
65+
&& self.inner.srs_name == other.inner.srs_name
66+
}
67+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
use feedparser_rs::{MediaContent as CoreMediaContent, MediaThumbnail as CoreMediaThumbnail};
2+
use pyo3::prelude::*;
3+
4+
/// Represents a Media RSS thumbnail image.
5+
///
6+
/// Media RSS (MRSS) is a namespace extension for RSS that provides richer media
7+
/// content metadata. Thumbnails are preview images for media content.
8+
#[pyclass(name = "MediaThumbnail", module = "feedparser_rs")]
9+
#[derive(Clone)]
10+
pub struct PyMediaThumbnail {
11+
inner: CoreMediaThumbnail,
12+
}
13+
14+
impl PyMediaThumbnail {
15+
pub fn from_core(core: CoreMediaThumbnail) -> Self {
16+
Self { inner: core }
17+
}
18+
}
19+
20+
#[pymethods]
21+
impl PyMediaThumbnail {
22+
#[getter]
23+
fn url(&self) -> &str {
24+
&self.inner.url
25+
}
26+
27+
#[getter]
28+
fn width(&self) -> Option<u32> {
29+
self.inner.width
30+
}
31+
32+
#[getter]
33+
fn height(&self) -> Option<u32> {
34+
self.inner.height
35+
}
36+
37+
fn __repr__(&self) -> String {
38+
format!(
39+
"MediaThumbnail(url='{}', width={:?}, height={:?})",
40+
self.inner.url, self.inner.width, self.inner.height
41+
)
42+
}
43+
44+
fn __eq__(&self, other: &Self) -> bool {
45+
self.inner.url == other.inner.url
46+
&& self.inner.width == other.inner.width
47+
&& self.inner.height == other.inner.height
48+
}
49+
}
50+
51+
/// Represents a Media RSS content item.
52+
///
53+
/// Media RSS content elements describe actual media files (video, audio, images)
54+
/// with metadata like MIME type, file size, dimensions, and duration.
55+
#[pyclass(name = "MediaContent", module = "feedparser_rs")]
56+
#[derive(Clone)]
57+
pub struct PyMediaContent {
58+
inner: CoreMediaContent,
59+
}
60+
61+
impl PyMediaContent {
62+
pub fn from_core(core: CoreMediaContent) -> Self {
63+
Self { inner: core }
64+
}
65+
}
66+
67+
#[pymethods]
68+
impl PyMediaContent {
69+
#[getter]
70+
fn url(&self) -> &str {
71+
&self.inner.url
72+
}
73+
74+
#[getter]
75+
#[pyo3(name = "type")]
76+
fn content_type(&self) -> Option<&str> {
77+
self.inner.content_type.as_deref()
78+
}
79+
80+
#[getter]
81+
fn filesize(&self) -> Option<u64> {
82+
self.inner.filesize
83+
}
84+
85+
#[getter]
86+
fn width(&self) -> Option<u32> {
87+
self.inner.width
88+
}
89+
90+
#[getter]
91+
fn height(&self) -> Option<u32> {
92+
self.inner.height
93+
}
94+
95+
#[getter]
96+
fn duration(&self) -> Option<u64> {
97+
self.inner.duration
98+
}
99+
100+
fn __repr__(&self) -> String {
101+
format!(
102+
"MediaContent(url='{}', type='{}')",
103+
self.inner.url,
104+
self.inner.content_type.as_deref().unwrap_or("unknown")
105+
)
106+
}
107+
108+
fn __eq__(&self, other: &Self) -> bool {
109+
self.inner.url == other.inner.url
110+
&& self.inner.content_type == other.inner.content_type
111+
&& self.inner.filesize == other.inner.filesize
112+
&& self.inner.width == other.inner.width
113+
&& self.inner.height == other.inner.height
114+
&& self.inner.duration == other.inner.duration
115+
}
116+
}

crates/feedparser-rs-py/src/types/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ pub mod common;
22
pub mod datetime;
33
pub mod entry;
44
pub mod feed_meta;
5+
pub mod geo;
6+
pub mod media;
57
pub mod parsed_feed;
68
pub mod podcast;
79
pub mod syndication;

0 commit comments

Comments
 (0)