pax_global_header00006660000000000000000000000064147114157010014513gustar00rootroot0000000000000052 comment=ed2b9b82af8510452790a6ef31cc7ec9be48eef9 python-georss-client-0.18/000077500000000000000000000000001471141570100155405ustar00rootroot00000000000000python-georss-client-0.18/.coveragerc000066400000000000000000000000351471141570100176570ustar00rootroot00000000000000[run] source = georss_client python-georss-client-0.18/.github/000077500000000000000000000000001471141570100171005ustar00rootroot00000000000000python-georss-client-0.18/.github/dependabot.yml000066400000000000000000000003151471141570100217270ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "pip" directory: "/" schedule: interval: "weekly" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" python-georss-client-0.18/.github/workflows/000077500000000000000000000000001471141570100211355ustar00rootroot00000000000000python-georss-client-0.18/.github/workflows/ci.yaml000066400000000000000000000025201471141570100224130ustar00rootroot00000000000000name: CI on: push: branches: - master pull_request: ~ workflow_dispatch: jobs: test: name: Python ${{ matrix.python-version }} runs-on: ubuntu-latest env: USING_COVERAGE: '3.10' strategy: fail-fast: true matrix: python-version: [ "3.9", "3.10", "3.11", "3.12" ] steps: - uses: "actions/checkout@v4" - uses: "actions/setup-python@v5" with: python-version: "${{ matrix.python-version }}" - name: "Install dependencies" run: | set -xe python -VV python -m site python -m pip install --upgrade pip setuptools wheel python -m pip install -e . python -m pip install -e .[tests] - name: "Run tests for ${{ matrix.python-version }}" run: | pytest \ -qq \ --timeout=9 \ --durations=10 \ -n auto \ --cov georss_client \ --cov-report xml \ -o console_output_style=count \ -p no:sugar \ tests python -m coverage xml - name: "Upload coverage to Codecov" if: "contains(env.USING_COVERAGE, matrix.python-version)" uses: "codecov/codecov-action@v4" with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true python-georss-client-0.18/.gitignore000066400000000000000000000023011471141570100175240ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ # IDE .idea/ python-georss-client-0.18/.pre-commit-config.yaml000066400000000000000000000003151471141570100220200ustar00rootroot00000000000000repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.7.1 hooks: # Run the linter. - id: ruff args: [ --fix ] # Run the formatter. - id: ruff-format python-georss-client-0.18/CHANGELOG.md000066400000000000000000000065511471141570100173600ustar00rootroot00000000000000# Changes ## 0.18 (02/11/2024) * Removed Python 3.8 support. * Removed dateparser and replaced with python-dateutil. * Bumped xmltodict to 0.14.2. * Bumped ruff to 0.7.1. * Code quality improvements. ## 0.17 (31/01/2024) * Provide backwards compatibility with v0.15 by exposing constants. ## 0.16 (31/01/2024) * Removed Python 3.7 support. * Added Python 3.11 support. * Added Python 3.12 support. * Bumped requests to 2.31.0. * Bumped dateparser to 1.2.0. * Bumped haversine to 2.8.1. * Bumped xmltodict to 0.13.0. * Bumped library versions: black, flake8, isort. * Migrated to pytest. * Code quality improvements. ## 0.15 (16/02/2022) * No functional changes. * Migrated to github actions. * Added Python 3.10 support. * Removed Python 3.6 support. * Bumped library versions: black, flake8, isort. ## 0.14 (08/06/2021) * Add license tag (thanks @fabaff). * General code improvements. ## 0.13 (20/04/2021) * Python 3.9 support. ## 0.12 (30/12/2020) * Add non-standard namespace used by [EMSC feed](https://www.emsc-csem.org/service/rss/rss.php). ## 0.11 (18/10/2020) * Excluded tests from package (thanks @scop). * Python 3.8 support. ## 0.10 (05/12/2019) * Fix handling feeds starting with byte order mark. ## 0.9 (01/04/2019) * Migrated Instituto Geográfico Nacional Sismología feed integration to [python-georss-ign-sismologia-client](https://github.com/exxamalte/python-georss-ign-sismologia-client) * Migrated generic GeoRSS feed integration to [python-georss-generic-client](https://github.com/exxamalte/python-georss-generic-client) * Migrated Western Australia Department of Fire and Emergency Services feed integration to [python-georss-wa-dfes-client](https://github.com/exxamalte/python-georss-wa-dfes-client) * Migrated Queensland Fire and Emergency Services (QFES) Bushfire Alert feed integration to [python-georss-qfes-bushfire-alert-client](https://github.com/exxamalte/python-georss-qfes-bushfire-alert-client) * Migrated Tasmania Fire Service Incidents feed to [python-georss-tfs-incidents-client](https://github.com/exxamalte/python-georss-tfs-incidents-client). * Migrated INGV Centro Nazionale Terremoti (Earthquakes) feed to [python-georss-ingv-centro-nazionale-terremoti-client](https://github.com/exxamalte/python-georss-ingv-centro-nazionale-terremoti-client) * Migrated Natural Resources Canada Earthquakes feed [python-georss-nrcan-earthquakes-client](https://github.com/exxamalte/python-georss-nrcan-earthquakes-client) * Dropped Python 3.5 support. ## 0.8 (24/03/2019) * Fixed issue where the feed entries do not have any suitable timestamps. ## 0.7 (24/03/2019) * Simple Feed Manager for all feeds added. ## 0.6 (20/03/2019) * Support for Instituto Geográfico Nacional Sismología (Earthquakes) feed. ## 0.5 (14/12/2018) * Built-in XML parser. * Python 3.7 support. ## 0.4 (01/11/2018) * Third-party library updates. ## 0.3 (08/10/2018) * Filter out entries without any geo location data. * Support for Natural Resources Canada Earthquakes feed. * Support for INGV Centro Nazionale Terremoti (Earthquakes) feed. ## 0.2 (05/10/2018) * Support for Tasmania Fire Service Incidents feed. * Support for Western Australia Department of Fire and Emergency Services feed. ## 0.1 (27/09/2018) * Initial release with support for generic GeoRSS feeds and the QFES Bushfire Alert feed. * Calculating distance to home coordinates. * Support for filtering by distance and category for all feeds. python-georss-client-0.18/LICENSE000066400000000000000000000261351471141570100165540ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. python-georss-client-0.18/MANIFEST.in000066400000000000000000000000141471141570100172710ustar00rootroot00000000000000prune tests python-georss-client-0.18/README.md000066400000000000000000000107651471141570100170300ustar00rootroot00000000000000# python-georss-client [![Build Status](https://img.shields.io/github/actions/workflow/status/exxamalte/python-georss-client/ci.yaml)](https://github.com/exxamalte/python-georss-client/actions/workflows/ci.yaml) [![codecov](https://codecov.io/gh/exxamalte/python-georss-client/branch/master/graph/badge.svg?token=8L93XZYRJK)](https://codecov.io/gh/exxamalte/python-georss-client) [![PyPi](https://img.shields.io/pypi/v/georss-client.svg)](https://pypi.python.org/pypi/georss-client) [![Version](https://img.shields.io/pypi/pyversions/georss-client.svg)](https://pypi.python.org/pypi/georss-client) [![Maintainability](https://api.codeclimate.com/v1/badges/ed2a70f3af0c2324dcce/maintainability)](https://codeclimate.com/github/exxamalte/python-georss-client/maintainability) This library is a framework to build concrete libraries for convenient access to [GeoRSS](http://www.georss.org/) Feeds. ## Installation `pip install georss-client` ## Known Implementations | Library | Source | Topic | |---------|--------|-------| | [python-georss-generic-client](https://github.com/exxamalte/python-georss-generic-client) | Generic GeoRSS Feeds | misc | | [python-georss-ign-sismologia-client](https://github.com/exxamalte/python-georss-ign-sismologia-client) | Instituto Geográfico Nacional Sismología | Earthquakes | | [python-georss-ingv-centro-nazionale-terremoti-client](https://github.com/exxamalte/python-georss-ingv-centro-nazionale-terremoti-client) | INGV Centro Nazionale Terremoti | Earthquakes | | [python-georss-nrcan-earthquakes-client](https://github.com/exxamalte/python-georss-nrcan-earthquakes-client) | Natural Resources Canada | Earthquakes | | [python-georss-qld-bushfire-alert-client](https://github.com/exxamalte/python-georss-qld-bushfire-alert-client) | Queensland Bushfire Alert | Fires | | [python-georss-tfs-incidents-client](https://github.com/exxamalte/python-georss-tfs-incidents-client) | Tasmania Fire Service Incidents | Fires | | [python-georss-wa-dfes-client](https://github.com/exxamalte/python-georss-wa-dfes-client) | Western Australia Department of Fire and Emergency Services | Fires | ## Usage Each implementation extracts relevant information from the GeoRSS feed. Not all feeds contain the same level of information, or present their information in different ways. After instantiating a particular class and supply the required parameters, you can call `update` to retrieve the feed data. The return value will be a tuple of a status code and the actual data in the form of a list of feed entries specific to the selected feed. Status Codes * _UPDATE_OK_: Update went fine and data was retrieved. The library may still return empty data, for example because no entries fulfilled the filter criteria. * _UPDATE_OK_NO_DATA_: Update went fine but no data was retrieved, for example because the server indicated that there was not update since the last request. * _UPDATE_ERROR_: Something went wrong during the update ## Feed Managers The Feed Managers help managing feed updates over time, by notifying the consumer of the feed about new feed entries, updates and removed entries compared to the last feed update. * If the current feed update is the first one, then all feed entries will be reported as new. The feed manager will keep track of all feed entries' external IDs that it has successfully processed. * If the current feed update is not the first one, then the feed manager will produce three sets: * Feed entries that were not in the previous feed update but are in the current feed update will be reported as new. * Feed entries that were in the previous feed update and are still in the current feed update will be reported as to be updated. * Feed entries that were in the previous feed update but are not in the current feed update will be reported to be removed. * If the current update fails, then all feed entries processed in the previous feed update will be reported to be removed. After a successful update from the feed, the feed manager will provide two different dates: * `last_update` will be the timestamp of the last successful update from the feed. This date may be useful if the consumer of this library wants to treat intermittent errors from feed updates differently. * `last_timestamp` will be the latest timestamp extracted from the feed data. This requires that the underlying feed data actually contains a suitable date. This date may be useful if the consumer of this library wants to process feed entries differently if they haven't actually been updated. python-georss-client-0.18/codecov.yml000066400000000000000000000000171471141570100177030ustar00rootroot00000000000000comment: false python-georss-client-0.18/georss_client/000077500000000000000000000000001471141570100204005ustar00rootroot00000000000000python-georss-client-0.18/georss_client/__init__.py000066400000000000000000000004671471141570100225200ustar00rootroot00000000000000"""Base class for GeoRSS services.""" from georss_client.consts import ATTR_ATTRIBUTION # noqa: F401 from .consts import ( # noqa: F401 CUSTOM_ATTRIBUTE, UPDATE_ERROR, UPDATE_OK, UPDATE_OK_NO_DATA, ) from .feed import GeoRssFeed # noqa: F401 from .feed_entry import FeedEntry # noqa: F401 python-georss-client-0.18/georss_client/consts.py000066400000000000000000000040121471141570100222600ustar00rootroot00000000000000"""Constants. Constants for feeds and feed entries. """ from __future__ import annotations from typing import Final ATTR_ATTRIBUTION: Final = "attribution" CUSTOM_ATTRIBUTE: Final = "custom_attribute" XML_ATTR_HREF: Final = "@href" XML_ATTR_TERM: Final = "@term" XML_CDATA: Final = "#text" XML_TAG_AUTHOR: Final = "author" XML_TAG_CATEGORY: Final = "category" XML_TAG_CHANNEL: Final = "channel" XML_TAG_CONTENT: Final = "content" XML_TAG_CONTRIBUTOR: Final = "contributor" XML_TAG_COPYRIGHT: Final = "copyright" XML_TAG_DC_DATE: Final = "dc:date" XML_TAG_DESCRIPTION: Final = "description" XML_TAG_DOCS: Final = "docs" XML_TAG_ENTRY: Final = "entry" XML_TAG_FEED: Final = "feed" XML_TAG_GENERATOR: Final = "generator" XML_TAG_GEO_LAT: Final = "geo:lat" XML_TAG_GEO_LONG: Final = "geo:long" XML_TAG_GEO_POINT: Final = "geo:Point" XML_TAG_GEORSS_POINT: Final = "georss:point" XML_TAG_GEORSS_POLYGON: Final = "georss:polygon" XML_TAG_GEORSS_WHERE: Final = "georss:where" XML_TAG_GML_EXTERIOR: Final = "gml:exterior" XML_TAG_GML_LINEAR_RING: Final = "gml:LinearRing" XML_TAG_GML_POINT: Final = "gml:Point" XML_TAG_GML_POLYGON: Final = "gml:Polygon" XML_TAG_GML_POS: Final = "gml:pos" XML_TAG_GML_POS_LIST: Final = "gml:posList" XML_TAG_GUID: Final = "guid" XML_TAG_HEIGHT: Final = "height" XML_TAG_ID: Final = "id" XML_TAG_IMAGE: Final = "image" XML_TAG_ITEM: Final = "item" XML_TAG_LANGUAGE: Final = "language" XML_TAG_LAST_BUILD_DATE: Final = "lastBuildDate" XML_TAG_LINK: Final = "link" XML_TAG_MANAGING_EDITOR: Final = "managingEditor" XML_TAG_NAME: Final = "name" XML_TAG_PUB_DATE: Final = "pubDate" XML_TAG_PUBLISHED: Final = "published" XML_TAG_RIGHTS: Final = "rights" XML_TAG_RSS: Final = "rss" XML_TAG_SOURCE: Final = "source" XML_TAG_SUBTITLE: Final = "subtitle" XML_TAG_SUMMARY: Final = "summary" XML_TAG_TITLE: Final = "title" XML_TAG_TTL: Final = "ttl" XML_TAG_UPDATED: Final = "updated" XML_TAG_URL: Final = "url" XML_TAG_WIDTH: Final = "width" UPDATE_OK: Final = "OK" UPDATE_OK_NO_DATA: Final = "OK_NO_DATA" UPDATE_ERROR: Final = "ERROR" python-georss-client-0.18/georss_client/exceptions.py000066400000000000000000000001431471141570100231310ustar00rootroot00000000000000"""Exceptions for this library.""" class GeoRssException(Exception): """GeoRSS Exception.""" python-georss-client-0.18/georss_client/feed.py000066400000000000000000000137131471141570100216620ustar00rootroot00000000000000"""GeoRSS Feed.""" from __future__ import annotations import codecs from datetime import datetime import logging import requests from .consts import ATTR_ATTRIBUTION, UPDATE_ERROR, UPDATE_OK, UPDATE_OK_NO_DATA from .xml_parser import Feed, XmlParser from .xml_parser.feed_item import FeedItem _LOGGER = logging.getLogger(__name__) class GeoRssFeed: """GeoRSS feed base class.""" def __init__( self, home_coordinates: tuple[float, float], url: str, filter_radius: float | None = None, filter_categories: list[str] | None = None, ): """Initialise this service.""" self._home_coordinates: tuple[float, float] = home_coordinates self._filter_radius: float | None = filter_radius self._filter_categories: list[str] | None = filter_categories self._url: str = url self._request = requests.Request(method="GET", url=url).prepare() self._last_timestamp: datetime | None = None def __repr__(self): """Return string representation of this feed.""" return f"<{self.__class__.__name__}(home={self._home_coordinates}, url={self._url}, radius={self._filter_radius}, categories={self._filter_categories})>" def _new_entry( self, home_coordinates: tuple[float, float], rss_entry: FeedItem, global_data: dict, ): """Generate a new entry.""" def _additional_namespaces(self): """Provide additional namespaces, relevant for this feed.""" def update(self): """Update from external source and return filtered entries.""" status, data = self._fetch() if status == UPDATE_OK: if data: global_data = self._extract_from_feed(data) # Extract data from feed entries. entries: list = [ self._new_entry(self._home_coordinates, rss_entry, global_data) for rss_entry in data.entries ] filtered_entries = self._filter_entries(entries) self._last_timestamp = self._extract_last_timestamp(filtered_entries) return UPDATE_OK, filtered_entries # Should not happen. return UPDATE_OK, None if status == UPDATE_OK_NO_DATA: # Happens for example if the server returns 304 return UPDATE_OK_NO_DATA, None # Error happened while fetching the feed. self._last_timestamp = None return UPDATE_ERROR, None def _fetch(self) -> tuple[str, Feed | None]: """Fetch GeoRSS data from external source.""" try: with requests.Session() as session: response = session.send(self._request, timeout=10) if response.ok: self._pre_process_response(response) parser = XmlParser(self._additional_namespaces()) feed_data = parser.parse(response.text) self.parser = parser self.feed_data = feed_data return UPDATE_OK, feed_data _LOGGER.warning( "Fetching data from %s failed with status %s", self._request.url, response.status_code, ) return UPDATE_ERROR, None except requests.exceptions.RequestException as request_ex: _LOGGER.warning( "Fetching data from %s failed with %s", self._request.url, request_ex ) return UPDATE_ERROR, None def _pre_process_response(self, response): """Pre-process the response.""" if response: _LOGGER.debug("Response encoding %s", response.encoding) if response.content.startswith(codecs.BOM_UTF8): _LOGGER.debug( "UTF8 byte order mark detected, " "setting encoding to 'utf-8-sig'" ) response.encoding = "utf-8-sig" def _filter_entries(self, entries): """Filter the provided entries.""" filtered_entries = entries _LOGGER.debug("Entries before filtering %s", filtered_entries) # Always remove entries without geometry filtered_entries = list( filter(lambda entry: entry.geometry is not None, filtered_entries) ) # Filter by distance. if self._filter_radius: filtered_entries = list( filter( lambda entry: entry.distance_to_home <= self._filter_radius, filtered_entries, ) ) # Filter by category. if self._filter_categories: filtered_entries = list( filter( lambda entry: len( {entry.category}.intersection(self._filter_categories) ) > 0, filtered_entries, ) ) _LOGGER.debug("Entries after filtering %s", filtered_entries) return filtered_entries def _extract_from_feed(self, feed: Feed) -> dict: """Extract global metadata from feed.""" global_data: dict = {} author: str | None = feed.author if author: global_data[ATTR_ATTRIBUTION] = author return global_data def _extract_last_timestamp(self, feed_entries) -> datetime | None: """Determine latest (newest) entry from the filtered feed.""" if feed_entries: dates: list[datetime] = sorted( [entry.published for entry in feed_entries if entry.published], reverse=True, ) if dates: last_timestamp: datetime = dates[0] _LOGGER.debug("Last timestamp: %s", last_timestamp) return last_timestamp return None @property def last_timestamp(self) -> datetime | None: """Return the last timestamp extracted from this feed.""" return self._last_timestamp python-georss-client-0.18/georss_client/feed_entry.py000066400000000000000000000100111471141570100230670ustar00rootroot00000000000000"""Feed Entry.""" from __future__ import annotations from datetime import datetime import re from .consts import CUSTOM_ATTRIBUTE from .geo_rss_distance_helper import GeoRssDistanceHelper from .xml_parser.feed_item import FeedItem from .xml_parser.geometry import Geometry class FeedEntry: """Feed entry base class.""" def __init__(self, home_coordinates: tuple[float, float], rss_entry: FeedItem): """Initialise this feed entry.""" self._home_coordinates: tuple[float, float] = home_coordinates self._rss_entry: FeedItem = rss_entry def __repr__(self): """Return string representation of this entry.""" return f"<{self.__class__.__name__}(id={self.external_id})>" @property def geometry(self) -> Geometry | None: """Return all geometry details of this entry.""" if self._rss_entry: return self._rss_entry.geometry return None @property def coordinates(self) -> tuple[float, float] | None: """Return the best coordinates (latitude, longitude) of this entry.""" if self.geometry: return GeoRssDistanceHelper.extract_coordinates(self.geometry) return None @property def external_id(self) -> str | None: """Return the external id of this entry.""" if self._rss_entry: external_id = self._rss_entry.guid if not external_id: external_id = self.title if not external_id: # Use geometry as ID as a fallback. external_id = hash(self.coordinates) return external_id return None def _search_in_external_id(self, regexp) -> str | None: """Find a sub-string in the entry's external id.""" if self.external_id: match = re.search(regexp, self.external_id) if match: return match.group(CUSTOM_ATTRIBUTE) return None @property def title(self) -> str | None: """Return the title of this entry.""" if self._rss_entry: return self._rss_entry.title return None def _search_in_title(self, regexp): """Find a sub-string in the entry's title.""" if self.title: match = re.search(regexp, self.title) if match: return match.group(CUSTOM_ATTRIBUTE) return None @property def category(self) -> str | None: """Return the category of this entry.""" if ( self._rss_entry and self._rss_entry.category and isinstance(self._rss_entry.category, list) ): # To keep this simple, just return the first category. return self._rss_entry.category[0] return None @property def attribution(self) -> str | None: """Return the attribution of this entry.""" return None @property def distance_to_home(self) -> float: """Return the distance in km of this entry to the home coordinates.""" return GeoRssDistanceHelper.distance_to_geometry( self._home_coordinates, self.geometry ) @property def description(self) -> str | None: """Return the description of this entry.""" if self._rss_entry and self._rss_entry.description: return self._rss_entry.description return None @property def published(self) -> datetime | None: """Return the published date of this entry.""" if self._rss_entry: return self._rss_entry.published_date return None @property def updated(self) -> datetime | None: """Return the updated date of this entry.""" if self._rss_entry: return self._rss_entry.updated_date return None def _search_in_description(self, regexp): """Find a sub-string in the entry's description.""" if self.description: match = re.search(regexp, self.description) if match: return match.group(CUSTOM_ATTRIBUTE) return None python-georss-client-0.18/georss_client/feed_manager.py000066400000000000000000000076641471141570100233640ustar00rootroot00000000000000"""Base class for the feed manager. This allows managing feeds and their entries throughout their life-cycle. """ from __future__ import annotations from datetime import datetime import logging from typing import Callable from . import GeoRssFeed from .consts import UPDATE_OK, UPDATE_OK_NO_DATA _LOGGER = logging.getLogger(__name__) class FeedManagerBase: """Generic Feed manager.""" def __init__( self, feed: GeoRssFeed, generate_callback: Callable[[str], None], update_callback: Callable[[str], None], remove_callback: Callable[[str], None], ): """Initialise feed manager.""" self._feed: GeoRssFeed = feed self.feed_entries: dict = {} self._managed_external_ids = set() self._last_update: datetime | None = None self._generate_callback: Callable[[str], None] = generate_callback self._update_callback: Callable[[str], None] = update_callback self._remove_callback: Callable[[str], None] = remove_callback def __repr__(self): """Return string representation of this feed.""" return f"<{self.__class__.__name__}(feed={self._feed})>" def update(self): """Update the feed and then update connected entities.""" status, feed_entries = self._feed.update() if status == UPDATE_OK: _LOGGER.debug("Data retrieved %s", feed_entries) # Keep a copy of all feed entries for future lookups by entities. self.feed_entries = {entry.external_id: entry for entry in feed_entries} # Record current time of update. self._last_update = datetime.now() # For entity management the external ids from the feed are used. feed_external_ids = set(self.feed_entries) remove_external_ids = self._managed_external_ids.difference( feed_external_ids ) self._remove_entities(remove_external_ids) update_external_ids = self._managed_external_ids.intersection( feed_external_ids ) self._update_entities(update_external_ids) create_external_ids = feed_external_ids.difference( self._managed_external_ids ) self._generate_new_entities(create_external_ids) elif status == UPDATE_OK_NO_DATA: _LOGGER.debug("Update successful, but no data received from %s", self._feed) else: _LOGGER.warning( "Update not successful, no data received from %s", self._feed ) # Remove all entities. self._remove_entities(self._managed_external_ids.copy()) # Remove all feed entries and managed external ids. self.feed_entries.clear() self._managed_external_ids.clear() def _generate_new_entities(self, external_ids): """Generate new entities for events.""" for external_id in external_ids: self._generate_callback(external_id) _LOGGER.debug("New entity added %s", external_id) self._managed_external_ids.add(external_id) def _update_entities(self, external_ids): """Update entities.""" for external_id in external_ids: _LOGGER.debug("Existing entity found %s", external_id) self._update_callback(external_id) def _remove_entities(self, external_ids): """Remove entities.""" for external_id in external_ids: _LOGGER.debug("Entity not current anymore %s", external_id) self._managed_external_ids.remove(external_id) self._remove_callback(external_id) @property def last_timestamp(self) -> datetime | None: """Return the last timestamp extracted from this feed.""" return self._feed.last_timestamp @property def last_update(self) -> datetime | None: """Return the last successful update of this feed.""" return self._last_update python-georss-client-0.18/georss_client/geo_rss_distance_helper.py000066400000000000000000000061431471141570100256300ustar00rootroot00000000000000"""GeoRSS Distance Helper.""" from __future__ import annotations import logging from haversine import haversine from .xml_parser.geometry import Geometry, Point, Polygon _LOGGER = logging.getLogger(__name__) class GeoRssDistanceHelper: """Helper to calculate distances between GeoRSS geometries.""" @staticmethod def extract_coordinates(geometry: Geometry) -> tuple[float, float] | None: """Extract the best coordinates from the feature for display.""" latitude = longitude = None if isinstance(geometry, Point): # Just extract latitude and longitude directly. latitude, longitude = geometry.latitude, geometry.longitude elif isinstance(geometry, Polygon): centroid = geometry.centroid latitude, longitude = centroid.latitude, centroid.longitude _LOGGER.debug("Centroid of %s is %s", geometry, (latitude, longitude)) else: _LOGGER.debug("Not implemented: %s", type(geometry)) return latitude, longitude @staticmethod def distance_to_geometry( home_coordinates: tuple[float, float], geometry: Geometry ) -> float: """Calculate the distance between home coordinates and geometry.""" distance: float = float("inf") if isinstance(geometry, Point): distance = GeoRssDistanceHelper._distance_to_point( home_coordinates, geometry ) elif isinstance(geometry, Polygon): distance = GeoRssDistanceHelper._distance_to_polygon( home_coordinates, geometry ) else: _LOGGER.debug("Not implemented: %s", type(geometry)) return distance @staticmethod def _distance_to_point( home_coordinates: tuple[float, float], point: Point ) -> float: """Calculate the distance between home coordinates and the point.""" # Swap coordinates to match: (latitude, longitude). return GeoRssDistanceHelper._distance_to_coordinates( home_coordinates, (point.latitude, point.longitude) ) @staticmethod def _distance_to_polygon( home_coordinates: tuple[float, float], polygon: Polygon ) -> float: """Calculate the distance between home coordinates and the polygon.""" distance = float("inf") # Calculate distance from polygon by calculating the distance # to each point of the polygon but not to each edge of the # polygon; should be good enough for point in polygon.points: distance = min( distance, GeoRssDistanceHelper._distance_to_coordinates( home_coordinates, (point.latitude, point.longitude) ), ) return distance @staticmethod def _distance_to_coordinates( home_coordinates: tuple[float, float], coordinates: tuple[float, float] ) -> float: """Calculate the distance between home coordinates and the coordinates.""" # Expecting coordinates in format: (latitude, longitude). return haversine(coordinates, home_coordinates) python-georss-client-0.18/georss_client/xml_parser/000077500000000000000000000000001471141570100225545ustar00rootroot00000000000000python-georss-client-0.18/georss_client/xml_parser/__init__.py000066400000000000000000000063561471141570100246770ustar00rootroot00000000000000"""XML Parser.""" from __future__ import annotations from datetime import datetime import logging import dateutil import xmltodict from georss_client.consts import ( XML_TAG_CHANNEL, XML_TAG_DC_DATE, XML_TAG_FEED, XML_TAG_GEO_LAT, XML_TAG_GEO_LONG, XML_TAG_GEORSS_POINT, XML_TAG_GEORSS_POLYGON, XML_TAG_GML_POS, XML_TAG_GML_POS_LIST, XML_TAG_HEIGHT, XML_TAG_LAST_BUILD_DATE, XML_TAG_PUB_DATE, XML_TAG_PUBLISHED, XML_TAG_RSS, XML_TAG_TTL, XML_TAG_UPDATED, XML_TAG_WIDTH, ) from georss_client.xml_parser.feed import Feed _LOGGER = logging.getLogger(__name__) DEFAULT_NAMESPACES = { "http://www.w3.org/2005/Atom": None, "http://purl.org/dc/elements/1.1/": "dc", "http://www.georss.org/georss": "georss", "http://www.w3.org/2003/01/geo/wgs84_pos#": "geo", "http://www.w3.org/2003/01/geo/": "geo", "http://www.opengis.net/gml": "gml", "http://www.gdacs.org/": "gdacs", } KEYS_DATE = [ XML_TAG_DC_DATE, XML_TAG_LAST_BUILD_DATE, XML_TAG_PUB_DATE, XML_TAG_PUBLISHED, XML_TAG_UPDATED, ] KEYS_FLOAT = [XML_TAG_GEO_LAT, XML_TAG_GEO_LONG] KEYS_FLOAT_LIST = [ XML_TAG_GEORSS_POLYGON, XML_TAG_GML_POS_LIST, XML_TAG_GML_POS, XML_TAG_GEORSS_POINT, ] KEYS_INT = [XML_TAG_HEIGHT, XML_TAG_TTL, XML_TAG_WIDTH] class XmlParser: """Built-in XML parser.""" def __init__(self, additional_namespaces: dict | None = None): """Initialise the XML parser.""" self._namespaces = DEFAULT_NAMESPACES if additional_namespaces: self._namespaces.update(additional_namespaces) @staticmethod def postprocessor( path: list[str], key: str, value: str ) -> tuple[str, str | float | int | datetime | tuple]: """Conduct type conversion for selected keys.""" try: if key in KEYS_DATE and value: return key, dateutil.parser.parse(value) if key in KEYS_FLOAT and value: return key, float(value) if key in KEYS_FLOAT_LIST and value: # Turn white-space separated list of numbers into # list of floats. coordinate_values = value.split() point_coordinates: list[float] = [ float(coordinate_values[i]) for i in range(len(coordinate_values)) ] return key, tuple(point_coordinates) if key in KEYS_INT and value: return key, int(value) except (ValueError, TypeError) as error: _LOGGER.warning("Unable to process (%s/%s): %s", key, value, error) return key, value def parse(self, xml: str) -> Feed | None: """Parse the provided xml.""" if xml: parsed_dict = xmltodict.parse( xml, process_namespaces=True, namespaces=self._namespaces, postprocessor=XmlParser.postprocessor, ) if XML_TAG_RSS in parsed_dict: rss = parsed_dict.get(XML_TAG_RSS) if XML_TAG_CHANNEL in rss: return Feed(rss.get(XML_TAG_CHANNEL)) if XML_TAG_FEED in parsed_dict: return Feed(parsed_dict.get(XML_TAG_FEED)) return None python-georss-client-0.18/georss_client/xml_parser/feed.py000066400000000000000000000044501471141570100240340ustar00rootroot00000000000000"""GeoRSS feed models.""" from __future__ import annotations import logging from georss_client.consts import ( XML_TAG_COPYRIGHT, XML_TAG_DOCS, XML_TAG_ENTRY, XML_TAG_GENERATOR, XML_TAG_IMAGE, XML_TAG_ITEM, XML_TAG_LANGUAGE, XML_TAG_RIGHTS, XML_TAG_SUBTITLE, XML_TAG_TTL, ) from georss_client.xml_parser.feed_image import FeedImage from georss_client.xml_parser.feed_item import FeedItem from georss_client.xml_parser.feed_or_feed_item import FeedOrFeedItem _LOGGER = logging.getLogger(__name__) class Feed(FeedOrFeedItem): """Represents a feed.""" @property def subtitle(self) -> str | None: """Return the subtitle of this feed.""" return self._attribute_with_text([XML_TAG_SUBTITLE]) @property def copyright(self) -> str | None: """Return the copyright of this feed.""" return self._attribute_with_text([XML_TAG_COPYRIGHT, XML_TAG_RIGHTS]) @property def rights(self) -> str | None: """Return the rights of this feed.""" return self.copyright @property def generator(self) -> str | None: """Return the generator of this feed.""" return self._attribute_with_text([XML_TAG_GENERATOR]) @property def language(self) -> str | None: """Return the language of this feed.""" return self._attribute([XML_TAG_LANGUAGE]) @property def docs(self) -> str | None: """Return the docs URL of this feed.""" return self._attribute_with_text([XML_TAG_DOCS]) @property def ttl(self) -> int | None: """Return the ttl of this feed.""" return self._attribute([XML_TAG_TTL]) @property def image(self) -> FeedImage | None: """Return the image of this feed.""" image = self._attribute([XML_TAG_IMAGE]) if image: return FeedImage(image) return None @property def entries(self) -> list[FeedItem]: """Return the entries of this feed.""" items = self._attribute([XML_TAG_ITEM, XML_TAG_ENTRY]) entries = [] if items and isinstance(items, list): entries = [FeedItem(item) for item in items] else: # A single item in the feed is not represented as an array. entries.append(FeedItem(items)) return entries python-georss-client-0.18/georss_client/xml_parser/feed_dict_source.py000066400000000000000000000054461471141570100264250ustar00rootroot00000000000000"""GeoRSS feed dict source.""" from __future__ import annotations from typing import Optional from georss_client.consts import ( XML_ATTR_HREF, XML_CDATA, XML_TAG_CONTENT, XML_TAG_DESCRIPTION, XML_TAG_LINK, XML_TAG_SUMMARY, XML_TAG_TITLE, ) class FeedDictSource: """Represents a subset of a feed based on a dict.""" def __init__(self, source: dict): """Initialise feed.""" self._source: dict = source def __repr__(self): """Return string representation of this feed item.""" return f"<{self.__class__.__name__}({self.link})>" def _attribute(self, names: list[str]) -> Optional: """Get an attribute from this feed or feed item.""" if self._source and names: # Try each name, and return the first value that is not None. for name in names: value = self._source.get(name, None) if value: return value return None def _attribute_with_text(self, names: list[str]) -> Optional: """Get an attribute with text from this feed or feed item.""" value = self._attribute(names) if value and isinstance(value, dict) and XML_CDATA in value: # Value value = value.get(XML_CDATA) return value @staticmethod def _attribute_in_structure(obj, keys: list[str]) -> Optional: """Return the attribute found under the chain of keys.""" key = keys.pop(0) if key in obj: return ( FeedDictSource._attribute_in_structure(obj[key], keys) if keys else obj[key] ) return None @property def title(self) -> str | None: """Return the title of this feed or feed item.""" return self._attribute_with_text([XML_TAG_TITLE]) @property def description(self) -> str | None: """Return the description of this feed or feed item.""" return self._attribute_with_text( [XML_TAG_DESCRIPTION, XML_TAG_SUMMARY, XML_TAG_CONTENT] ) @property def summary(self) -> str | None: """Return the summary of this feed or feed item.""" return self.description @property def content(self) -> str | None: """Return the content of this feed or feed item.""" return self.description @property def link(self) -> str | None: """Return the link of this feed or feed item.""" link = self._attribute([XML_TAG_LINK]) if link and XML_ATTR_HREF in link: link = link.get(XML_ATTR_HREF) return link def get_additional_attribute(self, name: str) -> Optional: """Get an additional attribute not provided as property.""" return self._attribute([name]) python-georss-client-0.18/georss_client/xml_parser/feed_image.py000066400000000000000000000013211471141570100251700ustar00rootroot00000000000000"""GeoRSS feed image.""" from __future__ import annotations from georss_client.consts import XML_TAG_HEIGHT, XML_TAG_URL, XML_TAG_WIDTH from georss_client.xml_parser.feed_dict_source import FeedDictSource class FeedImage(FeedDictSource): """Represents a feed image.""" @property def url(self) -> str | None: """Return the url of this feed image.""" return self._attribute([XML_TAG_URL]) @property def height(self) -> int | None: """Return the height of this feed image.""" return self._attribute([XML_TAG_HEIGHT]) @property def width(self) -> int | None: """Return the width of this feed image.""" return self._attribute([XML_TAG_WIDTH]) python-georss-client-0.18/georss_client/xml_parser/feed_item.py000066400000000000000000000162761471141570100250630ustar00rootroot00000000000000"""GeoRSS feed item.""" from __future__ import annotations from georss_client.consts import ( XML_TAG_GEO_LAT, XML_TAG_GEO_LONG, XML_TAG_GEO_POINT, XML_TAG_GEORSS_POINT, XML_TAG_GEORSS_POLYGON, XML_TAG_GEORSS_WHERE, XML_TAG_GML_EXTERIOR, XML_TAG_GML_LINEAR_RING, XML_TAG_GML_POINT, XML_TAG_GML_POLYGON, XML_TAG_GML_POS, XML_TAG_GML_POS_LIST, XML_TAG_GUID, XML_TAG_ID, XML_TAG_SOURCE, ) from georss_client.xml_parser.feed_or_feed_item import FeedOrFeedItem from georss_client.xml_parser.geometry import Geometry, Point, Polygon class FeedItem(FeedOrFeedItem): """Represents a feed item.""" def __repr__(self): """Return string representation of this feed item.""" return f"<{self.__class__.__name__}({self.guid})>" @property def guid(self) -> str | None: """Return the guid of this feed item.""" return self._attribute_with_text([XML_TAG_GUID, XML_TAG_ID]) @property def id(self) -> str | None: """Return the id of this feed item.""" return self.guid @property def source(self) -> str | None: """Return the source of this feed item.""" return self._attribute([XML_TAG_SOURCE]) @property def geometries(self) -> list[Geometry] | None: """Return all geometries of this feed item.""" geometries = [] for entry in [ self._geometry_georss_point(), self._geometry_georss_where(), self._geometry_geo_point(), self._geometry_geo_long_lat(), self._geometry_georss_polygon(), ]: if entry: geometries.extend(entry) # Filter out any duplicates. unique_geometries = [] for i in geometries: if i not in unique_geometries: unique_geometries.append(i) return unique_geometries def _geometry_georss_point(self) -> list[Point] | None: """Check for georss:point tag.""" # -0.5 119.8 point = self._attribute([XML_TAG_GEORSS_POINT]) if point: if isinstance(point, tuple): return FeedItem._create_georss_point_single(point) return FeedItem._create_georss_point_multiple(point) return None @staticmethod def _create_georss_point_single(point: tuple) -> list[Point]: """Create single point from provided coordinates.""" return [Point(point[0], point[1])] @staticmethod def _create_georss_point_multiple(point: list) -> list[Point]: """Create multiple points from provided coordinates.""" return [Point(entry[0], entry[1]) for entry in point] def _geometry_georss_where(self) -> list[Geometry] | None: """Check for georss:where tag.""" where = self._attribute([XML_TAG_GEORSS_WHERE]) if where: # Point: # # # 44.11 -66.23 # # pos = self._attribute_in_structure( where, [XML_TAG_GML_POINT, XML_TAG_GML_POS] ) if pos: return [Point(pos[0], pos[1])] # Polygon: # # # # # # -71.106216 42.366661 # -71.105576 42.367104 # -71.104378 42.367134 # -71.103729 42.366249 # -71.098793 42.363331 # -71.101028 42.362541 # -71.106865 42.366123 # -71.106216 42.366661 # # # # # pos_list = self._attribute_in_structure( where, [ XML_TAG_GML_POLYGON, XML_TAG_GML_EXTERIOR, XML_TAG_GML_LINEAR_RING, XML_TAG_GML_POS_LIST, ], ) if pos_list: return self._create_polygon(pos_list) return None def _geometry_geo_point(self) -> list[Point] | None: """Check for geo:Point tag.""" # # 38.3728 # 15.7213 # point = self._attribute([XML_TAG_GEO_POINT]) if point: lat = point.get(XML_TAG_GEO_LAT) long = point.get(XML_TAG_GEO_LONG) if long and lat: return [Point(lat, long)] return None def _geometry_geo_long_lat(self) -> list[Point] | None: """Check for geo:long and geo:lat tags.""" # 119.948006 # -23.126413 lat = self._attribute([XML_TAG_GEO_LAT]) long = self._attribute([XML_TAG_GEO_LONG]) if long and lat: return [Point(lat, long)] return None def _geometry_georss_polygon(self) -> list[Polygon] | None: """Check for georss:polygon tag.""" # # -34.937663524 148.597260613 # -34.9377026399999 148.597169138 # -34.9377002169999 148.59708737 # -34.9376945989999 148.59705595 # -34.9376863529999 148.596955098 # -34.937663524 148.597260613 # polygon = self._attribute([XML_TAG_GEORSS_POLYGON]) if polygon: return self._create_polygon(polygon) return None @staticmethod def _create_polygon(polygon_data) -> list[Polygon] | None: """Create a polygon from the provided coordinates.""" if polygon_data: # Either tuple or an array of tuples. if isinstance(polygon_data, tuple): return FeedItem._create_polygon_single(polygon_data) return FeedItem._create_polygon_multiple(polygon_data) return None @staticmethod def _create_polygon_single(polygon_data: tuple) -> list[Polygon]: """Create polygon from provided tuple of coordinates.""" if len(polygon_data) % 2 != 0: # Not even number of coordinates - chop last entry. polygon_data = polygon_data[0 : len(polygon_data) - 1] return [ Polygon( [ Point(polygon_data[i], polygon_data[i + 1]) for i in range(0, len(polygon_data), 2) ] ) ] @staticmethod def _create_polygon_multiple(polygon_data: list) -> list[Polygon]: """Create polygon from provided list of coordinates.""" polygons = [] for entry in polygon_data: polygons.extend(FeedItem._create_polygon(entry)) return polygons @property def geometry(self) -> Geometry | None: """Return the first geometry of this feed item for backwards compatibility reasons.""" return self.geometries[0] if self.geometries else None python-georss-client-0.18/georss_client/xml_parser/feed_or_feed_item.py000066400000000000000000000057051471141570100265410ustar00rootroot00000000000000"""GeoRSS feed or feed item.""" from __future__ import annotations import datetime from georss_client.consts import ( XML_ATTR_TERM, XML_TAG_AUTHOR, XML_TAG_CATEGORY, XML_TAG_CONTRIBUTOR, XML_TAG_DC_DATE, XML_TAG_LAST_BUILD_DATE, XML_TAG_MANAGING_EDITOR, XML_TAG_NAME, XML_TAG_PUB_DATE, XML_TAG_PUBLISHED, XML_TAG_UPDATED, ) from georss_client.xml_parser.feed_dict_source import FeedDictSource class FeedOrFeedItem(FeedDictSource): """Represents the common base of feed and its items.""" @property def category(self) -> list | None: """Return the categories of this feed item.""" category = self._attribute([XML_TAG_CATEGORY]) if category: if isinstance(category, (str, dict)): # If it's a string or a dict, wrap in list. category = [category] result = [] for item in category: if XML_ATTR_TERM in item: # item = item.get(XML_ATTR_TERM) result.append(item) return result return None @property def published_date(self) -> datetime.datetime | None: """Return the published date of this feed or feed item.""" parsed_date = self._attribute( [XML_TAG_PUB_DATE, XML_TAG_PUBLISHED, XML_TAG_DC_DATE] ) return parsed_date if isinstance(parsed_date, datetime.datetime) else None @property def pub_date(self) -> datetime.datetime | None: """Return the published date of this feed or feed item.""" return self.published_date @property def updated_date(self) -> datetime.datetime | None: """Return the updated date of this feed or feed item.""" parsed_date = self._attribute([XML_TAG_LAST_BUILD_DATE, XML_TAG_UPDATED]) return parsed_date if isinstance(parsed_date, datetime.datetime) else None @property def last_build_date(self) -> datetime.datetime | None: """Return the last build date of this feed.""" return self.updated_date @property def author(self) -> str | None: """Return the author of this feed.""" # jrc-ems@ec.europa.eu managing_editor = self._attribute([XML_TAG_MANAGING_EDITOR]) if managing_editor: return managing_editor # # Istituto Nazionale di Geofisica e Vulcanologia # http://www.ingv.it # author = self._attribute([XML_TAG_AUTHOR, XML_TAG_CONTRIBUTOR]) if author: return author.get(XML_TAG_NAME, None) return None @property def contributor(self) -> str | None: """Return the contributor of this feed.""" return self.author @property def managing_editor(self) -> str | None: """Return the managing editor of this feed.""" return self.author python-georss-client-0.18/georss_client/xml_parser/geometry.py000066400000000000000000000046361471141570100247720ustar00rootroot00000000000000"""Geometry models.""" from __future__ import annotations class Geometry: """Represents a geometry.""" class Point(Geometry): """Represents a point.""" def __init__(self, latitude, longitude): """Initialise point.""" self._latitude = latitude self._longitude = longitude def __repr__(self): """Return string representation of this point.""" return f"<{self.__class__.__name__}(latitude={self.latitude}, longitude={self.longitude})>" def __hash__(self) -> int: """Return unique hash of this geometry.""" return hash((self.latitude, self.longitude)) def __eq__(self, other: object) -> bool: """Return if this object is equal to other object.""" return ( self.__class__ == other.__class__ and self.latitude == other.latitude and self.longitude == other.longitude ) @property def latitude(self) -> float | None: """Return the latitude of this point.""" return self._latitude @property def longitude(self) -> float | None: """Return the longitude of this point.""" return self._longitude class Polygon(Geometry): """Represents a polygon.""" def __init__(self, points: list[Point]): """Initialise polygon.""" self._points: list[Point] = points def __repr__(self): """Return string representation of this polygon.""" return f"<{self.__class__.__name__}(centroid={self.centroid})>" def __hash__(self) -> int: """Return unique hash of this geometry.""" return hash(self.points) def __eq__(self, other: object) -> bool: """Return if this object is equal to other object.""" return self.__class__ == other.__class__ and self.points == other.points @property def points(self) -> list[Point] | None: """Return the points of this polygon.""" return self._points @property def centroid(self) -> Point: """Find the polygon's centroid as a best approximation.""" longitudes_list: list[float] = [point.longitude for point in self.points] latitudes_list: list[float] = [point.latitude for point in self.points] number_of_points: int = len(self.points) longitude: float = sum(longitudes_list) / number_of_points latitude: float = sum(latitudes_list) / number_of_points return Point(latitude, longitude) python-georss-client-0.18/pyproject.toml000066400000000000000000000162171471141570100204630ustar00rootroot00000000000000[build-system] requires = [ "setuptools", "wheel" ] build-backend = "setuptools.build_meta" [project] name = "georss_client" version = "0.18" requires-python = ">= 3.9" authors = [ {name = "Malte Franken", email = "coding@subspace.de"}, ] description = "A GeoRSS client library." readme = "README.md" license = {file = "LICENSE"} keywords = ["homeassistant", "georss"] classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Development Status :: 5 - Production/Stable", ] dependencies = [ "haversine>=2.8.1", "xmltodict>=0.14.2", "requests>=2.31.0", "python-dateutil>=2.9.0", ] [project.optional-dependencies] tests = [ "pytest", "pytest-timeout", "pytest-xdist", "pytest-cov", "coverage", "mock", ] [project.urls] Repository = "https://github.com/exxamalte/python-georss-client" Issues = "https://github.com/exxamalte/python-georss-client/issues" Changelog = "https://github.com/exxamalte/python-georss-client/blob/main/CHANGELOG.md" [tool.setuptools.packages.find] include = ["georss_client*"] [tool.ruff.lint] select = [ "A001", # Variable {name} is shadowing a Python builtin "B002", # Python does not support the unary prefix increment "B005", # Using .strip() with multi-character strings is misleading "B007", # Loop control variable {name} not used within loop body "B014", # Exception handler with duplicate exception "B015", # Pointless comparison. Did you mean to assign a value? Otherwise, prepend assert or remove it. "B017", # pytest.raises(BaseException) should be considered evil "B018", # Found useless attribute access. Either assign it to a variable or remove it. "B023", # Function definition does not bind loop variable {name} "B026", # Star-arg unpacking after a keyword argument is strongly discouraged "B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)? "B904", # Use raise from to specify exception cause "B905", # zip() without an explicit strict= parameter "BLE", "C", # complexity "COM818", # Trailing comma on bare tuple prohibited "D", # docstrings "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) "E", # pycodestyle "F", # pyflakes/autoflake "FLY", # flynt "G", # flake8-logging-format "I", # isort "INP", # flake8-no-pep420 "ISC", # flake8-implicit-str-concat "ICN001", # import concentions; {name} should be imported as {asname} "LOG", # flake8-logging "N804", # First argument of a class method should be named cls "N805", # First argument of a method should be named self "N815", # Variable {name} in class scope should not be mixedCase "PERF", # Perflint "PGH", # pygrep-hooks "PIE", # flake8-pie "PL", # pylint "PT", # flake8-pytest-style "PYI", # flake8-pyi "RET", # flake8-return "RSE", # flake8-raise "RUF005", # Consider iterable unpacking instead of concatenation "RUF006", # Store a reference to the return value of asyncio.create_task "RUF010", # Use explicit conversion flag "RUF013", # PEP 484 prohibits implicit Optional "RUF018", # Avoid assignment expressions in assert statements "RUF019", # Unnecessary key check before dictionary access # "RUF100", # Unused `noqa` directive; temporarily every now and then to clean them up "S102", # Use of exec detected "S103", # bad-file-permissions "S108", # hardcoded-temp-file "S306", # suspicious-mktemp-usage "S307", # suspicious-eval-usage "S313", # suspicious-xmlc-element-tree-usage "S314", # suspicious-xml-element-tree-usage "S315", # suspicious-xml-expat-reader-usage "S316", # suspicious-xml-expat-builder-usage "S317", # suspicious-xml-sax-usage "S318", # suspicious-xml-mini-dom-usage "S319", # suspicious-xml-pull-dom-usage "S320", # suspicious-xmle-tree-usage "S601", # paramiko-call "S602", # subprocess-popen-with-shell-equals-true "S604", # call-with-shell-equals-true "S608", # hardcoded-sql-expression "S609", # unix-command-wildcard-injection "SIM", # flake8-simplify "SLF", # flake8-self "SLOT", # flake8-slots "T100", # Trace found: {name} used "T20", # flake8-print "TID251", # Banned imports "TRY", # tryceratops "UP", # pyupgrade "W", # pycodestyle ] ignore = [ "D202", # No blank lines allowed after function docstring "D203", # 1 blank line required before class docstring "D213", # Multi-line docstring summary should start at the second line "D406", # Section name should end with a newline "D407", # Section name underlining "E501", # line too long "PLC1901", # {existing} can be simplified to {replacement} as an empty string is falsey; too many false positives "PLR0911", # Too many return statements ({returns} > {max_returns}) "PLR0912", # Too many branches ({branches} > {max_branches}) "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) "PLR0915", # Too many statements ({statements} > {max_statements}) "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target "PT004", # Fixture {fixture} does not return anything, add leading underscore "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception "PT012", # `pytest.raises()` block should contain a single simple statement "PT018", # Assertion should be broken down into multiple parts "RUF001", # String contains ambiguous unicode character. "RUF002", # Docstring contains ambiguous unicode character. "RUF003", # Comment contains ambiguous unicode character. "RUF015", # Prefer next(...) over single element slice "SIM102", # Use a single if statement instead of nested if statements "SIM103", # Return the condition {condition} directly "SIM108", # Use ternary operator {contents} instead of if-else-block "SIM115", # Use context handler for opening files "TRY003", # Avoid specifying long messages outside the exception class "TRY400", # Use `logging.exception` instead of `logging.error` # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules "W191", "E111", "E114", "E117", "D206", "D300", "Q", "COM812", "COM819", "ISC001", # Disabled because ruff does not understand type of __all__ generated by a function "PLE0605", ] [tool.ruff.lint.isort] force-sort-within-sections = true known-first-party = [ "georss_client", ] combine-as-imports = true split-on-trailing-comma = false [tool.pytest.ini_options] testpaths = [ "tests", ] python-georss-client-0.18/tests/000077500000000000000000000000001471141570100167025ustar00rootroot00000000000000python-georss-client-0.18/tests/__init__.py000066400000000000000000000007441471141570100210200ustar00rootroot00000000000000"""Tests for georss-client library.""" from georss_client.feed import GeoRssFeed from georss_client.feed_entry import FeedEntry from georss_client.xml_parser.feed_item import FeedItem class MockGeoRssFeed(GeoRssFeed): """Mock GeoRSS feed.""" def _new_entry( self, home_coordinates: tuple[float, float], rss_entry: FeedItem, global_data: dict, ): """Generate a new entry.""" return FeedEntry(home_coordinates, rss_entry) python-georss-client-0.18/tests/fixtures/000077500000000000000000000000001471141570100205535ustar00rootroot00000000000000python-georss-client-0.18/tests/fixtures/generic_feed_1.xml000066400000000000000000000032261471141570100241170ustar00rootroot00000000000000 Attribution 1 1234 Title 1 2018-09-23T08:30:00 2018-09-23T08:35:00 -37.2345 149.1234 2345 Title 2 2018-09-23T08:40:00 2018-09-23T08:45:00 -37.4567 149.3456 Title 3 2018-09-23T08:50:00 2018-09-23T08:55:00 -37.6789 149.5678 2018-09-23T09:00:00 2018-09-23T09:05:00 -37.8901 149.7890 5678 Title 5 2018-09-23T09:10:00 2018-09-23T09:15:00 -37.6789 149.5678 6789 Title 6 2018-09-23T09:20:00 2018-09-23T09:25:00 python-georss-client-0.18/tests/fixtures/generic_feed_2.xml000066400000000000000000000007261471141570100241220ustar00rootroot00000000000000 Title 1 2018-09-23 08:30:00 1234 Category 1 149.1234 -37.2345 python-georss-client-0.18/tests/fixtures/generic_feed_3.xml000066400000000000000000000113261471141570100241210ustar00rootroot00000000000000 Title 1 2018-09-23 08:30:00 1234 Category 1 -34.937663524 148.597260613 -34.9377026399999 148.597169138 -34.9377002169999 148.59708737 -34.9376945989999 148.59705595 -34.9376863529999 148.596955098 -34.937644093 148.596885162 -34.937577786 148.596806399 -34.937470286 148.596710107 -34.9373719229999 148.596710377 -34.9372816329999 148.596754801 -34.9371067559999 148.596801847 -34.9369941959999 148.596833284 -34.936907488 148.596867297 -34.936816829 148.59694105 -34.936707218 148.597012356 -34.93659451 148.597192568 -34.9367060779999 148.597376943 -34.9367854569999 148.597650825 -34.937114943 148.597659041 -34.937336692 148.597589818 -34.937417185 148.597501208 -34.9375217259999 148.597421427 -34.937663524 148.597260613 Title 2 2018-09-23 09:30:00 2345 Category 2 -34.937170989 148.597182317 -34.937663524 148.597260613 -34.9377026399999 148.597169138 -34.9377002169999 148.59708737 -34.9376945989999 148.59705595 -34.9376863529999 148.596955098 -34.937644093 148.596885162 -34.937577786 148.596806399 -34.937470286 148.596710107 -34.9373719229999 148.596710377 -34.9372816329999 148.596754801 -34.9371067559999 148.596801847 -34.9369941959999 148.596833284 -34.936907488 148.596867297 -34.936816829 148.59694105 -34.936707218 148.597012356 -34.93659451 148.597192568 -34.9367060779999 148.597376943 -34.9367854569999 148.597650825 -34.937114943 148.597659041 -34.937336692 148.597589818 -34.937417185 148.597501208 -34.9375217259999 148.597421427 -34.937663524 148.597260613 Title 3 2018-09-23 10:30:00 3456 Category 3 -29.956955208 152.447148625 -29.9576690449999 152.447624518 -29.9589182629999 152.446315814 -29.960405426 152.44536403 -29.962963347 152.445066597 -29.964391023 152.445720949 -29.9658186999999 152.445483003 -29.966473052 152.444531218 -29.967722269 152.444055326 -29.968852512 152.44381738 -29.969030973 152.441735352 -29.9680791869999 152.440069729 -29.9671274029999 152.437928214 -29.967841242 152.436500538 -29.968852512 152.434180563 -29.970756082 152.43251494 -29.970518135 152.430670858 -29.969982757 152.429600101 -29.9693878909999 152.426744747 -29.9693878909999 152.425911936 -29.970042243 152.423413502 -29.97010173 152.420558149 -29.97010173 152.419130473 -29.970934541 152.416869984 -29.972600164 152.41419309 -29.973432975 152.411754144 -29.9728975959999 152.410861845 -29.972600164 152.408006492 -29.9733140009999 152.405805491 -29.9724217039999 152.403663976 -29.970518135 152.404853707 -29.9684361069999 152.4075306 -29.9667704839999 152.409136735 -29.9641530769999 152.410088521 -29.9620115619999 152.409910061 -29.960762346 152.41181363 -29.958680317 152.411992089 -29.956419828 152.41127825 -29.952969611 152.411992089 -29.950352204 152.412765414 -29.950292716 152.442211244 -29.952493718 152.441021513 -29.954635233 152.439831783 -29.956538802 152.439355891 -29.957371613 152.439474863 -29.95725264 152.441021513 -29.957371613 152.442687136 -29.95606291 152.443400974 -29.9536834479999 152.444709679 -29.9518988529999 152.445899408 -29.9505306629999 152.447089139 -29.949400418 152.447802978 -29.949519392 152.447862464 -29.953742935 152.447624518 -29.9554085579999 152.447148625 -29.956955208 152.447148625 -29.9483653529999 152.448029026 -29.949793029 152.447672107 -29.949793029 152.44779108 -29.9508637869999 152.446839296 -29.952410437 152.445649565 -29.955384763 152.443745996 -29.9574073049999 152.442615752 -29.9571098719999 152.439284507 -29.9547898979999 152.439581939 -29.951637112 152.441128589 -29.9487817589999 152.44350805 -29.9477704879999 152.441901914 -29.94705665 152.441723454 -29.9456289729999 152.442080374 -29.9439633499999 152.442913185 -29.9425356739999 152.446482376 -29.94277362 152.449694649 -29.943249512 152.451895651 -29.944915135 152.450884379 -29.946342811 152.450527461 -29.9472945959999 152.450408487 -29.9482463799999 152.449278244 -29.9483653529999 152.448029026 python-georss-client-0.18/tests/fixtures/generic_feed_4.xml000066400000000000000000000017711471141570100241250ustar00rootroot00000000000000 Attribution 1 1234 Title 1 UPDATED 2018-09-23T08:30:00 2018-09-23T08:35:00 -37.2345 149.1234 2345 Title 2 2018-09-23T08:40:00 2018-09-23T08:45:00 -37.4567 149.3456 6789 Title 6 2018-09-23T09:20:00 2018-09-23T09:25:00 -37.6789 149.5678 python-georss-client-0.18/tests/fixtures/generic_feed_5.xml000066400000000000000000000005171471141570100241230ustar00rootroot00000000000000 Attribution 1 1234 Title 1 -37.2345 149.1234 python-georss-client-0.18/tests/fixtures/generic_feed_6.xml000066400000000000000000000007071471141570100241250ustar00rootroot00000000000000 Title 1 2018-09-23 08:30:00 1234 Category 1 149.1234 -37.2345 python-georss-client-0.18/tests/fixtures/xml_parser_bom_1.xml000066400000000000000000000002441471141570100245260ustar00rootroot00000000000000 Title 1 python-georss-client-0.18/tests/fixtures/xml_parser_complex_1.xml000066400000000000000000000105121471141570100254170ustar00rootroot00000000000000 Feed Title 1 Feed Subtitle 1 Feed Description 1 Feed Link 1 Sun, 09 Dec 2018 08:30:00 GMT 2018-12-09T08:45:00+00:00 Feed Copyright 1 Feed Generator 1 Feed Language 1 http://docs.url/documentation.html 42 Feed Author 1 Feed Category 1 Image Title 1 http://image.url/image.png http://feed.link/feed.rss Image Description 1 123 234 Feed Random 1 Title 1 Description 1 Link 1 Sun, 09 Dec 2018 07:30:00 GMT 2018-12-09T07:45:00+00:00 GUID 1 Source 1 Category 1 Random 1 -37.4567 149.3456 Title 2 Description 2 Sun, 09 Dec 2018 07:35:00 GMT 2018-12-09T07:50:00+00:00 GUID 2 -37.5678 149.4567 Title 3 Description 3 Sun, 09 Dec 2018 07:40:00 GMT 2018-12-09T07:55:00+00:00 GUID 3 Category 3A Category 3B Category 3C -37.6789 149.5678 Title 4 Description 4 Author 4 Sun, 30 Sep 2018 21:36:48 +1000 -37.7890 149.6789 Title 5 Description 5 Thu, 20 Sep 2018 18:01:55 +0200 -30.1 150.1 -30.2 150.2 -30.4 150.4 -30.8 150.8 -30.1 150.1 Title 6 Description 6 Sun, 7 Oct 2018 19:52:00 -0200 -30.1 150.1 -30.2 150.2 -30.4 150.4 -30.8 150.8 -30.1 150.1 python-georss-client-0.18/tests/fixtures/xml_parser_complex_2.xml000066400000000000000000000020001471141570100254110ustar00rootroot00000000000000 Feed Title 1 Feed Subtitle 1 INVALID Author 1 2018-12-09T09:00:00+00:00 Feed Rights 1 Feed Generator 1 Image Title 1 http://image.url/image.png http://feed.link/feed.rss Title 6 INVALID DATE -37.4567 149.3456 python-georss-client-0.18/tests/fixtures/xml_parser_complex_3.xml000066400000000000000000000012641471141570100254250ustar00rootroot00000000000000 <description/> <language/> <pubDate/> <lastBuildDate/> <ttl/> <rights type="text">Feed Rights 1</rights> <item> <title/> <published/> <georss:point/> </item> <item> <title/> <geo:Point> <random/> </geo:Point> </item> </channel> </rss> ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������python-georss-client-0.18/tests/fixtures/xml_parser_geometries_1.xml��������������������������������0000664�0000000�0000000�00000005064�14711415701�0026121�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������<?xml version="1.0" encoding="UTF-8"?> <rss xmlns:georss="http://www.georss.org/georss" xmlns:gml='http://www.opengis.net/gml' xmlns:geo="http://www.w3.org/2003/01/geo/wgs84_pos#" xmlns:gdacs="http://www.gdacs.org" version="2.0"> <channel> <title>Feed Title 1 Title 1 -37.4567 149.3456 -37.5678 149.4567 -37.6789 149.5678 Title 2 -37.4567 149.3456 -37.5678 149.4567 -37.6789 149.5678 Title 3 -37.4567 149.3456 -37.6789 149.5678 Title 4 -37.4567 149.3456 -37.7890 149.6789 Title 5 -37.4567 149.3456 -30.1 150.1 -30.2 150.2 -30.4 150.4 -30.8 150.8 -30.1 150.1 -30.1 150.1 -30.3 150.3 -30.5 150.5 -30.9 150.9 -30.1 150.1 Title 6 -37.4567 149.3456 -30.1 150.1 -30.2 150.2 -30.4 150.4 -30.8 150.8 -30.1 150.1 python-georss-client-0.18/tests/fixtures/xml_parser_simple_1.xml000066400000000000000000000003261471141570100252430ustar00rootroot00000000000000 Title 1 python-georss-client-0.18/tests/fixtures/xml_parser_simple_2.xml000066400000000000000000000003041471141570100252400ustar00rootroot00000000000000 Title 1 python-georss-client-0.18/tests/fixtures/xml_parser_simple_3.xml000066400000000000000000000003321471141570100252420ustar00rootroot00000000000000 Title 1 python-georss-client-0.18/tests/test_feed.py000066400000000000000000000201511471141570100212150ustar00rootroot00000000000000"""Tests for feed.""" import datetime from unittest import mock import pytest import requests from georss_client.consts import UPDATE_ERROR, UPDATE_OK from georss_client.feed import GeoRssFeed from tests import MockGeoRssFeed from tests.utils import load_fixture HOME_COORDINATES_1 = (-31.0, 151.0) HOME_COORDINATES_2 = (-37.0, 150.0) @mock.patch("requests.Request") @mock.patch("requests.Session") def test_update_ok(mock_session, mock_request): """Test updating feed is ok.""" mock_session.return_value.__enter__.return_value.send.return_value.ok = True mock_session.return_value.__enter__.return_value.send.return_value.text = ( load_fixture("generic_feed_1.xml") ) feed = MockGeoRssFeed(HOME_COORDINATES_1, None) assert ( repr(feed) == "" ) status, entries = feed.update() assert status == UPDATE_OK assert entries is not None assert len(entries) == 5 feed_entry = entries[0] assert feed_entry.title == "Title 1" assert feed_entry.external_id == "1234" assert feed_entry.category == "Category 1" assert feed_entry.published == datetime.datetime(2018, 9, 23, 8, 30) assert feed_entry.updated == datetime.datetime(2018, 9, 23, 8, 35) assert feed_entry.coordinates == (-37.2345, 149.1234) assert feed_entry.distance_to_home == pytest.approx(714.4, 0.1) feed_entry = entries[1] assert feed_entry.title == "Title 2" assert feed_entry.external_id == "2345" assert feed_entry.attribution is None assert repr(feed_entry) == "" feed_entry = entries[2] assert feed_entry.title == "Title 3" assert feed_entry.external_id == "Title 3" feed_entry = entries[3] assert feed_entry.title is None assert feed_entry.external_id == hash(feed_entry.coordinates) feed_entry = entries[4] assert feed_entry.title == "Title 5" assert feed_entry.external_id == "5678" @mock.patch("requests.Request") @mock.patch("requests.Session") def test_update_ok_feed_2(mock_session, mock_request): """Test updating feed is ok.""" mock_session.return_value.__enter__.return_value.send.return_value.ok = True mock_session.return_value.__enter__.return_value.send.return_value.text = ( load_fixture("generic_feed_2.xml") ) feed = MockGeoRssFeed(HOME_COORDINATES_1, None) status, entries = feed.update() assert status == UPDATE_OK assert entries is not None assert len(entries) == 1 feed_entry = entries[0] assert feed_entry.title == "Title 1" assert feed_entry.external_id == "1234" assert feed_entry.category == "Category 1" assert feed_entry.coordinates == (-37.2345, 149.1234) assert feed_entry.distance_to_home == pytest.approx(714.4, 0.1) @mock.patch("requests.Request") @mock.patch("requests.Session") def test_update_ok_feed_3(mock_session, mock_request): """Test updating feed is ok.""" mock_session.return_value.__enter__.return_value.send.return_value.ok = True mock_session.return_value.__enter__.return_value.send.return_value.text = ( load_fixture("generic_feed_3.xml") ) feed = MockGeoRssFeed(HOME_COORDINATES_1, None) status, entries = feed.update() assert status == UPDATE_OK assert entries is not None assert len(entries) == 3 feed_entry = entries[0] assert feed_entry.external_id == "1234" assert feed_entry.coordinates == ( pytest.approx(-34.93728111547821), pytest.approx(148.59710883878262), ) assert feed_entry.distance_to_home == pytest.approx(491.7, 0.1) feed_entry = entries[1] assert feed_entry.external_id == "2345" assert feed_entry.coordinates == (-34.937170989, 148.597182317) assert feed_entry.distance_to_home == pytest.approx(491.8, 0.1) feed_entry = entries[2] assert feed_entry.external_id == "3456" assert feed_entry.coordinates == ( pytest.approx(-29.962746645660683), pytest.approx(152.43090880416074), ) assert feed_entry.distance_to_home == pytest.approx(176.5, 0.1) @mock.patch("requests.Request") @mock.patch("requests.Session") def test_update_ok_feed_6(mock_session, mock_request): """Test updating feed is ok.""" mock_session.return_value.__enter__.return_value.send.return_value.ok = True mock_session.return_value.__enter__.return_value.send.return_value.text = ( load_fixture("generic_feed_6.xml") ) feed = MockGeoRssFeed(HOME_COORDINATES_1, None) status, entries = feed.update() assert status == UPDATE_OK assert entries is not None assert len(entries) == 1 feed_entry = entries[0] assert feed_entry.title == "Title 1" assert feed_entry.external_id == "1234" assert feed_entry.category == "Category 1" assert feed_entry.coordinates == (-37.2345, 149.1234) assert feed_entry.distance_to_home == pytest.approx(714.4, 0.1) @mock.patch("requests.Request") @mock.patch("requests.Session") def test_update_ok_with_radius_filtering(mock_session, mock_request): """Test updating feed is ok.""" mock_session.return_value.__enter__.return_value.send.return_value.ok = True mock_session.return_value.__enter__.return_value.send.return_value.text = ( load_fixture("generic_feed_1.xml") ) feed = MockGeoRssFeed(HOME_COORDINATES_2, None, filter_radius=90.0) status, entries = feed.update() assert status == UPDATE_OK assert entries is not None assert len(entries) == 4 assert entries[0].distance_to_home == pytest.approx(82.0, 0.1) assert entries[1].distance_to_home == pytest.approx(77.0, 0.1) assert entries[2].distance_to_home == pytest.approx(84.6, 0.1) @mock.patch("requests.Request") @mock.patch("requests.Session") def test_update_ok_with_radius_and_category_filtering(mock_session, mock_request): """Test updating feed is ok.""" mock_session.return_value.__enter__.return_value.send.return_value.ok = True mock_session.return_value.__enter__.return_value.send.return_value.text = ( load_fixture("generic_feed_1.xml") ) feed = MockGeoRssFeed( HOME_COORDINATES_2, None, filter_radius=90.0, filter_categories=["Category 2"], ) status, entries = feed.update() assert status == UPDATE_OK assert entries is not None assert len(entries) == 1 assert entries[0].distance_to_home == pytest.approx(77.0, 0.1) feed = MockGeoRssFeed( HOME_COORDINATES_2, None, filter_radius=90.0, filter_categories=["Category 4"], ) status, entries = feed.update() assert status == UPDATE_OK assert entries is not None assert len(entries) == 0 @mock.patch("requests.Request") @mock.patch("requests.Session") def test_update_error(mock_session, mock_request): """Test updating feed results in error.""" mock_session.return_value.__enter__.return_value.send.return_value.ok = False feed = MockGeoRssFeed(HOME_COORDINATES_1, None) status, entries = feed.update() assert status == UPDATE_ERROR @mock.patch("requests.Request") @mock.patch("requests.Session") def test_update_with_request_exception(mock_session, mock_request): """Test updating feed raises exception.""" mock_session.return_value.__enter__.return_value.send.side_effect = ( requests.exceptions.RequestException ) feed = GeoRssFeed(HOME_COORDINATES_1, None) status, entries = feed.update() assert status == UPDATE_ERROR assert entries is None @mock.patch("requests.Request") @mock.patch("requests.Session") def test_update_bom(mock_session, mock_request): """Test updating feed with BOM (byte order mark) is ok.""" mock_session.return_value.__enter__.return_value.send.return_value.ok = True mock_session.return_value.__enter__.return_value.send.return_value.text = ( load_fixture("xml_parser_bom_1.xml") ) feed = MockGeoRssFeed(HOME_COORDINATES_1, None) assert ( repr(feed) == "" ) status, entries = feed.update() assert status == UPDATE_OK assert entries is not None assert len(entries) == 0 python-georss-client-0.18/tests/test_feed_entry.py000066400000000000000000000041751471141570100224460ustar00rootroot00000000000000"""Tests for feed entry.""" import datetime from unittest import mock from georss_client import FeedEntry def test_simple_feed_entry(): """Test feed entry behaviour.""" feed_entry = FeedEntry(None, None) assert repr(feed_entry) == "" assert feed_entry.geometry is None assert feed_entry.coordinates is None assert feed_entry.title is None assert feed_entry.category is None assert feed_entry.attribution is None assert feed_entry.description is None assert feed_entry.published is None assert feed_entry.updated is None assert ( feed_entry._search_in_external_id(r"External ID (?P.+)$") # noqa: SLF001 is None ) assert feed_entry._search_in_title(r"Title (?P.+)$") is None # noqa: SLF001 assert ( feed_entry._search_in_description(r"Description (?P.+)$") # noqa: SLF001 is None ) def test_feed_entry_search_in_attributes(): """Test feed entry behaviour.""" rss_entry = mock.MagicMock() type(rss_entry).guid = mock.PropertyMock(return_value="Test 123") type(rss_entry).title = mock.PropertyMock(return_value="Title 123") type(rss_entry).description = mock.PropertyMock(return_value="Description 123") type(rss_entry).category = mock.PropertyMock( return_value=["Category 1", "Category 2"] ) updated = datetime.datetime(2019, 4, 1, 8, 30, tzinfo=datetime.timezone.utc) type(rss_entry).updated_date = mock.PropertyMock(return_value=updated) feed_entry = FeedEntry(None, rss_entry) assert repr(feed_entry) == "" assert ( feed_entry._search_in_external_id(r"Test (?P.+)$") # noqa: SLF001 == "123" ) assert feed_entry._search_in_title(r"Title (?P.+)$") == "123" # noqa: SLF001 assert ( feed_entry._search_in_description(r"Description (?P.+)$") # noqa: SLF001 == "123" ) assert feed_entry.category == "Category 1" assert feed_entry.description == "Description 123" assert feed_entry.updated == updated python-georss-client-0.18/tests/test_feed_manager.py000066400000000000000000000127051471141570100227150ustar00rootroot00000000000000"""Test for the Feed Manager.""" import datetime from unittest import mock import pytest from georss_client.feed_manager import FeedManagerBase from tests import MockGeoRssFeed from tests.utils import load_fixture HOME_COORDINATES_1 = (-31.0, 151.0) HOME_COORDINATES_2 = (-37.0, 150.0) @mock.patch("requests.Request") @mock.patch("requests.Session") def test_feed_manager(mock_session, mock_request): """Test the feed manager.""" mock_session.return_value.__enter__.return_value.send.return_value.ok = True mock_session.return_value.__enter__.return_value.send.return_value.text = ( load_fixture("generic_feed_1.xml") ) feed = MockGeoRssFeed(HOME_COORDINATES_1, None) # This will just record calls and keep track of external ids. generated_entity_external_ids = [] updated_entity_external_ids = [] removed_entity_external_ids = [] def _generate_entity(external_id): """Generate new entity.""" generated_entity_external_ids.append(external_id) def _update_entity(external_id): """Update entity.""" updated_entity_external_ids.append(external_id) def _remove_entity(external_id): """Remove entity.""" removed_entity_external_ids.append(external_id) feed_manager = FeedManagerBase( feed, _generate_entity, _update_entity, _remove_entity ) assert ( repr(feed_manager) == ")>" ) feed_manager.update() entries = feed_manager.feed_entries assert entries is not None assert len(entries) == 5 assert feed_manager.last_update is not None assert feed_manager.last_timestamp == datetime.datetime(2018, 9, 23, 9, 10) assert len(generated_entity_external_ids) == 5 assert len(updated_entity_external_ids) == 0 assert len(removed_entity_external_ids) == 0 feed_entry = entries.get("1234") assert feed_entry.title == "Title 1" assert feed_entry.external_id == "1234" assert feed_entry.coordinates == (-37.2345, 149.1234) assert feed_entry.distance_to_home == pytest.approx(714.4, 0.1) assert repr(feed_entry) == "" feed_entry = entries.get("2345") assert feed_entry.title == "Title 2" assert feed_entry.external_id == "2345" feed_entry = entries.get("Title 3") assert feed_entry.title == "Title 3" assert feed_entry.external_id == "Title 3" external_id = hash((-37.8901, 149.7890)) feed_entry = entries.get(external_id) assert feed_entry.title is None assert feed_entry.external_id == external_id feed_entry = entries.get("5678") assert feed_entry.title == "Title 5" assert feed_entry.external_id == "5678" # Simulate an update with several changes. generated_entity_external_ids.clear() updated_entity_external_ids.clear() removed_entity_external_ids.clear() mock_session.return_value.__enter__.return_value.send.return_value.text = ( load_fixture("generic_feed_4.xml") ) feed_manager.update() entries = feed_manager.feed_entries assert entries is not None assert len(entries) == 3 assert len(generated_entity_external_ids) == 1 assert len(updated_entity_external_ids) == 2 assert len(removed_entity_external_ids) == 3 feed_entry = entries.get("1234") assert feed_entry.title == "Title 1 UPDATED" feed_entry = entries.get("2345") assert feed_entry.title == "Title 2" feed_entry = entries.get("6789") assert feed_entry.title == "Title 6" # Simulate an update with no data. generated_entity_external_ids.clear() updated_entity_external_ids.clear() removed_entity_external_ids.clear() mock_session.return_value.__enter__.return_value.send.return_value.ok = False feed_manager.update() entries = feed_manager.feed_entries assert len(entries) == 0 assert len(generated_entity_external_ids) == 0 assert len(updated_entity_external_ids) == 0 assert len(removed_entity_external_ids) == 3 @mock.patch("requests.Request") @mock.patch("requests.Session") def test_feed_manager_no_timestamp(mock_session, mock_request): """Test updating feed is ok.""" mock_session.return_value.__enter__.return_value.send.return_value.ok = True mock_session.return_value.__enter__.return_value.send.return_value.text = ( load_fixture("generic_feed_5.xml") ) feed = MockGeoRssFeed(HOME_COORDINATES_1, None) # This will just record calls and keep track of external ids. generated_entity_external_ids = [] updated_entity_external_ids = [] removed_entity_external_ids = [] def _generate_entity(external_id): """Generate new entity.""" generated_entity_external_ids.append(external_id) def _update_entity(external_id): """Update entity.""" updated_entity_external_ids.append(external_id) def _remove_entity(external_id): """Remove entity.""" removed_entity_external_ids.append(external_id) feed_manager = FeedManagerBase( feed, _generate_entity, _update_entity, _remove_entity ) assert ( repr(feed_manager) == ")>" ) feed_manager.update() entries = feed_manager.feed_entries assert entries is not None assert len(entries) == 1 assert feed_manager.last_timestamp is None python-georss-client-0.18/tests/test_geo_rss_distance_helper.py000066400000000000000000000046011471141570100251660ustar00rootroot00000000000000"""Tests for georss distance helper.""" from unittest.mock import MagicMock import pytest from georss_client.geo_rss_distance_helper import GeoRssDistanceHelper from georss_client.xml_parser.geometry import Point, Polygon def test_extract_coordinates_from_point(): """Test extracting coordinates from point.""" mock_point = Point(-30.0, 151.0) latitude, longitude = GeoRssDistanceHelper.extract_coordinates(mock_point) assert latitude == -30.0 assert longitude == 151.0 def test_extract_coordinates_from_polygon(): """Test extracting coordinates from polygon.""" mock_polygon = Polygon( [ Point(-30.0, 151.0), Point(-30.0, 151.5), Point(-30.5, 151.5), Point(-30.5, 151.0), Point(-30.0, 151.0), ] ) latitude, longitude = GeoRssDistanceHelper.extract_coordinates(mock_polygon) assert latitude == pytest.approx(-30.2, 0.1) assert longitude == pytest.approx(151.2, 0.1) def test_extract_coordinates_from_unsupported_geometry(): """Test extracting coordinates from unsupported geometry.""" mock_unsupported_geometry = MagicMock() latitude, longitude = GeoRssDistanceHelper.extract_coordinates( mock_unsupported_geometry ) assert latitude is None assert longitude is None def test_distance_to_point(): """Test calculating distance to point.""" home_coordinates = [-31.0, 150.0] mock_point = Point(-30.0, 151.0) distance = GeoRssDistanceHelper.distance_to_geometry(home_coordinates, mock_point) assert distance == pytest.approx(146.8, 0.1) def test_distance_to_polygon(): """Test calculating distance to point.""" home_coordinates = [-31.0, 150.0] mock_polygon = Polygon( [ Point(-30.0, 151.0), Point(-30.0, 151.5), Point(-30.5, 151.5), Point(-30.5, 151.0), Point(-30.0, 151.0), ] ) distance = GeoRssDistanceHelper.distance_to_geometry(home_coordinates, mock_polygon) assert distance == pytest.approx(110.6, 0.1) def test_distance_to_unsupported_geometry(): """Test calculating distance to unsupported geometry.""" home_coordinates = [-31.0, 150.0] mock_unsupported_geometry = MagicMock() distance = GeoRssDistanceHelper.distance_to_geometry( home_coordinates, mock_unsupported_geometry ) assert distance == float("inf") python-georss-client-0.18/tests/test_geometries.py000066400000000000000000000026721471141570100224650ustar00rootroot00000000000000"""Test geometries.""" from georss_client.xml_parser.geometry import Point, Polygon def test_point(): """Test point.""" point = Point(-37.1234, 149.2345) assert point.latitude == -37.1234 assert point.longitude == 149.2345 assert repr(point) == "" def test_point_equality(): """Test points.""" point1 = Point(10.0, 15.0) point2 = Point(10.0, 15.0) assert point1 == point2 def test_polygon(): """Test polygon.""" polygon = Polygon( [ Point(-30.1, 150.1), Point(-30.2, 150.2), Point(-30.4, 150.4), Point(-30.8, 150.8), Point(-30.1, 150.1), ] ) assert len(polygon.points) == 5 assert polygon.centroid.latitude == -30.32 assert polygon.centroid.longitude == 150.32 assert ( repr(polygon) == ")>" ) def test_polygon_equality(): """Test points.""" polygon1 = Polygon( [ Point(30.0, 30.0), Point(30.0, 35.0), Point(35.0, 35.0), Point(35.0, 30.0), Point(30.0, 30.0), ] ) polygon2 = Polygon( [ Point(30.0, 30.0), Point(30.0, 35.0), Point(35.0, 35.0), Point(35.0, 30.0), Point(30.0, 30.0), ] ) assert polygon1 == polygon2 python-georss-client-0.18/tests/test_xml_parser.py000066400000000000000000000246151471141570100224770ustar00rootroot00000000000000"""Tests for XML parser.""" import datetime from pyexpat import ExpatError import pytest from georss_client.xml_parser import XmlParser from georss_client.xml_parser.geometry import Point, Polygon from tests.utils import load_fixture def test_simple_1(): """Test parsing various actual XML files.""" xml_parser = XmlParser() xml = load_fixture("xml_parser_simple_1.xml") feed = xml_parser.parse(xml) assert feed is not None assert feed.entries is not None assert len(feed.entries) == 1 def test_simple_2(): """Test parsing various actual XML files.""" xml_parser = XmlParser() xml = load_fixture("xml_parser_simple_2.xml") feed = xml_parser.parse(xml) assert feed is not None assert feed.entries is not None assert len(feed.entries) == 1 def test_simple_3(): """Test parsing various actual XML files.""" xml_parser = XmlParser() xml = load_fixture("xml_parser_simple_3.xml") feed = xml_parser.parse(xml) assert feed is None def test_complex_1(): """Test parsing various actual XML files.""" xml_parser = XmlParser() xml = load_fixture("xml_parser_complex_1.xml") feed = xml_parser.parse(xml) assert feed is not None assert feed.title == "Feed Title 1" assert feed.subtitle == "Feed Subtitle 1" assert feed.description == "Feed Description 1" assert feed.summary == "Feed Description 1" assert feed.content == "Feed Description 1" assert feed.link == "Feed Link 1" assert feed.published_date == datetime.datetime( 2018, 12, 9, 8, 30, tzinfo=datetime.timezone.utc ) assert feed.pub_date == datetime.datetime( 2018, 12, 9, 8, 30, tzinfo=datetime.timezone.utc ) assert feed.updated_date == datetime.datetime( 2018, 12, 9, 8, 45, tzinfo=datetime.timezone.utc ) assert feed.last_build_date == datetime.datetime( 2018, 12, 9, 8, 45, tzinfo=datetime.timezone.utc ) assert feed.copyright == "Feed Copyright 1" assert feed.rights == "Feed Copyright 1" assert feed.generator == "Feed Generator 1" assert feed.language == "Feed Language 1" assert feed.docs == "http://docs.url/documentation.html" assert feed.ttl == 42 assert feed.author == "Feed Author 1" assert feed.contributor == "Feed Author 1" assert feed.managing_editor == "Feed Author 1" assert feed.category == ["Feed Category 1"] assert feed.image is not None assert feed.image.title == "Image Title 1" assert feed.image.url == "http://image.url/image.png" assert feed.image.link == "http://feed.link/feed.rss" assert feed.image.description == "Image Description 1" assert feed.image.width == 123 assert feed.image.height == 234 assert feed.get_additional_attribute("random") == "Feed Random 1" assert repr(feed) == "" assert feed.entries is not None assert len(feed.entries) == 6 feed_entry = feed.entries[0] assert feed_entry.title == "Title 1" assert feed_entry.description == "Description 1" assert feed_entry.link == "Link 1" assert feed_entry.published_date == datetime.datetime( 2018, 12, 9, 7, 30, tzinfo=datetime.timezone.utc ) assert feed_entry.updated_date == datetime.datetime( 2018, 12, 9, 7, 45, tzinfo=datetime.timezone.utc ) assert feed_entry.guid == "GUID 1" assert feed_entry.id == "GUID 1" assert feed_entry.source == "Source 1" assert feed_entry.category == ["Category 1"] assert isinstance(feed_entry.geometry, Point) assert feed_entry.geometry.latitude == -37.4567 assert feed_entry.geometry.longitude == 149.3456 assert feed_entry.get_additional_attribute("random") == "Random 1" assert repr(feed_entry) == "" feed_entry = feed.entries[1] assert feed_entry.title == "Title 2" assert feed_entry.description == "Description 2" assert feed_entry.link == "Link 2" assert feed_entry.published_date == datetime.datetime( 2018, 12, 9, 7, 35, tzinfo=datetime.timezone.utc ) assert feed_entry.updated_date == datetime.datetime( 2018, 12, 9, 7, 50, tzinfo=datetime.timezone.utc ) assert feed_entry.guid == "GUID 2" assert feed_entry.category == ["Category 2"] assert isinstance(feed_entry.geometry, Point) assert feed_entry.geometry.latitude == -37.5678 assert feed_entry.geometry.longitude == 149.4567 feed_entry = feed.entries[2] assert feed_entry.title == "Title 3" assert feed_entry.description == "Description 3" assert feed_entry.published_date == datetime.datetime( 2018, 12, 9, 7, 40, tzinfo=datetime.timezone.utc ) assert feed_entry.updated_date == datetime.datetime( 2018, 12, 9, 7, 55, tzinfo=datetime.timezone.utc ) assert feed_entry.guid == "GUID 3" assert feed_entry.category == ["Category 3A", "Category 3B", "Category 3C"] assert isinstance(feed_entry.geometry, Point) assert feed_entry.geometry.latitude == -37.6789 assert feed_entry.geometry.longitude == 149.5678 feed_entry = feed.entries[3] assert feed_entry.title == "Title 4" assert feed_entry.description == "Description 4" assert feed_entry.author == "Author 4" assert feed_entry.contributor == "Author 4" assert feed_entry.category == ["Category 4A", "Category 4B"] assert feed_entry.published_date == datetime.datetime( 2018, 9, 30, 21, 36, 48, tzinfo=datetime.timezone(datetime.timedelta(hours=10), "AEST"), ) assert isinstance(feed_entry.geometry, Point) assert feed_entry.geometry.latitude == -37.789 assert feed_entry.geometry.longitude == 149.6789 feed_entry = feed.entries[4] assert feed_entry.title == "Title 5" assert feed_entry.description == "Description 5" assert feed_entry.published_date == datetime.datetime( 2018, 9, 20, 18, 1, 55, tzinfo=datetime.timezone(datetime.timedelta(hours=2), "CEST"), ) assert isinstance(feed_entry.geometry, Polygon) assert feed_entry.geometry.centroid.latitude == -30.32 assert feed_entry.geometry.centroid.longitude == 150.32 feed_entry = feed.entries[5] assert feed_entry.title == "Title 6" assert feed_entry.description == "Description 6" assert feed_entry.published_date == datetime.datetime( 2018, 10, 7, 19, 52, tzinfo=datetime.timezone(datetime.timedelta(hours=-2)), ) assert isinstance(feed_entry.geometry, Polygon) assert feed_entry.geometry.centroid.latitude == -30.32 assert feed_entry.geometry.centroid.longitude == 150.32 def test_complex_2(): """Test parsing various actual XML files.""" xml_parser = XmlParser() xml = load_fixture("xml_parser_complex_2.xml") feed = xml_parser.parse(xml) assert feed is not None assert feed.title == "Feed Title 1" assert feed.subtitle == "Feed Subtitle 1" assert feed.ttl == "INVALID" assert feed.author == "Author 1" assert feed.last_build_date == datetime.datetime( 2018, 12, 9, 9, 0, tzinfo=datetime.timezone.utc ) assert feed.updated_date == datetime.datetime( 2018, 12, 9, 9, 0, tzinfo=datetime.timezone.utc ) assert feed.copyright == "Feed Rights 1" assert feed.rights == "Feed Rights 1" assert feed.generator == "Feed Generator 1" assert feed.image is not None assert feed.image.title == "Image Title 1" assert feed.image.url == "http://image.url/image.png" assert feed.image.link == "http://feed.link/feed.rss" assert feed.image.description is None assert feed.image.width is None assert feed.image.height is None assert feed.docs is None assert feed.entries is not None assert len(feed.entries) == 1 feed_entry = feed.entries[0] assert feed_entry.title == "Title 6" assert feed_entry.published_date is None def test_complex_3(): """Test parsing various actual XML files.""" xml_parser = XmlParser() xml = load_fixture("xml_parser_complex_3.xml") feed = xml_parser.parse(xml) assert feed is not None assert feed.title is None assert feed.subtitle is None assert feed.description is None assert feed.language is None assert feed.published_date is None assert feed.last_build_date is None assert feed.ttl is None assert feed.rights == "Feed Rights 1" assert feed.image is None assert feed.entries is not None assert len(feed.entries) == 2 feed_entry = feed.entries[0] assert feed_entry.title is None assert feed_entry.published_date is None assert feed_entry.geometry is None feed_entry = feed.entries[1] assert feed_entry.title is None assert feed_entry.geometry is None def test_geometries(): """Test parsing various geometries in entries.""" xml_parser = XmlParser() xml = load_fixture("xml_parser_geometries_1.xml") feed = xml_parser.parse(xml) assert feed is not None assert feed.title == "Feed Title 1" assert feed.entries is not None assert len(feed.entries) == 6 feed_entry = feed.entries[0] assert feed_entry.title == "Title 1" assert feed_entry.geometries is not None assert len(feed_entry.geometries) == 3 feed_entry = feed.entries[1] assert feed_entry.title == "Title 2" assert feed_entry.geometries is not None assert len(feed_entry.geometries) == 3 feed_entry = feed.entries[2] assert feed_entry.title == "Title 3" assert feed_entry.geometries is not None assert len(feed_entry.geometries) == 2 feed_entry = feed.entries[3] assert feed_entry.title == "Title 4" assert feed_entry.geometries is not None assert len(feed_entry.geometries) == 2 feed_entry = feed.entries[4] assert feed_entry.title == "Title 5" assert feed_entry.geometries is not None assert len(feed_entry.geometries) == 3 feed_entry = feed.entries[5] assert feed_entry.title == "Title 6" assert feed_entry.geometries is not None assert len(feed_entry.geometries) == 2 def test_byte_order_mark(): """Test parsing an XML file with byte order mark.""" xml_parser = XmlParser() # Create XML starting with byte order mark. xml = ( "\xef\xbb\xbf" "Title 1" "" ) # This will raise an error because the parser can't handle with pytest.raises(ExpatError): xml_parser.parse(xml) python-georss-client-0.18/tests/utils.py000066400000000000000000000003541471141570100204160ustar00rootroot00000000000000"""Test utilities.""" import os def load_fixture(filename): """Load a fixture.""" path = os.path.join(os.path.dirname(__file__), "fixtures", filename) with open(path, encoding="utf-8") as fptr: return fptr.read()