diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 53af932..0000000 --- a/.flake8 +++ /dev/null @@ -1,5 +0,0 @@ -[flake8] -max-line-length = 88 -per-file-ignores = __init__.py:F401 -ignore = D100,D101,D104,D105,D107 -exclude = .git,__pycache__,old,build,dist,venv,.venv,tests diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index a4812c2..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,8 +0,0 @@ -version: 2 - -updates: - - package-ecosystem: "pip" - directory: "/" - schedule: - interval: "daily" - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6b2e7a..a394e13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,21 +1,22 @@ name: Lint and test on: + schedule: + - cron: "0 0 1 * *" + # TODO: Uncomment after V2 is finished push: paths-ignore: - - '.github/**' - - '!.github/workflows/ci.yml' - - '.gitignore' - - 'README.md' + - ".gitignore" + - "README.md" pull_request: jobs: - build: + test: strategy: max-parallel: 6 matrix: os: [ "ubuntu-latest", "windows-latest", "macos-latest" ] - python-version: [ 3.7, 3.8, 3.9, '3.10' ] + python-version: [ "3.8", "3.9", "3.10", "3.11"] runs-on: ${{ matrix.os }} @@ -27,7 +28,7 @@ jobs: - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} id: setup-python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} cache-dependency-path: pyproject.toml @@ -37,14 +38,11 @@ jobs: if: steps.setup-python.outputs.cache-hit != 'true' run: poetry install - - name: Lint code with flake8 - run: poetry run flake8 - - name: Lint code with black run: poetry run black --check . - - name: Lint code with isort - run: poetry run isort --check-only . + - name: Lint code with ruff + run: poetry run ruff check . - name: Test code with pytest - run: poetry run pytest + run: poetry run pytest --doctest-modules diff --git a/.github/workflows/publish_to_pypi.yml b/.github/workflows/publish_to_pypi.yml index bae8e66..ccbc5b3 100644 --- a/.github/workflows/publish_to_pypi.yml +++ b/.github/workflows/publish_to_pypi.yml @@ -1,9 +1,10 @@ -name: Publish Package to PyPI with poetry +name: Publish to PyPI on: push: tags: - - 'v*' + - "v*" + # TODO: Only on CI success jobs: build-and-test-publish: @@ -13,4 +14,4 @@ jobs: - name: Build and publish to pypi uses: JRubics/poetry-publish@v1.9 with: - pypi_token: ${{ secrets.pypi_password }} \ No newline at end of file + pypi_token: ${{ secrets.pypi_password }} diff --git a/.gitignore b/.gitignore index a47d57f..952e18e 100644 --- a/.gitignore +++ b/.gitignore @@ -113,4 +113,4 @@ venv.bak/ .mypy_cache/ .rss-parser -poetry.lock +.ruff_cache \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5e00f78..993d5d5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: - repo: local hooks: - - id: black + - id: black-format-staged name: black entry: poetry args: @@ -23,26 +23,14 @@ repos: language: system types: [ python ] stages: [ commit ] - # Black should use the config from the pyproject.toml file - - id: isort - name: isort + - id: ruff-check-global + name: ruff entry: poetry args: - run - - isort + - ruff + - check language: system types: [ python ] - stages: [ commit ] - # isort's config is also stored in pyproject.toml - - - id: flake8 - name: flake8 - entry: poetry - args: - - run - - flake8 - language: system - always_run: true - pass_filenames: false - stages: [ push ] \ No newline at end of file + stages: [ commit, push ] \ No newline at end of file diff --git a/README.md b/README.md index 8638e43..423a8af 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,12 @@ [![License](https://img.shields.io/pypi/l/rss-parser?color=success)](https://github.com/dhvcc/rss-parser/blob/master/LICENSE) [![GitHub Pages](https://badgen.net/github/status/dhvcc/rss-parser/gh-pages?label=docs)](https://dhvcc.github.io/rss-parser#documentation) -[![Pypi publish](https://github.com/dhvcc/rss-parser/workflows/Pypi%20publish/badge.svg)](https://github.com/dhvcc/rss-parser/actions?query=workflow%3A%22Pypi+publish%22) +![CI](https://github.com/dhvcc/rss-parser/actions/workflows/ci.yml/badge.svg?branch=master) +![PyPi publish](https://github.com/dhvcc/rss-parser/actions/workflows/publish_to_pypi.yml/badge.svg?branch=master) ## About -`rss-parser` is typed python RSS parsing module built using `BeautifulSoup` and `pydantic` +`rss-parser` is typed python RSS parsing module built using [pydantic](https://github.com/pydantic/pydantic) and [xmltodict](https://github.com/martinblech/xmltodict) ## Installation @@ -27,34 +28,153 @@ or ```bash git clone https://github.com/dhvcc/rss-parser.git cd rss-parser -pip install . +poetry build +pip install dist/*.whl ``` ## Usage +### Quickstart + ```python from rss_parser import Parser from requests import get -rss_url = "https://feedforall.com/sample.xml" -xml = get(rss_url) +rss_url = "https://rss.art19.com/apology-line" +response = get(rss_url) -# Limit feed output to 5 items -# To disable limit simply do not provide the argument or use None -parser = Parser(xml=xml.content, limit=5) -feed = parser.parse() +rss = Parser.parse(response.text) -# Print out feed meta data -print(feed.language) -print(feed.version) +# Print out rss meta data +print("Language", rss.channel.language) +print("RSS", rss.version) # Iteratively print feed items -for item in feed.feed: +for item in rss.channel.items: print(item.title) - print(item.description) + print(item.description[:50]) + +# Language en +# RSS 2.0 +# Wondery Presents - Flipping The Bird: Elon vs Twitter +#

When Elon Musk posted a video of himself arrivi +# Introducing: The Apology Line +#

If you could call a number and say you’re sorry +``` + +Here we can see that description is still somehow has

- this is beacause it's placed as [CDATA](https://www.w3resource.com/xml/CDATA-sections.php) like so + +```xml +If you could call ...

]]> +``` + +### Overriding schema + +If you want to customize the schema or provide a custom one - use `schema` keyword argument of the parser + +```python +from rss_parser.models import XMLBaseModel +from rss_parser.models.rss import RSS +from rss_parser.models.types import Tag + +class CustomSchema(RSS, XMLBaseModel): + channel: None = None # Removing previous channel field + custom: Tag[str] + +with open("tests/samples/custom.xml") as f: + data = f.read() + +rss = Parser.parse(data, schema=CustomSchema) + +print("RSS", rss.version) +print("Custom", rss.custom) + +# RSS 2.0 +# Custom Custom tag data +``` + +### xmltodict + +This library uses [xmltodict](https://github.com/martinblech/xmltodict) to parse XML data. You can see the detailed documentation [here](https://github.com/martinblech/xmltodict#xmltodict) + +The basic thing you should know is that your data is processed into dictionaries + +For example, this data + +```xml +content +``` + +will result in the following + +```python +{ + "tag": "content" +} +``` + +*But*, when handling attributes, the content of the tag will be also a dictionary + +```xml +data +``` + +Turns into + +```python +{ + "tag": { + "@attr": "1", + "@data-value": "data", + "#text": "content" + } +} +``` + +### Tag field + +This is a generic field that handles tags as raw data or a dictonary returned with attributes + +*Although this is a complex class, it forwards most of the methods to it's content attribute, so you don't notice a difference if you're only after the .content value* + +Example +```python +from rss_parser.models import XMLBaseModel +class Model(XMLBaseModel): + number: Tag[int] + string: Tag[str] + +m = Model( + number=1, + string={'@attr': '1', '#text': 'content'}, +) + +m.number.content == 1 # Content value is an integer, as per the generic type + +m.number.content + 10 == m.number + 10 # But you're still able to use the Tag itself in common operators + +m.number.bit_length() == 1 # As it's the case for methods/attributes not found in the Tag itself + +type(m.number), type(m.number.content) == (, ) # types are NOT the same, however, the interfaces are very similar most of the time + +m.number.attributes == {} # The attributes are empty by default + +m.string.attributes == {'attr': '1'} # But are populated when provided. Note that the @ symbol is trimmed from the beggining, however, camelCase is not converted + +# Generic argument types are handled by pydantic - let's try to provide a string for a Tag[int] number + +m = Model(number='not_a_number', string={'@customAttr': 'v', '#text': 'str tag value'}) # This will lead in the following traceback + +# Traceback (most recent call last): +# ... +# pydantic.error_wrappers.ValidationError: 1 validation error for Model +# number -> content +# value is not a valid integer (type=type_error.integer) ``` +**If you wish to avoid all of the method/attribute forwarding "magic" - you should use `rss_parser.models.types.TagRaw`** + ## Contributing Pull requests are welcome. For major changes, please open an issue first diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..ab84987 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,841 @@ +# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. + +[[package]] +name = "appnope" +version = "0.1.3" +description = "Disable App Nap on macOS >= 10.9" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "appnope-0.1.3-py2.py3-none-any.whl", hash = "sha256:265a455292d0bd8a72453494fa24df5a11eb18373a60c7c0430889f22548605e"}, + {file = "appnope-0.1.3.tar.gz", hash = "sha256:02bd91c4de869fbb1e1c50aafc4098827a7a54ab2f39d9dcba6c9547ed920e24"}, +] + +[[package]] +name = "asttokens" +version = "2.2.1" +description = "Annotate AST trees with source code positions" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "asttokens-2.2.1-py2.py3-none-any.whl", hash = "sha256:6b0ac9e93fb0335014d382b8fa9b3afa7df546984258005da0b9e7095b3deb1c"}, + {file = "asttokens-2.2.1.tar.gz", hash = "sha256:4622110b2a6f30b77e1473affaa97e711bc2f07d3f10848420ff1898edbe94f3"}, +] + +[package.dependencies] +six = "*" + +[package.extras] +test = ["astroid", "pytest"] + +[[package]] +name = "backcall" +version = "0.2.0" +description = "Specifications for callback functions passed in to an API" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, + {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, +] + +[[package]] +name = "black" +version = "22.12.0" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "black-22.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9eedd20838bd5d75b80c9f5487dbcb06836a43833a37846cf1d8c1cc01cef59d"}, + {file = "black-22.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:159a46a4947f73387b4d83e87ea006dbb2337eab6c879620a3ba52699b1f4351"}, + {file = "black-22.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d30b212bffeb1e252b31dd269dfae69dd17e06d92b87ad26e23890f3efea366f"}, + {file = "black-22.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:7412e75863aa5c5411886804678b7d083c7c28421210180d67dfd8cf1221e1f4"}, + {file = "black-22.12.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c116eed0efb9ff870ded8b62fe9f28dd61ef6e9ddd28d83d7d264a38417dcee2"}, + {file = "black-22.12.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1f58cbe16dfe8c12b7434e50ff889fa479072096d79f0a7f25e4ab8e94cd8350"}, + {file = "black-22.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77d86c9f3db9b1bf6761244bc0b3572a546f5fe37917a044e02f3166d5aafa7d"}, + {file = "black-22.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:82d9fe8fee3401e02e79767016b4907820a7dc28d70d137eb397b92ef3cc5bfc"}, + {file = "black-22.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:101c69b23df9b44247bd88e1d7e90154336ac4992502d4197bdac35dd7ee3320"}, + {file = "black-22.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:559c7a1ba9a006226f09e4916060982fd27334ae1998e7a38b3f33a37f7a2148"}, + {file = "black-22.12.0-py3-none-any.whl", hash = "sha256:436cc9167dd28040ad90d3b404aec22cedf24a6e4d7de221bec2730ec0c97bcf"}, + {file = "black-22.12.0.tar.gz", hash = "sha256:229351e5a18ca30f447bf724d007f890f97e13af070bb6ad4c0a441cd7596a2f"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} +typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "cfgv" +version = "3.3.1" +description = "Validate configuration and produce human readable error messages." +category = "dev" +optional = false +python-versions = ">=3.6.1" +files = [ + {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, + {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, +] + +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, + {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "decorator" +version = "5.1.1" +description = "Decorators for Humans" +category = "dev" +optional = false +python-versions = ">=3.5" +files = [ + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, +] + +[[package]] +name = "distlib" +version = "0.3.6" +description = "Distribution utilities" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, + {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.1.1" +description = "Backport of PEP 654 (exception groups)" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, + {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "executing" +version = "1.2.0" +description = "Get the currently executing AST node of a frame, and other information" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "executing-1.2.0-py2.py3-none-any.whl", hash = "sha256:0314a69e37426e3608aada02473b4161d4caf5a4b244d1d0c48072b8fee7bacc"}, + {file = "executing-1.2.0.tar.gz", hash = "sha256:19da64c18d2d851112f09c287f8d3dbbdf725ab0e569077efb6cdcbd3497c107"}, +] + +[package.extras] +tests = ["asttokens", "littleutils", "pytest", "rich"] + +[[package]] +name = "filelock" +version = "3.12.0" +description = "A platform independent file lock." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "filelock-3.12.0-py3-none-any.whl", hash = "sha256:ad98852315c2ab702aeb628412cbf7e95b7ce8c3bf9565670b4eaecf1db370a9"}, + {file = "filelock-3.12.0.tar.gz", hash = "sha256:fc03ae43288c013d2ea83c8597001b1129db351aad9c57fe2409327916b8e718"}, +] + +[package.extras] +docs = ["furo (>=2023.3.27)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] + +[[package]] +name = "identify" +version = "2.5.24" +description = "File identification library for Python" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "identify-2.5.24-py2.py3-none-any.whl", hash = "sha256:986dbfb38b1140e763e413e6feb44cd731faf72d1909543178aa79b0e258265d"}, + {file = "identify-2.5.24.tar.gz", hash = "sha256:0aac67d5b4812498056d28a9a512a483f5085cc28640b02b258a59dac34301d4"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "ipython" +version = "8.12.2" +description = "IPython: Productive Interactive Computing" +category = "dev" +optional = false +python-versions = ">=3.8" +files = [ + {file = "ipython-8.12.2-py3-none-any.whl", hash = "sha256:ea8801f15dfe4ffb76dea1b09b847430ffd70d827b41735c64a0638a04103bfc"}, + {file = "ipython-8.12.2.tar.gz", hash = "sha256:c7b80eb7f5a855a88efc971fda506ff7a91c280b42cdae26643e0f601ea281ea"}, +] + +[package.dependencies] +appnope = {version = "*", markers = "sys_platform == \"darwin\""} +backcall = "*" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +decorator = "*" +jedi = ">=0.16" +matplotlib-inline = "*" +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} +pickleshare = "*" +prompt-toolkit = ">=3.0.30,<3.0.37 || >3.0.37,<3.1.0" +pygments = ">=2.4.0" +stack-data = "*" +traitlets = ">=5" +typing-extensions = {version = "*", markers = "python_version < \"3.10\""} + +[package.extras] +all = ["black", "curio", "docrepr", "ipykernel", "ipyparallel", "ipywidgets", "matplotlib", "matplotlib (!=3.2.0)", "nbconvert", "nbformat", "notebook", "numpy (>=1.21)", "pandas", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio", "qtconsole", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "trio", "typing-extensions"] +black = ["black"] +doc = ["docrepr", "ipykernel", "matplotlib", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "typing-extensions"] +kernel = ["ipykernel"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["ipywidgets", "notebook"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["pytest (<7.1)", "pytest-asyncio", "testpath"] +test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.21)", "pandas", "pytest (<7.1)", "pytest-asyncio", "testpath", "trio"] + +[[package]] +name = "jedi" +version = "0.18.2" +description = "An autocompletion tool for Python that can be used for text editors." +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "jedi-0.18.2-py2.py3-none-any.whl", hash = "sha256:203c1fd9d969ab8f2119ec0a3342e0b49910045abe6af0a3ae83a5764d54639e"}, + {file = "jedi-0.18.2.tar.gz", hash = "sha256:bae794c30d07f6d910d32a7048af09b5a39ed740918da923c6b780790ebac612"}, +] + +[package.dependencies] +parso = ">=0.8.0,<0.9.0" + +[package.extras] +docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] +qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] +testing = ["Django (<3.1)", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] + +[[package]] +name = "markdown-it-py" +version = "2.2.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "markdown-it-py-2.2.0.tar.gz", hash = "sha256:7c9a5e412688bc771c67432cbfebcdd686c93ce6484913dccf06cb5a0bea35a1"}, + {file = "markdown_it_py-2.2.0-py3-none-any.whl", hash = "sha256:5a35f8d1870171d9acc47b99612dc146129b631baf04970128b568f190d0cc30"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["attrs", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "matplotlib-inline" +version = "0.1.6" +description = "Inline Matplotlib backend for Jupyter" +category = "dev" +optional = false +python-versions = ">=3.5" +files = [ + {file = "matplotlib-inline-0.1.6.tar.gz", hash = "sha256:f887e5f10ba98e8d2b150ddcf4702c1e5f8b3a20005eb0f74bfdbd360ee6f304"}, + {file = "matplotlib_inline-0.1.6-py3-none-any.whl", hash = "sha256:f1f41aab5328aa5aaea9b16d083b128102f8712542f819fe7e6a420ff581b311"}, +] + +[package.dependencies] +traitlets = "*" + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +category = "dev" +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "nodeenv" +version = "1.8.0" +description = "Node.js virtual environment builder" +category = "dev" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +files = [ + {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, + {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, +] + +[package.dependencies] +setuptools = "*" + +[[package]] +name = "packaging" +version = "23.1" +description = "Core utilities for Python packages" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, + {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, +] + +[[package]] +name = "parso" +version = "0.8.3" +description = "A Python Parser" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"}, + {file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"}, +] + +[package.extras] +qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] +testing = ["docopt", "pytest (<6.0.0)"] + +[[package]] +name = "pathspec" +version = "0.11.1" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"}, + {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"}, +] + +[[package]] +name = "pexpect" +version = "4.8.0" +description = "Pexpect allows easy control of interactive console applications." +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, + {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"}, +] + +[package.dependencies] +ptyprocess = ">=0.5" + +[[package]] +name = "pickleshare" +version = "0.7.5" +description = "Tiny 'shelve'-like database with concurrency support" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"}, + {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, +] + +[[package]] +name = "platformdirs" +version = "3.5.1" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "platformdirs-3.5.1-py3-none-any.whl", hash = "sha256:e2378146f1964972c03c085bb5662ae80b2b8c06226c54b2ff4aa9483e8a13a5"}, + {file = "platformdirs-3.5.1.tar.gz", hash = "sha256:412dae91f52a6f84830f39a8078cecd0e866cb72294a5c66808e74d5e88d251f"}, +] + +[package.extras] +docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.2.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] + +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pre-commit" +version = "2.21.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pre_commit-2.21.0-py2.py3-none-any.whl", hash = "sha256:e2f91727039fc39a92f58a588a25b87f936de6567eed4f0e673e0507edc75bad"}, + {file = "pre_commit-2.21.0.tar.gz", hash = "sha256:31ef31af7e474a8d8995027fefdfcf509b5c913ff31f2015b4ec4beb26a6f658"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "prompt-toolkit" +version = "3.0.38" +description = "Library for building powerful interactive command lines in Python" +category = "dev" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "prompt_toolkit-3.0.38-py3-none-any.whl", hash = "sha256:45ea77a2f7c60418850331366c81cf6b5b9cf4c7fd34616f733c5427e6abbb1f"}, + {file = "prompt_toolkit-3.0.38.tar.gz", hash = "sha256:23ac5d50538a9a38c8bde05fecb47d0b403ecd0662857a86f886f798563d5b9b"}, +] + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, + {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +] + +[[package]] +name = "pure-eval" +version = "0.2.2" +description = "Safely evaluate AST nodes without side effects" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "pure_eval-0.2.2-py3-none-any.whl", hash = "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350"}, + {file = "pure_eval-0.2.2.tar.gz", hash = "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3"}, +] + +[package.extras] +tests = ["pytest"] + +[[package]] +name = "pydantic" +version = "1.10.7" +description = "Data validation and settings management using python type hints" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pydantic-1.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e79e999e539872e903767c417c897e729e015872040e56b96e67968c3b918b2d"}, + {file = "pydantic-1.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:01aea3a42c13f2602b7ecbbea484a98169fb568ebd9e247593ea05f01b884b2e"}, + {file = "pydantic-1.10.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:516f1ed9bc2406a0467dd777afc636c7091d71f214d5e413d64fef45174cfc7a"}, + {file = "pydantic-1.10.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae150a63564929c675d7f2303008d88426a0add46efd76c3fc797cd71cb1b46f"}, + {file = "pydantic-1.10.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ecbbc51391248116c0a055899e6c3e7ffbb11fb5e2a4cd6f2d0b93272118a209"}, + {file = "pydantic-1.10.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f4a2b50e2b03d5776e7f21af73e2070e1b5c0d0df255a827e7c632962f8315af"}, + {file = "pydantic-1.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:a7cd2251439988b413cb0a985c4ed82b6c6aac382dbaff53ae03c4b23a70e80a"}, + {file = "pydantic-1.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:68792151e174a4aa9e9fc1b4e653e65a354a2fa0fed169f7b3d09902ad2cb6f1"}, + {file = "pydantic-1.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe2507b8ef209da71b6fb5f4e597b50c5a34b78d7e857c4f8f3115effaef5fe"}, + {file = "pydantic-1.10.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10a86d8c8db68086f1e30a530f7d5f83eb0685e632e411dbbcf2d5c0150e8dcd"}, + {file = "pydantic-1.10.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d75ae19d2a3dbb146b6f324031c24f8a3f52ff5d6a9f22f0683694b3afcb16fb"}, + {file = "pydantic-1.10.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:464855a7ff7f2cc2cf537ecc421291b9132aa9c79aef44e917ad711b4a93163b"}, + {file = "pydantic-1.10.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:193924c563fae6ddcb71d3f06fa153866423ac1b793a47936656e806b64e24ca"}, + {file = "pydantic-1.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:b4a849d10f211389502059c33332e91327bc154acc1845f375a99eca3afa802d"}, + {file = "pydantic-1.10.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cc1dde4e50a5fc1336ee0581c1612215bc64ed6d28d2c7c6f25d2fe3e7c3e918"}, + {file = "pydantic-1.10.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0cfe895a504c060e5d36b287ee696e2fdad02d89e0d895f83037245218a87fe"}, + {file = "pydantic-1.10.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:670bb4683ad1e48b0ecb06f0cfe2178dcf74ff27921cdf1606e527d2617a81ee"}, + {file = "pydantic-1.10.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:950ce33857841f9a337ce07ddf46bc84e1c4946d2a3bba18f8280297157a3fd1"}, + {file = "pydantic-1.10.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c15582f9055fbc1bfe50266a19771bbbef33dd28c45e78afbe1996fd70966c2a"}, + {file = "pydantic-1.10.7-cp37-cp37m-win_amd64.whl", hash = "sha256:82dffb306dd20bd5268fd6379bc4bfe75242a9c2b79fec58e1041fbbdb1f7914"}, + {file = "pydantic-1.10.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8c7f51861d73e8b9ddcb9916ae7ac39fb52761d9ea0df41128e81e2ba42886cd"}, + {file = "pydantic-1.10.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6434b49c0b03a51021ade5c4daa7d70c98f7a79e95b551201fff682fc1661245"}, + {file = "pydantic-1.10.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d34ab766fa056df49013bb6e79921a0265204c071984e75a09cbceacbbdd5d"}, + {file = "pydantic-1.10.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:701daea9ffe9d26f97b52f1d157e0d4121644f0fcf80b443248434958fd03dc3"}, + {file = "pydantic-1.10.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:cf135c46099ff3f919d2150a948ce94b9ce545598ef2c6c7bf55dca98a304b52"}, + {file = "pydantic-1.10.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0f85904f73161817b80781cc150f8b906d521fa11e3cdabae19a581c3606209"}, + {file = "pydantic-1.10.7-cp38-cp38-win_amd64.whl", hash = "sha256:9f6f0fd68d73257ad6685419478c5aece46432f4bdd8d32c7345f1986496171e"}, + {file = "pydantic-1.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c230c0d8a322276d6e7b88c3f7ce885f9ed16e0910354510e0bae84d54991143"}, + {file = "pydantic-1.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:976cae77ba6a49d80f461fd8bba183ff7ba79f44aa5cfa82f1346b5626542f8e"}, + {file = "pydantic-1.10.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d45fc99d64af9aaf7e308054a0067fdcd87ffe974f2442312372dfa66e1001d"}, + {file = "pydantic-1.10.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d2a5ebb48958754d386195fe9e9c5106f11275867051bf017a8059410e9abf1f"}, + {file = "pydantic-1.10.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:abfb7d4a7cd5cc4e1d1887c43503a7c5dd608eadf8bc615413fc498d3e4645cd"}, + {file = "pydantic-1.10.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:80b1fab4deb08a8292d15e43a6edccdffa5377a36a4597bb545b93e79c5ff0a5"}, + {file = "pydantic-1.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:d71e69699498b020ea198468e2480a2f1e7433e32a3a99760058c6520e2bea7e"}, + {file = "pydantic-1.10.7-py3-none-any.whl", hash = "sha256:0cd181f1d0b1d00e2b705f1bf1ac7799a2d938cce3376b8007df62b29be3c2c6"}, + {file = "pydantic-1.10.7.tar.gz", hash = "sha256:cfc83c0678b6ba51b0532bea66860617c4cd4251ecf76e9846fa5a9f3454e97e"}, +] + +[package.dependencies] +typing-extensions = ">=4.2.0" + +[package.extras] +dotenv = ["python-dotenv (>=0.10.4)"] +email = ["email-validator (>=1.0.3)"] + +[[package]] +name = "pygments" +version = "2.15.1" +description = "Pygments is a syntax highlighting package written in Python." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "Pygments-2.15.1-py3-none-any.whl", hash = "sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1"}, + {file = "Pygments-2.15.1.tar.gz", hash = "sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c"}, +] + +[package.extras] +plugins = ["importlib-metadata"] + +[[package]] +name = "pytest" +version = "7.3.1" +description = "pytest: simple powerful testing with Python" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-7.3.1-py3-none-any.whl", hash = "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362"}, + {file = "pytest-7.3.1.tar.gz", hash = "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + +[[package]] +name = "pyyaml" +version = "6.0" +description = "YAML parser and emitter for Python" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, + {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, + {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, + {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, + {file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"}, + {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"}, + {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"}, + {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"}, + {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, + {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, + {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, + {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, + {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, + {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, + {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, + {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, + {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, + {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, + {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, + {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, +] + +[[package]] +name = "rich" +version = "13.3.5" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +category = "dev" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "rich-13.3.5-py3-none-any.whl", hash = "sha256:69cdf53799e63f38b95b9bf9c875f8c90e78dd62b2f00c13a911c7a3b9fa4704"}, + {file = "rich-13.3.5.tar.gz", hash = "sha256:2d11b9b8dd03868f09b4fffadc84a6a8cda574e40dc90821bd845720ebb8e89c"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0,<3.0.0" +pygments = ">=2.13.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "ruff" +version = "0.0.269" +description = "An extremely fast Python linter, written in Rust." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.0.269-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:3569bcdee679045c09c0161fabc057599759c49219a08d9a4aad2cc3982ccba3"}, + {file = "ruff-0.0.269-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:56347da63757a56cbce7d4b3d6044ca4f1941cd1bbff3714f7554360c3361f83"}, + {file = "ruff-0.0.269-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6da8ee25ef2f0cc6cc8e6e20942c1d44d25a36dce35070d7184655bc14f63f63"}, + {file = "ruff-0.0.269-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd81b8e681b9eaa6cf15484f3985bd8bd97c3d114e95bff3e8ea283bf8865062"}, + {file = "ruff-0.0.269-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f19f59ca3c28742955241fb452f3346241ddbd34e72ac5cb3d84fadebcf6bc8"}, + {file = "ruff-0.0.269-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f062059b8289a4fab7f6064601b811d447c2f9d3d432a17f689efe4d68988450"}, + {file = "ruff-0.0.269-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f5dc7aac52c58e82510217e3c7efd80765c134c097c2815d59e40face0d1fe6"}, + {file = "ruff-0.0.269-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e131b4dbe798c391090c6407641d6ab12c0fa1bb952379dde45e5000e208dabb"}, + {file = "ruff-0.0.269-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a374434e588e06550df0f8dcb74777290f285678de991fda4e1063c367ab2eb2"}, + {file = "ruff-0.0.269-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cec2f4b84a14b87f1b121488649eb5b4eaa06467a2387373f750da74bdcb5679"}, + {file = "ruff-0.0.269-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:374b161753a247904aec7a32d45e165302b76b6e83d22d099bf3ff7c232c888f"}, + {file = "ruff-0.0.269-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9ca0a1ddb1d835b5f742db9711c6cf59f213a1ad0088cb1e924a005fd399e7d8"}, + {file = "ruff-0.0.269-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a20658f0b97d207c7841c13d528f36d666bf445b00b01139f28a8ccb80093bb"}, + {file = "ruff-0.0.269-py3-none-win32.whl", hash = "sha256:03ff42bc91ceca58e0f0f072cb3f9286a9208f609812753474e799a997cdad1a"}, + {file = "ruff-0.0.269-py3-none-win_amd64.whl", hash = "sha256:f3b59ccff57b21ef0967ea8021fd187ec14c528ec65507d8bcbe035912050776"}, + {file = "ruff-0.0.269-py3-none-win_arm64.whl", hash = "sha256:bbeb857b1e508a4487bdb02ca1e6d41dd8d5ac5335a5246e25de8a3dff38c1ff"}, + {file = "ruff-0.0.269.tar.gz", hash = "sha256:11ddcfbab32cf5c420ea9dd5531170ace5a3e59c16d9251c7bd2581f7b16f602"}, +] + +[[package]] +name = "setuptools" +version = "67.8.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "setuptools-67.8.0-py3-none-any.whl", hash = "sha256:5df61bf30bb10c6f756eb19e7c9f3b473051f48db77fddbe06ff2ca307df9a6f"}, + {file = "setuptools-67.8.0.tar.gz", hash = "sha256:62642358adc77ffa87233bc4d2354c4b2682d214048f500964dbe760ccedf102"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "stack-data" +version = "0.6.2" +description = "Extract data from python stack frames and tracebacks for informative displays" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "stack_data-0.6.2-py3-none-any.whl", hash = "sha256:cbb2a53eb64e5785878201a97ed7c7b94883f48b87bfb0bbe8b623c74679e4a8"}, + {file = "stack_data-0.6.2.tar.gz", hash = "sha256:32d2dd0376772d01b6cb9fc996f3c8b57a357089dec328ed4b6553d037eaf815"}, +] + +[package.dependencies] +asttokens = ">=2.1.0" +executing = ">=1.2.0" +pure-eval = "*" + +[package.extras] +tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "traitlets" +version = "5.9.0" +description = "Traitlets Python configuration system" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "traitlets-5.9.0-py3-none-any.whl", hash = "sha256:9e6ec080259b9a5940c797d58b613b5e31441c2257b87c2e795c5228ae80d2d8"}, + {file = "traitlets-5.9.0.tar.gz", hash = "sha256:f6cde21a9c68cf756af02035f72d5a723bf607e862e7be33ece505abf4a3bad9"}, +] + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] +test = ["argcomplete (>=2.0)", "pre-commit", "pytest", "pytest-mock"] + +[[package]] +name = "typing-extensions" +version = "4.5.0" +description = "Backported and Experimental Type Hints for Python 3.7+" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"}, + {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"}, +] + +[[package]] +name = "virtualenv" +version = "20.23.0" +description = "Virtual Python Environment builder" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "virtualenv-20.23.0-py3-none-any.whl", hash = "sha256:6abec7670e5802a528357fdc75b26b9f57d5d92f29c5462ba0fbe45feacc685e"}, + {file = "virtualenv-20.23.0.tar.gz", hash = "sha256:a85caa554ced0c0afbd0d638e7e2d7b5f92d23478d05d17a76daeac8f279f924"}, +] + +[package.dependencies] +distlib = ">=0.3.6,<1" +filelock = ">=3.11,<4" +platformdirs = ">=3.2,<4" + +[package.extras] +docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.3.1)", "pytest-env (>=0.8.1)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=67.7.1)", "time-machine (>=2.9)"] + +[[package]] +name = "wcwidth" +version = "0.2.6" +description = "Measures the displayed width of unicode strings in a terminal" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "wcwidth-0.2.6-py2.py3-none-any.whl", hash = "sha256:795b138f6875577cd91bba52baf9e445cd5118fd32723b460e30a0af30ea230e"}, + {file = "wcwidth-0.2.6.tar.gz", hash = "sha256:a5220780a404dbe3353789870978e472cfe477761f06ee55077256e509b156d0"}, +] + +[[package]] +name = "xmltodict" +version = "0.13.0" +description = "Makes working with XML feel like you are working with JSON" +category = "main" +optional = false +python-versions = ">=3.4" +files = [ + {file = "xmltodict-0.13.0-py2.py3-none-any.whl", hash = "sha256:aa89e8fd76320154a40d19a0df04a4695fb9dc5ba977cbb68ab3e4eb225e7852"}, + {file = "xmltodict-0.13.0.tar.gz", hash = "sha256:341595a488e3e01a85a9d8911d8912fd922ede5fecc4dce437eb4b6c8d037e56"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.8" +content-hash = "dff05dc75eb798d4b8fbecebd26416d29112a8b709592165393a6c46547dc9ed" diff --git a/poetry.toml b/poetry.toml new file mode 100644 index 0000000..ab1033b --- /dev/null +++ b/poetry.toml @@ -0,0 +1,2 @@ +[virtualenvs] +in-project = true diff --git a/pyproject.toml b/pyproject.toml index 7a7e763..5723c64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,9 @@ [tool.poetry] name = "rss-parser" -version = "0.2.4" +version = "1.0.0" description = "Typed pythonic RSS parser" authors = ["dhvcc <1337kwiz@gmail.com>"] -license = "GPLv3" +license = "GPL-3.0" readme = "README.md" keywords = [ "python", @@ -11,7 +11,6 @@ keywords = [ "cli", "rss", "parser", - "scraper", "gplv3", "typed", "typed-python", @@ -29,7 +28,11 @@ classifiers = [ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ] +packages = [{include = "rss_parser"}] + [tool.poetry.urls] "Homepage" = "https://dhvcc.github.io/rss-parser" @@ -37,45 +40,59 @@ classifiers = [ "Bug Tracker" = "https://github.com/dhvcc/rss-parser/issues" [tool.poetry.dependencies] -python = "^3.7" -bs4 = ">=0.0.1" +python = "^3.8" pydantic = ">=1.6.1" -lxml = ">=4.6.5" -requests = ">=2.24.0" pytest = "^7.1.2" +xmltodict = "^0.13.0" -[tool.poetry.dev-dependencies] +[tool.poetry.group.dev.dependencies] +ipython = "*" black = "^22.3.0" pre-commit = "^2.12.0" -flake8 = "^5.0.3" -isort = "^5.8.0" -flake8-docstrings = "^1.6.0" +ruff = "*" +rich = "*" -[build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" +[tool.pytest.ini_options] +addopts = "-color=yes" [tool.black] -line-length = 88 -target-version = ['py37', 'py38', 'py39', 'py310'] -exclude = ''' -( - \.eggs - | \.git - | build - | dist - | venv - | .venv -) -''' +line-length = 120 +target-version = ["py38"] -[tool.isort] -profile = "black" -multi_line_output = 3 -include_trailing_comma = true -force_grid_wrap = 0 -use_parentheses = true -ensure_newline_before_comments = true -line_length = 88 -skip_gitignore = true -skip_glob = ['**/.venv/**'] +[tool.ruff] +line-length = 120 +target-version = "py38" +respect-gitignore = true +select = [ + "PL", # pylint + "F", # pyflakes + "E", # pycodestyle errors + "W", # pycodestyle warnings + "I", # isort + "N", # pep8-naming + "S", # flake8-bandit + "A", # flake8-builtins + "C40", # flake8-comprehensions + "T10", # flake8-debugger + "EXE", # flake8-executable + "T20", # flake8-print + "TID", # flake8-tidy-imports + "TCH", # flake8-type-checking + "ARG", # flake8-unused-arguments + "RUF", # ruff +] + +[tool.ruff.per-file-ignores] +"tests/**.py" = [ + "S101", # Use of assert detected + "ARG001", # Unused function argument + "S311", # Allow use of random +] +"**/__init__.py" = [ + "F401" +] + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/rss_parser/_parser.py b/rss_parser/_parser.py index 50f579b..991d8bf 100644 --- a/rss_parser/_parser.py +++ b/rss_parser/_parser.py @@ -1,160 +1,43 @@ -import re -from typing import Any, List, Optional +from typing import ClassVar, Optional, Type -from bs4 import BeautifulSoup +from xmltodict import parse -from .models import RSSFeed +from rss_parser.models import XMLBaseModel +from rss_parser.models.rss import RSS + +# >>> FUTURE +# TODO: May be support generator based approach for big rss feeds +# TODO: Add cli to parse to json +# TODO: Possibly bundle as deb/rpm/exe +# TODO: Atom support +# TODO: Older RSS versions? class Parser: """Parser for rss files.""" - def __init__(self, xml: str, limit=None): - self.xml = xml - self.limit = limit - - self.raw_data = None - self.rss = None + schema: ClassVar[Type[XMLBaseModel]] = RSS @staticmethod - def _check_atom(soup: BeautifulSoup): - if soup.feed: + def _check_atom(root: dict): + if "feed" in root: raise NotImplementedError("ATOM feed is not currently supported") - @classmethod - def get_soup(cls, xml: str, parser: str = "xml") -> BeautifulSoup: - """ - Get the BeautifulSoup object with a specified parser. - - :param xml: The xml content - :param parser: The parser type. Default is xml - :return: The BeautifulSoup object - """ - soup = BeautifulSoup(xml, parser) - cls._check_atom(soup) - return soup - - @staticmethod - def check_none( - item: object, - default: str, - item_dict: Optional[str] = None, - default_dict: Optional[str] = None, - ) -> Any: - """ - Check if the item_dict in item is None, else returns default_dict of default. - - :param item: The first object. - :param default: The default object. - :param item_dict: The item dictionary. - :param default_dict: The default dictionary. - :return: The (not None) final object. - """ - if item: - return item[item_dict] - else: - if default_dict: - return default[default_dict] - else: - return default - @staticmethod - def get_text(item: object, attribute: str) -> str: - """ - Return the text information about an attribute of an object. - - If it is not present, it will return an empty string. - :param item: The object with the attribute - :param attribute: The attribute which has a 'text' attribute - :return: The string of the text of the specified attribute - """ - return getattr(getattr(item, attribute, ""), "text", "") + def to_xml(data: str, *args, **kwargs): + return parse(str(data), *args, **kwargs) - def parse(self, entries: Optional[List[str]] = List) -> RSSFeed: + @classmethod + def parse(cls, data: str, *, schema: Optional[Type[XMLBaseModel]] = None) -> XMLBaseModel: """ - Parse the rss and each item of the feed. - - Missing attributes will be replaced by an empty string. The - information of the optional entries are stored in a dictionary - under the attribute "other" of each item. + Parse XML data into schema (default: RSS 2.0). - :param entries: An optional list of additional rss tags that can be recovered - from each item - :return: The RSSFeed which describe the rss information + :param data: string of XML data that needs to be parsed + :return: "schema" object """ - main_soup = self.get_soup(self.xml) - - self.raw_data = { - "title": main_soup.title.text, - "version": main_soup.rss.get("version"), - "language": getattr(main_soup.language, "text", ""), - "description": getattr(main_soup.description, "text", ""), - "feed": [], - } - - items = main_soup.findAll("item") - - if self.limit is not None: - items = items[: self.limit] - - for item in items: - # Using html.parser instead of lxml because lxml can't parse - description_soup = self.get_soup( - self.get_text(item, "description"), "html.parser" - ) - - item_dict = { - "title": self.get_text(item, "title"), - "link": self.get_text(item, "link"), - "publish_date": self.get_text(item, "pubDate"), - "category": self.get_text(item, "category"), - "description": getattr(description_soup, "text", ""), - "description_links": [ - anchor.get("href") - for anchor in description_soup.findAll("a") - # if statement to avoid non true values in the list - if anchor.get("href") - ], - "description_images": [ - {"alt": image.get("alt", ""), "source": image.get("src")} - for image in description_soup.findAll("img") - ], - } - - try: - # Add user-defined entries - item_dict.update({"other": {}}) - for entrie in entries: - value = self.get_text(item, entrie) - value = re.sub(f"", "", value) - item_dict["other"].update({entrie: value}) - - item_dict.update( - { - "enclosure": { - "content": "", - "attrs": { - "url": item.enclosure["url"], - "length": item.enclosure["length"], - "type": item.enclosure["type"], - }, - }, - "itunes": { - "content": "", - "attrs": { - "href": self.check_none( - item.find("itunes:image"), - main_soup.find("itunes:image"), - "href", - "href", - ) - }, - }, - } - ) - except (TypeError, KeyError, AttributeError): - pass + root = cls.to_xml(data) + cls._check_atom(root) - self.raw_data["feed"].append(item_dict) + schema = schema or cls.schema - return RSSFeed(**self.raw_data) + return schema.parse_obj(root["rss"]) diff --git a/rss_parser/models.py b/rss_parser/models.py deleted file mode 100644 index 3585c65..0000000 --- a/rss_parser/models.py +++ /dev/null @@ -1,57 +0,0 @@ -from typing import List, Optional - -from pydantic import BaseModel - - -class ItunesAttrs(BaseModel): - href: str - - -class Itunes(BaseModel): - content: str - attrs: Optional[ItunesAttrs] - - -class EnclosureAttrs(BaseModel): - url: str - length: int - type: str - - -class Enclosure(BaseModel): - content: str - attrs: Optional[EnclosureAttrs] - - -class DescriptionImage(BaseModel): - alt: Optional[str] - source: str - - -class FeedItem(BaseModel): - title: str - link: str - publish_date: Optional[str] - category: Optional[str] - description: str - description_links: Optional[List[str]] - description_images: Optional[List[DescriptionImage]] - enclosure: Optional[Enclosure] - itunes: Optional[Itunes] - other: Optional[dict] - - # stackoverflow.com/questions/10994229/how-to-make-an-object-properly-hashable - # added this, so you can call/use FeedItems in a set() to avoid duplicates - def __hash__(self): - return hash(self.title.strip()) - - def __eq__(self, other): - return self.title.strip() == other.title.strip() - - -class RSSFeed(BaseModel): - title: str - version: Optional[str] - language: Optional[str] - description: Optional[str] - feed: List[FeedItem] diff --git a/rss_parser/models/__init__.py b/rss_parser/models/__init__.py new file mode 100644 index 0000000..7a95a7e --- /dev/null +++ b/rss_parser/models/__init__.py @@ -0,0 +1,29 @@ +""" +Models created according to https://www.rssboard.org/rss-specification. + +Some types and validation may be a bit custom to account for broken standards in some RSS feeds. +""" +from json import loads + +from pydantic import BaseModel +from pydantic.json import pydantic_encoder + +from rss_parser.models.utils import camel_case + + +class XMLBaseModel(BaseModel): + class Config: + # Not really sure if we want for the schema obj to be immutable, disabling for now + # allow_mutation = False + alias_generator = camel_case + + def json_plain(self, **kw): + """ + Run pydantic's json with custom encoder to encode Tags as only content. + """ + from rss_parser.models.types import Tag + + return self.json(models_as_dict=False, encoder=Tag.flatten_tag_encoder, **kw) + + def dict_plain(self, **kw): + return loads(self.json_plain(**kw)) diff --git a/rss_parser/models/channel.py b/rss_parser/models/channel.py new file mode 100644 index 0000000..5993e71 --- /dev/null +++ b/rss_parser/models/channel.py @@ -0,0 +1,81 @@ +from typing import List, Optional + +from pydantic import Field + +from rss_parser.models import XMLBaseModel +from rss_parser.models.image import Image +from rss_parser.models.item import Item +from rss_parser.models.text_input import TextInput +from rss_parser.models.types.date import DateTimeOrStr +from rss_parser.models.types.tag import Tag + + +class RequiredChannelElementsMixin(XMLBaseModel): + """https://www.rssboard.org/rss-specification#requiredChannelElements.""" + + title: Tag[str] = None # GoUpstate.com News Headlines + "The name of the channel. It's how people refer to your service. If you have an HTML website that contains " "the same information as your RSS file, the title of your channel should be the same as the title of your " "website." # noqa + + link: Tag[str] = None # http://www.goupstate.com/ + "The URL to the HTML website corresponding to the channel." + + description: Tag[str] = None # The latest news from GoUpstate.com, a Spartanburg Herald-Journal Web site. + "Phrase or sentence describing the channel." + + +class OptionalChannelElementsMixin(XMLBaseModel): + """https://www.rssboard.org/rss-specification#optionalChannelElements.""" + + items: Optional[List[Tag[Item]]] = Field(alias="item", default=[]) + + language: Optional[Tag[str]] = None # en-us + "The language the channel is written in. This allows aggregators to group all Italian language sites, " "for example, on a single page." # noqa + + copyright: Optional[Tag[str]] = None # Copyright 2002, Spartanburg Herald-Journal # noqa + "Copyright notice for content in the channel." + + managing_editor: Optional[Tag[str]] = None # geo@herald.com (George Matesky) + "Email address for person responsible for editorial content." + + web_master: Optional[Tag[str]] = None # betty@herald.com (Betty Guernsey) + "Email address for person responsible for technical issues relating to channel." + + pub_date: Optional[Tag[DateTimeOrStr]] = None # Sat, 07 Sep 2002 00:00:01 GMT + "The publication date for the content in the channel. For example, the New York Times publishes on a daily " "basis, the publication date flips once every 24 hours. That's when the pubDate of the channel changes. All " "date-times in RSS conform to the Date and Time Specification of RFC 822, with the exception that the year " "may be expressed with two characters or four characters (four preferred)." # noqa + + last_build_date: Optional[Tag[DateTimeOrStr]] = None # Sat, 07 Sep 2002 09:42:31 GMT + "The last time the content of the channel changed." + + category: Optional[Tag[str]] = None # Newspapers + "Specify one or more categories that the channel belongs to. Follows the same rules as the -level " "category element." # noqa + + generator: Optional[Tag[str]] = None # MightyInHouse Content System v2.3 + "A string indicating the program used to generate the channel." + + docs: Optional[Tag[str]] = None # https://www.rssboard.org/rss-specification + "A URL that points to the documentation for the format used in the RSS file. It's probably a pointer to this " "page. It's for people who might stumble across an RSS file on a Web server 25 years from now and wonder what " "it is." # noqa + + cloud: Optional[Tag[str]] = None # + "Allows processes to register with a cloud to be notified of updates to the channel, implementing a lightweight " "publish-subscribe protocol for RSS feeds." # noqa + + ttl: Optional[Tag[str]] = None # 60 + "ttl stands for time to live. It's a number of minutes that indicates how long a channel can be cached before " "refreshing from the source." # noqa + + image: Optional[Tag[Image]] = None + "Specifies a GIF, JPEG or PNG image that can be displayed with the channel." + + rating: Optional[Tag[TextInput]] = None + "The PICS rating for the channel." + + text_input: Optional[Tag[str]] = None + "Specifies a text input box that can be displayed with the channel." + + skip_hours: Optional[Tag[str]] = None + "A hint for aggregators telling them which hours they can skip. This element contains up to 24 " "sub-elements whose value is a number between 0 and 23, representing a time in GMT, when aggregators, if " "they support the feature, may not read the channel on hours listed in the element. The hour " "beginning at midnight is hour zero." # noqa + + skip_days: Optional[Tag[str]] = None + "A hint for aggregators telling them which days they can skip. This element contains up to seven " "sub-elements whose value is Monday, Tuesday, Wednesday, Thursday, Friday, Saturday or Sunday. Aggregators " "may not read the channel during days listed in the element." # noqa + + +class Channel(RequiredChannelElementsMixin, OptionalChannelElementsMixin, XMLBaseModel): + pass diff --git a/rss_parser/models/image.py b/rss_parser/models/image.py new file mode 100644 index 0000000..82eb031 --- /dev/null +++ b/rss_parser/models/image.py @@ -0,0 +1,26 @@ +from typing import Optional + +from rss_parser.models import XMLBaseModel +from rss_parser.models.types.tag import Tag + + +class Image(XMLBaseModel): + """https://www.rssboard.org/rss-specification#ltimagegtSubelementOfLtchannelgt.""" + + url: Tag[str] = None + "The URL of a GIF, JPEG or PNG image that represents the channel." + + title: Tag[str] = None + "Describes the image, it's used in the ALT attribute of the HTML tag when the channel is rendered in HTML." + + link: Tag[str] = None + "The URL of the site, when the channel is rendered, the image is a link to the site. (Note, in practice the " "image and <link> should have the same value as the channel's <title> and <link>." # noqa + + width: Optional[Tag[int]] = None + "Number, indicating the width of the image in pixels." + + height: Optional[Tag[int]] = None + "Number, indicating the height of the image in pixels." + + description: Optional[Tag[str]] = None + "Contains text that is included in the TITLE attribute of the link formed around the image in the HTML rendering." diff --git a/rss_parser/models/item.py b/rss_parser/models/item.py new file mode 100644 index 0000000..72fbdbd --- /dev/null +++ b/rss_parser/models/item.py @@ -0,0 +1,45 @@ +from typing import Optional + +from rss_parser.models import XMLBaseModel +from rss_parser.models.types.tag import Tag + + +class RequiredItemElementsMixin(XMLBaseModel): + title: Tag[str] = None # Venice Film Festival Tries to Quit Sinking + "The title of the item." + + link: Tag[str] = None # http://nytimes.com/2004/12/07FEST.html + "The URL of the item." + + description: Tag[ + str + ] = None # <description>Some of the most heated chatter at the Venice Film Festival this week was + # about the way that the arrival of the stars at the Palazzo del Cinema was being staged.</description> + "The item synopsis." + + +class OptionalItemElementsMixin(XMLBaseModel): + author: Optional[Tag[str]] = None + "Email address of the author of the item." + + category: Optional[Tag[str]] = None + "Includes the item in one or more categories." + + comments: Optional[Tag[str]] = None + "URL of a page for comments relating to the item." + + enclosure: Optional[Tag[str]] = None + "Describes a media object that is attached to the item." + + guid: Optional[Tag[str]] = None + "A string that uniquely identifies the item." + + pub_date: Optional[Tag[str]] = None + "Indicates when the item was published." + + source: Optional[Tag[str]] = None + "The RSS channel that the item came from." + + +class Item(RequiredItemElementsMixin, OptionalItemElementsMixin, XMLBaseModel): + """https://www.rssboard.org/rss-specification#hrelementsOfLtitemgt.""" diff --git a/rss_parser/models/rss.py b/rss_parser/models/rss.py new file mode 100644 index 0000000..2b241ae --- /dev/null +++ b/rss_parser/models/rss.py @@ -0,0 +1,14 @@ +from typing import Optional + +from pydantic import Field + +from rss_parser.models import XMLBaseModel +from rss_parser.models.channel import Channel +from rss_parser.models.types.tag import Tag + + +class RSS(XMLBaseModel): + """RSS 2.0.""" + + version: Optional[Tag[str]] = Field(alias="@version") + channel: Tag[Channel] diff --git a/rss_parser/models/text_input.py b/rss_parser/models/text_input.py new file mode 100644 index 0000000..38c8a19 --- /dev/null +++ b/rss_parser/models/text_input.py @@ -0,0 +1,23 @@ +from rss_parser.models import XMLBaseModel +from rss_parser.models.types.tag import Tag + + +class TextInput(XMLBaseModel): + """ + The purpose of the <textInput> element is something of a mystery. You can use it to specify a search engine box. + Or to allow a reader to provide feedback. Most aggregators ignore it. + + https://www.rssboard.org/rss-specification#lttextinputgtSubelementOfLtchannelgt + """ + + title: Tag[str] = None + "The label of the Submit button in the text input area." + + description: Tag[str] = None + "Explains the text input area." + + name: Tag[str] = None + "The name of the text object in the text input area." + + link: Tag[str] = None + "The URL of the CGI script that processes text input requests." diff --git a/rss_parser/models/types/__init__.py b/rss_parser/models/types/__init__.py new file mode 100644 index 0000000..5a18184 --- /dev/null +++ b/rss_parser/models/types/__init__.py @@ -0,0 +1,2 @@ +from rss_parser.models.types.date import DateTimeOrStr +from rss_parser.models.types.tag import Tag diff --git a/rss_parser/models/types/date.py b/rss_parser/models/types/date.py new file mode 100644 index 0000000..d232cd4 --- /dev/null +++ b/rss_parser/models/types/date.py @@ -0,0 +1,41 @@ +from datetime import datetime +from email.utils import parsedate_to_datetime + + +class DateTimeOrStr(datetime): + @classmethod + def __get_validators__(cls): + yield validate_dt_or_str + + @classmethod + def __get_pydantic_json_schema__(cls, field_schema): + field_schema.update( + examples=[datetime(1970, 1, 1, 0, 0, 0)], + ) + + @classmethod + def validate(cls, v): + return validate_dt_or_str(v) + + def __repr__(self): + return f"DateTimeOrStp({super().__repr__()})" + + +def validate_dt_or_str(value: str) -> datetime: + # Try to parse standard (RFC 822) + try: + return parsedate_to_datetime(value) + except ValueError: + pass + # Try ISO + try: + return datetime.fromisoformat(value) + except ValueError: + pass + # Try timestamp + try: + return datetime.fromtimestamp(int(value)) + except ValueError: + pass + + return value diff --git a/rss_parser/models/types/tag.py b/rss_parser/models/types/tag.py new file mode 100644 index 0000000..3e48def --- /dev/null +++ b/rss_parser/models/types/tag.py @@ -0,0 +1,150 @@ +import warnings +from copy import deepcopy +from json import loads +from math import ceil, floor, trunc +from operator import add, eq, floordiv, ge, gt, index, invert, le, lt, mod, mul, ne, neg, pos, pow, sub, truediv +from typing import Generic, Optional, Type, TypeVar, Union + +from pydantic import create_model +from pydantic.generics import GenericModel +from pydantic.json import pydantic_encoder + +from rss_parser.models import XMLBaseModel + +T = TypeVar("T") + + +class TagRaw(GenericModel, Generic[T]): + """ + >>> from rss_parser.models import XMLBaseModel + >>> class Model(XMLBaseModel): + ... number: Tag[int] + ... string: Tag[str] + >>> m = Model( + ... number=1, + ... string={'@attr': '1', '#text': 'content'}, + ... ) + >>> # Content value is an integer, as per the generic type + >>> m.number.content + 1 + >>> # But you're still able to use the Tag itself in common operators + >>> m.number.content + 10 == m.number + 10 + True + >>> # As it's the case for methods/attributes not found in the Tag itself + >>> m.number.bit_length() + 1 + >>> # types are NOT the same, however, the interfaces are very similar most of the time + >>> type(m.number), type(m.number.content) + (<class 'rss_parser.models.image.Tag[int]'>, <class 'int'>) + >>> # The attributes are empty by default + >>> m.number.attributes + {} + >>> # But are populated when provided. + >>> # Note that the @ symbol is trimmed from the beggining, however, camelCase is not converted + >>> m.string.attributes + {'attr': '1'} + >>> # Generic argument types are handled by pydantic - let's try to provide a string for a Tag[int] number + >>> m = Model(number='not_a_number', string={'@customAttr': 'v', '#text': 'str tag value'}) + Traceback (most recent call last): + ... + pydantic.error_wrappers.ValidationError: 1 validation error for Model + number -> content + value is not a valid integer (type=type_error.integer) + """ + + # Optional in case of self-closing tags + content: Optional[T] + attributes: dict + + def __getattr__(self, item): + """Forward default getattr for content for simplicity.""" + return getattr(self.content, item) + + def __getitem__(self, key): + return self.content[key] + + def __setitem__(self, key, value): + self.content[key] = value + + @classmethod + def __get_validators__(cls): + yield cls.pre_convert + yield cls.validate + + @classmethod + def pre_convert(cls, v: Union[T, dict], **kwargs): # noqa + """Used to split tag's text with other xml attributes.""" + if isinstance(v, dict): + data = deepcopy(v) + attributes = {k.lstrip("@"): v for k, v in data.items() if k.startswith("@")} + content = data.pop("#text", data) if not len(attributes) == len(data) else None + return {"content": content, "attributes": attributes} + return {"content": v, "attributes": {}} + + @classmethod + def flatten_tag_encoder(cls, v): + """Encoder that translates Tag objects (dict) to plain .content values (T).""" + bases = v.__class__.__bases__ + if XMLBaseModel in bases: + # Can't pass encoder to .dict :/ + return loads(v.json_plain()) + if cls in bases: + return v.content + + return pydantic_encoder(v) + + +_OPERATOR_MAPPING = { + # Unary + "__pos__": pos, + "__neg__": neg, + "__abs__": abs, + "__invert__": invert, + "__round__": round, + "__floor__": floor, + "__ceil__": ceil, + # Conversion + "__str__": str, + "__int__": int, + "__float__": float, + "__bool__": bool, + "__complex__": complex, + "__oct__": oct, + "__hex__": hex, + "__index__": index, + "__trunc__": trunc, + # Comparison + "__lt__": lt, + "__gt__": gt, + "__le__": le, + "__eq__": eq, + "__ne__": ne, + "__ge__": ge, + # Arithmetic + "__add__": add, + "__sub__": sub, + "__mul__": mul, + "__truediv__": truediv, + "__floordiv__": floordiv, + "__mod__": mod, + "__pow__": pow, +} + + +def _make_proxy_operator(operator): + def f(self, *args): + return operator(self.content, *args) + + f.__name__ = operator.__name__ + + return f + + +with warnings.catch_warnings(): + # Ignoring pydantic's warnings when inserting dunder methods (this is not a field so we don't care) + warnings.filterwarnings("ignore", message="fields may not start with an underscore") + Tag: Type[TagRaw] = create_model( + "Tag", + __base__=(TagRaw, Generic[T]), + **{method: _make_proxy_operator(operator) for method, operator in _OPERATOR_MAPPING.items()}, + ) diff --git a/rss_parser/models/utils.py b/rss_parser/models/utils.py new file mode 100644 index 0000000..465971c --- /dev/null +++ b/rss_parser/models/utils.py @@ -0,0 +1,6 @@ +from re import sub + + +def camel_case(s): + s = sub(r"([_\-])+", " ", s).title().replace(" ", "") + return "".join([s[0].lower(), s[1:]]) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..713fe5e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,21 @@ +from json import loads +from pathlib import Path + +import pytest + +# Get relative path to samples dir no matter the working dir +sample_dir = Path(__file__).parent.resolve() / "samples" + + +@pytest.fixture +def sample_and_result(request): + with open(sample_dir / f"{request.param[0]}.xml", encoding="utf-8") as sample: + plain = len(request.param) > 1 and request.param[1] + with open(sample_dir / f"{request.param[0]}{'_plain' if plain else ''}.json", encoding="utf-8") as result: + return sample.read(), loads(result.read()) + + +@pytest.fixture +def atom_feed(): + with open(sample_dir / "atom.xml") as f: + return f.read() diff --git a/tests/fixtures.py b/tests/fixtures.py deleted file mode 100644 index 25fc486..0000000 --- a/tests/fixtures.py +++ /dev/null @@ -1,18 +0,0 @@ -from pathlib import Path - -import pytest - -# Get relative path to samples dir no matter the working dir -sample_dir = Path(__file__).parent.resolve() / "samples" - - -@pytest.fixture -def rss_version_2(): - with open(sample_dir / "rss_2.xml") as f: - return f.read() - - -@pytest.fixture -def atom_feed(): - with open(sample_dir / "atom.xml") as f: - return f.read() diff --git a/tests/samples/apology_line.json b/tests/samples/apology_line.json new file mode 100644 index 0000000..58f5db9 --- /dev/null +++ b/tests/samples/apology_line.json @@ -0,0 +1,144 @@ +{ + "channel": { + "attributes": {}, + "content": { + "category": null, + "cloud": null, + "copyright": { + "attributes": {}, + "content": "\u00a9 2021 Wondery, Inc. All rights reserved" + }, + "description": { + "attributes": {}, + "content": "<p>If you could call a number and say you\u2019re sorry, and no one would know\u2026what would you apologize for? For fifteen years, you could call a number in Manhattan and do just that. This is the story of the line, and the man at the other end who became consumed by his own creation. He was known as \u201cMr. Apology.\u201d As thousands of callers flooded the line, confessing to everything from shoplifting to infidelity, drug dealing to murder, Mr. Apology realized he couldn\u2019t just listen. He had to do something, even if it meant risking everything. From Wondery the makers of Dr. Death and The Shrink Next Door, comes a story about empathy, deception and obsession. Marissa Bridge, who knew Mr. Apology better than anyone, hosts this six episode series.</p><p>All episodes are available now. You can binge the series ad-free on Wondery+ or on Amazon Music with a Prime membership or Amazon Music Unlimited subscription. </p>" + }, + "docs": null, + "generator": { + "attributes": {}, + "content": "ART19" + }, + "image": { + "attributes": {}, + "content": { + "description": null, + "height": null, + "link": { + "attributes": {}, + "content": "https://wondery.com/shows/the-apology-line/?utm_source=rss" + }, + "title": { + "attributes": {}, + "content": "The Apology Line" + }, + "url": { + "attributes": {}, + "content": "https://content.production.cdn.art19.com/images/be/e1/82/c2/bee182c2-14b7-491b-b877-272ab6754025/bd4ab6d08d7b723678a682b6e399d26523245b3ba83f61617b9b28396aba1092b101cd86707576ec021b77e143b447463342b352f8825265b15310c989b6cb93.jpeg" + }, + "width": null + } + }, + "items": [ + { + "attributes": {}, + "content": { + "author": null, + "category": null, + "comments": null, + "description": { + "attributes": {}, + "content": "<p>When Elon Musk posted a video of himself arriving at Twitter HQ carrying a white sink along with the message \u201clet that sink in!\u201d It marked the end of a dramatic takeover. Musk had gone from Twitter critic to \u201cChief Twit\u201d in the space of just a few months but his arrival didn\u2019t put an end to questions about his motives. Musk had earned a reputation as a business maverick. From PayPal to Tesla to SpaceX, his name was synonymous with big, earth-shattering ideas. So, what did he want with a social media platform? And was this all really in the name of free speech...or was this all in the name of Elon Musk? </p><p><br></p><p>From Wondery, the makers of WeCrashed and In God We Lust, comes the wild story of how the richest man alive took charge of the world\u2019s \u201cdigital public square.\u201d</p><p><br></p><p>Listen to Flipping The Bird: <a href=\"http://Wondery.fm/FTB_TAL\" rel=\"noopener noreferrer\" target=\"_blank\">Wondery.fm/FTB_TAL</a></p><p>See Privacy Policy at <a href=\"https://art19.com/privacy\" rel=\"noopener noreferrer\" target=\"_blank\">https://art19.com/privacy</a> and California Privacy Notice at <a href=\"https://art19.com/privacy#do-not-sell-my-info\" rel=\"noopener noreferrer\" target=\"_blank\">https://art19.com/privacy#do-not-sell-my-info</a>.</p>" + }, + "enclosure": { + "attributes": { + "length": "4824502", + "type": "audio/mpeg", + "url": "https://dts.podtrac.com/redirect.mp3/chrt.fm/track/9EE2G/pdst.fm/e/rss.art19.com/episodes/7bfce2e9-7889-480a-afde-46e810e82b1a.mp3?rss_browser=BAhJIhRweXRob24tcmVxdWVzdHMGOgZFVA%3D%3D--ac965bdf6559f894a935511702ea4ac963845aca" + }, + "content": null + }, + "guid": { + "attributes": { + "isPermaLink": "false" + }, + "content": "gid://art19-episode-locator/V0/tdroPC934g1_yKpnqnfmA67RAho9P0W6PUiIY-tBw3U" + }, + "link": null, + "pub_date": { + "attributes": {}, + "content": "Mon, 01 May 2023 08:00:00 -0000" + }, + "source": null, + "title": { + "attributes": {}, + "content": "Wondery Presents - Flipping The Bird: Elon vs Twitter" + } + } + }, + { + "attributes": {}, + "content": { + "author": null, + "category": null, + "comments": null, + "description": { + "attributes": {}, + "content": "<p>If you could call a number and say you\u2019re sorry, and no one would know\u2026what would you apologize for? For fifteen years, you could call a number in Manhattan and do just that. This is the story of the line, and the man at the other end who became consumed by his own creation. He was known as \u201cMr. Apology.\u201d As thousands of callers flooded the line, confessing to everything from shoplifting to infidelity, drug dealing to murder, Mr. Apology realized he couldn\u2019t just listen. He had to do something, even if it meant risking everything. From Wondery the makers of Dr Death and The Shrink Next Door, comes a story about empathy, deception and obsession. Marissa Bridge, who knew Mr. Apology better than anyone, hosts this six episode series.</p><p>All episodes are available now. You can binge the series ad-free on Wondery+ or on Amazon Music with a Prime membership or Amazon Music Unlimited subscription.</p><p>See Privacy Policy at <a href=\"https://art19.com/privacy\" rel=\"noopener noreferrer\" target=\"_blank\">https://art19.com/privacy</a> and California Privacy Notice at <a href=\"https://art19.com/privacy#do-not-sell-my-info\" rel=\"noopener noreferrer\" target=\"_blank\">https://art19.com/privacy#do-not-sell-my-info</a>.</p>" + }, + "enclosure": { + "attributes": { + "length": "2320091", + "type": "audio/mpeg", + "url": "https://dts.podtrac.com/redirect.mp3/chrt.fm/track/9EE2G/pdst.fm/e/rss.art19.com/episodes/a462e9fa-5e7b-4b0a-b992-d59fa1ca06cd.mp3?rss_browser=BAhJIhRweXRob24tcmVxdWVzdHMGOgZFVA%3D%3D--ac965bdf6559f894a935511702ea4ac963845aca" + }, + "content": null + }, + "guid": { + "attributes": { + "isPermaLink": "false" + }, + "content": "gid://art19-episode-locator/V0/2E7Nce-ZiX0Rmo017w7js5BvvKiOIMjWELujxOvJync" + }, + "link": null, + "pub_date": { + "attributes": {}, + "content": "Tue, 05 Jan 2021 03:26:59 -0000" + }, + "source": null, + "title": { + "attributes": {}, + "content": "Introducing: The Apology Line" + } + } + } + ], + "language": { + "attributes": {}, + "content": "en" + }, + "last_build_date": null, + "link": { + "attributes": {}, + "content": "https://wondery.com/shows/the-apology-line/?utm_source=rss" + }, + "managing_editor": { + "attributes": {}, + "content": "iwonder@wondery.com (Wondery)" + }, + "pub_date": null, + "rating": null, + "skip_days": null, + "skip_hours": null, + "text_input": null, + "title": { + "attributes": {}, + "content": "The Apology Line" + }, + "ttl": null, + "web_master": null + } + }, + "version": { + "attributes": {}, + "content": "2.0" + } +} \ No newline at end of file diff --git a/tests/samples/apology_line.xml b/tests/samples/apology_line.xml new file mode 100644 index 0000000..84869ec --- /dev/null +++ b/tests/samples/apology_line.xml @@ -0,0 +1,87 @@ +<?xml version="1.0" encoding="UTF-8"?> +<rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:art19="https://art19.com/xmlns/rss-extensions/1.0" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0/" version="2.0"> + <channel> + <title>The Apology Line + + If you could call a number and say you’re sorry, and no one would know…what would you apologize for? For fifteen years, you could call a number in Manhattan and do just that. This is the story of the line, and the man at the other end who became consumed by his own creation. He was known as “Mr. Apology.” As thousands of callers flooded the line, confessing to everything from shoplifting to infidelity, drug dealing to murder, Mr. Apology realized he couldn’t just listen. He had to do something, even if it meant risking everything. From Wondery the makers of Dr. Death and The Shrink Next Door, comes a story about empathy, deception and obsession. Marissa Bridge, who knew Mr. Apology better than anyone, hosts this six episode series.

All episodes are available now. You can binge the series ad-free on Wondery+ or on Amazon Music with a Prime membership or Amazon Music Unlimited subscription.

]]> +
+ iwonder@wondery.com (Wondery) + © 2021 Wondery, Inc. All rights reserved + ART19 + + https://wondery.com/shows/the-apology-line/?utm_source=rss + + Wondery + iwonder@wondery.com + + Wondery + + If you could call a number and say you’re sorry, and no one would know…what would you apologize for? For fifteen years, you could call a number in Manhattan and do just that. This is the story of the line, and the man at the other end who became consumed by his own creation. He was known as “Mr. Apology.” As thousands of callers flooded the line, confessing to everything from shoplifting to infidelity, drug dealing to murder, Mr. Apology realized he couldn’t just listen. He had to do something, even if it meant risking everything. From Wondery the makers of Dr. Death and The Shrink Next Door, comes a story about empathy, deception and obsession. Marissa Bridge, who knew Mr. Apology better than anyone, hosts this six episode series.

All episodes are available now. You can binge the series ad-free on Wondery+ or on Amazon Music with a Prime membership or Amazon Music Unlimited subscription.

]]> +
+ en + yes + + Exhibit C,Binge-worthy true crime,New York City,Binge Worthy Documentary ,Apology Line,Apology,Murder,This American Life,society,true crime,serial killer + serial + + + https://content.production.cdn.art19.com/images/be/e1/82/c2/bee182c2-14b7-491b-b877-272ab6754025/bd4ab6d08d7b723678a682b6e399d26523245b3ba83f61617b9b28396aba1092b101cd86707576ec021b77e143b447463342b352f8825265b15310c989b6cb93.jpeg + https://wondery.com/shows/the-apology-line/?utm_source=rss + The Apology Line + + + Wondery Presents - Flipping The Bird: Elon vs Twitter + + When Elon Musk posted a video of himself arriving at Twitter HQ carrying a white sink along with the message “let that sink in!” It marked the end of a dramatic takeover. Musk had gone from Twitter critic to “Chief Twit” in the space of just a few months but his arrival didn’t put an end to questions about his motives. Musk had earned a reputation as a business maverick. From PayPal to Tesla to SpaceX, his name was synonymous with big, earth-shattering ideas. So, what did he want with a social media platform? And was this all really in the name of free speech...or was this all in the name of Elon Musk? 


From Wondery, the makers of WeCrashed and In God We Lust, comes the wild story of how the richest man alive took charge of the world’s “digital public square.”


Listen to Flipping The Bird: Wondery.fm/FTB_TAL

See Privacy Policy at https://art19.com/privacy and California Privacy Notice at https://art19.com/privacy#do-not-sell-my-info.

]]> +
+ Wondery Presents - Flipping The Bird: Elon vs Twitter + trailer + When Elon Musk posted a video of himself arriving at Twitter HQ carrying a white sink along with the message “let that sink in!” It marked the end of a dramatic takeover. Musk had gone from Twitter critic to “Chief Twit” in the space of just a few months but his arrival didn’t put an end to questions about his motives. Musk had earned a reputation as a business maverick. From PayPal to Tesla to SpaceX, his name was synonymous with big, earth-shattering ideas. So, what did he want with a social media platform? And was this all really in the name of free speech...or was this all in the name of Elon Musk?  + + + + +From Wondery, the makers of WeCrashed and In God We Lust, comes the wild story of how the richest man alive took charge of the world’s “digital public square.” + + + + +Listen to Flipping The Bird: Wondery.fm/FTB_TAL + +See Privacy Policy at https://art19.com/privacy and California Privacy Notice at https://art19.com/privacy#do-not-sell-my-info. + + When Elon Musk posted a video of himself arriving at Twitter HQ carrying a white sink along with the message “let that sink in!” It marked the end of a dramatic takeover. Musk had gone from Twitter critic to “Chief Twit” in the space of just a few months but his arrival didn’t put an end to questions about his motives. Musk had earned a reputation as a business maverick. From PayPal to Tesla to SpaceX, his name was synonymous with big, earth-shattering ideas. So, what did he want with a social media platform? And was this all really in the name of free speech...or was this all in the name of Elon Musk? 


From Wondery, the makers of WeCrashed and In God We Lust, comes the wild story of how the richest man alive took charge of the world’s “digital public square.”


Listen to Flipping The Bird: Wondery.fm/FTB_TAL

See Privacy Policy at https://art19.com/privacy and California Privacy Notice at https://art19.com/privacy#do-not-sell-my-info.

]]> +
+ gid://art19-episode-locator/V0/tdroPC934g1_yKpnqnfmA67RAho9P0W6PUiIY-tBw3U + Mon, 01 May 2023 08:00:00 -0000 + yes + + Serial killer,TRUE CRIME,Society,This American Life,MURDER,Apology,Apology Line,Binge Worthy Documentary,New York City,Binge-worthy true crime,exhibit c + 00:05:01 + +
+ + Introducing: The Apology Line + + If you could call a number and say you’re sorry, and no one would know…what would you apologize for? For fifteen years, you could call a number in Manhattan and do just that. This is the story of the line, and the man at the other end who became consumed by his own creation. He was known as “Mr. Apology.” As thousands of callers flooded the line, confessing to everything from shoplifting to infidelity, drug dealing to murder, Mr. Apology realized he couldn’t just listen. He had to do something, even if it meant risking everything. From Wondery the makers of Dr Death and The Shrink Next Door, comes a story about empathy, deception and obsession. Marissa Bridge, who knew Mr. Apology better than anyone, hosts this six episode series.

All episodes are available now. You can binge the series ad-free on Wondery+ or on Amazon Music with a Prime membership or Amazon Music Unlimited subscription.

See Privacy Policy at https://art19.com/privacy and California Privacy Notice at https://art19.com/privacy#do-not-sell-my-info.

]]> +
+ Introducing: The Apology Line + trailer + If you could call a number and say you’re sorry, and no one would know…what would you apologize for? For fifteen years, you could call a number in Manhattan and do just that. This is the story of the line, and the man at the other end who became consumed by his own creation. He was known as “Mr. Apology.” As thousands of callers flooded the line, confessing to everything from shoplifting to infidelity, drug dealing to murder, Mr. Apology realized he couldn’t just listen. He had to do something, even if it meant risking everything. From Wondery the makers of Dr Death and The Shrink Next Door, comes a story about empathy, deception and obsession. Marissa Bridge, who knew Mr. Apology better than anyone, hosts this six episode series. + +All episodes are available now. You can binge the series ad-free on Wondery+ or on Amazon Music with a Prime membership or Amazon Music Unlimited subscription. + +See Privacy Policy at https://art19.com/privacy and California Privacy Notice at https://art19.com/privacy#do-not-sell-my-info. + + If you could call a number and say you’re sorry, and no one would know…what would you apologize for? For fifteen years, you could call a number in Manhattan and do just that. This is the story of the line, and the man at the other end who became consumed by his own creation. He was known as “Mr. Apology.” As thousands of callers flooded the line, confessing to everything from shoplifting to infidelity, drug dealing to murder, Mr. Apology realized he couldn’t just listen. He had to do something, even if it meant risking everything. From Wondery the makers of Dr Death and The Shrink Next Door, comes a story about empathy, deception and obsession. Marissa Bridge, who knew Mr. Apology better than anyone, hosts this six episode series.

All episodes are available now. You can binge the series ad-free on Wondery+ or on Amazon Music with a Prime membership or Amazon Music Unlimited subscription.

See Privacy Policy at https://art19.com/privacy and California Privacy Notice at https://art19.com/privacy#do-not-sell-my-info.

]]> +
+ gid://art19-episode-locator/V0/2E7Nce-ZiX0Rmo017w7js5BvvKiOIMjWELujxOvJync + Tue, 05 Jan 2021 03:26:59 -0000 + yes + + Exhibit C,New York City,Murder,This American Life,society,serial killer,true crime,Apology Line,Binge Worthy Documentary ,Binge-worthy true crime,Apology + 00:02:24 + +
+ + diff --git a/tests/samples/apology_line_plain.json b/tests/samples/apology_line_plain.json new file mode 100644 index 0000000..f55e13c --- /dev/null +++ b/tests/samples/apology_line_plain.json @@ -0,0 +1,57 @@ +{ + "channel": { + "category": null, + "cloud": null, + "copyright": "\u00a9 2021 Wondery, Inc. All rights reserved", + "description": "

If you could call a number and say you\u2019re sorry, and no one would know\u2026what would you apologize for? For fifteen years, you could call a number in Manhattan and do just that. This is the story of the line, and the man at the other end who became consumed by his own creation. He was known as \u201cMr. Apology.\u201d As thousands of callers flooded the line, confessing to everything from shoplifting to infidelity, drug dealing to murder, Mr. Apology realized he couldn\u2019t just listen. He had to do something, even if it meant risking everything. From Wondery the makers of Dr. Death and The Shrink Next Door, comes a story about empathy, deception and obsession. Marissa Bridge, who knew Mr. Apology better than anyone, hosts this six episode series.

All episodes are available now. You can binge the series ad-free on Wondery+ or on Amazon Music with a Prime membership or Amazon Music Unlimited subscription.

", + "docs": null, + "generator": "ART19", + "image": { + "description": null, + "height": null, + "link": "https://wondery.com/shows/the-apology-line/?utm_source=rss", + "title": "The Apology Line", + "url": "https://content.production.cdn.art19.com/images/be/e1/82/c2/bee182c2-14b7-491b-b877-272ab6754025/bd4ab6d08d7b723678a682b6e399d26523245b3ba83f61617b9b28396aba1092b101cd86707576ec021b77e143b447463342b352f8825265b15310c989b6cb93.jpeg", + "width": null + }, + "items": [ + { + "author": null, + "category": null, + "comments": null, + "description": "

When Elon Musk posted a video of himself arriving at Twitter HQ carrying a white sink along with the message \u201clet that sink in!\u201d It marked the end of a dramatic takeover. Musk had gone from Twitter critic to \u201cChief Twit\u201d in the space of just a few months but his arrival didn\u2019t put an end to questions about his motives. Musk had earned a reputation as a business maverick. From PayPal to Tesla to SpaceX, his name was synonymous with big, earth-shattering ideas. So, what did he want with a social media platform? And was this all really in the name of free speech...or was this all in the name of Elon Musk? 


From Wondery, the makers of WeCrashed and In God We Lust, comes the wild story of how the richest man alive took charge of the world\u2019s \u201cdigital public square.\u201d


Listen to Flipping The Bird: Wondery.fm/FTB_TAL

See Privacy Policy at https://art19.com/privacy and California Privacy Notice at https://art19.com/privacy#do-not-sell-my-info.

", + "enclosure": null, + "guid": "gid://art19-episode-locator/V0/tdroPC934g1_yKpnqnfmA67RAho9P0W6PUiIY-tBw3U", + "link": null, + "pub_date": "Mon, 01 May 2023 08:00:00 -0000", + "source": null, + "title": "Wondery Presents - Flipping The Bird: Elon vs Twitter" + }, + { + "author": null, + "category": null, + "comments": null, + "description": "

If you could call a number and say you\u2019re sorry, and no one would know\u2026what would you apologize for? For fifteen years, you could call a number in Manhattan and do just that. This is the story of the line, and the man at the other end who became consumed by his own creation. He was known as \u201cMr. Apology.\u201d As thousands of callers flooded the line, confessing to everything from shoplifting to infidelity, drug dealing to murder, Mr. Apology realized he couldn\u2019t just listen. He had to do something, even if it meant risking everything. From Wondery the makers of Dr Death and The Shrink Next Door, comes a story about empathy, deception and obsession. Marissa Bridge, who knew Mr. Apology better than anyone, hosts this six episode series.

All episodes are available now. You can binge the series ad-free on Wondery+ or on Amazon Music with a Prime membership or Amazon Music Unlimited subscription.

See Privacy Policy at https://art19.com/privacy and California Privacy Notice at https://art19.com/privacy#do-not-sell-my-info.

", + "enclosure": null, + "guid": "gid://art19-episode-locator/V0/2E7Nce-ZiX0Rmo017w7js5BvvKiOIMjWELujxOvJync", + "link": null, + "pub_date": "Tue, 05 Jan 2021 03:26:59 -0000", + "source": null, + "title": "Introducing: The Apology Line" + } + ], + "language": "en", + "last_build_date": null, + "link": "https://wondery.com/shows/the-apology-line/?utm_source=rss", + "managing_editor": "iwonder@wondery.com (Wondery)", + "pub_date": null, + "rating": null, + "skip_days": null, + "skip_hours": null, + "text_input": null, + "title": "The Apology Line", + "ttl": null, + "web_master": null + }, + "version": "2.0" +} \ No newline at end of file diff --git a/tests/samples/custom.xml b/tests/samples/custom.xml new file mode 100644 index 0000000..d050924 --- /dev/null +++ b/tests/samples/custom.xml @@ -0,0 +1,4 @@ + + + Custom tag data + \ No newline at end of file diff --git a/tests/samples/rss_2.json b/tests/samples/rss_2.json new file mode 100644 index 0000000..efe8fa3 --- /dev/null +++ b/tests/samples/rss_2.json @@ -0,0 +1,413 @@ +{ + "channel": { + "attributes": {}, + "content": { + "category": { + "attributes": { + "domain": "www.dmoz.com" + }, + "content": "Computers/Software/Internet/Site Management/Content Management" + }, + "cloud": null, + "copyright": { + "attributes": {}, + "content": "Copyright 2004 NotePage, Inc." + }, + "description": { + "attributes": {}, + "content": "RSS is a fascinating technology. The uses for RSS are expanding daily. Take a closer look at how various industries are using the benefits of RSS in their businesses." + }, + "docs": { + "attributes": {}, + "content": "http://blogs.law.harvard.edu/tech/rss" + }, + "generator": { + "attributes": {}, + "content": "FeedForAll Beta1 (0.0.1.8)" + }, + "image": { + "attributes": {}, + "content": { + "description": { + "attributes": {}, + "content": "FeedForAll Sample Feed" + }, + "height": { + "attributes": {}, + "content": 48 + }, + "link": { + "attributes": {}, + "content": "http://www.feedforall.com/industry-solutions.htm" + }, + "title": { + "attributes": {}, + "content": "FeedForAll Sample Feed" + }, + "url": { + "attributes": {}, + "content": "http://www.feedforall.com/ffalogo48x48.gif" + }, + "width": { + "attributes": {}, + "content": 48 + } + } + }, + "items": [ + { + "attributes": {}, + "content": { + "author": null, + "category": { + "attributes": { + "domain": "www.dmoz.com" + }, + "content": "Computers/Software/Internet/Site Management/Content Management" + }, + "comments": { + "attributes": {}, + "content": "http://www.feedforall.com/forum" + }, + "description": { + "attributes": {}, + "content": "FeedForAll helps Restaurant's communicate with customers. Let your customers know the latest specials or events.
\n
\nRSS feed uses include:
\nDaily Specials
\nEntertainment
\nCalendar of Events
" + }, + "enclosure": null, + "guid": null, + "link": { + "attributes": {}, + "content": "http://www.feedforall.com/restaurant.htm" + }, + "pub_date": { + "attributes": {}, + "content": "Tue, 19 Oct 2004 11:09:11 -0400" + }, + "source": null, + "title": { + "attributes": {}, + "content": "RSS Solutions for Restaurants" + } + } + }, + { + "attributes": {}, + "content": { + "author": null, + "category": { + "attributes": { + "domain": "www.dmoz.com" + }, + "content": "Computers/Software/Internet/Site Management/Content Management" + }, + "comments": { + "attributes": {}, + "content": "http://www.feedforall.com/forum" + }, + "description": { + "attributes": {}, + "content": "FeedForAll helps Educational Institutions communicate with students about school wide activities, events, and schedules.
\n
\nRSS feed uses include:
\nHomework Assignments
\nSchool Cancellations
\nCalendar of Events
\nSports Scores
\nClubs/Organization Meetings
\nLunches Menus
" + }, + "enclosure": null, + "guid": null, + "link": { + "attributes": {}, + "content": "http://www.feedforall.com/schools.htm" + }, + "pub_date": { + "attributes": {}, + "content": "Tue, 19 Oct 2004 11:09:09 -0400" + }, + "source": null, + "title": { + "attributes": {}, + "content": "RSS Solutions for Schools and Colleges" + } + } + }, + { + "attributes": {}, + "content": { + "author": null, + "category": { + "attributes": { + "domain": "www.dmoz.com" + }, + "content": "Computers/Software/Internet/Site Management/Content Management" + }, + "comments": { + "attributes": {}, + "content": "http://www.feedforall.com/forum" + }, + "description": { + "attributes": {}, + "content": "FeedForAll helps Computer Service Companies communicate with clients about cyber security and related issues.
\n
\nUses include:
\nCyber Security Alerts
\nSpecials
\nJob Postings
" + }, + "enclosure": null, + "guid": null, + "link": { + "attributes": {}, + "content": "http://www.feedforall.com/computer-service.htm" + }, + "pub_date": { + "attributes": {}, + "content": "Tue, 19 Oct 2004 11:09:07 -0400" + }, + "source": null, + "title": { + "attributes": {}, + "content": "RSS Solutions for Computer Service Companies" + } + } + }, + { + "attributes": {}, + "content": { + "author": null, + "category": { + "attributes": { + "domain": "www.dmoz.com" + }, + "content": "Computers/Software/Internet/Site Management/Content Management" + }, + "comments": { + "attributes": {}, + "content": "http://www.feedforall.com/forum" + }, + "description": { + "attributes": {}, + "content": "FeedForAll helps Governments communicate with the general public about positions on various issues, and keep the community aware of changes in important legislative issues.
\n

\nRSS uses Include:
\nLegislative Calendar
\nVotes
\nBulletins
" + }, + "enclosure": null, + "guid": null, + "link": { + "attributes": {}, + "content": "http://www.feedforall.com/government.htm" + }, + "pub_date": { + "attributes": {}, + "content": "Tue, 19 Oct 2004 11:09:05 -0400" + }, + "source": null, + "title": { + "attributes": {}, + "content": "RSS Solutions for Governments" + } + } + }, + { + "attributes": {}, + "content": { + "author": null, + "category": { + "attributes": { + "domain": "www.dmoz.com" + }, + "content": "Computers/Software/Internet/Site Management/Content Management" + }, + "comments": { + "attributes": {}, + "content": "http://www.feedforall.com/forum" + }, + "description": { + "attributes": {}, + "content": "FeedForAll helps Politicians communicate with the general public about positions on various issues, and keep the community notified of their schedule.
\n
\nUses Include:
\nBlogs
\nSpeaking Engagements
\nStatements
\n
" + }, + "enclosure": null, + "guid": null, + "link": { + "attributes": {}, + "content": "http://www.feedforall.com/politics.htm" + }, + "pub_date": { + "attributes": {}, + "content": "Tue, 19 Oct 2004 11:09:03 -0400" + }, + "source": null, + "title": { + "attributes": {}, + "content": "RSS Solutions for Politicians" + } + } + }, + { + "attributes": {}, + "content": { + "author": null, + "category": { + "attributes": { + "domain": "www.dmoz.com" + }, + "content": "Computers/Software/Internet/Site Management/Content Management" + }, + "comments": { + "attributes": {}, + "content": "http://www.feedforall.com/forum" + }, + "description": { + "attributes": {}, + "content": "FeedForAll helps Meteorologists communicate with the general public about storm warnings and weather alerts, in specific regions. Using RSS meteorologists are able to quickly disseminate urgent and life threatening weather warnings.
\n
\nUses Include:
\nWeather Alerts
\nPlotting Storms
\nSchool Cancellations
" + }, + "enclosure": null, + "guid": null, + "link": { + "attributes": {}, + "content": "http://www.feedforall.com/weather.htm" + }, + "pub_date": { + "attributes": {}, + "content": "Tue, 19 Oct 2004 11:09:01 -0400" + }, + "source": null, + "title": { + "attributes": {}, + "content": "RSS Solutions for Meteorologists" + } + } + }, + { + "attributes": {}, + "content": { + "author": null, + "category": { + "attributes": { + "domain": "www.dmoz.com" + }, + "content": "Computers/Software/Internet/Site Management/Content Management" + }, + "comments": { + "attributes": {}, + "content": "http://www.feedforall.com/forum" + }, + "description": { + "attributes": {}, + "content": "FeedForAll helps Realtors and Real Estate companies communicate with clients informing them of newly available properties, and open house announcements. RSS helps to reach a targeted audience and spread the word in an inexpensive, professional manner.
\n

\nFeeds can be used for:
\nOpen House Dates
\nNew Properties For Sale
\nMortgage Rates
" + }, + "enclosure": null, + "guid": null, + "link": { + "attributes": {}, + "content": "http://www.feedforall.com/real-estate.htm" + }, + "pub_date": { + "attributes": {}, + "content": "Tue, 19 Oct 2004 11:08:59 -0400" + }, + "source": null, + "title": { + "attributes": {}, + "content": "RSS Solutions for Realtors & Real Estate Firms" + } + } + }, + { + "attributes": {}, + "content": { + "author": null, + "category": { + "attributes": { + "domain": "www.dmoz.com" + }, + "content": "Computers/Software/Internet/Site Management/Content Management" + }, + "comments": { + "attributes": {}, + "content": "http://www.feedforall.com/forum" + }, + "description": { + "attributes": {}, + "content": "FeedForAll helps Banks, Credit Unions and Mortgage companies communicate with the general public about rate changes in a prompt and professional manner.
\n
\nUses include:
\nMortgage Rates
\nForeign Exchange Rates
\nBank Rates
\nSpecials
" + }, + "enclosure": null, + "guid": null, + "link": { + "attributes": {}, + "content": "http://www.feedforall.com/banks.htm" + }, + "pub_date": { + "attributes": {}, + "content": "Tue, 19 Oct 2004 11:08:57 -0400" + }, + "source": null, + "title": { + "attributes": {}, + "content": "RSS Solutions for Banks / Mortgage Companies" + } + } + }, + { + "attributes": {}, + "content": { + "author": null, + "category": { + "attributes": { + "domain": "www.dmoz.com" + }, + "content": "Computers/Software/Internet/Site Management/Content Management" + }, + "comments": { + "attributes": {}, + "content": "http://www.feedforall.com/forum" + }, + "description": { + "attributes": {}, + "content": "FeedForAll helps Law Enforcement Professionals communicate with the general public and other agencies in a prompt and efficient manner. Using RSS police are able to quickly disseminate urgent and life threatening information.
\n
\nUses include:
\nAmber Alerts
\nSex Offender Community Notification
\nWeather Alerts
\nScheduling
\nSecurity Alerts
\nPolice Report
\nMeetings
" + }, + "enclosure": null, + "guid": null, + "link": { + "attributes": {}, + "content": "http://www.feedforall.com/law-enforcement.htm" + }, + "pub_date": { + "attributes": {}, + "content": "Tue, 19 Oct 2004 11:08:56 -0400" + }, + "source": null, + "title": { + "attributes": {}, + "content": "RSS Solutions for Law Enforcement" + } + } + } + ], + "language": { + "attributes": {}, + "content": "en-us" + }, + "last_build_date": { + "attributes": {}, + "content": "2004-10-19T13:39:14-04:00" + }, + "link": { + "attributes": {}, + "content": "http://www.feedforall.com/industry-solutions.htm" + }, + "managing_editor": { + "attributes": {}, + "content": "marketing@feedforall.com" + }, + "pub_date": { + "attributes": {}, + "content": "2004-10-19T13:38:55-04:00" + }, + "rating": null, + "skip_days": null, + "skip_hours": null, + "text_input": null, + "title": { + "attributes": {}, + "content": "FeedForAll Sample Feed" + }, + "ttl": null, + "web_master": { + "attributes": {}, + "content": "webmaster@feedforall.com" + } + } + }, + "version": { + "attributes": {}, + "content": "2.0" + } +} \ No newline at end of file diff --git a/tests/samples/rss_2_no_category_attr.json b/tests/samples/rss_2_no_category_attr.json new file mode 100644 index 0000000..d32566a --- /dev/null +++ b/tests/samples/rss_2_no_category_attr.json @@ -0,0 +1,409 @@ +{ + "channel": { + "attributes": {}, + "content": { + "category": { + "attributes": {}, + "content": "Computers/Software/Internet/Site Management/Content Management" + }, + "cloud": null, + "copyright": { + "attributes": {}, + "content": "Copyright 2004 NotePage, Inc." + }, + "description": { + "attributes": {}, + "content": "RSS is a fascinating technology. The uses for RSS are expanding daily. Take a closer look at how various industries are using the benefits of RSS in their businesses." + }, + "docs": { + "attributes": {}, + "content": "http://blogs.law.harvard.edu/tech/rss" + }, + "generator": { + "attributes": {}, + "content": "FeedForAll Beta1 (0.0.1.8)" + }, + "image": { + "attributes": {}, + "content": { + "description": { + "attributes": {}, + "content": "FeedForAll Sample Feed" + }, + "height": { + "attributes": {}, + "content": 48 + }, + "link": { + "attributes": {}, + "content": "http://www.feedforall.com/industry-solutions.htm" + }, + "title": { + "attributes": {}, + "content": "FeedForAll Sample Feed" + }, + "url": { + "attributes": {}, + "content": "http://www.feedforall.com/ffalogo48x48.gif" + }, + "width": { + "attributes": {}, + "content": 48 + } + } + }, + "items": [ + { + "attributes": {}, + "content": { + "author": null, + "category": { + "attributes": {}, + "content": "Computers/Software/Internet/Site Management/Content Management" + }, + "comments": { + "attributes": {}, + "content": "http://www.feedforall.com/forum" + }, + "description": { + "attributes": {}, + "content": "FeedForAll helps Restaurant's communicate with customers. Let your customers know the latest specials or events.
\n
\nRSS feed uses include:
\nDaily Specials
\nEntertainment
\nCalendar of Events
" + }, + "enclosure": null, + "guid": null, + "link": { + "attributes": {}, + "content": "http://www.feedforall.com/restaurant.htm" + }, + "pub_date": { + "attributes": {}, + "content": "Tue, 19 Oct 2004 11:09:11 -0400" + }, + "source": null, + "title": { + "attributes": {}, + "content": "RSS Solutions for Restaurants" + } + } + }, + { + "attributes": {}, + "content": { + "author": null, + "category": { + "attributes": { + "domain": "www.dmoz.com" + }, + "content": "Computers/Software/Internet/Site Management/Content Management" + }, + "comments": { + "attributes": {}, + "content": "http://www.feedforall.com/forum" + }, + "description": { + "attributes": {}, + "content": "FeedForAll helps Educational Institutions communicate with students about school wide activities, events, and schedules.
\n
\nRSS feed uses include:
\nHomework Assignments
\nSchool Cancellations
\nCalendar of Events
\nSports Scores
\nClubs/Organization Meetings
\nLunches Menus
" + }, + "enclosure": null, + "guid": null, + "link": { + "attributes": {}, + "content": "http://www.feedforall.com/schools.htm" + }, + "pub_date": { + "attributes": {}, + "content": "Tue, 19 Oct 2004 11:09:09 -0400" + }, + "source": null, + "title": { + "attributes": {}, + "content": "RSS Solutions for Schools and Colleges" + } + } + }, + { + "attributes": {}, + "content": { + "author": null, + "category": { + "attributes": { + "domain": "www.dmoz.com" + }, + "content": "Computers/Software/Internet/Site Management/Content Management" + }, + "comments": { + "attributes": {}, + "content": "http://www.feedforall.com/forum" + }, + "description": { + "attributes": {}, + "content": "FeedForAll helps Computer Service Companies communicate with clients about cyber security and related issues.
\n
\nUses include:
\nCyber Security Alerts
\nSpecials
\nJob Postings
" + }, + "enclosure": null, + "guid": null, + "link": { + "attributes": {}, + "content": "http://www.feedforall.com/computer-service.htm" + }, + "pub_date": { + "attributes": {}, + "content": "Tue, 19 Oct 2004 11:09:07 -0400" + }, + "source": null, + "title": { + "attributes": {}, + "content": "RSS Solutions for Computer Service Companies" + } + } + }, + { + "attributes": {}, + "content": { + "author": null, + "category": { + "attributes": { + "domain": "www.dmoz.com" + }, + "content": "Computers/Software/Internet/Site Management/Content Management" + }, + "comments": { + "attributes": {}, + "content": "http://www.feedforall.com/forum" + }, + "description": { + "attributes": {}, + "content": "FeedForAll helps Governments communicate with the general public about positions on various issues, and keep the community aware of changes in important legislative issues.
\n

\nRSS uses Include:
\nLegislative Calendar
\nVotes
\nBulletins
" + }, + "enclosure": null, + "guid": null, + "link": { + "attributes": {}, + "content": "http://www.feedforall.com/government.htm" + }, + "pub_date": { + "attributes": {}, + "content": "Tue, 19 Oct 2004 11:09:05 -0400" + }, + "source": null, + "title": { + "attributes": {}, + "content": "RSS Solutions for Governments" + } + } + }, + { + "attributes": {}, + "content": { + "author": null, + "category": { + "attributes": { + "domain": "www.dmoz.com" + }, + "content": "Computers/Software/Internet/Site Management/Content Management" + }, + "comments": { + "attributes": {}, + "content": "http://www.feedforall.com/forum" + }, + "description": { + "attributes": {}, + "content": "FeedForAll helps Politicians communicate with the general public about positions on various issues, and keep the community notified of their schedule.
\n
\nUses Include:
\nBlogs
\nSpeaking Engagements
\nStatements
\n
" + }, + "enclosure": null, + "guid": null, + "link": { + "attributes": {}, + "content": "http://www.feedforall.com/politics.htm" + }, + "pub_date": { + "attributes": {}, + "content": "Tue, 19 Oct 2004 11:09:03 -0400" + }, + "source": null, + "title": { + "attributes": {}, + "content": "RSS Solutions for Politicians" + } + } + }, + { + "attributes": {}, + "content": { + "author": null, + "category": { + "attributes": { + "domain": "www.dmoz.com" + }, + "content": "Computers/Software/Internet/Site Management/Content Management" + }, + "comments": { + "attributes": {}, + "content": "http://www.feedforall.com/forum" + }, + "description": { + "attributes": {}, + "content": "FeedForAll helps Meteorologists communicate with the general public about storm warnings and weather alerts, in specific regions. Using RSS meteorologists are able to quickly disseminate urgent and life threatening weather warnings.
\n
\nUses Include:
\nWeather Alerts
\nPlotting Storms
\nSchool Cancellations
" + }, + "enclosure": null, + "guid": null, + "link": { + "attributes": {}, + "content": "http://www.feedforall.com/weather.htm" + }, + "pub_date": { + "attributes": {}, + "content": "Tue, 19 Oct 2004 11:09:01 -0400" + }, + "source": null, + "title": { + "attributes": {}, + "content": "RSS Solutions for Meteorologists" + } + } + }, + { + "attributes": {}, + "content": { + "author": null, + "category": { + "attributes": { + "domain": "www.dmoz.com" + }, + "content": "Computers/Software/Internet/Site Management/Content Management" + }, + "comments": { + "attributes": {}, + "content": "http://www.feedforall.com/forum" + }, + "description": { + "attributes": {}, + "content": "FeedForAll helps Realtors and Real Estate companies communicate with clients informing them of newly available properties, and open house announcements. RSS helps to reach a targeted audience and spread the word in an inexpensive, professional manner.
\n

\nFeeds can be used for:
\nOpen House Dates
\nNew Properties For Sale
\nMortgage Rates
" + }, + "enclosure": null, + "guid": null, + "link": { + "attributes": {}, + "content": "http://www.feedforall.com/real-estate.htm" + }, + "pub_date": { + "attributes": {}, + "content": "Tue, 19 Oct 2004 11:08:59 -0400" + }, + "source": null, + "title": { + "attributes": {}, + "content": "RSS Solutions for Realtors & Real Estate Firms" + } + } + }, + { + "attributes": {}, + "content": { + "author": null, + "category": { + "attributes": { + "domain": "www.dmoz.com" + }, + "content": "Computers/Software/Internet/Site Management/Content Management" + }, + "comments": { + "attributes": {}, + "content": "http://www.feedforall.com/forum" + }, + "description": { + "attributes": {}, + "content": "FeedForAll helps Banks, Credit Unions and Mortgage companies communicate with the general public about rate changes in a prompt and professional manner.
\n
\nUses include:
\nMortgage Rates
\nForeign Exchange Rates
\nBank Rates
\nSpecials
" + }, + "enclosure": null, + "guid": null, + "link": { + "attributes": {}, + "content": "http://www.feedforall.com/banks.htm" + }, + "pub_date": { + "attributes": {}, + "content": "Tue, 19 Oct 2004 11:08:57 -0400" + }, + "source": null, + "title": { + "attributes": {}, + "content": "RSS Solutions for Banks / Mortgage Companies" + } + } + }, + { + "attributes": {}, + "content": { + "author": null, + "category": { + "attributes": { + "domain": "www.dmoz.com" + }, + "content": "Computers/Software/Internet/Site Management/Content Management" + }, + "comments": { + "attributes": {}, + "content": "http://www.feedforall.com/forum" + }, + "description": { + "attributes": {}, + "content": "FeedForAll helps Law Enforcement Professionals communicate with the general public and other agencies in a prompt and efficient manner. Using RSS police are able to quickly disseminate urgent and life threatening information.
\n
\nUses include:
\nAmber Alerts
\nSex Offender Community Notification
\nWeather Alerts
\nScheduling
\nSecurity Alerts
\nPolice Report
\nMeetings
" + }, + "enclosure": null, + "guid": null, + "link": { + "attributes": {}, + "content": "http://www.feedforall.com/law-enforcement.htm" + }, + "pub_date": { + "attributes": {}, + "content": "Tue, 19 Oct 2004 11:08:56 -0400" + }, + "source": null, + "title": { + "attributes": {}, + "content": "RSS Solutions for Law Enforcement" + } + } + } + ], + "language": { + "attributes": {}, + "content": "en-us" + }, + "last_build_date": { + "attributes": {}, + "content": "2004-10-19T13:39:14-04:00" + }, + "link": { + "attributes": {}, + "content": "http://www.feedforall.com/industry-solutions.htm" + }, + "managing_editor": { + "attributes": {}, + "content": "marketing@feedforall.com" + }, + "pub_date": { + "attributes": {}, + "content": "2004-10-19T13:38:55-04:00" + }, + "rating": null, + "skip_days": null, + "skip_hours": null, + "text_input": null, + "title": { + "attributes": {}, + "content": "FeedForAll Sample Feed" + }, + "ttl": null, + "web_master": { + "attributes": {}, + "content": "webmaster@feedforall.com" + } + } + }, + "version": { + "attributes": {}, + "content": "2.0" + } +} \ No newline at end of file diff --git a/tests/samples/rss_2_no_category_attr.xml b/tests/samples/rss_2_no_category_attr.xml new file mode 100644 index 0000000..cb00947 --- /dev/null +++ b/tests/samples/rss_2_no_category_attr.xml @@ -0,0 +1,151 @@ + + + + FeedForAll Sample Feed + RSS is a fascinating technology. The uses for RSS are expanding daily. Take a closer look at how various industries are using the benefits of RSS in their businesses. + http://www.feedforall.com/industry-solutions.htm + Computers/Software/Internet/Site Management/Content Management + Copyright 2004 NotePage, Inc. + http://blogs.law.harvard.edu/tech/rss + en-us + Tue, 19 Oct 2004 13:39:14 -0400 + marketing@feedforall.com + Tue, 19 Oct 2004 13:38:55 -0400 + webmaster@feedforall.com + FeedForAll Beta1 (0.0.1.8) + + http://www.feedforall.com/ffalogo48x48.gif + FeedForAll Sample Feed + http://www.feedforall.com/industry-solutions.htm + FeedForAll Sample Feed + 48 + 48 + + + RSS Solutions for Restaurants + <b>FeedForAll </b>helps Restaurant's communicate with customers. Let your customers know the latest specials or events.<br> +<br> +RSS feed uses include:<br> +<i><font color="#FF0000">Daily Specials <br> +Entertainment <br> +Calendar of Events </i></font> + http://www.feedforall.com/restaurant.htm + Computers/Software/Internet/Site Management/Content Management + http://www.feedforall.com/forum + Tue, 19 Oct 2004 11:09:11 -0400 + + + RSS Solutions for Schools and Colleges + FeedForAll helps Educational Institutions communicate with students about school wide activities, events, and schedules.<br> +<br> +RSS feed uses include:<br> +<i><font color="#0000FF">Homework Assignments <br> +School Cancellations <br> +Calendar of Events <br> +Sports Scores <br> +Clubs/Organization Meetings <br> +Lunches Menus </i></font> + http://www.feedforall.com/schools.htm + Computers/Software/Internet/Site Management/Content Management + http://www.feedforall.com/forum + Tue, 19 Oct 2004 11:09:09 -0400 + + + RSS Solutions for Computer Service Companies + FeedForAll helps Computer Service Companies communicate with clients about cyber security and related issues. <br> +<br> +Uses include:<br> +<i><font color="#0000FF">Cyber Security Alerts <br> +Specials<br> +Job Postings </i></font> + http://www.feedforall.com/computer-service.htm + Computers/Software/Internet/Site Management/Content Management + http://www.feedforall.com/forum + Tue, 19 Oct 2004 11:09:07 -0400 + + + RSS Solutions for Governments + FeedForAll helps Governments communicate with the general public about positions on various issues, and keep the community aware of changes in important legislative issues. <b><i><br> +</b></i><br> +RSS uses Include:<br> +<i><font color="#00FF00">Legislative Calendar<br> +Votes<br> +Bulletins</i></font> + http://www.feedforall.com/government.htm + Computers/Software/Internet/Site Management/Content Management + http://www.feedforall.com/forum + Tue, 19 Oct 2004 11:09:05 -0400 + + + RSS Solutions for Politicians + FeedForAll helps Politicians communicate with the general public about positions on various issues, and keep the community notified of their schedule. <br> +<br> +Uses Include:<br> +<i><font color="#FF0000">Blogs<br> +Speaking Engagements <br> +Statements<br> + </i></font> + http://www.feedforall.com/politics.htm + Computers/Software/Internet/Site Management/Content Management + http://www.feedforall.com/forum + Tue, 19 Oct 2004 11:09:03 -0400 + + + RSS Solutions for Meteorologists + FeedForAll helps Meteorologists communicate with the general public about storm warnings and weather alerts, in specific regions. Using RSS meteorologists are able to quickly disseminate urgent and life threatening weather warnings. <br> +<br> +Uses Include:<br> +<i><font color="#0000FF">Weather Alerts<br> +Plotting Storms<br> +School Cancellations </i></font> + http://www.feedforall.com/weather.htm + Computers/Software/Internet/Site Management/Content Management + http://www.feedforall.com/forum + Tue, 19 Oct 2004 11:09:01 -0400 + + + RSS Solutions for Realtors & Real Estate Firms + FeedForAll helps Realtors and Real Estate companies communicate with clients informing them of newly available properties, and open house announcements. RSS helps to reach a targeted audience and spread the word in an inexpensive, professional manner. <font color="#0000FF"><br> +</font><br> +Feeds can be used for:<br> +<i><font color="#FF0000">Open House Dates<br> +New Properties For Sale<br> +Mortgage Rates</i></font> + http://www.feedforall.com/real-estate.htm + Computers/Software/Internet/Site Management/Content Management + http://www.feedforall.com/forum + Tue, 19 Oct 2004 11:08:59 -0400 + + + RSS Solutions for Banks / Mortgage Companies + FeedForAll helps <b>Banks, Credit Unions and Mortgage companies</b> communicate with the general public about rate changes in a prompt and professional manner. <br> +<br> +Uses include:<br> +<i><font color="#0000FF">Mortgage Rates<br> +Foreign Exchange Rates <br> +Bank Rates<br> +Specials</i></font> + http://www.feedforall.com/banks.htm + Computers/Software/Internet/Site Management/Content Management + http://www.feedforall.com/forum + Tue, 19 Oct 2004 11:08:57 -0400 + + + RSS Solutions for Law Enforcement + <b>FeedForAll</b> helps Law Enforcement Professionals communicate with the general public and other agencies in a prompt and efficient manner. Using RSS police are able to quickly disseminate urgent and life threatening information. <br> +<br> +Uses include:<br> +<i><font color="#0000FF">Amber Alerts<br> +Sex Offender Community Notification <br> +Weather Alerts <br> +Scheduling <br> +Security Alerts <br> +Police Report <br> +Meetings</i></font> + http://www.feedforall.com/law-enforcement.htm + Computers/Software/Internet/Site Management/Content Management + http://www.feedforall.com/forum + Tue, 19 Oct 2004 11:08:56 -0400 + + + diff --git a/tests/samples/rss_2_no_category_attr_plain.json b/tests/samples/rss_2_no_category_attr_plain.json new file mode 100644 index 0000000..d404ff4 --- /dev/null +++ b/tests/samples/rss_2_no_category_attr_plain.json @@ -0,0 +1,141 @@ +{ + "channel": { + "category": "Computers/Software/Internet/Site Management/Content Management", + "cloud": null, + "copyright": "Copyright 2004 NotePage, Inc.", + "description": "RSS is a fascinating technology. The uses for RSS are expanding daily. Take a closer look at how various industries are using the benefits of RSS in their businesses.", + "docs": "http://blogs.law.harvard.edu/tech/rss", + "generator": "FeedForAll Beta1 (0.0.1.8)", + "image": { + "description": "FeedForAll Sample Feed", + "height": 48, + "link": "http://www.feedforall.com/industry-solutions.htm", + "title": "FeedForAll Sample Feed", + "url": "http://www.feedforall.com/ffalogo48x48.gif", + "width": 48 + }, + "items": [ + { + "author": null, + "category": "Computers/Software/Internet/Site Management/Content Management", + "comments": "http://www.feedforall.com/forum", + "description": "FeedForAll helps Restaurant's communicate with customers. Let your customers know the latest specials or events.
\n
\nRSS feed uses include:
\nDaily Specials
\nEntertainment
\nCalendar of Events
", + "enclosure": null, + "guid": null, + "link": "http://www.feedforall.com/restaurant.htm", + "pub_date": "Tue, 19 Oct 2004 11:09:11 -0400", + "source": null, + "title": "RSS Solutions for Restaurants" + }, + { + "author": null, + "category": "Computers/Software/Internet/Site Management/Content Management", + "comments": "http://www.feedforall.com/forum", + "description": "FeedForAll helps Educational Institutions communicate with students about school wide activities, events, and schedules.
\n
\nRSS feed uses include:
\nHomework Assignments
\nSchool Cancellations
\nCalendar of Events
\nSports Scores
\nClubs/Organization Meetings
\nLunches Menus
", + "enclosure": null, + "guid": null, + "link": "http://www.feedforall.com/schools.htm", + "pub_date": "Tue, 19 Oct 2004 11:09:09 -0400", + "source": null, + "title": "RSS Solutions for Schools and Colleges" + }, + { + "author": null, + "category": "Computers/Software/Internet/Site Management/Content Management", + "comments": "http://www.feedforall.com/forum", + "description": "FeedForAll helps Computer Service Companies communicate with clients about cyber security and related issues.
\n
\nUses include:
\nCyber Security Alerts
\nSpecials
\nJob Postings
", + "enclosure": null, + "guid": null, + "link": "http://www.feedforall.com/computer-service.htm", + "pub_date": "Tue, 19 Oct 2004 11:09:07 -0400", + "source": null, + "title": "RSS Solutions for Computer Service Companies" + }, + { + "author": null, + "category": "Computers/Software/Internet/Site Management/Content Management", + "comments": "http://www.feedforall.com/forum", + "description": "FeedForAll helps Governments communicate with the general public about positions on various issues, and keep the community aware of changes in important legislative issues.
\n

\nRSS uses Include:
\nLegislative Calendar
\nVotes
\nBulletins
", + "enclosure": null, + "guid": null, + "link": "http://www.feedforall.com/government.htm", + "pub_date": "Tue, 19 Oct 2004 11:09:05 -0400", + "source": null, + "title": "RSS Solutions for Governments" + }, + { + "author": null, + "category": "Computers/Software/Internet/Site Management/Content Management", + "comments": "http://www.feedforall.com/forum", + "description": "FeedForAll helps Politicians communicate with the general public about positions on various issues, and keep the community notified of their schedule.
\n
\nUses Include:
\nBlogs
\nSpeaking Engagements
\nStatements
\n
", + "enclosure": null, + "guid": null, + "link": "http://www.feedforall.com/politics.htm", + "pub_date": "Tue, 19 Oct 2004 11:09:03 -0400", + "source": null, + "title": "RSS Solutions for Politicians" + }, + { + "author": null, + "category": "Computers/Software/Internet/Site Management/Content Management", + "comments": "http://www.feedforall.com/forum", + "description": "FeedForAll helps Meteorologists communicate with the general public about storm warnings and weather alerts, in specific regions. Using RSS meteorologists are able to quickly disseminate urgent and life threatening weather warnings.
\n
\nUses Include:
\nWeather Alerts
\nPlotting Storms
\nSchool Cancellations
", + "enclosure": null, + "guid": null, + "link": "http://www.feedforall.com/weather.htm", + "pub_date": "Tue, 19 Oct 2004 11:09:01 -0400", + "source": null, + "title": "RSS Solutions for Meteorologists" + }, + { + "author": null, + "category": "Computers/Software/Internet/Site Management/Content Management", + "comments": "http://www.feedforall.com/forum", + "description": "FeedForAll helps Realtors and Real Estate companies communicate with clients informing them of newly available properties, and open house announcements. RSS helps to reach a targeted audience and spread the word in an inexpensive, professional manner.
\n

\nFeeds can be used for:
\nOpen House Dates
\nNew Properties For Sale
\nMortgage Rates
", + "enclosure": null, + "guid": null, + "link": "http://www.feedforall.com/real-estate.htm", + "pub_date": "Tue, 19 Oct 2004 11:08:59 -0400", + "source": null, + "title": "RSS Solutions for Realtors & Real Estate Firms" + }, + { + "author": null, + "category": "Computers/Software/Internet/Site Management/Content Management", + "comments": "http://www.feedforall.com/forum", + "description": "FeedForAll helps Banks, Credit Unions and Mortgage companies communicate with the general public about rate changes in a prompt and professional manner.
\n
\nUses include:
\nMortgage Rates
\nForeign Exchange Rates
\nBank Rates
\nSpecials
", + "enclosure": null, + "guid": null, + "link": "http://www.feedforall.com/banks.htm", + "pub_date": "Tue, 19 Oct 2004 11:08:57 -0400", + "source": null, + "title": "RSS Solutions for Banks / Mortgage Companies" + }, + { + "author": null, + "category": "Computers/Software/Internet/Site Management/Content Management", + "comments": "http://www.feedforall.com/forum", + "description": "FeedForAll helps Law Enforcement Professionals communicate with the general public and other agencies in a prompt and efficient manner. Using RSS police are able to quickly disseminate urgent and life threatening information.
\n
\nUses include:
\nAmber Alerts
\nSex Offender Community Notification
\nWeather Alerts
\nScheduling
\nSecurity Alerts
\nPolice Report
\nMeetings
", + "enclosure": null, + "guid": null, + "link": "http://www.feedforall.com/law-enforcement.htm", + "pub_date": "Tue, 19 Oct 2004 11:08:56 -0400", + "source": null, + "title": "RSS Solutions for Law Enforcement" + } + ], + "language": "en-us", + "last_build_date": "2004-10-19T13:39:14-04:00", + "link": "http://www.feedforall.com/industry-solutions.htm", + "managing_editor": "marketing@feedforall.com", + "pub_date": "2004-10-19T13:38:55-04:00", + "rating": null, + "skip_days": null, + "skip_hours": null, + "text_input": null, + "title": "FeedForAll Sample Feed", + "ttl": null, + "web_master": "webmaster@feedforall.com" + }, + "version": "2.0" +} \ No newline at end of file diff --git a/tests/samples/rss_2_plain.json b/tests/samples/rss_2_plain.json new file mode 100644 index 0000000..d404ff4 --- /dev/null +++ b/tests/samples/rss_2_plain.json @@ -0,0 +1,141 @@ +{ + "channel": { + "category": "Computers/Software/Internet/Site Management/Content Management", + "cloud": null, + "copyright": "Copyright 2004 NotePage, Inc.", + "description": "RSS is a fascinating technology. The uses for RSS are expanding daily. Take a closer look at how various industries are using the benefits of RSS in their businesses.", + "docs": "http://blogs.law.harvard.edu/tech/rss", + "generator": "FeedForAll Beta1 (0.0.1.8)", + "image": { + "description": "FeedForAll Sample Feed", + "height": 48, + "link": "http://www.feedforall.com/industry-solutions.htm", + "title": "FeedForAll Sample Feed", + "url": "http://www.feedforall.com/ffalogo48x48.gif", + "width": 48 + }, + "items": [ + { + "author": null, + "category": "Computers/Software/Internet/Site Management/Content Management", + "comments": "http://www.feedforall.com/forum", + "description": "FeedForAll helps Restaurant's communicate with customers. Let your customers know the latest specials or events.
\n
\nRSS feed uses include:
\nDaily Specials
\nEntertainment
\nCalendar of Events
", + "enclosure": null, + "guid": null, + "link": "http://www.feedforall.com/restaurant.htm", + "pub_date": "Tue, 19 Oct 2004 11:09:11 -0400", + "source": null, + "title": "RSS Solutions for Restaurants" + }, + { + "author": null, + "category": "Computers/Software/Internet/Site Management/Content Management", + "comments": "http://www.feedforall.com/forum", + "description": "FeedForAll helps Educational Institutions communicate with students about school wide activities, events, and schedules.
\n
\nRSS feed uses include:
\nHomework Assignments
\nSchool Cancellations
\nCalendar of Events
\nSports Scores
\nClubs/Organization Meetings
\nLunches Menus
", + "enclosure": null, + "guid": null, + "link": "http://www.feedforall.com/schools.htm", + "pub_date": "Tue, 19 Oct 2004 11:09:09 -0400", + "source": null, + "title": "RSS Solutions for Schools and Colleges" + }, + { + "author": null, + "category": "Computers/Software/Internet/Site Management/Content Management", + "comments": "http://www.feedforall.com/forum", + "description": "FeedForAll helps Computer Service Companies communicate with clients about cyber security and related issues.
\n
\nUses include:
\nCyber Security Alerts
\nSpecials
\nJob Postings
", + "enclosure": null, + "guid": null, + "link": "http://www.feedforall.com/computer-service.htm", + "pub_date": "Tue, 19 Oct 2004 11:09:07 -0400", + "source": null, + "title": "RSS Solutions for Computer Service Companies" + }, + { + "author": null, + "category": "Computers/Software/Internet/Site Management/Content Management", + "comments": "http://www.feedforall.com/forum", + "description": "FeedForAll helps Governments communicate with the general public about positions on various issues, and keep the community aware of changes in important legislative issues.
\n

\nRSS uses Include:
\nLegislative Calendar
\nVotes
\nBulletins
", + "enclosure": null, + "guid": null, + "link": "http://www.feedforall.com/government.htm", + "pub_date": "Tue, 19 Oct 2004 11:09:05 -0400", + "source": null, + "title": "RSS Solutions for Governments" + }, + { + "author": null, + "category": "Computers/Software/Internet/Site Management/Content Management", + "comments": "http://www.feedforall.com/forum", + "description": "FeedForAll helps Politicians communicate with the general public about positions on various issues, and keep the community notified of their schedule.
\n
\nUses Include:
\nBlogs
\nSpeaking Engagements
\nStatements
\n
", + "enclosure": null, + "guid": null, + "link": "http://www.feedforall.com/politics.htm", + "pub_date": "Tue, 19 Oct 2004 11:09:03 -0400", + "source": null, + "title": "RSS Solutions for Politicians" + }, + { + "author": null, + "category": "Computers/Software/Internet/Site Management/Content Management", + "comments": "http://www.feedforall.com/forum", + "description": "FeedForAll helps Meteorologists communicate with the general public about storm warnings and weather alerts, in specific regions. Using RSS meteorologists are able to quickly disseminate urgent and life threatening weather warnings.
\n
\nUses Include:
\nWeather Alerts
\nPlotting Storms
\nSchool Cancellations
", + "enclosure": null, + "guid": null, + "link": "http://www.feedforall.com/weather.htm", + "pub_date": "Tue, 19 Oct 2004 11:09:01 -0400", + "source": null, + "title": "RSS Solutions for Meteorologists" + }, + { + "author": null, + "category": "Computers/Software/Internet/Site Management/Content Management", + "comments": "http://www.feedforall.com/forum", + "description": "FeedForAll helps Realtors and Real Estate companies communicate with clients informing them of newly available properties, and open house announcements. RSS helps to reach a targeted audience and spread the word in an inexpensive, professional manner.
\n

\nFeeds can be used for:
\nOpen House Dates
\nNew Properties For Sale
\nMortgage Rates
", + "enclosure": null, + "guid": null, + "link": "http://www.feedforall.com/real-estate.htm", + "pub_date": "Tue, 19 Oct 2004 11:08:59 -0400", + "source": null, + "title": "RSS Solutions for Realtors & Real Estate Firms" + }, + { + "author": null, + "category": "Computers/Software/Internet/Site Management/Content Management", + "comments": "http://www.feedforall.com/forum", + "description": "FeedForAll helps Banks, Credit Unions and Mortgage companies communicate with the general public about rate changes in a prompt and professional manner.
\n
\nUses include:
\nMortgage Rates
\nForeign Exchange Rates
\nBank Rates
\nSpecials
", + "enclosure": null, + "guid": null, + "link": "http://www.feedforall.com/banks.htm", + "pub_date": "Tue, 19 Oct 2004 11:08:57 -0400", + "source": null, + "title": "RSS Solutions for Banks / Mortgage Companies" + }, + { + "author": null, + "category": "Computers/Software/Internet/Site Management/Content Management", + "comments": "http://www.feedforall.com/forum", + "description": "FeedForAll helps Law Enforcement Professionals communicate with the general public and other agencies in a prompt and efficient manner. Using RSS police are able to quickly disseminate urgent and life threatening information.
\n
\nUses include:
\nAmber Alerts
\nSex Offender Community Notification
\nWeather Alerts
\nScheduling
\nSecurity Alerts
\nPolice Report
\nMeetings
", + "enclosure": null, + "guid": null, + "link": "http://www.feedforall.com/law-enforcement.htm", + "pub_date": "Tue, 19 Oct 2004 11:08:56 -0400", + "source": null, + "title": "RSS Solutions for Law Enforcement" + } + ], + "language": "en-us", + "last_build_date": "2004-10-19T13:39:14-04:00", + "link": "http://www.feedforall.com/industry-solutions.htm", + "managing_editor": "marketing@feedforall.com", + "pub_date": "2004-10-19T13:38:55-04:00", + "rating": null, + "skip_days": null, + "skip_hours": null, + "text_input": null, + "title": "FeedForAll Sample Feed", + "ttl": null, + "web_master": "webmaster@feedforall.com" + }, + "version": "2.0" +} \ No newline at end of file diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 146d91f..dfdc982 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -1,21 +1,41 @@ +from json import dumps + import pytest from rss_parser import Parser -from tests.fixtures import atom_feed, rss_version_2 -def test_parses_rss_version_2(rss_version_2): +@pytest.mark.parametrize("sample_and_result", [["rss_2"], ["rss_2_no_category_attr"], ["apology_line"]], indirect=True) +def test_parses_rss_version_2(sample_and_result): # Expect basic RSSv2 to be parsed - parser = Parser(xml=rss_version_2) - rss = parser.parse() + sample, result = sample_and_result + rss = Parser.parse(sample) assert rss - assert rss.feed + + left = rss.json(indent=2, sort_keys=True) + right = dumps(result, indent=2, sort_keys=True, default=str) + + assert left == right + + +@pytest.mark.parametrize( + "sample_and_result", [["rss_2", True], ["rss_2_no_category_attr", True], ["apology_line", True]], indirect=True +) +def test_json_plain_ignores_attributes(sample_and_result): + # Expect basic RSSv2 to be parsed + sample, result = sample_and_result + rss = Parser.parse(sample) + + assert rss + + left = rss.json_plain(indent=2, sort_keys=True) + right = dumps(result, indent=2, sort_keys=True, default=str) + + assert left == right def test_fails_atom_feed(atom_feed): # Expect ATOM feed to fail since it's not supported - parser = Parser(xml=atom_feed) - with pytest.raises(NotImplementedError): - parser.parse() + Parser.parse(atom_feed) diff --git a/tests/test_tag.py b/tests/test_tag.py new file mode 100644 index 0000000..c1b73ef --- /dev/null +++ b/tests/test_tag.py @@ -0,0 +1,218 @@ +"""Unit tests in addition to doctests present.""" +from datetime import datetime, timedelta +from operator import add, eq, floordiv, ge, gt, le, lt, mod, mul, ne, pow, sub, truediv +from random import randint +from typing import Optional + +from rss_parser.models import XMLBaseModel +from rss_parser.models.types.tag import Tag + + +class Model(XMLBaseModel): + number: Optional[Tag[int]] + float_number: Optional[Tag[float]] + string: Optional[Tag[str]] + datetime: Optional[Tag[datetime]] + + +def rand_str(): + # FIXME: Yeah I know + return "string" + + +def rand_dt(): + return datetime(randint(1, 2023), randint(1, 12), randint(1, 28)) + + +def rand_td(): + return timedelta(randint(1, 1000)) + + +def test_comparison_operators_number(): + number = randint(1, 2**32) + operator_result_list = [ + [eq, number, True], + [eq, number + 1, False], + [eq, number - 1, False], + # + [ne, number, False], + [ne, number + 1, True], + [ne, number - 1, True], + # + [gt, number, False], + [gt, number + 1, False], + [gt, number - 1, True], + [gt, number - 0.1, True], + # + [lt, number, False], + [lt, number + 1, True], + [lt, number - 1, False], + [lt, number + 0.1, True], + # + [ge, number, True], + [ge, number + 1, False], + [ge, number - 1, True], + [ge, number - 0.1, True], + # + [le, number, True], + [le, number + 1, True], + [le, number - 1, False], + [le, number + 0.1, True], + ] + obj = Model(number=number) + + for operator, b_operand, expected in operator_result_list: + assert operator(obj.number, b_operand) is expected + + +def test_comparison_operators_float(): + number = float(randint(1, 2**32)) + operator_result_list = [ + [eq, number, True], + [eq, number + 1, False], + [eq, number - 1, False], + # + [ne, number, False], + [ne, number + 1, True], + [ne, number - 1, True], + # + [gt, number, False], + [gt, number + 1, False], + [gt, number - 1, True], + [gt, number - 0.1, True], + # + [lt, number, False], + [lt, number + 1, True], + [lt, number - 1, False], + [lt, number + 0.1, True], + # + [ge, number, True], + [ge, number + 1, False], + [ge, number - 1, True], + [ge, number - 0.1, True], + # + [le, number, True], + [le, number + 1, True], + [le, number - 1, False], + [le, number + 0.1, True], + ] + obj = Model(floatNumber=number) + + for operator, b_operand, expected in operator_result_list: + assert operator(obj.float_number, b_operand) is expected + + +def test_comparison_operators_string(): + def add_to_last_char(s, n): + return s[:-1] + chr(ord(s[-1]) + n) + + string = rand_str() + operator_result_list = [ + [eq, string, True], + [eq, string[:-1], False], + # + [ne, string, False], + [ne, string[:-1], True], + # + [gt, string, False], + [gt, string[:-1], True], + [gt, string + string, False], + [gt, add_to_last_char(string, 1), False], + [gt, add_to_last_char(string, -1), True], + # + [lt, string, False], + [lt, string[:-1], False], + [lt, string + string, True], + [lt, add_to_last_char(string, 1), True], + [lt, add_to_last_char(string, -1), False], + # + [ge, string, True], + [ge, string[:-1], True], + [ge, string + string, False], + [ge, add_to_last_char(string, 1), False], + [ge, add_to_last_char(string, -1), True], + # + [le, string, True], + [le, string[:-1], False], + [le, string + string, True], + [le, add_to_last_char(string, 1), True], + [le, add_to_last_char(string, -1), False], + ] + obj = Model(string=string) + + for operator, b_operand, expected in operator_result_list: + assert operator(obj.string, b_operand) is expected + + +def test_comparison_operators_datetime(): + dt = rand_dt() + delta = rand_td() + operator_result_list = [ + [eq, dt, True], + [eq, dt + delta, False], + [eq, dt - delta, False], + # + [ne, dt, False], + [ne, dt + delta, True], + [ne, dt - delta, True], + # + [gt, dt, False], + [gt, dt + delta, False], + [gt, dt - delta, True], + # + [lt, dt, False], + [lt, dt + delta, True], + [lt, dt - delta, False], + # + [ge, dt, True], + [ge, dt + delta, False], + [ge, dt - delta, True], + # + [le, dt, True], + [le, dt + delta, True], + [le, dt - delta, False], + ] + obj = Model(datetime=dt) + + for operator, b_operand, expected in operator_result_list: + assert operator(obj.datetime, b_operand) is expected + + +def test_arithmetic_operators_number(): + number = randint(1, 2**32) + b_operand = randint(1, 2**16) + operator_list = [add, sub, mul, truediv, floordiv, mod, pow] + obj = Model(number=number) + + for operator in operator_list: + assert operator(obj.number, b_operand) == operator(number, b_operand) + + +def test_arithmetic_operators_float(): + number = randint(1, 2**8) / 100 + b_operand = randint(1, 2**8) / 100 + operator_list = [add, sub, mul, truediv, floordiv, mod, pow] + obj = Model(floatNumber=number) + + for operator in operator_list: + assert operator(obj.float_number, b_operand) == operator(number, b_operand) + + +def test_arithmetic_operators_string_mul(): + string = rand_str() + int_operand = randint(1, 2**16) + string_operand = rand_str() + obj = Model(string=string) + + assert mul(obj.string, int_operand) == mul(string, int_operand) + assert add(obj.string, string_operand) == add(string, string_operand) + + +def test_arithmetic_operators_datetime(): + dt = rand_dt() + td_operand = rand_td() + operator_list = [add, sub] + obj = Model(datetime=dt) + + for operator in operator_list: + assert operator(obj.datetime, td_operand) == operator(dt, td_operand)