├── .github └── workflows │ └── python-publish.yml ├── .gitignore ├── AUTHORS.md ├── LICENSE ├── docs ├── Makefile ├── conf.py ├── index.rst └── make.bat ├── example ├── app.py └── app2.py ├── fastapi_rss ├── __init__.py ├── models │ ├── __init__.py │ ├── category.py │ ├── cloud.py │ ├── enclosure.py │ ├── feed.py │ ├── guid.py │ ├── image.py │ ├── item.py │ ├── itunes.py │ ├── source.py │ └── textinput.py ├── rss_response.py └── utils.py ├── poetry.lock ├── pylama.ini ├── pyproject.toml ├── readme.md └── tests ├── __init__.py ├── conftest.py ├── test_models.py └── test_rss_response.py /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: '3.8' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install poetry 33 | - name: Build package 34 | run: python -m poetry build --publish 35 | - name: Publish package 36 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 37 | with: 38 | user: __token__ 39 | password: ${{ secrets.PYPI_API_TOKEN }} 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | poetry.lock 141 | .vscode/ 142 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Authors 2 | 3 | - Simon Bordeyne - https://github.com/Dogeek 4 | - Core library 5 | - Maintainer 6 | - Alex Rodriguez - https://github.com/elreydetoda 7 | - Podcast support 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Dogeek 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | sys.path.insert(0, os.path.abspath('.')) 16 | 17 | import sphinx_rtd_theme 18 | 19 | # -- Project information ----------------------------------------------------- 20 | 21 | project = 'fastapi_rss' 22 | copyright = '2020, Dogeek' 23 | author = 'Dogeek' 24 | 25 | # The full version, including alpha/beta/rc tags 26 | release = '0.1.0' 27 | 28 | 29 | # -- General configuration --------------------------------------------------- 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [ 35 | 'sphinx.ext.autodoc', 36 | 'sphinx.ext.autosummary', 37 | 'sphinx.ext.viewcode', 38 | 'sphinx_rtd_theme', 39 | ] 40 | 41 | # Add any paths that contain templates here, relative to this directory. 42 | templates_path = ['_templates'] 43 | 44 | # List of patterns, relative to source directory, that match files and 45 | # directories to ignore when looking for source files. 46 | # This pattern also affects html_static_path and html_extra_path. 47 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 48 | 49 | 50 | # -- Options for HTML output ------------------------------------------------- 51 | 52 | # The theme to use for HTML and HTML Help pages. See the documentation for 53 | # a list of builtin themes. 54 | # 55 | html_theme = 'sphinx_rtd_theme' 56 | 57 | # Add any paths that contain custom static files (such as style sheets) here, 58 | # relative to this directory. They are copied after the builtin static files, 59 | # so a file named "default.css" will overwrite the builtin "default.css". 60 | html_static_path = ['_static'] 61 | 62 | master_doc = 'index' 63 | 64 | autosummary_generate = True 65 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. fastapi_rss documentation master file, created by 2 | sphinx-quickstart on Thu Dec 17 04:54:08 2020. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to fastapi_rss's documentation! 7 | ======================================= 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | 14 | 15 | Indices and tables 16 | ================== 17 | 18 | * :ref:`genindex` 19 | * :ref:`modindex` 20 | * :ref:`search` 21 | 22 | 23 | Models 24 | ====== 25 | 26 | .. automodule:: fastapi_rss.models.category 27 | :members: 28 | .. automodule:: fastapi_rss.models.cloud 29 | :members: 30 | .. automodule:: fastapi_rss.models.enclosure 31 | :members: 32 | .. automodule:: fastapi_rss.models.feed 33 | :members: 34 | .. automodule:: fastapi_rss.models.guid 35 | :members: 36 | .. automodule:: fastapi_rss.models.image 37 | :members: 38 | .. automodule:: fastapi_rss.models.item 39 | :members: 40 | .. automodule:: fastapi_rss.models.source 41 | :members: 42 | .. automodule:: fastapi_rss.models.textinput 43 | :members: 44 | 45 | 46 | RSSResponse 47 | =========== 48 | 49 | .. autoclass:: fastapi_rss.rss_response.RSSResponse 50 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /example/app.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from fastapi import FastAPI 4 | from fastapi_rss import ( 5 | RSSFeed, RSSResponse, Item, Category, CategoryAttrs, 6 | ) 7 | 8 | 9 | app = FastAPI() 10 | 11 | 12 | @app.get('/rss') 13 | async def root(): 14 | feed_data = { 15 | 'title': 'Scripting News', 16 | 'link': 'http://www.scripting.com/', 17 | 'description': 'A weblog about scripting and stuff like that.', 18 | 'language': 'en-us', 19 | 'copyright': 'Copyright 1997-2002 Dave Winer', 20 | 'last_build_date': datetime.datetime(2002, 9, 30, 11, 0, 0), 21 | 'docs': 'http://backend.userland.com/rss', 22 | 'generator': 'Radio UserLand v8.0.5', 23 | 'category': [Category( 24 | content='1765', attrs=CategoryAttrs(domain='Syndic8') 25 | )], 26 | 'managing_editor': 'dave@userland.com', 27 | 'webmaster': 'dave@userland.com', 28 | 'ttl': 40, 29 | 'item': [ 30 | Item(title='First item'), 31 | Item(title='Second item'), 32 | Item(title='Third item') 33 | ] 34 | } 35 | feed = RSSFeed(**feed_data) 36 | return RSSResponse(feed) 37 | 38 | 39 | if __name__ == '__main__': 40 | import uvicorn 41 | 42 | uvicorn.run('app:app', host='127.0.0.1', port=8080, log_level='info') 43 | -------------------------------------------------------------------------------- /example/app2.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from fastapi import FastAPI 4 | from fastapi_rss import ( 5 | RSSFeed, RSSResponse, Item, Category, 6 | CategoryAttrs, GUID, 7 | ) 8 | 9 | 10 | app = FastAPI() 11 | 12 | 13 | @app.get('/') 14 | async def root(): 15 | feed_data = { 16 | 'title': 'Test 2', 17 | 'link': '', 18 | 'description': "", 19 | 'language': 'en-us', 20 | 'copyright': 'Copyright', 21 | 'last_build_date': datetime.datetime( 22 | year=2021, month=1, day=11, 23 | hour=2, minute=49, second=32 24 | ), 25 | 'managing_editor': 'self@example.com', 26 | 'webmaster': 'self@example.com', 27 | 'generator': 'Test', 28 | 'ttl': 30, 29 | 'item': [ 30 | Item( 31 | title='Test', 32 | link='https://www.example.com/projects/2020/12/31/test', 33 | description='', 34 | author='Dogeek', 35 | category=Category( 36 | content='0001', 37 | attrs=CategoryAttrs(domain='test') 38 | ), 39 | pub_date=datetime.datetime( 40 | year=2020, month=12, day=31, 41 | hour=12, minute=40, second=16, 42 | ), 43 | guid=GUID(content='abcdefghijklmnopqrstuvwxyz') 44 | ) 45 | ], 46 | } 47 | feed = RSSFeed(**feed_data) 48 | return RSSResponse(feed) 49 | 50 | 51 | if __name__ == '__main__': 52 | import uvicorn 53 | 54 | uvicorn.run('app2:app', host='127.0.0.1', port=8081, log_level='info') -------------------------------------------------------------------------------- /fastapi_rss/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | 3 | __version__ = '0.2.1' 4 | 5 | from fastapi_rss.models import * 6 | from fastapi_rss.rss_response import RSSResponse 7 | -------------------------------------------------------------------------------- /fastapi_rss/models/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | 3 | from fastapi_rss.models.category import Category, CategoryAttrs 4 | from fastapi_rss.models.image import Image 5 | from fastapi_rss.models.itunes import ItunesAttrs, Itunes 6 | from fastapi_rss.models.cloud import Cloud, CloudAttrs 7 | from fastapi_rss.models.item import Item 8 | from fastapi_rss.models.textinput import TextInput 9 | from fastapi_rss.models.enclosure import Enclosure, EnclosureAttrs 10 | from fastapi_rss.models.guid import GUID, GUIDAttrs 11 | from fastapi_rss.models.source import Source, SourceAttrs 12 | 13 | from fastapi_rss.models.feed import RSSFeed 14 | -------------------------------------------------------------------------------- /fastapi_rss/models/category.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class CategoryAttrs(BaseModel): 7 | ''' 8 | A string that identifies a categorization taxonomy. 9 | It's a forward-slash separated string which identifies 10 | a hierarchic location in the indicated taxonomy. 11 | 12 | Processors may establish conventions for the interpretation of categories 13 | ''' 14 | domain: Optional[str] = None 15 | 16 | 17 | class Category(BaseModel): 18 | ''' 19 | An optional sub-element of a channel or an item 20 | ''' 21 | content: str 22 | attrs: Optional[CategoryAttrs] = None 23 | -------------------------------------------------------------------------------- /fastapi_rss/models/cloud.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class CloudAttrs(BaseModel): 7 | domain: Optional[str] = None 8 | port: Optional[str] = None 9 | path: Optional[str] = None 10 | register_procedure: Optional[str] = None 11 | protocol: Optional[str] = None 12 | 13 | 14 | class Cloud(BaseModel): 15 | attrs: Optional[CloudAttrs] = None 16 | -------------------------------------------------------------------------------- /fastapi_rss/models/enclosure.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class EnclosureAttrs(BaseModel): 5 | url: str 6 | length: int 7 | type: str 8 | 9 | 10 | class Enclosure(BaseModel): 11 | attrs: EnclosureAttrs 12 | -------------------------------------------------------------------------------- /fastapi_rss/models/feed.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from email.utils import format_datetime 3 | from typing import Any, Dict, List, Optional, Union 4 | 5 | from fastapi import __version__ as faversion 6 | from lxml import etree 7 | from pydantic import BaseModel, Field 8 | 9 | from fastapi_rss import __version__ as farssversion 10 | from fastapi_rss.models.category import Category 11 | from fastapi_rss.models.cloud import Cloud 12 | from fastapi_rss.models.image import Image 13 | from fastapi_rss.models.item import Item 14 | from fastapi_rss.models.textinput import TextInput 15 | from fastapi_rss.utils import get_locale_code, to_camelcase 16 | 17 | 18 | class RSSFeed(BaseModel): 19 | title: str 20 | link: str 21 | description: str 22 | 23 | language: Optional[str] = get_locale_code() 24 | copyright: Optional[str] = None 25 | managing_editor: Optional[str] = None 26 | webmaster: Optional[str] = None 27 | pub_date: Optional[datetime] = None 28 | last_build_date: Optional[datetime] = None 29 | category: Optional[List[Category]] = Field(default_factory=list) 30 | generator: str = f'FastAPI v{faversion} w/ FastAPI_RSS v{farssversion}' 31 | docs: str = 'https://validator.w3.org/feed/docs/rss2.html' 32 | cloud: Optional[Cloud] = None 33 | ttl: int = 60 34 | image: Optional[Image] = None 35 | text_input: Optional[TextInput] = None 36 | skip_hours: List[int] = Field(default_factory=list) 37 | skip_days: List[str] = Field(default_factory=list) 38 | 39 | item: List[Item] = Field(default_factory=list) 40 | 41 | @staticmethod 42 | def _get_attrs(value: Union[dict, BaseModel]) -> Dict[str, str]: 43 | ''' 44 | Gets attrs from value, keys are passed to camel case and values to str 45 | 46 | :return: Attrs as dictionary 47 | ''' 48 | attrs = None 49 | if hasattr(value, 'attrs'): 50 | attrs = value.attrs.dict() 51 | elif 'attrs' in value: 52 | attrs = value['attrs'] 53 | 54 | attrs = attrs or {} 55 | 56 | # if boolean then string in lower case 57 | return { 58 | to_camelcase(k): str(v).lower() if isinstance(v, bool) else str(v) 59 | for k, v in attrs.items() 60 | } 61 | 62 | @classmethod 63 | def _generate_tree_list(cls, root: etree.ElementBase, key: str, 64 | value: List[dict]) -> None: 65 | for item in value: 66 | attrs = cls._get_attrs(item) 67 | content = item.pop('content', None) 68 | itemroot = etree.SubElement(root, key, attrs) 69 | if content is not None: 70 | itemroot.text = content 71 | else: 72 | cls.generate_tree(itemroot, item) 73 | 74 | @classmethod 75 | def _generate_tree_object(cls, root: etree.ElementBase, key: str, 76 | value: Union[dict, BaseModel]) -> None: 77 | attrs = cls._get_attrs(value) 78 | if hasattr(value, 'content'): 79 | content = value.content 80 | elif 'content' in value: 81 | content = value['content'] 82 | else: 83 | content = None 84 | 85 | if key == 'itunes': 86 | # Used for podcast image 87 | etree.SubElement( 88 | root, '{http://www.itunes.com/dtds/podcast-1.0.dtd}image', 89 | attrs, 90 | ) 91 | return 92 | 93 | element: etree.ElementBase = etree.SubElement(root, key, attrs) 94 | if content: 95 | element.text = content 96 | 97 | @staticmethod 98 | def _generate_tree_default(root: etree.ElementBase, key: str, value: Any) -> None: 99 | element: etree.ElementBase = etree.SubElement(root, key) 100 | if isinstance(value, datetime): 101 | # parse datetime as specified in RFC 2822 102 | value = format_datetime(value) 103 | else: 104 | value = str(value) 105 | element.text = value 106 | 107 | @classmethod 108 | def generate_tree(cls, root: etree.Element, dict_: dict): 109 | handlers = { 110 | (list,): cls._generate_tree_list, 111 | (BaseModel, dict): cls._generate_tree_object, 112 | } 113 | for key, value in dict_.items(): 114 | if value is None: 115 | continue 116 | handler = cls._generate_tree_default 117 | for handler_types, _handler in handlers.items(): 118 | if isinstance(value, handler_types): 119 | handler = _handler 120 | break 121 | handler(root, to_camelcase(key), value) 122 | 123 | def tostring(self, nsmap: Optional[Dict[str, str]] = None): 124 | nsmap = nsmap or {} 125 | rss = etree.Element('rss', version='2.0', nsmap=nsmap) 126 | channel = etree.SubElement(rss, 'channel') 127 | self.generate_tree(channel, self.dict()) 128 | return etree.tostring(rss, pretty_print=True, xml_declaration=True) 129 | -------------------------------------------------------------------------------- /fastapi_rss/models/guid.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class GUIDAttrs(BaseModel): 7 | is_permalink: bool 8 | 9 | 10 | class GUID(BaseModel): 11 | content: str 12 | attrs: Optional[GUIDAttrs] = None 13 | -------------------------------------------------------------------------------- /fastapi_rss/models/image.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class Image(BaseModel): 7 | url: str 8 | title: str 9 | link: str 10 | 11 | width: Optional[int] = None 12 | height: Optional[int] = None 13 | description: Optional[str] = None 14 | -------------------------------------------------------------------------------- /fastapi_rss/models/item.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Optional 3 | 4 | from pydantic import BaseModel 5 | 6 | from fastapi_rss.models.category import Category 7 | from fastapi_rss.models.enclosure import Enclosure 8 | from fastapi_rss.models.guid import GUID 9 | from fastapi_rss.models.itunes import Itunes 10 | from fastapi_rss.models.source import Source 11 | 12 | 13 | class Item(BaseModel): 14 | title: str 15 | link: Optional[str] = None 16 | description: Optional[str] = None 17 | author: Optional[str] = None 18 | category: Optional[Category] = None 19 | comments: Optional[str] = None 20 | enclosure: Optional[Enclosure] = None 21 | guid: Optional[GUID] = None 22 | pub_date: Optional[datetime.datetime] = None 23 | source: Optional[Source] = None 24 | itunes: Optional[Itunes] = None 25 | -------------------------------------------------------------------------------- /fastapi_rss/models/itunes.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class ItunesAttrs(BaseModel): 7 | href: str 8 | 9 | 10 | class Itunes(BaseModel): 11 | content: str 12 | attrs: Optional[ItunesAttrs] = None 13 | -------------------------------------------------------------------------------- /fastapi_rss/models/source.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class SourceAttrs(BaseModel): 5 | url: str 6 | 7 | 8 | class Source(BaseModel): 9 | content: str 10 | attrs: SourceAttrs 11 | -------------------------------------------------------------------------------- /fastapi_rss/models/textinput.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class TextInput(BaseModel): 5 | title: str 6 | description: str 7 | name: str 8 | link: str 9 | -------------------------------------------------------------------------------- /fastapi_rss/rss_response.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | from typing import Mapping 3 | 4 | from starlette.responses import Response 5 | 6 | from fastapi_rss.models import RSSFeed 7 | 8 | 9 | class RSSResponse(Response): 10 | ''' 11 | A subclass of starlette.responses.Response which will set the content 12 | to an RSS XML document. It takes one argument, an RSSFeed object which will 13 | be converted to an XML document. 14 | ''' 15 | media_type = 'application/xml' 16 | charset = 'utf-8' 17 | 18 | @property 19 | def etag(self) -> str: 20 | ''' 21 | Generates a SHA1 sum of the body of the response so that the server can 22 | support the ETag protocol. 23 | 24 | :return: The SHA1 hex digest of the body of the response 25 | :rtype: str 26 | ''' 27 | return hashlib.sha1(self.body).hexdigest() 28 | 29 | def init_headers(self, headers: Mapping[str, str] = None) -> None: 30 | newheaders = { 31 | 'Accept-Range': 'bytes', 32 | 'Connection': 'Keep-Alive', 33 | 'ETag': self.etag, 34 | 'Keep-Alive': 'timeout=5, max=100', 35 | } 36 | 37 | headers = headers or {} 38 | for headername in newheaders: 39 | if headername not in headers: 40 | headers[headername] = newheaders[headername] 41 | super().init_headers(headers) 42 | 43 | def render(self, rss: RSSFeed, itunes: bool = False) -> bytes: 44 | nsmap = None 45 | if itunes: 46 | nsmap = { 47 | 'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd' 48 | } 49 | return rss.tostring(nsmap) 50 | -------------------------------------------------------------------------------- /fastapi_rss/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility functions for the fastapi_rss package 3 | """ 4 | import locale 5 | import subprocess 6 | from typing import Optional 7 | 8 | 9 | def git_config(key: str, _global=True) -> str: 10 | """ 11 | Checks git config for a key and returns the value 12 | 13 | :param key: The git config key to check 14 | :type key: str 15 | :param _global: whether to check in the local dir config or not, 16 | defaults to True 17 | :type _global: bool, optional 18 | :return: The value of the git config key 19 | :rtype: str 20 | """ 21 | cmd = ['git', 'config'] 22 | if _global: 23 | cmd.append('--global') 24 | cmd.append(key) 25 | return subprocess.check_output(cmd).decode('utf8').strip('\n') 26 | 27 | 28 | def get_locale_code() -> Optional[str]: 29 | """ 30 | Returns the locale code of the current system 31 | 32 | :return: The locale code of the current system or None if not found 33 | :rtype: str | None 34 | """ 35 | locale_code, encoding = locale.getlocale(locale.LC_CTYPE) 36 | del encoding 37 | if locale_code is not None: 38 | return locale_code.lower() 39 | return locale_code 40 | 41 | 42 | def to_camelcase(string: str) -> str: 43 | """ 44 | Turns a string into camelcasea 45 | 46 | :param string: The string to camelcase 47 | :type string: str 48 | :return: the camelcased string 49 | :rtype: str 50 | """ 51 | string = string.split('_') 52 | for i, el in enumerate(string): 53 | if i == 0: 54 | continue 55 | string[i] = el.capitalize() 56 | return ''.join(string) 57 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "anyio" 3 | version = "3.6.1" 4 | description = "High level compatibility layer for multiple asynchronous event loop implementations" 5 | category = "main" 6 | optional = false 7 | python-versions = ">=3.6.2" 8 | 9 | [package.dependencies] 10 | idna = ">=2.8" 11 | sniffio = ">=1.1" 12 | typing-extensions = {version = "*", markers = "python_version < \"3.8\""} 13 | 14 | [package.extras] 15 | doc = ["packaging", "sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] 16 | test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] 17 | trio = ["trio (>=0.16)"] 18 | 19 | [[package]] 20 | name = "atomicwrites" 21 | version = "1.4.1" 22 | description = "Atomic file writes." 23 | category = "dev" 24 | optional = false 25 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 26 | 27 | [[package]] 28 | name = "attrs" 29 | version = "22.1.0" 30 | description = "Classes Without Boilerplate" 31 | category = "dev" 32 | optional = false 33 | python-versions = ">=3.5" 34 | 35 | [package.extras] 36 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] 37 | docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] 38 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] 39 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "cloudpickle"] 40 | 41 | [[package]] 42 | name = "certifi" 43 | version = "2022.9.14" 44 | description = "Python package for providing Mozilla's CA Bundle." 45 | category = "dev" 46 | optional = false 47 | python-versions = ">=3.6" 48 | 49 | [[package]] 50 | name = "charset-normalizer" 51 | version = "2.1.1" 52 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 53 | category = "dev" 54 | optional = false 55 | python-versions = ">=3.6.0" 56 | 57 | [package.extras] 58 | unicode_backport = ["unicodedata2"] 59 | 60 | [[package]] 61 | name = "colorama" 62 | version = "0.4.5" 63 | description = "Cross-platform colored terminal text." 64 | category = "dev" 65 | optional = false 66 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 67 | 68 | [[package]] 69 | name = "fastapi" 70 | version = "0.85.0" 71 | description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" 72 | category = "main" 73 | optional = false 74 | python-versions = ">=3.7" 75 | 76 | [package.dependencies] 77 | pydantic = ">=1.6.2,<1.7 || >1.7,<1.7.1 || >1.7.1,<1.7.2 || >1.7.2,<1.7.3 || >1.7.3,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0" 78 | starlette = "0.20.4" 79 | 80 | [package.extras] 81 | all = ["email-validator (>=1.1.1,<2.0.0)", "itsdangerous (>=1.1.0,<3.0.0)", "jinja2 (>=2.11.2,<4.0.0)", "orjson (>=3.2.1,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "pyyaml (>=5.3.1,<7.0.0)", "requests (>=2.24.0,<3.0.0)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)", "uvicorn[standard] (>=0.12.0,<0.19.0)"] 82 | dev = ["autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<6.0.0)", "pre-commit (>=2.17.0,<3.0.0)", "uvicorn[standard] (>=0.12.0,<0.19.0)"] 83 | doc = ["mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "typer (>=0.4.1,<0.7.0)"] 84 | test = ["anyio[trio] (>=3.2.1,<4.0.0)", "black (==22.8.0)", "databases[sqlite] (>=0.3.2,<0.7.0)", "email-validator (>=1.1.1,<2.0.0)", "flake8 (>=3.8.3,<6.0.0)", "flask (>=1.1.2,<3.0.0)", "httpx (>=0.23.0,<0.24.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.971)", "orjson (>=3.2.1,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "peewee (>=3.13.3,<4.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "pytest (>=7.1.3,<8.0.0)", "python-jose[cryptography] (>=3.3.0,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "pyyaml (>=5.3.1,<7.0.0)", "requests (>=2.24.0,<3.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "types-orjson (==3.6.2)", "types-ujson (==5.4.0)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)"] 85 | 86 | [[package]] 87 | name = "idna" 88 | version = "3.4" 89 | description = "Internationalized Domain Names in Applications (IDNA)" 90 | category = "main" 91 | optional = false 92 | python-versions = ">=3.5" 93 | 94 | [[package]] 95 | name = "importlib-metadata" 96 | version = "4.12.0" 97 | description = "Read metadata from Python packages" 98 | category = "dev" 99 | optional = false 100 | python-versions = ">=3.7" 101 | 102 | [package.dependencies] 103 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 104 | zipp = ">=0.5" 105 | 106 | [package.extras] 107 | docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] 108 | perf = ["ipython"] 109 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] 110 | 111 | [[package]] 112 | name = "iniconfig" 113 | version = "1.1.1" 114 | description = "iniconfig: brain-dead simple config-ini parsing" 115 | category = "dev" 116 | optional = false 117 | python-versions = "*" 118 | 119 | [[package]] 120 | name = "isort" 121 | version = "5.10.1" 122 | description = "A Python utility / library to sort Python imports." 123 | category = "dev" 124 | optional = false 125 | python-versions = ">=3.6.1,<4.0" 126 | 127 | [package.extras] 128 | pipfile_deprecated_finder = ["pipreqs", "requirementslib"] 129 | requirements_deprecated_finder = ["pipreqs", "pip-api"] 130 | colors = ["colorama (>=0.4.3,<0.5.0)"] 131 | plugins = ["setuptools"] 132 | 133 | [[package]] 134 | name = "lxml" 135 | version = "4.9.1" 136 | description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." 137 | category = "main" 138 | optional = false 139 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*" 140 | 141 | [package.extras] 142 | cssselect = ["cssselect (>=0.7)"] 143 | html5 = ["html5lib"] 144 | htmlsoup = ["beautifulsoup4"] 145 | source = ["Cython (>=0.29.7)"] 146 | 147 | [[package]] 148 | name = "mccabe" 149 | version = "0.7.0" 150 | description = "McCabe checker, plugin for flake8" 151 | category = "dev" 152 | optional = false 153 | python-versions = ">=3.6" 154 | 155 | [[package]] 156 | name = "packaging" 157 | version = "21.3" 158 | description = "Core utilities for Python packages" 159 | category = "dev" 160 | optional = false 161 | python-versions = ">=3.6" 162 | 163 | [package.dependencies] 164 | pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" 165 | 166 | [[package]] 167 | name = "pastel" 168 | version = "0.2.1" 169 | description = "Bring colors to your terminal." 170 | category = "dev" 171 | optional = false 172 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 173 | 174 | [[package]] 175 | name = "pluggy" 176 | version = "1.0.0" 177 | description = "plugin and hook calling mechanisms for python" 178 | category = "dev" 179 | optional = false 180 | python-versions = ">=3.6" 181 | 182 | [package.dependencies] 183 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 184 | 185 | [package.extras] 186 | dev = ["pre-commit", "tox"] 187 | testing = ["pytest", "pytest-benchmark"] 188 | 189 | [[package]] 190 | name = "poethepoet" 191 | version = "0.12.3" 192 | description = "A task runner that works well with poetry." 193 | category = "dev" 194 | optional = false 195 | python-versions = ">=3.6.2" 196 | 197 | [package.dependencies] 198 | pastel = ">=0.2.1,<0.3.0" 199 | tomli = ">=1.2.2,<2.0.0" 200 | 201 | [package.extras] 202 | poetry_plugin = ["poetry (>=1.0,<2.0)"] 203 | 204 | [[package]] 205 | name = "py" 206 | version = "1.11.0" 207 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 208 | category = "dev" 209 | optional = false 210 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 211 | 212 | [[package]] 213 | name = "pycodestyle" 214 | version = "2.9.1" 215 | description = "Python style guide checker" 216 | category = "dev" 217 | optional = false 218 | python-versions = ">=3.6" 219 | 220 | [[package]] 221 | name = "pydantic" 222 | version = "1.10.2" 223 | description = "Data validation and settings management using python type hints" 224 | category = "main" 225 | optional = false 226 | python-versions = ">=3.7" 227 | 228 | [package.dependencies] 229 | typing-extensions = ">=4.1.0" 230 | 231 | [package.extras] 232 | dotenv = ["python-dotenv (>=0.10.4)"] 233 | email = ["email-validator (>=1.0.3)"] 234 | 235 | [[package]] 236 | name = "pydocstyle" 237 | version = "6.1.1" 238 | description = "Python docstring style checker" 239 | category = "dev" 240 | optional = false 241 | python-versions = ">=3.6" 242 | 243 | [package.dependencies] 244 | snowballstemmer = "*" 245 | 246 | [package.extras] 247 | toml = ["toml"] 248 | 249 | [[package]] 250 | name = "pyflakes" 251 | version = "2.5.0" 252 | description = "passive checker of Python programs" 253 | category = "dev" 254 | optional = false 255 | python-versions = ">=3.6" 256 | 257 | [[package]] 258 | name = "pylama" 259 | version = "8.4.1" 260 | description = "Code audit tool for python" 261 | category = "dev" 262 | optional = false 263 | python-versions = ">=3.7" 264 | 265 | [package.dependencies] 266 | mccabe = ">=0.7.0" 267 | pycodestyle = ">=2.9.1" 268 | pydocstyle = ">=6.1.1" 269 | pyflakes = ">=2.5.0" 270 | 271 | [package.extras] 272 | all = ["pylint", "eradicate", "radon", "mypy", "vulture"] 273 | eradicate = ["eradicate"] 274 | mypy = ["mypy"] 275 | pylint = ["pylint"] 276 | radon = ["radon"] 277 | tests = ["pytest (>=7.1.2)", "pytest-mypy", "eradicate (>=2.0.0)", "radon (>=5.1.0)", "mypy", "pylint (>=2.11.1)", "pylama-quotes", "toml", "vulture", "types-setuptools", "types-toml"] 278 | toml = ["toml (>=0.10.2)"] 279 | vulture = ["vulture"] 280 | 281 | [[package]] 282 | name = "pyparsing" 283 | version = "3.0.9" 284 | description = "pyparsing module - Classes and methods to define and execute parsing grammars" 285 | category = "dev" 286 | optional = false 287 | python-versions = ">=3.6.8" 288 | 289 | [package.extras] 290 | diagrams = ["railroad-diagrams", "jinja2"] 291 | 292 | [[package]] 293 | name = "pytest" 294 | version = "6.2.5" 295 | description = "pytest: simple powerful testing with Python" 296 | category = "dev" 297 | optional = false 298 | python-versions = ">=3.6" 299 | 300 | [package.dependencies] 301 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 302 | attrs = ">=19.2.0" 303 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 304 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 305 | iniconfig = "*" 306 | packaging = "*" 307 | pluggy = ">=0.12,<2.0" 308 | py = ">=1.8.2" 309 | toml = "*" 310 | 311 | [package.extras] 312 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 313 | 314 | [[package]] 315 | name = "requests" 316 | version = "2.28.1" 317 | description = "Python HTTP for Humans." 318 | category = "dev" 319 | optional = false 320 | python-versions = ">=3.7, <4" 321 | 322 | [package.dependencies] 323 | certifi = ">=2017.4.17" 324 | charset-normalizer = ">=2,<3" 325 | idna = ">=2.5,<4" 326 | urllib3 = ">=1.21.1,<1.27" 327 | 328 | [package.extras] 329 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 330 | use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] 331 | 332 | [[package]] 333 | name = "sniffio" 334 | version = "1.3.0" 335 | description = "Sniff out which async library your code is running under" 336 | category = "main" 337 | optional = false 338 | python-versions = ">=3.7" 339 | 340 | [[package]] 341 | name = "snowballstemmer" 342 | version = "2.2.0" 343 | description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." 344 | category = "dev" 345 | optional = false 346 | python-versions = "*" 347 | 348 | [[package]] 349 | name = "starlette" 350 | version = "0.20.4" 351 | description = "The little ASGI library that shines." 352 | category = "main" 353 | optional = false 354 | python-versions = ">=3.7" 355 | 356 | [package.dependencies] 357 | anyio = ">=3.4.0,<5" 358 | typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} 359 | 360 | [package.extras] 361 | full = ["itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests"] 362 | 363 | [[package]] 364 | name = "toml" 365 | version = "0.10.2" 366 | description = "Python Library for Tom's Obvious, Minimal Language" 367 | category = "dev" 368 | optional = false 369 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 370 | 371 | [[package]] 372 | name = "tomli" 373 | version = "1.2.3" 374 | description = "A lil' TOML parser" 375 | category = "dev" 376 | optional = false 377 | python-versions = ">=3.6" 378 | 379 | [[package]] 380 | name = "typing-extensions" 381 | version = "4.3.0" 382 | description = "Backported and Experimental Type Hints for Python 3.7+" 383 | category = "main" 384 | optional = false 385 | python-versions = ">=3.7" 386 | 387 | [[package]] 388 | name = "urllib3" 389 | version = "1.26.12" 390 | description = "HTTP library with thread-safe connection pooling, file post, and more." 391 | category = "dev" 392 | optional = false 393 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" 394 | 395 | [package.extras] 396 | brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] 397 | secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "urllib3-secure-extra", "ipaddress"] 398 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 399 | 400 | [[package]] 401 | name = "yapf" 402 | version = "0.32.0" 403 | description = "A formatter for Python code." 404 | category = "dev" 405 | optional = false 406 | python-versions = "*" 407 | 408 | [[package]] 409 | name = "zipp" 410 | version = "3.8.1" 411 | description = "Backport of pathlib-compatible object wrapper for zip files" 412 | category = "dev" 413 | optional = false 414 | python-versions = ">=3.7" 415 | 416 | [package.extras] 417 | docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"] 418 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] 419 | 420 | [metadata] 421 | lock-version = "1.1" 422 | python-versions = "^3.7" 423 | content-hash = "f5db2fd3b433e33153bfd4a651140bca98cc4072e16093ffc58d4872635b2f3c" 424 | 425 | [metadata.files] 426 | anyio = [ 427 | {file = "anyio-3.6.1-py3-none-any.whl", hash = "sha256:cb29b9c70620506a9a8f87a309591713446953302d7d995344d0d7c6c0c9a7be"}, 428 | {file = "anyio-3.6.1.tar.gz", hash = "sha256:413adf95f93886e442aea925f3ee43baa5a765a64a0f52c6081894f9992fdd0b"}, 429 | ] 430 | atomicwrites = [] 431 | attrs = [] 432 | certifi = [] 433 | charset-normalizer = [] 434 | colorama = [ 435 | {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, 436 | {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, 437 | ] 438 | fastapi = [] 439 | idna = [] 440 | importlib-metadata = [] 441 | iniconfig = [ 442 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 443 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 444 | ] 445 | isort = [ 446 | {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, 447 | {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, 448 | ] 449 | lxml = [] 450 | mccabe = [ 451 | {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, 452 | {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, 453 | ] 454 | packaging = [ 455 | {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, 456 | {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, 457 | ] 458 | pastel = [ 459 | {file = "pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364"}, 460 | {file = "pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d"}, 461 | ] 462 | pluggy = [ 463 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 464 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 465 | ] 466 | poethepoet = [ 467 | {file = "poethepoet-0.12.3-py3-none-any.whl", hash = "sha256:5b645546d6bbda29cb362d563276c85c19e65dc8d84ed4155964f6e639cb3796"}, 468 | {file = "poethepoet-0.12.3.tar.gz", hash = "sha256:34d6396a5c9c9741d6cb72afb71797a9c522df1c37717ee15d84d73a1b353387"}, 469 | ] 470 | py = [ 471 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 472 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 473 | ] 474 | pycodestyle = [] 475 | pydantic = [] 476 | pydocstyle = [ 477 | {file = "pydocstyle-6.1.1-py3-none-any.whl", hash = "sha256:6987826d6775056839940041beef5c08cc7e3d71d63149b48e36727f70144dc4"}, 478 | {file = "pydocstyle-6.1.1.tar.gz", hash = "sha256:1d41b7c459ba0ee6c345f2eb9ae827cab14a7533a88c5c6f7e94923f72df92dc"}, 479 | ] 480 | pyflakes = [] 481 | pylama = [] 482 | pyparsing = [ 483 | {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, 484 | {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, 485 | ] 486 | pytest = [ 487 | {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, 488 | {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, 489 | ] 490 | requests = [] 491 | sniffio = [] 492 | snowballstemmer = [ 493 | {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, 494 | {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, 495 | ] 496 | starlette = [] 497 | toml = [ 498 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 499 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 500 | ] 501 | tomli = [ 502 | {file = "tomli-1.2.3-py3-none-any.whl", hash = "sha256:e3069e4be3ead9668e21cb9b074cd948f7b3113fd9c8bba083f48247aab8b11c"}, 503 | {file = "tomli-1.2.3.tar.gz", hash = "sha256:05b6166bff487dc068d322585c7ea4ef78deed501cc124060e0f238e89a9231f"}, 504 | ] 505 | typing-extensions = [] 506 | urllib3 = [] 507 | yapf = [ 508 | {file = "yapf-0.32.0-py2.py3-none-any.whl", hash = "sha256:8fea849025584e486fd06d6ba2bed717f396080fd3cc236ba10cb97c4c51cf32"}, 509 | {file = "yapf-0.32.0.tar.gz", hash = "sha256:a3f5085d37ef7e3e004c4ba9f9b3e40c54ff1901cd111f05145ae313a7c67d1b"}, 510 | ] 511 | zipp = [] 512 | -------------------------------------------------------------------------------- /pylama.ini: -------------------------------------------------------------------------------- 1 | [pylama] 2 | format = pylint 3 | skip = */.tox,*/.env/* 4 | linters = pylint,mccabe 5 | ignore=F0401,C0111,E731 6 | 7 | [pylama:pep8] 8 | max_line_length = 120 9 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "fastapi_rss" 3 | version = "0.3.0" 4 | description = "A library to generate RSS feeds for FastAPI" 5 | authors = ["Dogeek"] 6 | license = "MIT" 7 | classifiers = [ 8 | 'Development Status :: 5 - Production/Stable', 9 | 'Intended Audience :: Developers', 10 | 'License :: OSI Approved :: MIT License', 11 | 'Programming Language :: Python :: 3.7', 12 | 'Programming Language :: Python :: 3.8', 13 | 'Programming Language :: Python :: 3.9', 14 | 'Programming Language :: Python :: 3.10', 15 | ] 16 | 17 | [tool.poetry.dependencies] 18 | python = "^3.8" 19 | fastapi = ">=0.85.0,<1.0.0" 20 | lxml = "^5.3.0" 21 | 22 | [tool.poetry.dev-dependencies] 23 | pytest = "^6.2.5" 24 | pylama = "^8.4.1" 25 | yapf = "^0.32.0" 26 | isort = "^5.10.1" 27 | requests = "^2.27.1" 28 | poethepoet = "^0.12.2" 29 | 30 | [build-system] 31 | requires = ["poetry-core>=1.0.0"] 32 | build-backend = "poetry.core.masonry.api" 33 | 34 | [tool.yapf] 35 | based_on_style = 'pep8' 36 | spaces_before_comment = 2 37 | split_before_logical_operator = true 38 | split_before_arithmetic_operator = true 39 | split_before_bitwise_operator = true 40 | split_complex_comprehension = true 41 | split_before_first_argument = true 42 | split_before_dot = true 43 | split_before_dict_set_operator = true 44 | split_before_closing_bracket = true 45 | each_dict_entry_on_separate_line = true 46 | dedent_closing_brackets = true 47 | column_limit = 119 48 | coalesce_brackets = true 49 | 50 | [tool.pylint] 51 | max-line-length = 120 52 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # FastAPI RSS 2 | 3 | A library to easily integrate RSS Feeds into FastAPI 4 | 5 | ## Rationale 6 | 7 | The RSS standard has been around for a long time, and the specification has not 8 | changed much in the past 20 years. Regardless, it is still a useful format, and 9 | there is an added value to have an automated feed for updates about an API 10 | (notify users of upcoming changes), or in general as a backend for full-featured 11 | applications 12 | 13 | 14 | ## Usage 15 | 16 | You will need to import at least three classes to use that library: 17 | 18 | - `RSSResponse`, which is a subclass of `starlette.Response`. It's a `text/xml` typed response which will handle generating the XML from the pydantic models 19 | - `RSSFeed` which is a pydantic model that represents an RSS feed according to the `RSS v2.0` specification from the **World Wide Web Consortium** 20 | - `Item` which is another pydantic model that represents an item in the feed. 21 | 22 | Once those are imported, instanciate an `RSSFeed` object, with the appropriate parameters, then return an `RSSResponse` with that feed. 23 | 24 | Your endpoint should now return an appropriate XML document representing your RSS feed. 25 | Look at example/app.py for an example app that uses the **W3C** RSS example. 26 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sbordeyne/fastapi_rss/c974506647c2bd4a0f357d3cff3fd5f3dedfbb53/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from textwrap import dedent 3 | 4 | from fastapi import FastAPI 5 | from fastapi.testclient import TestClient 6 | from pytest import fixture 7 | 8 | from fastapi_rss import ( 9 | Enclosure, EnclosureAttrs, GUIDAttrs, RSSFeed, RSSResponse, Item, Category, 10 | CategoryAttrs, GUID, 11 | ) 12 | 13 | 14 | @fixture 15 | def expected_response(): 16 | return dedent('''\ 17 | 18 | 19 | 20 | Scripting News 21 | http://www.scripting.com/ 22 | A weblog about scripting and stuff like that. 23 | en-us 24 | Copyright 1997-2002 Dave Winer 25 | dave@userland.com 26 | dave@userland.com 27 | Mon, 30 Sep 2002 11:00:00 +0000 28 | 1765 29 | Radio UserLand v8.0.5 30 | http://backend.userland.com/rss 31 | 40 32 | 33 | First item 34 | 35 | 36 | Second item 37 | 38 | 39 | Third item 40 | 41 | 42 | 43 | ''') 44 | 45 | 46 | async def first(): 47 | feed_data = { 48 | 'title': 'Scripting News', 49 | 'link': 'http://www.scripting.com/', 50 | 'description': 'A weblog about scripting and stuff like that.', 51 | 'language': 'en-us', 52 | 'copyright': 'Copyright 1997-2002 Dave Winer', 53 | 'last_build_date': datetime.datetime(2002, 9, 30, 11, 0, 0), 54 | 'docs': 'http://backend.userland.com/rss', 55 | 'generator': 'Radio UserLand v8.0.5', 56 | 'category': [Category( 57 | content='1765', attrs=CategoryAttrs(domain='Syndic8') 58 | )], 59 | 'managing_editor': 'dave@userland.com', 60 | 'webmaster': 'dave@userland.com', 61 | 'ttl': 40, 62 | 'item': [ 63 | Item(title='First item'), 64 | Item(title='Second item'), 65 | Item(title='Third item') 66 | ] 67 | } 68 | feed = RSSFeed(**feed_data) 69 | return RSSResponse(feed) 70 | 71 | 72 | async def second(): 73 | feed_data = { 74 | 'title': 'Test 2', 75 | 'link': '', 76 | 'description': "", 77 | 'language': 'en-us', 78 | 'copyright': 'Copyright', 79 | 'last_build_date': datetime.datetime( 80 | year=2021, month=1, day=11, 81 | hour=2, minute=49, second=32 82 | ), 83 | 'managing_editor': 'self@example.com', 84 | 'webmaster': 'self@example.com', 85 | 'generator': 'Test', 86 | 'ttl': 30, 87 | 'item': [ 88 | Item( 89 | title='Test', 90 | link='https://www.example.com/projects/2020/12/31/test', 91 | description='', 92 | author='Dogeek', 93 | category=Category( 94 | content='0001', 95 | attrs=CategoryAttrs(domain='test') 96 | ), 97 | pub_date=datetime.datetime( 98 | year=2020, month=12, day=31, 99 | hour=12, minute=40, second=16, 100 | ), 101 | guid=GUID( 102 | content='abcdefghijklmnopqrstuvwxyz', 103 | attrs=GUIDAttrs(is_permalink=False) 104 | ), 105 | enclosure=Enclosure(attrs=EnclosureAttrs( 106 | url='https://example.com/', 107 | length=125, 108 | type='audio/mpeg' 109 | )) 110 | ) 111 | ], 112 | } 113 | feed = RSSFeed(**feed_data) 114 | return RSSResponse(feed) 115 | 116 | 117 | @fixture 118 | def client(): 119 | app = FastAPI() 120 | app.get('/1')(first) 121 | app.get('/2')(second) 122 | return TestClient(app) 123 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from importlib.metadata import version 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | from pydantic import BaseModel 7 | 8 | if TYPE_CHECKING: 9 | from pydantic.fields import ModelField 10 | 11 | pydantic_major = int(version('pydantic').split('.')[0]) 12 | if pydantic_major >= 2: 13 | from pydantic_core import PydanticUndefined 14 | else: 15 | from pydantic.fields import UndefinedType as PydanticUndefined 16 | 17 | from fastapi_rss.models import ( 18 | Category, CategoryAttrs, 19 | Image, 20 | ItunesAttrs, Itunes, 21 | Cloud, CloudAttrs, 22 | Item, 23 | TextInput, 24 | Enclosure, EnclosureAttrs, 25 | GUID, GUIDAttrs, 26 | Source, SourceAttrs, 27 | RSSFeed 28 | ) 29 | 30 | MODELS = [i for i in locals().values() if inspect.isclass(i) and issubclass(i, BaseModel)] 31 | 32 | 33 | @pytest.mark.parametrize('model',MODELS) 34 | def test_optionals_have_defaults(model): 35 | if pydantic_major >= 2: 36 | fields = model.model_fields 37 | else: 38 | fields = model.__fields__ 39 | 40 | field: 'ModelField' 41 | for name, field in fields.items(): 42 | if not field.required: 43 | assert field.field_info.default is not PydanticUndefined 44 | 45 | 46 | def test_instantiate_optionals(): 47 | feed = RSSFeed( 48 | title="My Feed", 49 | link="https://example.com", 50 | description="A feed!" 51 | ) 52 | item = Item( 53 | title="My Item" 54 | ) -------------------------------------------------------------------------------- /tests/test_rss_response.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | from fastapi.testclient import TestClient 4 | from lxml import etree 5 | 6 | 7 | def test_rss_sample_response(client: TestClient, expected_response: str, capsys): 8 | response = client.get('/1') 9 | assert response.status_code == 200 10 | tree = etree.fromstring(response.content) 11 | pretty: str = etree.tostring(tree, pretty_print=True, xml_declaration=True).decode('ascii') 12 | for line, expected_line in zip(pretty.splitlines(), expected_response.splitlines()): 13 | assert dedent(line) == dedent(expected_line), f'{line} != {expected_line}' 14 | 15 | 16 | def test_rss_guid(client: TestClient): 17 | response = client.get('/2') 18 | tree: etree._Element = etree.fromstring(response.content) 19 | assert response.status_code == 200 20 | assert tree.xpath('//guid') != [] 21 | assert tree.xpath('//guid')[0].text == 'abcdefghijklmnopqrstuvwxyz' 22 | assert tree.xpath('//guid')[0].attrib['isPermalink'] == 'false' 23 | 24 | 25 | def test_rss_enclosure(client: TestClient): 26 | response = client.get('/2') 27 | tree: etree._Element = etree.fromstring(response.content) 28 | assert response.status_code == 200 29 | assert tree.xpath('//enclosure') != [] 30 | enclosure = tree.xpath('//enclosure')[0].attrib 31 | assert enclosure['url'] == 'https://example.com/' 32 | assert enclosure['length'] == '125' 33 | assert enclosure['type'] == 'audio/mpeg' 34 | 35 | 36 | def test_rss_item_category(client: TestClient): 37 | response = client.get('/2') 38 | tree: etree._Element = etree.fromstring(response.content) 39 | assert response.status_code == 200 40 | assert tree.xpath('//category') != [] 41 | assert tree.xpath('//category')[0].attrib['domain'] == 'test' 42 | assert tree.xpath('//category')[0].text == '0001' 43 | --------------------------------------------------------------------------------