Skip to content

Commit 0998580

Browse files
committed
Actually add some minimal tests
1 parent aedc01f commit 0998580

7 files changed

Lines changed: 306 additions & 13 deletions

File tree

code/planet/__init__.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,14 @@ def __init__(self, config):
122122
self._channels = []
123123

124124
self.user_agent = USER_AGENT
125-
self.cache_directory = CACHE_DIRECTORY
126-
self.new_feed_items = NEW_FEED_ITEMS
125+
if self.config.has_option("Planet", "cache_directory"):
126+
self.cache_directory = self.config.get("Planet", "cache_directory")
127+
else:
128+
self.cache_directory = CACHE_DIRECTORY
129+
if self.config.has_option("Planet", "new_feed_items"):
130+
self.new_feed_items = int(self.config.get("Planet", "new_feed_items"))
131+
else:
132+
self.new_feed_items = NEW_FEED_ITEMS
127133
self.filter = None
128134
self.exclude = None
129135

@@ -235,10 +241,6 @@ def run(self, planet_name, planet_link, template_files, offline=False):
235241

236242
# Create a planet
237243
log.info("Loading cached data")
238-
if self.config.has_option("Planet", "cache_directory"):
239-
self.cache_directory = self.config.get("Planet", "cache_directory")
240-
if self.config.has_option("Planet", "new_feed_items"):
241-
self.new_feed_items = int(self.config.get("Planet", "new_feed_items"))
242244
self.user_agent = f"{planet_name} +{planet_link} {self.user_agent}"
243245
if self.config.has_option("Planet", "filter"):
244246
self.filter = self.config.get("Planet", "filter")
@@ -1013,7 +1015,7 @@ def __lt__(self, other):
10131015
else:
10141016
return False
10151017

1016-
def get_date(self, key):
1018+
def get_date(self, key: str) -> cache.TimeTuple | None:
10171019
"""Get (or update) the date key.
10181020
10191021
We check whether the date the entry claims to have been changed is
@@ -1034,16 +1036,18 @@ def get_date(self, key):
10341036
date = None
10351037

10361038
if date is not None:
1037-
if date > self._channel.updated:
1038-
date = self._channel.updated
1039+
if self._channel.updated is not None:
1040+
if date > self._channel.updated:
1041+
date = self._channel.updated
10391042
# elif date < self._channel.last_updated:
10401043
# date = self._channel.updated
10411044
elif key in self and self.key_type(key) != self.NULL:
10421045
return self.get_as_date(key)
10431046
else:
10441047
date = self._channel.updated
10451048

1046-
self.set_as_date(key, date)
1049+
if date is not None:
1050+
self.set_as_date(key, date)
10471051
return date
10481052

10491053
@property

code/planet/cache.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,16 @@
1313
import re
1414
import shelve
1515
import time
16-
from typing import Any
16+
from typing import Any, TypeAlias
1717

1818
# Regular expressions to sanitise cache filenames
1919
re_url_scheme = re.compile(r"^[^:]*://")
2020
re_slash = re.compile(r"[?/]+")
2121
re_initial_cruft = re.compile(r"^[,.]*")
2222
re_final_cruft = re.compile(r"[,.]*$")
2323

24+
TimeTuple: TypeAlias = tuple[int, int, int, int, int, int, int, int, int]
25+
2426

2527
class CachedInfo:
2628
"""Cached information.
@@ -206,7 +208,7 @@ def set_as_date(self, key, value, cached: bool = True):
206208
self._type[key] = self.DATE
207209
self._cached[key] = cached
208210

209-
def get_as_date(self, key):
211+
def get_as_date(self, key: str) -> TimeTuple | None:
210212
"""Return the key as a date value."""
211213
key = key.replace(" ", "_")
212214
if key not in self._value:

pyproject.toml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,19 @@ requires = ["hatchling"]
3131
build-backend = "hatchling.build"
3232

3333
[tool.hatch.build.targets.wheel]
34-
packages = ["core/planet"]
34+
packages = ["code/planet"]
3535

3636
[tool.uv]
3737
dev-dependencies = [
3838
"pyright>=1.1.383",
39+
"pytest-xdist>=3.6.1",
40+
"pytest>=8.3.3",
41+
"ruff>=0.6.9",
42+
]
43+
44+
[tool.pytest.ini_options]
45+
looponfailroots = ["code", "tests"]
46+
filterwarnings = [
47+
# I know looponfailroots is 'deprecated' but ... i'm tired of seeing it
48+
"ignore::DeprecationWarning:xdist.plugin"
3949
]

tests/conftest.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import configparser
2+
3+
import planet as planet_module
4+
import pytest
5+
6+
7+
@pytest.fixture(name="config")
8+
def get_config(tmp_path):
9+
config = configparser.ConfigParser()
10+
ini_text = f"""\
11+
[Planet]
12+
name = Test Planet
13+
output_dir = {tmp_path}/output
14+
cache_directory = {tmp_path}/cache
15+
16+
[https://example.com/rss]
17+
name = example rss
18+
19+
[https://example.com/atom]
20+
name = example atom
21+
"""
22+
23+
config.read_string(ini_text)
24+
return config
25+
26+
27+
@pytest.fixture(name="planet")
28+
def get_planet(config):
29+
return planet_module.Planet(config)
30+
31+
32+
@pytest.fixture(name="rss_channel")
33+
def get_rss_channel(planet):
34+
return planet_module.Channel(planet, "https://example.com/rss")
35+
36+
37+
@pytest.fixture(name="atom_channel")
38+
def get_atom_channel(planet):
39+
return planet_module.Channel(planet, "https://example.com/atom")

tests/fixtures/sample_atom.xml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<feed xmlns="http://www.w3.org/2005/Atom">
3+
<title>Example Atom Feed</title>
4+
<link href="https://example.com/atom"/>
5+
<updated>2021-10-21T16:29:00Z</updated>
6+
<author>
7+
<name>John Doe</name>
8+
<email>author@example.com</email>
9+
</author>
10+
<id>https://example.com/atom</id>
11+
<entry>
12+
<title>Example Entry 1</title>
13+
<link href="https://example.com/entry1"/>
14+
<id>https://example.com/entry1</id>
15+
<updated>2021-10-21T16:31:00Z</updated>
16+
<summary>This is a summary of entry 1</summary>
17+
</entry>
18+
<entry>
19+
<title>Example Entry 2</title>
20+
<link href="https://example.com/entry2"/>
21+
<id>https://example.com/entry2</id>
22+
<updated>2021-10-22T16:31:00Z</updated>
23+
<summary>This is a summary of entry 2</summary>
24+
</entry>
25+
</feed>

tests/fixtures/sample_rss.xml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<rss version="2.0">
3+
<channel>
4+
<title>Example RSS Feed</title>
5+
<link>https://example.com/rss</link>
6+
<description>This is an example of an RSS feed</description>
7+
<item>
8+
<title>Example Item 1</title>
9+
<link>https://example.com/item1</link>
10+
<description>This is a description of item 1</description>
11+
<author>author@example.com (John Doe)</author>
12+
<updated>Thu, 21 Oct 2021 16:29:00 +0000</updated>
13+
<guid>https://example.com/item1</guid>
14+
</item>
15+
<item>
16+
<title>Example Item 2</title>
17+
<link>https://example.com/item2</link>
18+
<description>This is a description of item 2</description>
19+
<author>author@example.com (John Doe)</author>
20+
<pubDate>Thu, 22 Oct 2021 16:29:00 +0000</pubDate>
21+
<guid>https://example.com/item2</guid>
22+
</item>
23+
</channel>
24+
</rss>

tests/test_newsitem.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import configparser
2+
import time
3+
from pathlib import Path
4+
from pprint import pprint
5+
6+
import feedparser
7+
import planet
8+
import pytest
9+
from planet.cache import utf8
10+
11+
# Ensure the `tests/fixtures/` directory exists and feeds are stored there.
12+
FIXTURES_DIR = Path(__file__).parent / "fixtures"
13+
14+
15+
@pytest.fixture(name="channel_cache")
16+
def channel_cache(rss_channel):
17+
try:
18+
yield rss_channel._cache
19+
finally:
20+
pprint(dict(rss_channel._cache))
21+
22+
23+
@pytest.fixture(scope="module", name="rss_feed")
24+
def load_rss_feed():
25+
"""Load and parse the sample RSS feed fixture."""
26+
with open(FIXTURES_DIR / "sample_rss.xml", encoding="utf-8") as rss_file:
27+
feed_data = rss_file.read()
28+
return feedparser.parse(feed_data)
29+
30+
31+
@pytest.fixture(scope="module", name="atom_feed")
32+
def load_atom_feed():
33+
"""Load and parse the sample Atom feed fixture."""
34+
with open(FIXTURES_DIR / "sample_atom.xml", encoding="utf-8") as atom_file:
35+
feed_data = atom_file.read()
36+
return feedparser.parse(feed_data)
37+
38+
39+
def test_newsitem_from_rss(rss_feed, rss_channel):
40+
"""Test that we can create a NewsItem from an RSS feed item."""
41+
item = rss_feed.entries[0]
42+
newsitem = planet.NewsItem(rss_channel, rss_feed.entries[0]["id"])
43+
newsitem.update(item)
44+
assert newsitem.title == "Example Item 1"
45+
assert newsitem.link == "https://example.com/item1"
46+
assert newsitem.date[0] == 2021
47+
assert newsitem.author == "author@example.com (John Doe)"
48+
assert newsitem.content == "This is a description of item 1"
49+
assert newsitem.summary == "This is a description of item 1"
50+
51+
52+
def test_newsitem_from_atom(atom_feed, atom_channel):
53+
"""Test that we can create a NewsItem from an RSS feed item."""
54+
item = atom_feed.entries[0]
55+
newsitem = planet.NewsItem(atom_channel, atom_feed.entries[0]["id"])
56+
newsitem.update(item)
57+
assert newsitem.title == "Example Entry 1"
58+
assert newsitem.link == "https://example.com/entry1"
59+
# parse the iso timestamp into a time tuple
60+
assert newsitem.date[0] == 2021
61+
assert newsitem.content == "This is a summary of entry 1"
62+
assert newsitem.summary == "This is a summary of entry 1"
63+
64+
65+
def test_caching_newsitem(rss_feed, rss_channel):
66+
"""Test that we can create a NewsItem from an RSS feed item."""
67+
item = rss_feed.entries[0]
68+
newsitem = planet.NewsItem(rss_channel, rss_feed.entries[0]["id"])
69+
newsitem.update(item)
70+
newsitem.cache_write()
71+
72+
# now try read the newsitem, but with the cache; we should be able to
73+
# get the values before updating
74+
newsitem = planet.NewsItem(rss_channel, rss_feed.entries[0]["id"])
75+
assert newsitem.title == "Example Item 1"
76+
assert newsitem.link == "https://example.com/item1"
77+
assert newsitem.date[0] == 2021
78+
assert newsitem.author == "author@example.com (John Doe)"
79+
assert newsitem.content == "This is a description of item 1"
80+
assert newsitem.summary == "This is a description of item 1"
81+
82+
83+
# These tests are aimed at testing the specifications of the cache; we are looking at key structures
84+
# and internals, so that we can have some sense of implementation consistency.
85+
86+
87+
@pytest.fixture(name="news_item")
88+
def news_item(
89+
rss_channel,
90+
rss_feed,
91+
):
92+
return planet.NewsItem(rss_channel, rss_feed.entries[0]["id"])
93+
94+
95+
@pytest.fixture(name="sample_entry")
96+
def sample_entry(rss_feed):
97+
return rss_feed.entries[0]
98+
99+
100+
def test_cache_write_and_read(news_item, sample_entry, channel_cache):
101+
# First, update the news item using the sample_entry
102+
news_item.update(sample_entry)
103+
news_item.cache_write(sync=True)
104+
105+
# Now, inspect the cache to see if keys have been stored correctly
106+
assert f"{news_item.id} title" in channel_cache
107+
assert f"{news_item.id} link" in channel_cache
108+
assert channel_cache[f"{news_item.id} title"] == utf8(sample_entry["title"])
109+
assert channel_cache[f"{news_item.id} link"] == utf8(sample_entry["link"])
110+
111+
# Date value stored as a string representation of the time tuple
112+
assert f"{news_item.id} updated" in channel_cache
113+
assert channel_cache[f"{news_item.id} updated"] == " ".join(
114+
map(str, sample_entry["updated_parsed"])
115+
)
116+
117+
118+
def test_cache_clear(news_item, sample_entry, channel_cache):
119+
# Update and save to cache
120+
news_item.update(sample_entry)
121+
news_item.cache_write(sync=True)
122+
123+
# Ensure keys are there
124+
assert f"{news_item.id} title" in channel_cache
125+
126+
# Now clear the cache for the news_item
127+
news_item.cache_clear(sync=True)
128+
129+
# Ensure keys are removed from the cache
130+
assert f"{news_item.id} title" not in channel_cache
131+
assert f"{news_item.id} link" not in channel_cache
132+
assert f"{news_item.id} updated" not in channel_cache
133+
134+
135+
def test_cache_key_type(news_item, sample_entry, channel_cache):
136+
# Update and save to cache
137+
news_item.update(sample_entry)
138+
news_item.cache_write(sync=True)
139+
140+
# Ensure keys and types are correct
141+
assert channel_cache[f"{news_item.id} title"] == "Example Item 1"
142+
assert channel_cache[f"{news_item.id} title type"] == "string"
143+
assert channel_cache[f"{news_item.id} updated type"] == "date"
144+
145+
146+
def test_cache_reload(news_item, sample_entry, rss_channel):
147+
# Update and save to cache
148+
news_item.update(sample_entry)
149+
news_item.cache_write(sync=True)
150+
151+
# Create a new NewsItem instance with the same cache, and reload
152+
new_item = planet.NewsItem(rss_channel, f"{news_item.id}")
153+
new_item.cache_read()
154+
155+
# Check that the data is retrieved as expected
156+
assert new_item.get("title") == "Example Item 1"
157+
assert new_item.get("link") == "https://example.com/item1"
158+
assert new_item.get("date") == sample_entry["date_parsed"]
159+
160+
161+
def test_cache_date_field(news_item, sample_entry, rss_channel, channel_cache):
162+
# Ensure the date field gets cached properly
163+
news_item.update(sample_entry)
164+
news_item.cache_write(sync=True)
165+
166+
# Check that the date type is correctly saved as dates
167+
assert f"{news_item.id} updated" in channel_cache
168+
assert f"{news_item.id} updated type" in channel_cache
169+
assert channel_cache[f"{news_item.id} updated type"] == "date"
170+
171+
# Reload item and ensure the date value is parsed correctly
172+
new_item = planet.NewsItem(rss_channel, f"{news_item.id}")
173+
new_item.cache_read()
174+
175+
# Verify that the date field is properly restored as date tuple
176+
assert new_item.get("date") == sample_entry["date_parsed"]
177+
178+
179+
def test_delete_key_from_cache(news_item, sample_entry, channel_cache):
180+
# Update and save to cache
181+
news_item.update(sample_entry)
182+
news_item.cache_write(sync=True)
183+
184+
# Delete 'title' key using NewsItem's del_key method
185+
news_item.del_key("title")
186+
news_item.cache_write(sync=True)
187+
188+
# Ensure 'title' key is deleted from cache
189+
assert f"{news_item.id} title" not in channel_cache

0 commit comments

Comments
 (0)