Skip to content

Commit 95fda08

Browse files
authored
Merge pull request #5 from ryankert01/copilot/add-unit-tests-and-workflow
Add unit tests with modular Python structure and CI workflow
2 parents 8f4767f + d61e9ec commit 95fda08

12 files changed

Lines changed: 691 additions & 96 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,18 @@
1-
name: scrape rss from friends
1+
name: Deploy RSS Feed
22

33
on:
44
push:
55
branches:
66
- main
7-
pull_request:
8-
branches:
9-
- main
107
schedule:
118
- cron: "0 10,22 * * *"
129

1310
jobs:
1411
build:
1512
runs-on: ubuntu-latest
16-
13+
permissions:
14+
contents: write
15+
1716
steps:
1817
- uses: actions/checkout@v3
1918
- uses: actions/setup-python@v3

.github/workflows/test.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: Test
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
- develop
8+
pull_request:
9+
branches:
10+
- main
11+
- develop
12+
13+
jobs:
14+
test:
15+
runs-on: ubuntu-latest
16+
permissions:
17+
contents: read
18+
19+
steps:
20+
- name: Checkout code
21+
uses: actions/checkout@v3
22+
23+
- name: Set up Python 3.11
24+
uses: actions/setup-python@v3
25+
with:
26+
python-version: 3.11
27+
28+
- name: Install dependencies
29+
run: |
30+
python -m pip install --upgrade pip
31+
pip install -r requirements.txt
32+
pip install -r requirements-dev.txt
33+
34+
- name: Run tests with pytest
35+
run: |
36+
pytest
37+
38+
- name: Generate JSON (deploy test)
39+
run: python src/main.py
40+
41+
- name: Upload coverage reports
42+
uses: actions/upload-artifact@v4
43+
with:
44+
name: coverage-report
45+
path: htmlcov/

.gitignore

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,42 @@
11
node_modules/
22
dist/
3-
assets/
3+
assets/
4+
5+
# Python
6+
__pycache__/
7+
*.py[cod]
8+
*$py.class
9+
*.so
10+
.Python
11+
env/
12+
venv/
13+
ENV/
14+
build/
15+
develop-eggs/
16+
dist/
17+
downloads/
18+
eggs/
19+
.eggs/
20+
lib/
21+
lib64/
22+
parts/
23+
sdist/
24+
var/
25+
wheels/
26+
*.egg-info/
27+
.installed.cfg
28+
*.egg
29+
30+
# Testing
31+
.pytest_cache/
32+
.coverage
33+
htmlcov/
34+
.tox/
35+
.hypothesis/
36+
37+
# IDE
38+
.vscode/
39+
.idea/
40+
*.swp
41+
*.swo
42+
*~

CONTRIBUTING.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Contributing to RSS Friend
2+
3+
Thank you for your interest in contributing to RSS Friend!
4+
5+
## Development Setup
6+
7+
1. Clone the repository:
8+
```bash
9+
git clone https://github.com/ryankert01/rss-friend.git
10+
cd rss-friend
11+
```
12+
13+
2. Install dependencies:
14+
```bash
15+
pip install -r requirements.txt
16+
pip install -r requirements-dev.txt
17+
```
18+
19+
## Running Tests
20+
21+
Run all tests:
22+
```bash
23+
pytest
24+
```
25+
26+
Run tests with coverage report:
27+
```bash
28+
pytest --cov=src --cov-report=html
29+
```
30+
31+
Run specific test file:
32+
```bash
33+
pytest tests/test_rss_aggregator.py
34+
```
35+
36+
Run specific test:
37+
```bash
38+
pytest tests/test_rss_aggregator.py::TestParseRssFeed::test_parses_valid_rss_feed
39+
```
40+
41+
## Code Style
42+
43+
- Follow PEP 8 style guidelines
44+
- Write descriptive docstrings for all functions
45+
- Add type hints where appropriate
46+
- Keep functions small and focused
47+
48+
## Pull Request Process
49+
50+
1. Create a feature branch from `main`
51+
2. Make your changes
52+
3. Add tests for new functionality
53+
4. Ensure all tests pass locally
54+
5. Update documentation as needed
55+
6. Submit a pull request
56+
57+
The CI will automatically:
58+
- Run tests on Python 3.11
59+
- Generate coverage reports
60+
- Check code quality
61+
62+
## Testing Guidelines
63+
64+
- Write tests for all new functions
65+
- Test edge cases and error conditions
66+
- Use mocking for external dependencies (HTTP requests, etc.)
67+
- Aim for high code coverage (>90%)
68+
69+
## Questions?
70+
71+
Feel free to open an issue if you have questions or need help!

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ https://ryankert01.github.io/rss-friend/unsort.json
7878

7979
```zsh
8080
pip install -r requirements.txt
81+
pip install -r requirements-dev.txt # For testing
8182
```
8283

8384
### To generate JSON file
@@ -86,3 +87,38 @@ run the script to generate JSON file
8687
```zsh
8788
python src/main.py
8889
```
90+
91+
### To run tests
92+
93+
Run the unit tests with pytest:
94+
```zsh
95+
pytest
96+
```
97+
98+
Run tests with coverage report:
99+
```zsh
100+
pytest --cov=src --cov-report=html
101+
```
102+
103+
## Project Structure
104+
105+
```
106+
rss-friend/
107+
├── src/
108+
│ ├── __init__.py
109+
│ ├── main.py # Main entry point
110+
│ └── rss_aggregator.py # Core RSS aggregation logic
111+
├── tests/
112+
│ ├── __init__.py
113+
│ └── test_rss_aggregator.py # Unit tests
114+
├── _data/
115+
│ └── friends.json # Configuration for RSS feeds
116+
├── requirements.txt # Production dependencies
117+
├── requirements-dev.txt # Development dependencies
118+
└── pytest.ini # Pytest configuration
119+
```
120+
121+
## CI/CD
122+
123+
- **Test Workflow**: Runs automatically on pull requests and commits to main/develop branches
124+
- **Deploy Workflow**: Runs on push to main branch and scheduled twice daily (10:00 and 22:00 UTC)

pytest.ini

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[pytest]
2+
testpaths = tests
3+
python_files = test_*.py
4+
python_classes = Test*
5+
python_functions = test_*
6+
addopts =
7+
-v
8+
--strict-markers
9+
--tb=short
10+
--cov=src
11+
--cov-report=term-missing
12+
--cov-report=html

requirements-dev.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pytest>=7.4.0
2+
pytest-cov>=4.1.0
3+
pytest-mock>=3.11.1

src/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""RSS Friend - Aggregates RSS feeds from friends' blogs."""
2+
3+
__version__ = "1.0.0"

src/main.py

Lines changed: 7 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,104 +1,21 @@
1+
"""Main entry point for RSS Friend aggregator."""
12

2-
import json
3-
import os
4-
import time
53
from pathlib import Path
6-
import feedparser
7-
import requests
4+
from rss_aggregator import aggregate_rss_feeds
85

96
# Constants
107
ASSETS_DIR = Path(__file__).parent / 'assets'
118
MAX_POSTS = 30
129
FRIENDS_JSON_PATH = Path(__file__).parent.parent / '_data' / 'friends.json'
1310

14-
def ensure_directory_exists(dir_path: Path):
15-
"""Ensures a directory exists, creating it if necessary."""
16-
dir_path.mkdir(parents=True, exist_ok=True)
17-
18-
def write_json_file(file_path: Path, data):
19-
"""Writes data to a JSON file."""
20-
with open(file_path, 'w', encoding='utf-8') as f:
21-
json.dump(data, f, ensure_ascii=False, indent=2)
22-
23-
def parse_rss_feed(friend: dict) -> list:
24-
"""Parses a single RSS feed."""
25-
friend_name = friend.get("title", "")
26-
friend_link = friend.get("link", "")
27-
rss_url = friend.get("feed", "")
28-
posts = []
29-
30-
if not rss_url or not rss_url.startswith('http'):
31-
print(f"Invalid RSS URL: {rss_url} (from: {friend_name})")
32-
return []
3311

12+
def main():
13+
"""Main entry point."""
3414
try:
35-
# Use requests to fetch the feed with a timeout and user-agent
36-
response = requests.get(rss_url, timeout=10, headers={'User-Agent': 'RSS Aggregator Bot'})
37-
response.raise_for_status() # Raise an exception for bad status codes
38-
39-
# Parse the feed content using feedparser
40-
feed = feedparser.parse(response.content)
41-
42-
for entry in feed.entries:
43-
# Get date
44-
date_tuple = entry.get("published_parsed") or entry.get("updated_parsed") or time.gmtime()
45-
date = time.strftime('%Y-%m-%dT%H:%M:%SZ', date_tuple)
46-
47-
posts.append({
48-
"title": entry.get("title", "No Title"),
49-
"link": entry.get("link", friend_link),
50-
"date": date,
51-
"author": {
52-
"name": friend_name,
53-
"link": friend_link
54-
}
55-
})
56-
except Exception as e:
57-
print(f"Failed to parse RSS feed ({friend_name} - {rss_url}): {e}")
58-
59-
return posts
60-
61-
def aggregate_rss_feeds():
62-
"""Aggregates RSS feeds from a list of friends."""
63-
try:
64-
ensure_directory_exists(ASSETS_DIR)
65-
66-
with open(FRIENDS_JSON_PATH, 'r', encoding='utf-8') as f:
67-
friends = json.load(f)
68-
69-
if not isinstance(friends, list) or not friends:
70-
print("No valid friends data found.")
71-
return
72-
73-
all_posts = []
74-
for friend in friends:
75-
all_posts.extend(parse_rss_feed(friend))
76-
77-
write_json_file(ASSETS_DIR / 'unsort.json', all_posts)
78-
79-
# Sort posts by date (newest first)
80-
sorted_posts = sorted(all_posts, key=lambda x: x['date'], reverse=True)[:MAX_POSTS]
81-
write_json_file(ASSETS_DIR / 'sorted.json', sorted_posts)
82-
83-
# Format posts
84-
formatted_posts = []
85-
for post in sorted_posts:
86-
t = time.strptime(post['date'], '%Y-%m-%dT%H:%M:%SZ')
87-
formatted_posts.append({
88-
"title": post["title"],
89-
"link": post["link"],
90-
"year": t.tm_year,
91-
"month": t.tm_mon,
92-
"day": t.tm_mday,
93-
"author": post["author"]
94-
})
95-
96-
write_json_file(ASSETS_DIR / 'rss.json', formatted_posts)
97-
98-
print(f"Processing complete - Aggregated {len(all_posts)} posts, saved the top {len(sorted_posts)}.")
99-
15+
aggregate_rss_feeds(FRIENDS_JSON_PATH, ASSETS_DIR, MAX_POSTS)
10016
except Exception as e:
10117
print(f"Main process failed: {e}")
10218

19+
10320
if __name__ == "__main__":
104-
aggregate_rss_feeds()
21+
main()

0 commit comments

Comments
 (0)