pax_global_header 0000666 0000000 0000000 00000000064 15216246500 0014513 g ustar 00root root 0000000 0000000 52 comment=38090c4fd8ef0218a401cd3376d05c33184be764
exxamalte-python-aio-georss-client-38090c4/ 0000775 0000000 0000000 00000000000 15216246500 0020620 5 ustar 00root root 0000000 0000000 exxamalte-python-aio-georss-client-38090c4/.coveragerc 0000664 0000000 0000000 00000000041 15216246500 0022734 0 ustar 00root root 0000000 0000000 [run]
source = aio_georss_client
exxamalte-python-aio-georss-client-38090c4/.github/ 0000775 0000000 0000000 00000000000 15216246500 0022160 5 ustar 00root root 0000000 0000000 exxamalte-python-aio-georss-client-38090c4/.github/dependabot.yml 0000664 0000000 0000000 00000000315 15216246500 0025007 0 ustar 00root root 0000000 0000000 version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
exxamalte-python-aio-georss-client-38090c4/.github/workflows/ 0000775 0000000 0000000 00000000000 15216246500 0024215 5 ustar 00root root 0000000 0000000 exxamalte-python-aio-georss-client-38090c4/.github/workflows/ci.yaml 0000664 0000000 0000000 00000002525 15216246500 0025500 0 ustar 00root root 0000000 0000000 name: CI
on:
push:
branches:
- master
pull_request: ~
workflow_dispatch:
jobs:
test:
name: Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
env:
USING_COVERAGE: '3.14'
strategy:
fail-fast: true
matrix:
python-version: [ "3.11", "3.12", "3.13", "3.14" ]
steps:
- uses: "actions/checkout@v6"
- uses: "actions/setup-python@v6"
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 aio_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@v7"
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
exxamalte-python-aio-georss-client-38090c4/.gitignore 0000664 0000000 0000000 00000003430 15216246500 0022610 0 ustar 00root root 0000000 0000000 # 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/
pip-wheel-metadata/
share/python-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/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# 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/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# PyCharm
.idea
exxamalte-python-aio-georss-client-38090c4/.pre-commit-config.yaml 0000664 0000000 0000000 00000000317 15216246500 0025102 0 ustar 00root root 0000000 0000000 repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.18
hooks:
# Run the linter.
- id: ruff
args: [ --fix ]
# Run the formatter.
- id: ruff-format
exxamalte-python-aio-georss-client-38090c4/CHANGELOG.md 0000664 0000000 0000000 00000004310 15216246500 0022427 0 ustar 00root root 0000000 0000000 # Changes
## 2026.6.0 (23/06/2026)
* Added Python 3.13 and 3.14 support.
* Removed Python 3.9 and 3.10 support.
* Bump haversine to 2.9.0
* Bump xmltodict to 1.0.4
* Bump ruff to 0.15.18
* Code quality improvements.
* Changing to date-based versioning
## 0.14 (13/10/2024)
* Bump xmltodict to 0.14.1
* Bump ruff to 0.6.9
* Improved test coverage.
* Code quality improvements.
## 0.13 (01/09/2024)
* Removed dateparser and replaced with python-dateutil.
* Removed Python 3.8 support.
* Bumped library versions: ruff.
* Code quality improvements.
## 0.12 (06/02/2024)
* Added Python 3.12 support.
* Removed Python 3.7 support.
* Bumped library versions: black, flake8, isort.
* Migrated to pytest.
* Code quality improvements.
## 0.11 (26/01/2023)
* Added Python 3.11 support.
* Removed deprecated asynctest.
* Bumped library versions: black, haversine, dateparser, xmltodict.
## 0.10 (09/04/2022)
* Improve error handling where the GeoRSS feed provides invalid XML.
## 0.9 (18/02/2022)
* No functional changes.
* Added Python 3.10 support.
* Removed Python 3.6 support.
* Bumped library versions: black, flake8, isort.
* Migrated to github actions.
## 0.8 (09/06/2021)
* Add license tag (thanks @fabaff).
* Set aiohttp to a release 3.7.4 or later (thanks @fabaff).
## 0.7 (31/12/2020)
* Add non-standard namespace used by [EMSC feed](https://www.emsc-csem.org/service/rss/rss.php).
## 0.6 (02/11/2020)
* Added geometry features filter to exclude geometries from being taken into
account for calculating distances.
* General code improvements.
## 0.5 (20/01/2020)
* Clear last timestamp when update fails.
## 0.4 (06/01/2020)
* Support multiple geometries per feed entry.
* Filter out duplicate geometries.
* Support GDACS bounding box.
* Improved distance calculation for polygons.
* Added conversion util for implementing libraries.
* Improved code quality.
## 0.3 (11/12/2019)
* Fixes GDACS XML namespace.
## 0.2 (11/12/2019)
* Fixes bug that prevented external source from being read correctly.
## 0.1 (11/12/2019)
* Initial release as base for GeoRSS feeds.
* Calculating distance to home coordinates.
* Support for filtering by distance and category.
* Filter out entries without any geo location data.
* Simple Feed Manager.
exxamalte-python-aio-georss-client-38090c4/LICENSE 0000664 0000000 0000000 00000026135 15216246500 0021634 0 ustar 00root root 0000000 0000000 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.
exxamalte-python-aio-georss-client-38090c4/MANIFEST.in 0000664 0000000 0000000 00000000014 15216246500 0022351 0 ustar 00root root 0000000 0000000 prune tests
exxamalte-python-aio-georss-client-38090c4/README.md 0000664 0000000 0000000 00000010136 15216246500 0022100 0 ustar 00root root 0000000 0000000 # python-aio-georss-client
[](https://github.com/exxamalte/python-aio-georss-client/actions/workflows/ci.yaml)
[](https://codecov.io/gh/exxamalte/python-aio-georss-client)
[](https://pypi.python.org/pypi/aio-georss-client)
[](https://pypi.python.org/pypi/aio-georss-client)
[](https://codeclimate.com/github/exxamalte/python-aio-georss-client/maintainability)
This library provides convenient async access to [GeoRSS](http://www.georss.org/) Feeds.
## Installation
`pip install aio-georss-client`
## Known Implementations
| Library | Source | Topic |
|----------|---------|--------|
| [aio_georss_gdacs](https://github.com/exxamalte/python-aio-georss-gdacs) | Global Disaster Alert and Coordination System (GDACS) | Natural Disasters |
## Usage
Each implementation extracts relevant information from the GeoJSON 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
* _OK_: Update went fine and data was retrieved. The library may still
return empty data, for example because no entries fulfilled the filter
criteria.
* _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.
* _ERROR_: Something went wrong during the update
## Geometry Features
This library supports 3 different types of geometries:
* Point
* Polygon
* Bounding Box
By default each feed entry is using all available geometries from the external
feed. If required however, you can exclude geometries by overriding
FeedEntry#features and only return the geometries you want to support in your
specific implementation.
## Feed Manager
The Feed Manager helps 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 provides two
different dates:
* `last_update` will be the timestamp of the last update from the feed
irrespective of whether it was successful or not.
* `last_update_successful` 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` (optional, depends on the feed data) 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.
exxamalte-python-aio-georss-client-38090c4/aio_georss_client/ 0000775 0000000 0000000 00000000000 15216246500 0024310 5 ustar 00root root 0000000 0000000 exxamalte-python-aio-georss-client-38090c4/aio_georss_client/__init__.py 0000664 0000000 0000000 00000000041 15216246500 0026414 0 ustar 00root root 0000000 0000000 """aio_georss_client library."""
exxamalte-python-aio-georss-client-38090c4/aio_georss_client/consts.py 0000664 0000000 0000000 00000004047 15216246500 0026200 0 ustar 00root root 0000000 0000000 """Constants for feeds and feed entries."""
from typing import Final
ATTR_ATTRIBUTION: Final = "attribution"
CUSTOM_ATTRIBUTE: Final = "custom_attribute"
DEFAULT_REQUEST_TIMEOUT: Final = 10
UPDATE_OK: Final = "OK"
UPDATE_OK_NO_DATA: Final = "OK_NO_DATA"
UPDATE_ERROR: Final = "ERROR"
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_GDACS_BBOX: Final = "gdacs:bbox"
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"
exxamalte-python-aio-georss-client-38090c4/aio_georss_client/exceptions.py 0000664 0000000 0000000 00000000143 15216246500 0027041 0 ustar 00root root 0000000 0000000 """Exceptions for this library."""
class GeoRssException(Exception):
"""GeoRSS Exception."""
exxamalte-python-aio-georss-client-38090c4/aio_georss_client/feed.py 0000664 0000000 0000000 00000016612 15216246500 0025573 0 ustar 00root root 0000000 0000000 """GeoRSS Feed."""
from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio
import codecs
from datetime import datetime
import logging
from pyexpat import ExpatError
from typing import Generic, TypeVar
import aiohttp
from aiohttp import ClientSession, client_exceptions
from .consts import (
ATTR_ATTRIBUTION,
DEFAULT_REQUEST_TIMEOUT,
UPDATE_ERROR,
UPDATE_OK,
UPDATE_OK_NO_DATA,
)
from .feed_entry import FeedEntry
from .xml_parser import Feed, XmlParser
from .xml_parser.feed_item import FeedItem
_LOGGER = logging.getLogger(__name__)
T_FEED_ENTRY = TypeVar("T_FEED_ENTRY", bound=FeedEntry)
class GeoRssFeed(Generic[T_FEED_ENTRY], ABC):
"""GeoRSS feed base class."""
def __init__(
self,
websession: ClientSession,
home_coordinates: tuple[float, float],
url: str,
filter_radius: float | None = None,
filter_categories: list[str] | None = None,
):
"""Initialise this service."""
self._websession = websession
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._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})>"
@abstractmethod
def _new_entry(
self,
home_coordinates: tuple[float, float],
rss_entry: FeedItem,
global_data: dict,
) -> T_FEED_ENTRY:
"""Generate a new entry."""
def _client_session_timeout(self) -> int:
"""Define client session timeout in seconds. Override if necessary."""
return DEFAULT_REQUEST_TIMEOUT
def _additional_namespaces(self):
"""Provide additional namespaces, relevant for this feed."""
async def update(self) -> tuple[str, list[T_FEED_ENTRY] | None]:
"""Update from external source and return filtered entries."""
status, rss_data = await self._fetch()
if status == UPDATE_OK:
if rss_data:
global_data = self._extract_from_feed(rss_data)
# Extract data from feed entries.
entries: list = [
self._new_entry(self._home_coordinates, rss_entry, global_data)
for rss_entry in rss_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
async def _fetch(
self, method: str = "GET", headers=None, params=None
) -> tuple[str, Feed | None]:
"""Fetch GeoRSS data from external source."""
try:
timeout = aiohttp.ClientTimeout(total=self._client_session_timeout())
async with self._websession.request(
method, self._url, headers=headers, params=params, timeout=timeout
) as response:
try:
response.raise_for_status()
text = await self._read_response(response)
parser = XmlParser(self._additional_namespaces())
feed_data = parser.parse(text)
self.parser = parser
self.feed_data = feed_data
return UPDATE_OK, feed_data
except client_exceptions.ClientError as client_error:
_LOGGER.warning(
"Fetching data from %s failed with %s", self._url, client_error
)
return UPDATE_ERROR, None
except ExpatError as expat_error:
_LOGGER.warning(
"Parsing data from %s failed with %s", self._url, expat_error
)
return UPDATE_OK_NO_DATA, None
except client_exceptions.ClientError as client_error:
_LOGGER.warning(
"Requesting data from %s failed with client error: %s",
self._url,
client_error,
)
return UPDATE_ERROR, None
except asyncio.TimeoutError:
_LOGGER.warning(
"Requesting data from %s failed with timeout error", self._url
)
return UPDATE_ERROR, None
async def _read_response(self, response):
"""Pre-process the response."""
if response:
raw_response = await response.read()
_LOGGER.debug("Response encoding %s", response.get_encoding())
if raw_response.startswith(codecs.BOM_UTF8):
return await response.text("utf-8-sig")
return await response.text()
return None
def _filter_entries(self, entries: list[T_FEED_ENTRY]):
"""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.geometries is not None
and len(entry.geometries) >= 1,
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: list[T_FEED_ENTRY]
) -> 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
exxamalte-python-aio-georss-client-38090c4/aio_georss_client/feed_entry.py 0000664 0000000 0000000 00000012707 15216246500 0027015 0 ustar 00root root 0000000 0000000 """Feed Entry."""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime
import logging
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 BoundingBox, Geometry, Point, Polygon
_LOGGER = logging.getLogger(__name__)
DEFAULT_FEATURES = [Point, Polygon, BoundingBox]
class FeedEntry(ABC):
"""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 features(self) -> list[type[Geometry]]:
"""Return the list of geometry types that this feed entry supports."""
return DEFAULT_FEATURES
@property
def geometries(self) -> list[Geometry] | None:
"""Return all geometries of this entry."""
if self._rss_entry:
# Return all geometries that are of type defined in features.
return list(
filter(lambda x: type(x) in self.features, self._rss_entry.geometries)
)
return None
@property
def coordinates(self) -> tuple[float, float] | None:
"""Return the best coordinates (latitude, longitude) of this entry."""
# This looks for the first point in the list of geometries. If there
# is no point then return the first entry.
if self.geometries and len(self.geometries) >= 1:
for entry in self.geometries:
if isinstance(entry, Point):
return GeoRssDistanceHelper.extract_coordinates(entry)
# No point found.
return GeoRssDistanceHelper.extract_coordinates(self.geometries[0])
return None
@property
def external_id(self) -> str | None:
"""Return the external id of this entry."""
if self._rss_entry:
external_id: str | None = 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 = str(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
@abstractmethod
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."""
# This goes through all geometries and reports back the closest
# distance to any of them.
distance: float = float("inf")
if self.geometries and len(self.geometries) >= 1:
for geometry in self.geometries:
distance = min(
distance,
GeoRssDistanceHelper.distance_to_geometry(
self._home_coordinates, geometry
),
)
return distance
@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
@staticmethod
def _string2boolean(value: str) -> bool:
"""Convert value to boolean."""
return isinstance(value, str) and value.strip().lower() in {"true", "yes", "1"}
exxamalte-python-aio-georss-client-38090c4/aio_georss_client/feed_manager.py 0000664 0000000 0000000 00000015477 15216246500 0027275 0 ustar 00root root 0000000 0000000 """Base class for the feed manager. This allows managing feeds and their entries throughout their life-cycle."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from datetime import datetime
import logging
from .consts import UPDATE_OK, UPDATE_OK_NO_DATA
from .feed import GeoRssFeed
from .feed_entry import FeedEntry
from .status_update import StatusUpdate
_LOGGER = logging.getLogger(__name__)
class FeedManagerBase:
"""Generic Feed manager."""
def __init__(
self,
feed: GeoRssFeed,
generate_async_callback: Callable[[str], Awaitable[None]],
update_async_callback: Callable[[str], Awaitable[None]],
remove_async_callback: Callable[[str], Awaitable[None]],
status_async_callback: Callable[[StatusUpdate], Awaitable[None]] | None = None,
):
"""Initialise feed manager."""
self._feed: GeoRssFeed = feed
self.feed_entries: dict = {}
self._managed_external_ids: set = set()
self._last_update: datetime | None = None
self._last_update_successful: datetime | None = None
self._generate_async_callback: Callable[[str], Awaitable[None]] = (
generate_async_callback
)
self._update_async_callback: Callable[[str], Awaitable[None]] = (
update_async_callback
)
self._remove_async_callback: Callable[[str], Awaitable[None]] = (
remove_async_callback
)
self._status_async_callback: Callable[[StatusUpdate], Awaitable[None]] = (
status_async_callback
)
def __repr__(self):
"""Return string representation of this feed."""
return f"<{self.__class__.__name__}(feed={self._feed})>"
async def update(self):
"""Update the feed and then update connected entities."""
status, feed_entries = await self._feed.update()
# Record current time of update.
self._last_update = datetime.now()
count_created: int = 0
count_updated: int = 0
count_removed: int = 0
await self._store_feed_entries(status, feed_entries)
if status == UPDATE_OK:
_LOGGER.debug("Data retrieved %s", feed_entries)
# Record current time of update.
self._last_update_successful = self._last_update
# For entity management the external ids from the feed are used.
feed_external_ids = {entry.external_id for entry in feed_entries}
count_removed = await self._update_feed_remove_entries(feed_external_ids)
count_updated = await self._update_feed_update_entries(feed_external_ids)
count_created = await self._update_feed_create_entries(feed_external_ids)
elif status == UPDATE_OK_NO_DATA:
_LOGGER.debug("Update successful, but no data received from %s", self._feed)
# Record current time of update.
self._last_update_successful = self._last_update
else:
_LOGGER.warning(
"Update not successful, no data received from %s", self._feed
)
# Remove all entities.
count_removed = await self._update_feed_remove_entries(set())
# Send status update to subscriber.
await self._status_update(status, count_created, count_updated, count_removed)
async def _store_feed_entries(
self, status: str, feed_entries: list[FeedEntry] | None
):
"""Keep a copy of all feed entries for future lookups."""
if feed_entries or status == UPDATE_OK_NO_DATA:
if status == UPDATE_OK:
self.feed_entries = {entry.external_id: entry for entry in feed_entries}
else:
self.feed_entries.clear()
async def _update_feed_create_entries(self, feed_external_ids: set[str]) -> int:
"""Create entities after feed update."""
create_external_ids: set[str] = feed_external_ids.difference(
self._managed_external_ids
)
count_created = len(create_external_ids)
await self._generate_new_entities(create_external_ids)
return count_created
async def _update_feed_update_entries(self, feed_external_ids: set[str]) -> int:
"""Update entities after feed update."""
update_external_ids: set[str] = self._managed_external_ids.intersection(
feed_external_ids
)
count_updated = len(update_external_ids)
await self._update_entities(update_external_ids)
return count_updated
async def _update_feed_remove_entries(self, feed_external_ids: set[str]) -> int:
"""Remove entities after feed update."""
remove_external_ids: set[str] = self._managed_external_ids.difference(
feed_external_ids
)
count_removed = len(remove_external_ids)
await self._remove_entities(remove_external_ids)
return count_removed
async def _generate_new_entities(self, external_ids: set[str]):
"""Generate new entities for events."""
for external_id in external_ids:
await self._generate_async_callback(external_id)
_LOGGER.debug("New entity added %s", external_id)
self._managed_external_ids.add(external_id)
async def _update_entities(self, external_ids: set[str]):
"""Update entities."""
for external_id in external_ids:
_LOGGER.debug("Existing entity found %s", external_id)
await self._update_async_callback(external_id)
async def _remove_entities(self, external_ids: set[str]):
"""Remove entities."""
for external_id in external_ids:
_LOGGER.debug("Entity not current anymore %s", external_id)
self._managed_external_ids.remove(external_id)
await self._remove_async_callback(external_id)
async def _status_update(
self, status: str, count_created: int, count_updated: int, count_removed: int
):
"""Provide status update."""
if self._status_async_callback:
await self._status_async_callback(
StatusUpdate(
status,
self.last_update,
self.last_update_successful,
self.last_timestamp,
len(self.feed_entries),
count_created,
count_updated,
count_removed,
)
)
@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 update of this feed."""
return self._last_update
@property
def last_update_successful(self) -> datetime | None:
"""Return the last successful update of this feed."""
return self._last_update_successful
exxamalte-python-aio-georss-client-38090c4/aio_georss_client/geo_rss_distance_helper.py 0000664 0000000 0000000 00000027733 15216246500 0031550 0 ustar 00root root 0000000 0000000 """GeoRSS Distance Helper."""
from __future__ import annotations
import logging
from haversine import haversine
from .xml_parser.geometry import BoundingBox, Geometry, Point, Polygon
_LOGGER = logging.getLogger(__name__)
class GeoRssDistanceHelper:
"""Helper to calculate distances between GeoRSS geometries."""
def __init__(self):
"""Initialize the geo distance helper."""
@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, BoundingBox)):
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
)
elif isinstance(geometry, BoundingBox):
distance = GeoRssDistanceHelper._distance_to_bounding_box(
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 = float("inf")
# Check if home is inside the polygon.
if polygon.is_inside(Point(home_coordinates[0], home_coordinates[1])):
return 0.0
# Calculate distance from polygon by first calculating the distance
# to each point of the polygon.
for point in polygon.points:
distance = min(
distance,
GeoRssDistanceHelper._distance_to_coordinates(
home_coordinates, (point.latitude, point.longitude)
),
)
# Next calculate the distance to each edge of the polygon.
for edge in polygon.edges:
distance = min(
distance, GeoRssDistanceHelper._distance_to_edge(home_coordinates, edge)
)
_LOGGER.debug(
"Distance between %s and %s: %s", home_coordinates, polygon, distance
)
return distance
@staticmethod
def _distance_to_bounding_box(
home_coordinates: tuple[float, float], bbox: BoundingBox
) -> float:
"""Calculate the distance between home coordinates and the bbox."""
distance: float = float("inf")
# Check if home is inside the bounding box.
# home_coordinates is tuple of (latitude, longitude)
if bbox.is_inside(Point(home_coordinates[0], home_coordinates[1])):
return 0.0
# Next find the point on the edge of the bounding box that is closest to the home coordinates.
target_point: tuple[float, float] | None = (
GeoRssDistanceHelper._find_bounding_box_target_point(home_coordinates, bbox)
)
if target_point:
distance = GeoRssDistanceHelper._distance_to_coordinates(
home_coordinates, target_point
)
_LOGGER.debug(
"Distance between %s and %s: %s", home_coordinates, bbox, distance
)
return distance
return distance
@staticmethod
def _find_bounding_box_target_point(
home_coordinates: tuple[float, float], bbox: BoundingBox
) -> tuple[float, float] | None:
"""Find best target point of bounding box."""
target_point: tuple[float, float] | None = None
# Distinguish 8 more cases / quadrants:
transposed_point_longitude: float = home_coordinates[1]
transposed_top_right_longitude = bbox.top_right.longitude
if bbox.bottom_left.longitude > bbox.top_right.longitude:
# bounding box spans across 180 degree longitude
transposed_top_right_longitude = bbox.top_right.longitude + 360
# only in this case, also transpose the point's longitude
if transposed_point_longitude < 0:
transposed_point_longitude += 360
target_point = GeoRssDistanceHelper._find_bounding_box_target_point_top(
home_coordinates,
bbox,
transposed_point_longitude,
transposed_top_right_longitude,
target_point,
)
target_point = GeoRssDistanceHelper._find_bounding_box_target_point_middle(
home_coordinates,
bbox,
transposed_point_longitude,
transposed_top_right_longitude,
target_point,
)
target_point = GeoRssDistanceHelper._find_bounding_box_target_point_bottom(
home_coordinates,
bbox,
transposed_point_longitude,
transposed_top_right_longitude,
target_point,
)
return target_point # noqa: RET504
@staticmethod
def _find_bounding_box_target_point_top(
home_coordinates: tuple[float, float],
bbox: BoundingBox,
transposed_point_longitude: float,
transposed_top_right_longitude: float,
target_point: tuple[float, float] | None,
) -> tuple[float, float] | None:
"""Find target point above bounding box."""
if home_coordinates[0] > bbox.top_right.latitude:
# 1 - above-left
if transposed_point_longitude < bbox.bottom_left.longitude:
# Calculate distance to top left point of bbox.
target_point = (bbox.top_right.latitude, bbox.bottom_left.longitude)
# 2 - above-centre
if (
bbox.bottom_left.longitude
<= transposed_point_longitude
<= transposed_top_right_longitude
):
# Calculate distance to top latitude of bbox.
target_point = (bbox.top_right.latitude, home_coordinates[1])
# 3 - above-right
if transposed_point_longitude > transposed_top_right_longitude:
# Calculate distance to top right point of bbox.
target_point = (bbox.top_right.latitude, bbox.top_right.longitude)
return target_point
@staticmethod
def _find_bounding_box_target_point_middle(
home_coordinates: tuple[float, float],
bbox: BoundingBox,
transposed_point_longitude: float,
transposed_top_right_longitude: float,
target_point: tuple[float, float] | None,
) -> tuple[float, float] | None:
"""Find target point left or right of bounding box."""
if bbox.top_right.latitude >= home_coordinates[0] >= bbox.bottom_left.latitude:
# 4 - left
if transposed_point_longitude < bbox.bottom_left.longitude:
# Calculate distance to left longitude of bbox.
target_point = (home_coordinates[0], bbox.bottom_left.longitude)
# 5 - right
if transposed_point_longitude > transposed_top_right_longitude:
# Calculate distance to right longitude of bbox.
target_point = (home_coordinates[0], bbox.top_right.longitude)
return target_point
@staticmethod
def _find_bounding_box_target_point_bottom(
home_coordinates: tuple[float, float],
bbox: BoundingBox,
transposed_point_longitude: float,
transposed_top_right_longitude: float,
target_point: tuple[float, float] | None,
) -> tuple[float, float] | None:
"""Find target point below bounding box."""
if home_coordinates[0] < bbox.bottom_left.latitude:
# 6 - below-left
if transposed_point_longitude < bbox.bottom_left.longitude:
# Calculate distance to bottom left point of bbox.
target_point = (bbox.bottom_left.latitude, bbox.bottom_left.longitude)
# 7 - below-centre
if (
bbox.bottom_left.longitude
<= transposed_point_longitude
<= transposed_top_right_longitude
):
# Calculate distance to bottom latitude of bbox.
target_point = (bbox.bottom_left.latitude, home_coordinates[1])
# 8 - below-right
if transposed_point_longitude > transposed_top_right_longitude:
# Calculate distance to bottom right point of bbox.
target_point = (bbox.bottom_left.latitude, bbox.top_right.longitude)
return target_point
@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)
@staticmethod
def _distance_to_edge(
home_coordinates: tuple[float, float], edge: tuple[Point, Point]
) -> float:
"""Calculate distance between home coordinates and provided edge."""
perpendicular_point: Point | None = GeoRssDistanceHelper._perpendicular_point(
edge, Point(home_coordinates[0], home_coordinates[1])
)
# If there is a perpendicular point on the edge -> calculate distance.
# If there isn't, then the distance to the end points of the edge will
# need to be considered separately.
if perpendicular_point:
distance = GeoRssDistanceHelper._distance_to_point(
home_coordinates, perpendicular_point
)
_LOGGER.debug(
"Distance between %s and %s: %s", home_coordinates, edge, distance
)
return distance
return float("inf")
@staticmethod
def _perpendicular_point(edge: tuple[Point, Point], point: Point) -> Point | None:
"""Find a perpendicular point on the edge to the provided point."""
a, b = edge
# Safety check: a and b can't be an edge if they are the same point.
if a == b:
return None
px = point.longitude
py = point.latitude
ax = a.longitude
ay = a.latitude
bx = b.longitude
by = b.latitude
# Alter longitude to cater for 180 degree crossings.
if px < 0:
px += 360.0
if ax < 0:
ax += 360.0
if bx < 0:
bx += 360.0
if ay > by or ax > bx:
ax, ay, bx, by = bx, by, ax, ay
dx = abs(bx - ax)
dy = abs(by - ay)
shortest_length = ((dx * (px - ax)) + (dy * (py - ay))) / (
(dx * dx) + (dy * dy)
)
rx = ax + dx * shortest_length
ry = ay + dy * shortest_length
if bx >= rx >= ax and by >= ry >= ay:
if rx > 180:
# Correct longitude.
rx -= 360.0
return Point(ry, rx)
return None
exxamalte-python-aio-georss-client-38090c4/aio_georss_client/status_update.py 0000664 0000000 0000000 00000004115 15216246500 0027550 0 ustar 00root root 0000000 0000000 """Status Update."""
from __future__ import annotations
from datetime import datetime
class StatusUpdate:
"""Status Update class."""
def __init__(
self,
status: str,
last_update: datetime | None,
last_update_successful: datetime | None,
last_timestamp: datetime | None,
total: int,
created: int,
updated: int,
removed: int,
):
"""Initialise this status update."""
self._status: str = status
self._last_update: datetime | None = last_update
self._last_update_successful: datetime | None = last_update_successful
self._last_timestamp: datetime | None = last_timestamp
self._total: int = total
self._created: int = created
self._updated: int = updated
self._removed: int = removed
def __repr__(self):
"""Return string representation of this entry."""
return f"<{self.__class__.__name__}({self.status}@{self.last_update})>"
@property
def status(self) -> str:
"""Return the status."""
return self._status
@property
def last_update(self) -> datetime | None:
"""Return the time when the feed was last updated."""
return self._last_update
@property
def last_update_successful(self) -> datetime | None:
"""Return the time when the feed was last updated successfully."""
return self._last_update_successful
@property
def last_timestamp(self) -> datetime | None:
"""Return the timestamp the latest entry in the feed."""
return self._last_timestamp
@property
def total(self) -> int:
"""Return the total number of managed entries."""
return self._total
@property
def created(self) -> int:
"""Return the number of created entries."""
return self._created
@property
def updated(self) -> int:
"""Return the number of updated entries."""
return self._updated
@property
def removed(self) -> int:
"""Return the number of removed entries."""
return self._removed
exxamalte-python-aio-georss-client-38090c4/aio_georss_client/xml_parser/ 0000775 0000000 0000000 00000000000 15216246500 0026464 5 ustar 00root root 0000000 0000000 exxamalte-python-aio-georss-client-38090c4/aio_georss_client/xml_parser/__init__.py 0000664 0000000 0000000 00000010672 15216246500 0030603 0 ustar 00root root 0000000 0000000 """XML Parser."""
from __future__ import annotations
from datetime import datetime
import logging
import dateutil
import xmltodict
from ..consts import (
XML_TAG_CHANNEL,
XML_TAG_DC_DATE,
XML_TAG_FEED,
XML_TAG_GDACS_BBOX,
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 .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,
XML_TAG_GDACS_BBOX,
]
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:
# Check if value is a dict -> need to extract #text attribute
if isinstance(value, dict):
value = value["#text"]
point_coordinates = XmlParser._process_coordinates(value)
# Return tuple of coordinates to make this conversion
# compatible with parsing multiple tags of the same type and
# combining all values into a single array.
# If we just returned an array here, coordinates would be mixed
# up like: [lat1, lon1, [lat2, lon2], [lat3, lon3]]
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
@staticmethod
def _process_coordinates(value: str) -> list[float]:
"""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 point_coordinates
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:
return XmlParser._create_feed_from_rss(parsed_dict)
if XML_TAG_FEED in parsed_dict:
return XmlParser._create_feed_from_feed(parsed_dict)
return None
@staticmethod
def _create_feed_from_rss(parsed_dict: dict) -> Feed | None:
"""Create feed from provided RSS data."""
rss = parsed_dict.get(XML_TAG_RSS)
if XML_TAG_CHANNEL in rss:
channel = rss.get(XML_TAG_CHANNEL)
return Feed(channel)
_LOGGER.warning(
"Invalid structure: %s not followed by %s", XML_TAG_RSS, XML_TAG_CHANNEL
)
return None
@staticmethod
def _create_feed_from_feed(parsed_dict: dict) -> Feed:
"""Create feed from provided Feed data."""
feed_data = parsed_dict.get(XML_TAG_FEED)
return Feed(feed_data)
exxamalte-python-aio-georss-client-38090c4/aio_georss_client/xml_parser/feed.py 0000664 0000000 0000000 00000004344 15216246500 0027746 0 ustar 00root root 0000000 0000000 """GeoRSS feed models."""
from __future__ import annotations
import logging
from ..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 .feed_image import FeedImage
from .feed_item import FeedItem
from .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: list[FeedItem] = []
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
exxamalte-python-aio-georss-client-38090c4/aio_georss_client/xml_parser/feed_dict_source.py 0000664 0000000 0000000 00000005437 15216246500 0032335 0 ustar 00root root 0000000 0000000 """GeoRSS feed dict source."""
from __future__ import annotations
from typing import Optional
from ..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: str = 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])
exxamalte-python-aio-georss-client-38090c4/aio_georss_client/xml_parser/feed_image.py 0000664 0000000 0000000 00000001255 15216246500 0031106 0 ustar 00root root 0000000 0000000 """GeoRSS feed image."""
from __future__ import annotations
from ..consts import XML_TAG_HEIGHT, XML_TAG_URL, XML_TAG_WIDTH
from .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])
exxamalte-python-aio-georss-client-38090c4/aio_georss_client/xml_parser/feed_item.py 0000664 0000000 0000000 00000020401 15216246500 0030754 0 ustar 00root root 0000000 0000000 """GeoRSS feed item."""
from __future__ import annotations
import logging
from ..consts import (
XML_TAG_GDACS_BBOX,
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 .feed_or_feed_item import FeedOrFeedItem
from .geometry import BoundingBox, Geometry, Point, Polygon
_LOGGER = logging.getLogger(__name__)
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(),
self._geometry_gdacs_bbox(),
]:
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_gdacs_bbox(self) -> list[BoundingBox] | None:
"""Check for gdacs:bbox tag."""
#
# 164.5652 172.5652 -24.9041 -16.9041
bbox = self._attribute([XML_TAG_GDACS_BBOX])
if bbox:
if isinstance(bbox, tuple):
return FeedItem._create_bbox_single(bbox)
return FeedItem._create_bbox_multiple(bbox)
return None
@staticmethod
def _create_bbox_single(bbox: tuple) -> list[BoundingBox]:
"""Create single bbox from provided tuple of coordinates."""
return [BoundingBox(Point(bbox[2], bbox[0]), Point(bbox[3], bbox[1]))]
@staticmethod
def _create_bbox_multiple(bbox: list) -> list[BoundingBox]:
"""Create multiple bboxes from provided list of coordinates."""
bounding_boxes = []
for entry in bbox:
if len(entry) == 4:
bounding_boxes.append(
BoundingBox(Point(entry[2], entry[0]), Point(entry[3], entry[1]))
)
else:
_LOGGER.warning("Insufficient data for " "bounding box: %s", entry)
return bounding_boxes
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
exxamalte-python-aio-georss-client-38090c4/aio_georss_client/xml_parser/feed_or_feed_item.py 0000664 0000000 0000000 00000006230 15216246500 0032443 0 ustar 00root root 0000000 0000000 """GeoRSS feed or feed item."""
from __future__ import annotations
import datetime
from ..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 .feed_dict_source import FeedDictSource
class FeedOrFeedItem(FeedDictSource):
"""Represents the common base of feed and its items."""
@property
def category(self) -> list[str] | 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]
return FeedOrFeedItem._create_categories(category)
return None
@staticmethod
def _create_categories(categories: list) -> list[str]:
"""Create categories from provided list."""
result: list[str] = []
for item in categories:
if XML_ATTR_TERM in item:
#
result.append(item.get(XML_ATTR_TERM))
else:
result.append(item)
return result
@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: str = 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:
name: str = author.get(XML_TAG_NAME, None)
return name
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
exxamalte-python-aio-georss-client-38090c4/aio_georss_client/xml_parser/geometry.py 0000664 0000000 0000000 00000015555 15216246500 0030704 0 ustar 00root root 0000000 0000000 """Geometry models."""
from __future__ import annotations
class Geometry:
"""Represents a geometry."""
class Point(Geometry):
"""Represents a point."""
def __init__(self, latitude: float, longitude: float):
"""Initialise point."""
self._latitude: float = latitude
self._longitude: float = 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 edges(self) -> list[tuple[Point, Point]]:
"""Return all edges of this polygon."""
edges: list[tuple[Point, Point]] = []
for i in range(1, len(self.points)):
previous = self.points[i - 1]
current = self.points[i]
edges.append((previous, current))
return edges
@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)
def is_inside(self, point: Point | None) -> bool:
"""Check if the provided point is inside this polygon."""
if point:
crossings: int = 0
for edge in self.edges:
if Polygon._ray_crosses_segment(point, edge):
crossings += 1
return crossings % 2 == 1
return False
@staticmethod
def _ray_crosses_segment(point: Point, edge: tuple[Point, Point]):
"""Use ray-casting algorithm to check provided point and edge."""
a, b = edge
px = point.longitude
py = point.latitude
ax = a.longitude
ay = a.latitude
bx = b.longitude
by = b.latitude
if ay > by:
ax = b.longitude
ay = b.latitude
bx = a.longitude
by = a.latitude
# Alter longitude to cater for 180 degree crossings.
if px < 0:
px += 360.0
if ax < 0:
ax += 360.0
if bx < 0:
bx += 360.0
if py in (ay, by):
py += 0.00000001
if (py > by or py < ay) or (px > max(ax, bx)):
return False
if px < min(ax, bx):
return True
red = ((by - ay) / (bx - ax)) if (ax != bx) else float("inf")
blue = ((py - ay) / (px - ax)) if (ax != px) else float("inf")
return blue >= red
class BoundingBox(Geometry):
"""Represents a bounding box (bbox)."""
#
# 164.5652 172.5652 -24.9041 -16.9041
def __init__(self, bottom_left: Point, top_right: Point):
"""Initialise bounding box."""
self._bottom_left: Point = bottom_left
self._top_right: Point = top_right
def __repr__(self):
"""Return string representation of this bounding box."""
return f"<{self.__class__.__name__}(bottom_left={self._bottom_left}, top_right={self._top_right})>"
def __hash__(self) -> int:
"""Return unique hash of this geometry."""
return hash((self.bottom_left, self.top_right))
def __eq__(self, other: object) -> bool:
"""Return if this object is equal to other object."""
return (
self.__class__ == other.__class__
and self.bottom_left == other.bottom_left
and self.top_right == other.top_right
)
@property
def bottom_left(self) -> Point:
"""Return bottom left point."""
return self._bottom_left
@property
def top_right(self) -> Point:
"""Return top right point."""
return self._top_right
@property
def centroid(self) -> Point:
"""Find the bounding box's centroid as a best approximation."""
transposed_top_right_longitude: float = self._top_right.longitude
if self._bottom_left.longitude > self._top_right.longitude:
# bounding box spans across 180 degree longitude
transposed_top_right_longitude = self._top_right.longitude + 360
longitude: float = (
self._bottom_left.longitude + transposed_top_right_longitude
) / 2
latitude: float = (self._bottom_left.latitude + self._top_right.latitude) / 2
return Point(latitude, longitude)
def is_inside(self, point: Point) -> bool:
"""Check if the provided point is inside this bounding box."""
if point:
transposed_point_longitude: float = point.longitude
transposed_top_right_longitude = self._top_right.longitude
if self._bottom_left.longitude > self._top_right.longitude:
# bounding box spans across 180 degree longitude
transposed_top_right_longitude = self._top_right.longitude + 360
if point.longitude < 0:
transposed_point_longitude += 360
return (
self._bottom_left.latitude <= point.latitude <= self._top_right.latitude
) and (
self._bottom_left.longitude
<= transposed_point_longitude
<= transposed_top_right_longitude
)
return False
exxamalte-python-aio-georss-client-38090c4/codecov.yml 0000664 0000000 0000000 00000000017 15216246500 0022763 0 ustar 00root root 0000000 0000000 comment: false
exxamalte-python-aio-georss-client-38090c4/pyproject.toml 0000664 0000000 0000000 00000013652 15216246500 0023543 0 ustar 00root root 0000000 0000000 [build-system]
requires = [
"setuptools",
"wheel"
]
build-backend = "setuptools.build_meta"
[project]
name = "aio_georss_client"
version = "2026.6.0"
requires-python = ">= 3.11"
authors = [
{name = "Malte Franken", email = "coding@subspace.de"},
]
description = "An async GeoRSS client library."
readme = "README.md"
license = "Apache-2.0"
keywords = ["homeassistant", "georss"]
classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Operating System :: OS Independent",
"Development Status :: 5 - Production/Stable",
]
dependencies = [
"aiohttp>=3.7.4,<4",
"haversine>=2.9.0",
"xmltodict>=1.0.4",
"python-dateutil>=2.9.0",
]
[project.optional-dependencies]
tests = [
"pytest-asyncio",
"pytest-timeout",
"pytest-xdist",
"pytest-cov",
"coverage",
"mock",
"aiointercept",
]
[project.urls]
Repository = "https://github.com/exxamalte/python-aio-georss-client"
Issues = "https://github.com/exxamalte/python-aio-georss-client/issues"
Changelog = "https://github.com/exxamalte/python-aio-georss-client/blob/main/CHANGELOG.md"
[tool.setuptools.packages.find]
include = ["aio_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
"FURB", # refurb
"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
"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
"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
"TRY003", # Avoid specifying long messages outside the exception class
"TRY300", # Consider moving this statement to an `else` block
"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",
]
[tool.ruff.lint.isort]
force-sort-within-sections = true
known-first-party = [
"aio_georss_client",
]
combine-as-imports = true
split-on-trailing-comma = false
[tool.pytest.ini_options]
testpaths = [
"tests",
]
exxamalte-python-aio-georss-client-38090c4/tests/ 0000775 0000000 0000000 00000000000 15216246500 0021762 5 ustar 00root root 0000000 0000000 exxamalte-python-aio-georss-client-38090c4/tests/__init__.py 0000664 0000000 0000000 00000003537 15216246500 0024103 0 ustar 00root root 0000000 0000000 """Tests for georss-client library."""
from __future__ import annotations
from typing import Final
from aio_georss_client.feed import GeoRssFeed
from aio_georss_client.feed_entry import DEFAULT_FEATURES, FeedEntry
from aio_georss_client.xml_parser.feed_item import FeedItem
from aio_georss_client.xml_parser.geometry import Geometry
MOCK_HOME_COORDINATES: Final = (0.0, 0.0)
class MockFeedEntry(FeedEntry):
"""Generic feed entry."""
@property
def attribution(self) -> str | None:
"""Return attribution."""
return None
class MockGeoRssFeed(GeoRssFeed[MockFeedEntry]):
"""Mock GeoRSS feed."""
def _new_entry(
self,
home_coordinates: tuple[float, float],
rss_entry: FeedItem,
global_data: dict,
) -> MockFeedEntry:
"""Generate a new entry."""
return MockFeedEntry(home_coordinates, rss_entry)
class MockSimpleFeedEntry(FeedEntry):
"""Mock feed entry."""
def __init__(
self,
home_coordinates: tuple[float, float] | None,
rss_entry: FeedItem,
features: list[type[Geometry]] = DEFAULT_FEATURES,
):
"""Initialise feed entry."""
super().__init__(home_coordinates, rss_entry)
self._features = features
@property
def features(self) -> list[type[Geometry]]:
"""Return features."""
return self._features
@property
def attribution(self) -> str | None:
"""Return attribution."""
return "mock attribution"
class MockFeedItem(FeedItem):
"""Mock feed item."""
def __init__(self, source: dict | None, geometries: list[Geometry] | None):
"""Initialise feed item."""
super().__init__(source)
self._geometries = geometries
@property
def geometries(self) -> list[Geometry] | None:
"""Return geometries."""
return self._geometries
exxamalte-python-aio-georss-client-38090c4/tests/conftest.py 0000664 0000000 0000000 00000000407 15216246500 0024162 0 ustar 00root root 0000000 0000000 """Configuration for tests."""
from aiointercept import aiointercept
import pytest_asyncio
@pytest_asyncio.fixture
async def mock_aiointercept():
"""Return aiointercept fixture."""
async with aiointercept(mock_external_urls=True) as m:
yield m
exxamalte-python-aio-georss-client-38090c4/tests/fixtures/ 0000775 0000000 0000000 00000000000 15216246500 0023633 5 ustar 00root root 0000000 0000000 exxamalte-python-aio-georss-client-38090c4/tests/fixtures/generic_feed_1.xml 0000664 0000000 0000000 00000003226 15216246500 0027177 0 ustar 00root root 0000000 0000000
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
exxamalte-python-aio-georss-client-38090c4/tests/fixtures/generic_feed_2.xml 0000664 0000000 0000000 00000000726 15216246500 0027202 0 ustar 00root root 0000000 0000000
-
Title 1
2018-09-23 08:30:00
1234
Category 1
149.1234
-37.2345
exxamalte-python-aio-georss-client-38090c4/tests/fixtures/generic_feed_3.xml 0000664 0000000 0000000 00000014072 15216246500 0027202 0 ustar 00root root 0000000 0000000
-
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
-
Title 4
2018-09-23 11:30:00
4567
Category 4
-33.2345 154.7890
-31.2345 152.7890
-
Title 5
2018-09-23 12:30:00
5678
Category 5
-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
-31.2345 152.7890
exxamalte-python-aio-georss-client-38090c4/tests/fixtures/generic_feed_4.xml 0000664 0000000 0000000 00000001771 15216246500 0027205 0 ustar 00root root 0000000 0000000
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
exxamalte-python-aio-georss-client-38090c4/tests/fixtures/generic_feed_5.xml 0000664 0000000 0000000 00000000517 15216246500 0027203 0 ustar 00root root 0000000 0000000
Attribution 1
1234
Title 1
-37.2345 149.1234
exxamalte-python-aio-georss-client-38090c4/tests/fixtures/generic_feed_6.xml 0000664 0000000 0000000 00000000606 15216246500 0027203 0 ustar 00root root 0000000 0000000
Attribution 1
1234
Title 1
164.5652 172.5652 -24.9041 -16.9041
exxamalte-python-aio-georss-client-38090c4/tests/fixtures/generic_feed_7.xml 0000664 0000000 0000000 00000004767 15216246500 0027220 0 ustar 00root root 0000000 0000000
Attribution 1
1234
Title 1
-37.2345 149.1234
-37.2345 149.1234
2345
Title 2
-37.2345 149.1234
149.1234
-37.2345
3456
Title 3
-37.2345 149.1234
-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
-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
4567
Title 4
-37.2345 149.1234
164.5652 172.5652 -24.9041 -16.9041
164.5652 172.5652 -24.9041 -16.9041
exxamalte-python-aio-georss-client-38090c4/tests/fixtures/generic_feed_8.xml 0000664 0000000 0000000 00000000707 15216246500 0027207 0 ustar 00root root 0000000 0000000
-
Title 1
2018-09-23 08:30:00
1234
Category 1
149.1234
-37.2345
exxamalte-python-aio-georss-client-38090c4/tests/fixtures/xml_parser_bom_1.xml 0000664 0000000 0000000 00000000244 15216246500 0027606 0 ustar 00root root 0000000 0000000 
-
Title 1
exxamalte-python-aio-georss-client-38090c4/tests/fixtures/xml_parser_complex_1.xml 0000664 0000000 0000000 00000010512 15216246500 0030477 0 ustar 00root root 0000000 0000000
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
exxamalte-python-aio-georss-client-38090c4/tests/fixtures/xml_parser_complex_2.xml 0000664 0000000 0000000 00000002000 15216246500 0030471 0 ustar 00root root 0000000 0000000
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
exxamalte-python-aio-georss-client-38090c4/tests/fixtures/xml_parser_complex_3.xml 0000664 0000000 0000000 00000001264 15216246500 0030505 0 ustar 00root root 0000000 0000000
Feed Rights 1
-
-
exxamalte-python-aio-georss-client-38090c4/tests/fixtures/xml_parser_geometries_1.xml 0000664 0000000 0000000 00000005723 15216246500 0031203 0 ustar 00root root 0000000 0000000
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
-
Title 7
164.1234 172.2345 -24.9876 -16.8765
164.2345 172.3456 -24.8765 -16.7654
-
Title 8
164.1234 172.2345 -24.9876 -16.8765
164.2345 172.3456 -24.8765
exxamalte-python-aio-georss-client-38090c4/tests/fixtures/xml_parser_simple_1.xml 0000664 0000000 0000000 00000000326 15216246500 0030323 0 ustar 00root root 0000000 0000000
-
Title 1
exxamalte-python-aio-georss-client-38090c4/tests/fixtures/xml_parser_simple_2.xml 0000664 0000000 0000000 00000000304 15216246500 0030320 0 ustar 00root root 0000000 0000000
Title 1
exxamalte-python-aio-georss-client-38090c4/tests/fixtures/xml_parser_simple_3.xml 0000664 0000000 0000000 00000000332 15216246500 0030322 0 ustar 00root root 0000000 0000000
-
Title 1
exxamalte-python-aio-georss-client-38090c4/tests/fixtures/xml_parser_simple_4.xml 0000664 0000000 0000000 00000000320 15216246500 0030320 0 ustar 00root root 0000000 0000000
-
1
exxamalte-python-aio-georss-client-38090c4/tests/fixtures/xml_parser_simple_5.xml 0000664 0000000 0000000 00000000332 15216246500 0030324 0 ustar 00root root 0000000 0000000
-
Title 1
exxamalte-python-aio-georss-client-38090c4/tests/test_feed.py 0000664 0000000 0000000 00000041776 15216246500 0024315 0 ustar 00root root 0000000 0000000 """Tests for base classes."""
import asyncio
import datetime
from http import HTTPStatus
from unittest.mock import MagicMock
import aiohttp
from aiohttp import ClientOSError
import pytest
from aio_georss_client.consts import UPDATE_ERROR, UPDATE_OK, UPDATE_OK_NO_DATA
from aio_georss_client.xml_parser.geometry import BoundingBox, Point, Polygon
from tests import MockGeoRssFeed
from tests.utils import load_fixture
HOME_COORDINATES_1 = (-31.0, 151.0)
HOME_COORDINATES_2 = (-37.0, 150.0)
@pytest.mark.asyncio
async def test_update_ok(mock_aiointercept):
"""Test updating feed is ok."""
mock_aiointercept.get(
"http://test.url/testpath",
status=HTTPStatus.OK,
body=load_fixture("generic_feed_1.xml"),
)
async with aiohttp.ClientSession(loop=asyncio.get_running_loop()) as websession:
feed = MockGeoRssFeed(
websession, HOME_COORDINATES_1, "http://test.url/testpath"
)
assert (
repr(feed) == ""
)
status, entries = await 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 round(abs(feed_entry.distance_to_home - 714.4), 1) == 0
assert repr(feed_entry) == ""
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 isinstance(feed_entry.external_id, str)
assert feed_entry.external_id == str(hash(feed_entry.coordinates))
feed_entry = entries[4]
assert feed_entry.title == "Title 5"
assert feed_entry.external_id == "5678"
@pytest.mark.asyncio
async def test_update_ok_feed_2(mock_aiointercept):
"""Test updating feed is ok."""
mock_aiointercept.get(
"http://test.url/testpath",
status=HTTPStatus.OK,
body=load_fixture("generic_feed_2.xml"),
)
async with aiohttp.ClientSession(loop=asyncio.get_running_loop()) as websession:
feed = MockGeoRssFeed(
websession, HOME_COORDINATES_1, "http://test.url/testpath"
)
status, entries = await 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 round(abs(feed_entry.distance_to_home - 714.4), 1) == 0
@pytest.mark.asyncio
async def test_update_ok_feed_3(mock_aiointercept):
"""Test updating feed is ok."""
mock_aiointercept.get(
"http://test.url/testpath",
status=HTTPStatus.OK,
body=load_fixture("generic_feed_3.xml"),
)
async with aiohttp.ClientSession(loop=asyncio.get_running_loop()) as websession:
feed = MockGeoRssFeed(
websession, HOME_COORDINATES_1, "http://test.url/testpath"
)
status, entries = await feed.update()
assert status == UPDATE_OK
assert entries is not None
assert len(entries) == 5
feed_entry = entries[0]
assert feed_entry.external_id == "1234"
assert len(feed_entry.geometries) == 1
assert isinstance(feed_entry.geometries[0], Polygon)
assert feed_entry.coordinates == (
pytest.approx(-34.93728111547821),
pytest.approx(148.59710883878262),
)
assert round(abs(feed_entry.distance_to_home - 491.7), 1) == 0
feed_entry = entries[1]
assert feed_entry.external_id == "2345"
assert len(feed_entry.geometries) == 2
assert isinstance(feed_entry.geometries[0], Point)
assert isinstance(feed_entry.geometries[1], Polygon)
assert feed_entry.coordinates == (
pytest.approx(-34.937170989),
pytest.approx(148.597182317),
)
assert round(abs(feed_entry.distance_to_home - 491.7), 1) == 0
feed_entry = entries[2]
assert feed_entry.external_id == "3456"
assert len(feed_entry.geometries) == 2
assert isinstance(feed_entry.geometries[0], Polygon)
assert isinstance(feed_entry.geometries[1], Polygon)
assert feed_entry.coordinates == (
pytest.approx(-29.962746645660683),
pytest.approx(152.43090880416074),
)
assert round(abs(feed_entry.distance_to_home - 176.5), 1) == 0
feed_entry = entries[3]
assert feed_entry.external_id == "4567"
assert len(feed_entry.geometries) == 2
assert isinstance(feed_entry.geometries[0], Point)
assert isinstance(feed_entry.geometries[1], Point)
assert feed_entry.coordinates == (
pytest.approx(-33.2345),
pytest.approx(154.789),
)
assert round(abs(feed_entry.distance_to_home - 172.3), 1) == 0
feed_entry = entries[4]
assert feed_entry.external_id == "5678"
assert len(feed_entry.geometries) == 2
assert isinstance(feed_entry.geometries[0], Point)
assert isinstance(feed_entry.geometries[1], Polygon)
assert feed_entry.coordinates == (
pytest.approx(-31.2345),
pytest.approx(152.789),
)
assert round(abs(feed_entry.distance_to_home - 172.3), 1) == 0
@pytest.mark.asyncio
async def test_update_ok_feed_6(mock_aiointercept):
"""Test updating feed is ok."""
mock_aiointercept.get(
"http://test.url/testpath",
status=HTTPStatus.OK,
body=load_fixture("generic_feed_6.xml"),
)
async with aiohttp.ClientSession(loop=asyncio.get_running_loop()) as websession:
feed = MockGeoRssFeed(
websession, HOME_COORDINATES_1, "http://test.url/testpath"
)
status, entries = await feed.update()
assert status == UPDATE_OK
assert entries is not None
assert len(entries) == 1
feed_entry = entries[0]
assert feed_entry.external_id == "1234"
assert len(feed_entry.geometries) == 1
assert isinstance(feed_entry.geometries[0], BoundingBox)
assert feed_entry.coordinates == (
pytest.approx(-20.9041),
pytest.approx(168.5652),
)
assert round(abs(feed_entry.distance_to_home - 1493.3), 1) == 0
@pytest.mark.asyncio
async def test_update_duplicate_geometries(mock_aiointercept):
"""Test updating feed is ok."""
mock_aiointercept.get(
"http://test.url/testpath",
status=HTTPStatus.OK,
body=load_fixture("generic_feed_7.xml"),
)
async with aiohttp.ClientSession(loop=asyncio.get_running_loop()) as websession:
feed = MockGeoRssFeed(
websession, HOME_COORDINATES_1, "http://test.url/testpath"
)
assert (
repr(feed) == ""
)
status, entries = await feed.update()
assert status == UPDATE_OK
assert entries is not None
assert len(entries) == 4
feed_entry = entries[0]
assert feed_entry.external_id == "1234"
assert len(feed_entry.geometries) == 1
assert isinstance(feed_entry.geometries[0], Point)
feed_entry = entries[1]
assert feed_entry.external_id == "2345"
assert len(feed_entry.geometries) == 1
assert isinstance(feed_entry.geometries[0], Point)
feed_entry = entries[2]
assert feed_entry.external_id == "3456"
assert len(feed_entry.geometries) == 2
assert isinstance(feed_entry.geometries[0], Point)
assert isinstance(feed_entry.geometries[1], Polygon)
feed_entry = entries[3]
assert feed_entry.external_id == "4567"
assert len(feed_entry.geometries) == 2
assert isinstance(feed_entry.geometries[0], Point)
assert isinstance(feed_entry.geometries[1], BoundingBox)
@pytest.mark.asyncio
async def test_update_ok_feed_8(mock_aiointercept):
"""Test updating feed is ok."""
mock_aiointercept.get(
"http://test.url/testpath",
status=HTTPStatus.OK,
body=load_fixture("generic_feed_8.xml"),
)
async with aiohttp.ClientSession(loop=asyncio.get_running_loop()) as websession:
feed = MockGeoRssFeed(
websession, HOME_COORDINATES_1, "http://test.url/testpath"
)
status, entries = await 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 == (
pytest.approx(-37.2345),
pytest.approx(149.1234),
)
assert round(abs(feed_entry.distance_to_home - 714.4), 1) == 0
@pytest.mark.asyncio
async def test_update_ok_with_radius_filtering(mock_aiointercept):
"""Test updating feed is ok."""
mock_aiointercept.get(
"http://test.url/testpath",
status=HTTPStatus.OK,
body=load_fixture("generic_feed_1.xml"),
)
async with aiohttp.ClientSession(loop=asyncio.get_running_loop()) as websession:
feed = MockGeoRssFeed(
websession,
HOME_COORDINATES_2,
"http://test.url/testpath",
filter_radius=90.0,
)
status, entries = await feed.update()
assert status == UPDATE_OK
assert entries is not None
assert len(entries) == 4
assert round(abs(entries[0].distance_to_home - 82.0), 1) == 0
assert round(abs(entries[1].distance_to_home - 77.0), 1) == 0
assert round(abs(entries[2].distance_to_home - 84.6), 1) == 0
@pytest.mark.asyncio
async def test_update_ok_with_radius_and_category_filtering(mock_aiointercept):
"""Test updating feed is ok."""
mock_aiointercept.get(
"http://test.url/testpath",
status=HTTPStatus.OK,
body=load_fixture("generic_feed_1.xml"),
)
async with aiohttp.ClientSession(loop=asyncio.get_running_loop()) as websession:
feed = MockGeoRssFeed(
websession,
HOME_COORDINATES_2,
"http://test.url/testpath",
filter_radius=90.0,
filter_categories=["Category 2"],
)
status, entries = await feed.update()
assert status == UPDATE_OK
assert entries is not None
assert len(entries) == 1
assert round(abs(entries[0].distance_to_home - 77.0), 1) == 0
mock_aiointercept.get(
"http://test.url/testpath",
status=HTTPStatus.OK,
body=load_fixture("generic_feed_1.xml"),
)
feed = MockGeoRssFeed(
websession,
HOME_COORDINATES_2,
"http://test.url/testpath",
filter_radius=90.0,
filter_categories=["Category 4"],
)
status, entries = await feed.update()
assert status == UPDATE_OK
assert entries is not None
assert len(entries) == 0
@pytest.mark.asyncio
async def test_update_error(mock_aiointercept):
"""Test updating feed results in error."""
mock_aiointercept.get(
"http://test.url/badpath",
status=HTTPStatus.NOT_FOUND,
)
async with aiohttp.ClientSession(loop=asyncio.get_running_loop()) as websession:
feed = MockGeoRssFeed(websession, HOME_COORDINATES_1, "http://test.url/badpath")
status, entries = await feed.update()
assert status == UPDATE_ERROR
assert feed.last_timestamp is None
@pytest.mark.asyncio
async def test_update_ok_then_error(mock_aiointercept):
"""Test updating feed goes fine, followed by an error."""
mock_aiointercept.get(
"http://test.url/testpath",
status=HTTPStatus.OK,
body=load_fixture("generic_feed_1.xml"),
)
async with aiohttp.ClientSession(loop=asyncio.get_running_loop()) as websession:
feed = MockGeoRssFeed(
websession, HOME_COORDINATES_1, "http://test.url/testpath"
)
assert (
repr(feed) == ""
)
status, entries = await feed.update()
assert status == UPDATE_OK
assert entries is not None
assert len(entries) == 5
assert feed.last_timestamp is not None
mock_aiointercept.get(
"http://test.url/testpath",
status=HTTPStatus.NOT_FOUND,
)
status, entries = await feed.update()
assert status == UPDATE_ERROR
assert feed.last_timestamp is None
@pytest.mark.asyncio
async def test_update_with_client_exception():
"""Test updating feed results in error."""
async with aiohttp.ClientSession(loop=asyncio.get_running_loop()):
mock_websession = MagicMock()
mock_websession.request.side_effect = ClientOSError
feed = MockGeoRssFeed(
mock_websession, HOME_COORDINATES_1, "http://test.url/badpath"
)
status, entries = await feed.update()
assert status == UPDATE_ERROR
assert feed.last_timestamp is None
@pytest.mark.asyncio
async def test_update_with_request_exception(mock_aiointercept):
"""Test updating feed raises exception."""
mock_aiointercept.get(
"http://test.url/badpath",
status=HTTPStatus.NOT_FOUND,
)
async with aiohttp.ClientSession(loop=asyncio.get_running_loop()) as websession:
feed = MockGeoRssFeed(websession, HOME_COORDINATES_1, "http://test.url/badpath")
status, entries = await feed.update()
assert status == UPDATE_ERROR
assert entries is None
assert feed.last_timestamp is None
@pytest.mark.asyncio
async def test_update_with_timeout_error():
"""Test updating feed results in timeout error."""
async with aiohttp.ClientSession(loop=asyncio.get_running_loop()):
mock_websession = MagicMock()
mock_websession.request.side_effect = asyncio.TimeoutError
feed = MockGeoRssFeed(
mock_websession, HOME_COORDINATES_1, "http://test.url/goodpath"
)
status, entries = await feed.update()
assert status == UPDATE_ERROR
assert feed.last_timestamp is None
@pytest.mark.asyncio
async def test_update_bom(mock_aiointercept):
"""Test updating feed with BOM (byte order mark) is ok."""
xml = (
"\xef\xbb\xbf"
"- Title 1"
"
"
)
mock_aiointercept.get(
"http://test.url/testpath",
status=HTTPStatus.OK,
body=xml.encode("iso-8859-1"),
)
async with aiohttp.ClientSession(loop=asyncio.get_running_loop()) as websession:
feed = MockGeoRssFeed(
websession, HOME_COORDINATES_1, "http://test.url/testpath"
)
assert (
repr(feed) == ""
)
status, entries = await feed.update()
assert status == UPDATE_OK
assert entries is not None
assert len(entries) == 0
@pytest.mark.asyncio
async def test_update_not_xml(mock_aiointercept):
"""Test updating feed where returned payload is not XML."""
# During tests it turned out that occasionally the GDACS server appears to return
# invalid payload (00 control characters) which results in an exception thrown:
# ExpatError: not well-formed (invalid token): line 1, column 0
not_xml = "\x00\x00\x00"
mock_aiointercept.get(
"http://test.url/testpath",
status=HTTPStatus.OK,
body=not_xml,
)
async with aiohttp.ClientSession(loop=asyncio.get_running_loop()) as websession:
feed = MockGeoRssFeed(
websession, HOME_COORDINATES_1, "http://test.url/testpath"
)
assert (
repr(feed) == ""
)
status, entries = await feed.update()
assert status == UPDATE_OK_NO_DATA
assert entries is None
exxamalte-python-aio-georss-client-38090c4/tests/test_feed_entry.py 0000664 0000000 0000000 00000007147 15216246500 0025530 0 ustar 00root root 0000000 0000000 """Test for the generic georss feed entry."""
import datetime
from unittest import mock
from aio_georss_client.xml_parser.geometry import BoundingBox, Point, Polygon
from . import MOCK_HOME_COORDINATES, MockFeedEntry, MockFeedItem, MockSimpleFeedEntry
def test_simple_feed_entry():
"""Test feed entry behaviour."""
feed_entry = MockSimpleFeedEntry(None, None)
assert repr(feed_entry) == ""
assert feed_entry.geometries is None
assert feed_entry.coordinates is None
assert feed_entry.title is None
assert feed_entry.external_id is None
assert feed_entry.attribution == "mock attribution"
assert feed_entry.category 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
)
# Test _string2boolean
assert not feed_entry._string2boolean("False") # noqa: SLF001
assert not feed_entry._string2boolean("no") # noqa: SLF001
assert feed_entry._string2boolean("True") # noqa: SLF001
assert feed_entry._string2boolean("yes") # noqa: SLF001
def test_feed_entry_features():
"""Test feed entry filtering by feature."""
point = Point(0.0, 0.0)
polygon = Polygon([point, point])
bounding_box = BoundingBox(point, point)
feed_item = MockFeedItem(
None, [point, polygon, point, polygon, polygon, bounding_box]
)
# 1. Include all
feed_entry = MockSimpleFeedEntry(None, feed_item, [Point, Polygon, BoundingBox])
assert len(feed_entry.geometries) == 6
# 2. Exclude points
feed_entry = MockSimpleFeedEntry(None, feed_item, [Polygon, BoundingBox])
assert len(feed_entry.geometries) == 4
# 3. Exclude polygons
feed_entry = MockSimpleFeedEntry(None, feed_item, [Point, BoundingBox])
assert len(feed_entry.geometries) == 3
# 4. Exclude bounding boxes
feed_entry = MockSimpleFeedEntry(None, feed_item, [Point, Polygon])
assert len(feed_entry.geometries) == 5
# 5. Exclude all
feed_entry = MockSimpleFeedEntry(None, feed_item, [])
assert len(feed_entry.geometries) == 0
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 = MockFeedEntry(MOCK_HOME_COORDINATES, rss_entry)
assert repr(feed_entry) == ""
assert feed_entry._search_in_external_id(r"Test (?P.+)$") == "123" # noqa: SLF001
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
exxamalte-python-aio-georss-client-38090c4/tests/test_feed_manager.py 0000664 0000000 0000000 00000025415 15216246500 0025777 0 ustar 00root root 0000000 0000000 """Test for the generic georss feed manager."""
import asyncio
import datetime
from http import HTTPStatus
from unittest import mock as async_mock
import aiohttp
import pytest
from aio_georss_client.consts import UPDATE_OK_NO_DATA
from aio_georss_client.feed_manager import FeedManagerBase
from aio_georss_client.status_update import StatusUpdate
from tests import MockGeoRssFeed
from tests.utils import load_fixture
HOME_COORDINATES_1 = (-31.0, 151.0)
HOME_COORDINATES_2 = (-37.0, 150.0)
@pytest.mark.asyncio
async def test_feed_manager(mock_aiointercept):
"""Test the feed manager."""
mock_aiointercept.get(
"http://test.url/testpath",
status=HTTPStatus.OK,
body=load_fixture("generic_feed_1.xml"),
)
async with aiohttp.ClientSession(loop=asyncio.get_running_loop()) as websession:
feed = MockGeoRssFeed(
websession, HOME_COORDINATES_1, "http://test.url/testpath"
)
# This will just record calls and keep track of external ids.
generated_entity_external_ids = []
updated_entity_external_ids = []
removed_entity_external_ids = []
async def _generate_entity(entity_external_id: str) -> None:
"""Generate new entity."""
generated_entity_external_ids.append(entity_external_id)
async def _update_entity(entity_external_id: str) -> None:
"""Update entity."""
updated_entity_external_ids.append(entity_external_id)
async def _remove_entity(entity_external_id: str) -> None:
"""Remove entity."""
removed_entity_external_ids.append(entity_external_id)
feed_manager = FeedManagerBase(
feed, _generate_entity, _update_entity, _remove_entity
)
assert (
repr(feed_manager) == ")>"
)
await 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 round(abs(feed_entry.distance_to_home - 714.4), 1) == 0
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 = str(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_aiointercept.get(
"http://test.url/testpath",
status=HTTPStatus.OK,
body=load_fixture("generic_feed_4.xml"),
)
await 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()
with async_mock.patch(
"aio_georss_client.feed.GeoRssFeed._fetch",
new_callable=async_mock.AsyncMock,
) as mock_fetch:
mock_fetch.return_value = (UPDATE_OK_NO_DATA, None)
await feed_manager.update()
entries = feed_manager.feed_entries
assert len(entries) == 3
assert len(generated_entity_external_ids) == 0
assert len(updated_entity_external_ids) == 0
assert len(removed_entity_external_ids) == 0
# Simulate an update producing an error.
generated_entity_external_ids.clear()
updated_entity_external_ids.clear()
removed_entity_external_ids.clear()
mock_aiointercept.get(
"http://test.url/testpath",
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
await 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
@pytest.mark.asyncio
async def test_feed_manager_no_timestamp(mock_aiointercept):
"""Test updating feed is ok."""
mock_aiointercept.get(
"http://test.url/testpath",
status=HTTPStatus.OK,
body=load_fixture("generic_feed_5.xml"),
)
async with aiohttp.ClientSession(loop=asyncio.get_running_loop()) as websession:
feed = MockGeoRssFeed(
websession, HOME_COORDINATES_1, "http://test.url/testpath"
)
# This will just record calls and keep track of external ids.
generated_entity_external_ids = []
updated_entity_external_ids = []
removed_entity_external_ids = []
async def _generate_entity(external_id: str) -> None:
"""Generate new entity."""
generated_entity_external_ids.append(external_id)
async def _update_entity(external_id: str) -> None:
"""Update entity."""
updated_entity_external_ids.append(external_id)
async def _remove_entity(external_id: str) -> None:
"""Remove entity."""
removed_entity_external_ids.append(external_id)
feed_manager = FeedManagerBase(
feed, _generate_entity, _update_entity, _remove_entity
)
assert (
repr(feed_manager) == ")>"
)
await feed_manager.update()
entries = feed_manager.feed_entries
assert entries is not None
assert len(entries) == 1
assert feed_manager.last_timestamp is None
@pytest.mark.asyncio
async def test_feed_manager_with_status_callback(mock_aiointercept):
"""Test the feed manager."""
mock_aiointercept.get(
"http://test.url/testpath",
status=HTTPStatus.OK,
body=load_fixture("generic_feed_1.xml"),
)
async with aiohttp.ClientSession(loop=asyncio.get_running_loop()) as websession:
feed = MockGeoRssFeed(
websession, HOME_COORDINATES_1, "http://test.url/testpath"
)
# This will just record calls and keep track of external ids.
generated_entity_external_ids = []
updated_entity_external_ids = []
removed_entity_external_ids = []
status_update = []
async def _generate_entity(external_id: str) -> None:
"""Generate new entity."""
generated_entity_external_ids.append(external_id)
async def _update_entity(external_id: str) -> None:
"""Update entity."""
updated_entity_external_ids.append(external_id)
async def _remove_entity(external_id: str) -> None:
"""Remove entity."""
removed_entity_external_ids.append(external_id)
async def _status(status_details: StatusUpdate) -> None:
"""Capture status update details."""
status_update.append(status_details)
feed_manager = FeedManagerBase(
feed, _generate_entity, _update_entity, _remove_entity, _status
)
assert (
repr(feed_manager) == ")>"
)
await 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
assert status_update[0].status == "OK"
assert status_update[0].last_update is not None
last_update_successful = status_update[0].last_update_successful
assert status_update[0].last_update == last_update_successful
assert status_update[0].last_timestamp == datetime.datetime(2018, 9, 23, 9, 10)
assert status_update[0].total == 5
assert status_update[0].created == 5
assert status_update[0].updated == 0
assert status_update[0].removed == 0
assert (
repr(status_update[0]) == f""
)
# Simulate an update with no data.
generated_entity_external_ids.clear()
updated_entity_external_ids.clear()
removed_entity_external_ids.clear()
status_update.clear()
mock_aiointercept.get(
"http://test.url/testpath",
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
await 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) == 5
assert status_update[0].status == "ERROR"
assert status_update[0].last_update is not None
assert status_update[0].last_update_successful is not None
assert status_update[0].last_update_successful == last_update_successful
assert status_update[0].total == 0
exxamalte-python-aio-georss-client-38090c4/tests/test_geometries.py 0000664 0000000 0000000 00000011004 15216246500 0025532 0 ustar 00root root 0000000 0000000 """Test geometries."""
from aio_georss_client.xml_parser.geometry import BoundingBox, 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
def test_point_in_polygon_1():
"""Test if point is in polygon."""
polygon = Polygon(
[Point(30.0, 30.0), Point(30.0, 35.0), Point(35.0, 35.0), Point(30.0, 30.0)]
)
# 1. Outside
point = Point(20.0, 20.0)
assert not polygon.is_inside(point)
# 2. Inside
point = Point(31.0, 32.0)
assert polygon.is_inside(point)
# 3. Inside
point = Point(30.0, 32.0)
assert polygon.is_inside(point)
# 4. Inside
point = Point(30.0, 35.0)
assert polygon.is_inside(point)
# 5. Outside
point = Point(34.0, 31.0)
assert not polygon.is_inside(point)
def test_point_in_polygon_2():
"""Test if point is in polygon."""
polygon = Polygon(
[
Point(30.0, -30.0),
Point(30.0, -25.0),
Point(35.0, -25.0),
Point(30.0, -30.0),
]
)
# 1. Outside
point = Point(20.0, -40.0)
assert not polygon.is_inside(point)
# 2. Inside
point = Point(31.0, -28.0)
assert polygon.is_inside(point)
# 3. Inside
point = Point(30.0, -28.0)
assert polygon.is_inside(point)
# 4. Inside
point = Point(30.0, -25.0)
assert polygon.is_inside(point)
# 5. Outside
point = Point(34.0, -29.0)
assert not polygon.is_inside(point)
# 6. Invalid point
assert not polygon.is_inside(None)
def test_bounding_box_1():
"""Test bounding box."""
bbox = BoundingBox(Point(-30.0, 148.0), Point(-28.0, 150.0))
assert (
repr(bbox) == ", "
"top_right="
")>"
)
assert bbox.centroid.latitude == -29.0
assert bbox.centroid.longitude == 149.0
assert bbox.is_inside(Point(-29.5, 148.1))
assert not bbox.is_inside(Point(-29.5, 147.9))
def test_bounding_box_2():
"""Test bounding box."""
bbox = BoundingBox(Point(-5.0, 175.0), Point(5.0, -175.0))
assert (
repr(bbox) == ", "
"top_right="
")>"
)
assert bbox.centroid.latitude == 0.0
assert bbox.centroid.longitude == 180.0
assert bbox.is_inside(Point(-2.5, 179.0))
assert not bbox.is_inside(Point(2.5, 170.0))
def test_bounding_box_3():
"""Test bounding box."""
bbox = BoundingBox(Point(-5.0, 175.0), Point(5.0, -175.0))
assert (
repr(bbox) == ", "
"top_right="
")>"
)
assert bbox.centroid.latitude == 0.0
assert bbox.centroid.longitude == 180.0
assert bbox.is_inside(Point(-2.5, -179.0))
assert not bbox.is_inside(Point(2.5, -170.0))
# Special case to increase test coverage.
assert not bbox.is_inside(None)
def test_bounding_box_equality():
"""Test points."""
bbox1 = BoundingBox(Point(10.0, 10.0), Point(20.0, 20.0))
bbox2 = BoundingBox(Point(10.0, 10.0), Point(20.0, 20.0))
assert bbox1 == bbox2
exxamalte-python-aio-georss-client-38090c4/tests/test_georss_distance_helper.py 0000664 0000000 0000000 00000021405 15216246500 0030110 0 ustar 00root root 0000000 0000000 """Tests for GeoRSS distance helper."""
from unittest.mock import MagicMock
import pytest
from aio_georss_client.geo_rss_distance_helper import GeoRssDistanceHelper
from aio_georss_client.xml_parser.geometry import BoundingBox, 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_1():
"""Test calculating distance to polygon."""
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_polygon_2():
"""Test calculating distance to polygon."""
home_coordinates = (-30.2, 151.2)
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(0.0, 0.1)
def test_distance_to_polygon_3():
"""Test calculating distance to polygon."""
home_coordinates = (-29.0, 151.2)
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(111.2, 0.1)
def test_distance_to_polygon_4():
"""Test calculating distance to polygon."""
home_coordinates = (30.0, 151.3)
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(0.0, 0.1)
def test_distance_to_polygon_5():
"""Test calculating distance to polygon."""
mock_polygon = Polygon(
[
Point(30.0, 179.0),
Point(30.0, -179.5),
Point(30.5, -179.5),
Point(30.5, 179.0),
Point(30.0, 179.0),
]
)
home_coordinates = (30.2, -177.0)
distance = GeoRssDistanceHelper.distance_to_geometry(home_coordinates, mock_polygon)
assert distance == pytest.approx(240.3, 0.1)
home_coordinates = (30.1, 178.0)
distance = GeoRssDistanceHelper.distance_to_geometry(home_coordinates, mock_polygon)
assert distance == pytest.approx(96.2, 0.1)
home_coordinates = (31.0, -179.8)
distance = GeoRssDistanceHelper.distance_to_geometry(home_coordinates, mock_polygon)
assert distance == pytest.approx(55.6, 0.1)
home_coordinates = (31.0, 179.8)
distance = GeoRssDistanceHelper.distance_to_geometry(home_coordinates, mock_polygon)
assert distance == pytest.approx(55.6, 0.1)
def test_distance_to_polygon_6():
"""Test calculating distance to polygon."""
mock_polygon = Polygon(
[
Point(-30.0, 179.0),
Point(-30.0, -179.5),
Point(-29.5, -179.5),
Point(-29.5, 179.0),
Point(-30.0, 179.0),
]
)
home_coordinates = (-29.8, -177.0)
distance = GeoRssDistanceHelper.distance_to_geometry(home_coordinates, mock_polygon)
assert distance == pytest.approx(241.2, 0.1)
home_coordinates = (-29.9, 178.0)
distance = GeoRssDistanceHelper.distance_to_geometry(home_coordinates, mock_polygon)
assert distance == pytest.approx(96.4, 0.1)
home_coordinates = (-29.0, -179.8)
distance = GeoRssDistanceHelper.distance_to_geometry(home_coordinates, mock_polygon)
assert distance == pytest.approx(55.6, 0.1)
home_coordinates = (-29.0, 179.8)
distance = GeoRssDistanceHelper.distance_to_geometry(home_coordinates, mock_polygon)
assert distance == pytest.approx(55.6, 0.1)
def test_distance_to_bbox_1():
"""Test calculating distance to bounding box."""
home_coordinates = (20.0, 20.0)
# 1. inside
mock_bbox = BoundingBox(Point(10.0, 10.0), Point(30.0, 30.0))
distance = GeoRssDistanceHelper.distance_to_geometry(home_coordinates, mock_bbox)
assert distance == pytest.approx(0.0, 0.1)
# 2. above-left
mock_bbox = BoundingBox(Point(10.0, 25.0), Point(15.0, 30.0))
distance = GeoRssDistanceHelper.distance_to_geometry(home_coordinates, mock_bbox)
assert distance == pytest.approx(768.1, 0.1)
# 3. above
mock_bbox = BoundingBox(Point(10.0, 15.0), Point(15.0, 25.0))
distance = GeoRssDistanceHelper.distance_to_geometry(home_coordinates, mock_bbox)
assert distance == pytest.approx(556.0, 0.1)
# 4. above-right
mock_bbox = BoundingBox(Point(10.0, 10.0), Point(15.0, 15.0))
distance = GeoRssDistanceHelper.distance_to_geometry(home_coordinates, mock_bbox)
assert distance == pytest.approx(768.1, 0.1)
# 5. left
mock_bbox = BoundingBox(Point(15.0, 25.0), Point(25.0, 30.0))
distance = GeoRssDistanceHelper.distance_to_geometry(home_coordinates, mock_bbox)
assert distance == pytest.approx(522.4, 0.1)
# 6. right
mock_bbox = BoundingBox(Point(15.0, 10.0), Point(25.0, 15.0))
distance = GeoRssDistanceHelper.distance_to_geometry(home_coordinates, mock_bbox)
assert distance == pytest.approx(522.4, 0.1)
# 7. below-left
mock_bbox = BoundingBox(Point(25.0, 25.0), Point(30.0, 30.0))
distance = GeoRssDistanceHelper.distance_to_geometry(home_coordinates, mock_bbox)
assert distance == pytest.approx(756.8, 0.1)
# 8. below
mock_bbox = BoundingBox(Point(25.0, 15.0), Point(30.0, 25.0))
distance = GeoRssDistanceHelper.distance_to_geometry(home_coordinates, mock_bbox)
assert distance == pytest.approx(556.0, 0.1)
# 9. below-right
mock_bbox = BoundingBox(Point(25.0, 10.0), Point(30.0, 15.0))
distance = GeoRssDistanceHelper.distance_to_geometry(home_coordinates, mock_bbox)
assert distance == pytest.approx(756.8, 0.1)
# special case
home_coordinates = (-20.0, -20.0)
mock_bbox = BoundingBox(Point(-30.0, -15.0), Point(-25.0, -10.0))
distance = GeoRssDistanceHelper.distance_to_geometry(home_coordinates, mock_bbox)
assert distance == pytest.approx(756.8, 0.1)
def test_distance_to_bbox_2():
"""Test calculating distance to bounding box."""
mock_bbox = BoundingBox(Point(5.0, 175.0), Point(15.0, -175.0))
# 1. inside
home_coordinates = (5.0, 176.0)
distance = GeoRssDistanceHelper.distance_to_geometry(home_coordinates, mock_bbox)
assert distance == pytest.approx(0.0, 0.1)
# 2. above-left
home_coordinates = (20.0, 170.0)
distance = GeoRssDistanceHelper.distance_to_geometry(home_coordinates, mock_bbox)
assert distance == pytest.approx(768.1, 0.1)
# 3. above-right
home_coordinates = (20.0, -170.0)
distance = GeoRssDistanceHelper.distance_to_geometry(home_coordinates, mock_bbox)
assert distance == pytest.approx(768.1, 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")
exxamalte-python-aio-georss-client-38090c4/tests/test_xml_parser.py 0000664 0000000 0000000 00000027215 15216246500 0025556 0 ustar 00root root 0000000 0000000 """Tests for XML parser."""
import datetime
from pyexpat import ExpatError
import pytest
from aio_georss_client.xml_parser import XmlParser
from aio_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 XML file with invalid tags."""
xml_parser = XmlParser()
xml = load_fixture("xml_parser_simple_3.xml")
feed = xml_parser.parse(xml)
assert feed is None
def test_simple_4():
"""Test parsing various actual XML files."""
xml_parser = XmlParser()
xml = load_fixture("xml_parser_simple_4.xml")
feed = xml_parser.parse(xml)
assert feed is not None
assert feed.entries is not None
assert len(feed.entries) == 1
# Double-check that an integer in the XML is automatically converted to string.
assert feed.entries[0].title == "1"
def test_simple_5():
"""Test parsing XML file with invalid tags."""
xml_parser = XmlParser()
xml = load_fixture("xml_parser_simple_5.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"]
geometries = feed_entry.geometries
assert len(geometries) == 1
assert isinstance(geometries[0], Point)
assert geometries[0].latitude == -37.4567
assert geometries[0].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"]
geometries = feed_entry.geometries
assert len(geometries) == 1
assert isinstance(geometries[0], Point)
assert geometries[0].latitude == -37.5678
assert geometries[0].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"]
geometries = feed_entry.geometries
assert len(geometries) == 1
assert isinstance(geometries[0], Point)
assert geometries[0].latitude == -37.6789
assert geometries[0].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"),
)
geometries = feed_entry.geometries
assert len(geometries) == 1
assert isinstance(geometries[0], Point)
assert geometries[0].latitude == -37.789
assert geometries[0].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"),
)
geometries = feed_entry.geometries
assert len(geometries) == 1
assert isinstance(geometries[0], Polygon)
assert geometries[0].centroid.latitude == -30.32
assert geometries[0].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)),
)
geometries = feed_entry.geometries
assert len(geometries) == 1
assert isinstance(geometries[0], Polygon)
assert geometries[0].centroid.latitude == -30.32
assert geometries[0].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 len(feed_entry.geometries) == 0
feed_entry = feed.entries[1]
assert feed_entry.title is None
assert len(feed_entry.geometries) == 0
def test_geometries_2():
"""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) == 8
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
feed_entry = feed.entries[6]
assert feed_entry.title == "Title 7"
assert feed_entry.geometries is not None
assert len(feed_entry.geometries) == 2
feed_entry = feed.entries[7]
assert feed_entry.title == "Title 8"
assert feed_entry.geometries is not None
assert len(feed_entry.geometries) == 1
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)
exxamalte-python-aio-georss-client-38090c4/tests/utils.py 0000664 0000000 0000000 00000000354 15216246500 0023476 0 ustar 00root root 0000000 0000000 """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()