From 8f1720a3baebbbf333df2bee7499cac18739d293 Mon Sep 17 00:00:00 2001 From: Ian Richardson Date: Tue, 3 Mar 2026 23:38:37 -0600 Subject: [PATCH 1/8] Add config flow & config entry support Introduce full config entry support and UI options for the Feedparser integration. Adds config_flow, options flow, constants, translations/strings, and migration logic (ENTRY_VERSION bump) with normalization helpers for inclusions/exclusions and scan_interval. Refactors sensor to support async_setup_entry, unique_id, configurable scan_interval, safer typing/casting, improved image/link/date handling, and keeps legacy YAML platform support. Updates manifest (config_flow:true, version bump), README with UI setup and configuration reference, adds devcontainer and helper scripts for development, and includes minor test and test-data cleanups. --- .devcontainer.json | 43 +++ .gitignore | 2 + README.md | 69 +++-- custom_components/feedparser/__init__.py | 130 +++++++- custom_components/feedparser/config_flow.py | 292 ++++++++++++++++++ custom_components/feedparser/const.py | 37 +++ custom_components/feedparser/manifest.json | 17 +- custom_components/feedparser/sensor.py | 172 ++++++++--- custom_components/feedparser/strings.json | 48 +++ .../feedparser/translations/en.json | 48 +++ scripts/develop | 38 +++ scripts/setup | 7 + test_hass/configuration.yaml | 3 +- tests/conftest.py | 6 +- tests/constants.py | 1 + tests/data/CTK.json | 2 +- tests/data/alle_meldungen.json | 2 +- tests/data/anp_nieuws.json | 2 +- tests/data/api_met_no_metalerts.json | 2 +- tests/data/bbc_europe.json | 2 +- tests/data/buienradar_nl.json | 2 +- tests/data/ct24.json | 2 +- tests/data/nu_nl.json | 2 +- tests/data/nu_nl_algemeen.json | 2 +- tests/data/skolmaten_se_ede_skola.json | 2 +- tests/data/stern_auto.json | 2 +- tests/data/zive.json | 2 +- tests/download.py | 1 + tests/feedsource.py | 1 + tests/generate_ha_config.py | 1 + tests/test_sensors.py | 2 +- 31 files changed, 854 insertions(+), 88 deletions(-) create mode 100644 .devcontainer.json create mode 100644 custom_components/feedparser/config_flow.py create mode 100644 custom_components/feedparser/const.py create mode 100644 custom_components/feedparser/strings.json create mode 100644 custom_components/feedparser/translations/en.json create mode 100644 scripts/develop create mode 100644 scripts/setup diff --git a/.devcontainer.json b/.devcontainer.json new file mode 100644 index 0000000..4ca50bb --- /dev/null +++ b/.devcontainer.json @@ -0,0 +1,43 @@ +{ + "name": "custom-components/feedparser", + "image": "mcr.microsoft.com/devcontainers/python:3.13", + "postCreateCommand": "bash scripts/setup", + "forwardPorts": [8123], + "portsAttributes": { + "8123": { + "label": "Home Assistant", + "onAutoForward": "notify" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "charliermarsh.ruff", + "github.vscode-pull-request-github", + "ms-python.python", + "ms-python.vscode-pylance", + "ryanluker.vscode-coverage-gutters" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "editor.formatOnPaste": true, + "editor.formatOnSave": true, + "editor.formatOnType": false, + "files.trimTrailingWhitespace": true, + "python.analysis.typeCheckingMode": "basic", + "python.analysis.autoImportCompletions": true, + "python.defaultInterpreterPath": "/usr/local/bin/python", + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + } + } + } + }, + "remoteUser": "vscode", + "features": { + "ghcr.io/devcontainers/features/python:1": { + "version": "3.13" + } + } +} diff --git a/.gitignore b/.gitignore index 8d65e89..c659ee3 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ test_hass/home-assistant_v2.db* test_hass/home-assistant.log* test_hass/sensors.yaml test_hass/www/ +test_hass/known_devices.yaml +test_hass/.ha_run.lock diff --git a/README.md b/README.md index 4102451..a393c14 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,6 @@ RSS feed custom component for [Home Assistant](https://www.home-assistant.io/) w [![Discord][discord-shield]][discord] [![Community Forum][forum-shield]][forum] -## Support -Hey dude! Help me out for a couple of :beers: or a :coffee:! - -[![coffee](https://www.buymeacoffee.com/assets/img/custom_images/black_img.png)](https://www.buymeacoffee.com/zJtVxUAgH) - - ## Installation [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration) @@ -31,6 +25,17 @@ Alternatively, click on the button below to add the repository: ## Configuration +### UI configuration (recommended) + +1. Go to **Settings** -> **Devices & Services** -> **Integrations**. +2. Click **Add Integration** and search for **Feedparser**. +3. Fill in at least `Name` and `Feed URL`. +4. Optional include/exclude fields are entered as comma-separated values. + +To update parsing behavior later, open the Feedparser integration card and use **Configure** (options flow). + +### YAML configuration (legacy) + **Example configuration.yaml:** ```yaml @@ -58,31 +63,49 @@ sensor: show_topn: 1 ``` -If you wish the integration to look for enclosures in the feed entries, add `image` to `inclusions` list. Do not use `enclosure`. -The integration tries to get the link to an image for the given feed item and stores it under the attribute named `image`. If it fails to find it, it assigns the Home Assistant logo to it instead. +If you wish the integration to look for enclosures in the feed entries, add `image` to the `inclusions` list. Do not use `enclosure`. +The integration tries to extract an image URL and stores it under the `image` attribute. If no image can be found, it uses the Home Assistant logo as a fallback. + +Note that the original `pubDate` field is available under `published`. Other date-like fields that may be present are `updated`, `created`, and `expired`. Refer to the original [feedparser date parsing documentation](https://feedparser.readthedocs.io/en/latest/date-parsing.html) for feed-specific details. -Note that the original `pubDate` field is available under `published` attribute for the given feed entry. Other date-type values that can be available are `updated`, `created` and `expired`. Please refer to [the documentation of the original feedparser](https://feedparser.readthedocs.io/en/latest/date-parsing.html) library. +### Configuration reference -**Configuration variables:** +The integration supports both UI setup (recommended) and legacy YAML setup. Most options are the same in both paths. -key | description -:--- | :--- -**platform (Required)** | The platform name -**name (Required)** | Name your feed -**feed_url (Required)** | The RSS feed URL -**date_format (Optional)** | strftime date format for date strings **Default** `%a, %b %d %I:%M %p` -**local_time (Optional)** | Whether to convert date into local time **Default** false -**show_topn (Optional)** | fetch how many entres from rss source,if not set then fetch all -**inclusions (Optional)** | List of fields to include from populating the list -**exclusions (Optional)** | List of fields to exclude from populating the list -**scan_interval (Optional)** | Update interval in hours +| Key | Required | Type | Default | Example | What it does | +| :-- | :-- | :-- | :-- | :-- | :-- | +| `platform` (YAML only) | Yes (YAML) | string | - | `feedparser` | Home Assistant platform name used in YAML mode. | +| `name` | Yes | string | - | `Engineering Feed` | Name shown for the sensor entity. | +| `feed_url` | Yes | URL string | - | `https://www.nu.nl/rss/Algemeen` | RSS/Atom feed URL to fetch and parse. Supports `http`, `https`, and `file` in dev/testing. | +| `date_format` | No | string (`strftime`) | `%a, %b %d %I:%M %p` | `%a, %d %b %Y %H:%M:%S %Z` | Output format for date fields in feed entries. | +| `local_time` | No | boolean | `false` | `true` | Converts parsed date values from feed timezone to Home Assistant local timezone. | +| `scan_interval` | No | duration object | `1 hour` | `{ hours: 1, minutes: 30 }` | Polling interval for refreshing feed data. Minimum effective value is 1 minute. | +| `show_topn` | No | integer | `9999` | `10` | Maximum number of entries exposed in sensor attributes. | +| `remove_summary_image` | No | boolean | `false` | `true` | Strips `` tags from the `summary` field. | +| `inclusions` | No | list of strings (YAML) / comma-separated string (UI) | all fields | `title, link, published, image` | If set, only listed fields are kept for each entry. | +| `exclusions` | No | list of strings (YAML) / comma-separated string (UI) | none | `summary, language` | Fields to remove from each entry after parsing. | -*** +### Notes and behavior details -Note: Will return all fields if no inclusions or exclusions are specified +- **`inclusions` vs `exclusions`**: If `inclusions` is set, only those fields are considered. `exclusions` then removes fields from that resulting set. +- **When neither `inclusions` nor `exclusions` is set**: all available feed fields are returned. +- **`image` extraction**: adding `image` to `inclusions` enables image URL extraction from enclosures or summary HTML. +- **UI vs YAML input format**: + - UI uses comma-separated text for `inclusions` and `exclusions`. + - YAML uses proper lists. + - UI config flow uses separate scan interval fields for hours/minutes; YAML uses `scan_interval` object keys (`hours`, `minutes`). +- **Date parsing**: if a feed date is malformed, parser behavior depends on feed content and fallback parsing. Due to how `custom_components` are loaded, it is normal to see a `ModuleNotFoundError` error on first boot after adding this, to resolve it, restart Home-Assistant. +## Development Container + +This repository includes a devcontainer inspired by the `custom-components/readme` blueprint. + +1. Open the repo in VS Code. +2. Run **Dev Containers: Reopen in Container**. +3. After dependencies install, run `bash scripts/develop` to start Home Assistant with `test_hass`. + [commits-shield]: https://img.shields.io/github/commit-activity/y/custom-components/feedparser.svg?style=for-the-badge [commits]: https://github.com/custom-components/feedparser/commits/master [discord]: https://discord.gg/Qa5fW2R diff --git a/custom_components/feedparser/__init__.py b/custom_components/feedparser/__init__.py index dc9fb17..c6e553c 100644 --- a/custom_components/feedparser/__init__.py +++ b/custom_components/feedparser/__init__.py @@ -1,7 +1,127 @@ -"""A component which allows you to parse an RSS feed into a sensor. +"""The Feedparser integration.""" -For more details about this component, please refer to the documentation at -https://github.com/custom-components/sensor.feedparser +from __future__ import annotations -Following spec from https://validator.w3.org/feed/docs/rss2.html -""" +from datetime import timedelta +from typing import TYPE_CHECKING + +from .const import ( + CONF_DATE_FORMAT, + CONF_EXCLUSIONS, + CONF_INCLUSIONS, + CONF_LOCAL_TIME, + CONF_REMOVE_SUMMARY_IMAGE, + CONF_SCAN_INTERVAL, + CONF_SHOW_TOPN, + DEFAULT_DATE_FORMAT, + DEFAULT_LOCAL_TIME, + DEFAULT_REMOVE_SUMMARY_IMAGE, + DEFAULT_SCAN_INTERVAL, + DEFAULT_TOPN, + ENTRY_VERSION, + OPTION_KEYS, + PLATFORMS, +) + +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant + + +def _normalize_list(value: object) -> list[str]: + """Normalize option values to list[str].""" + if isinstance(value, list): + return [item for item in value if isinstance(item, str)] + if isinstance(value, str): + return [item.strip() for item in value.split(",") if item.strip()] + return [] + + +def _normalize_scan_interval(value: object) -> dict[str, int]: + """Normalize scan_interval to {'hours': int, 'minutes': int}.""" + if isinstance(value, timedelta): + total_minutes = max(1, int(value.total_seconds() // 60)) + return { + "hours": total_minutes // 60, + "minutes": total_minutes % 60, + } + + if isinstance(value, dict): + raw_hours = value.get("hours") + raw_minutes = value.get("minutes") + + hours = raw_hours if isinstance(raw_hours, int) else 0 + minutes = raw_minutes if isinstance(raw_minutes, int) else 0 + total_minutes = max(1, (hours * 60) + minutes) + return { + "hours": total_minutes // 60, + "minutes": total_minutes % 60, + } + + default_minutes = int(DEFAULT_SCAN_INTERVAL.total_seconds() // 60) + return { + "hours": default_minutes // 60, + "minutes": default_minutes % 60, + } + + +async def async_setup(hass: HomeAssistant, config: dict) -> bool: # noqa: ARG001 + """Set up Feedparser from YAML (legacy path).""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Feedparser from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload config entry.""" + await async_unload_entry(hass, entry) + await async_setup_entry(hass, entry) + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old config entries to the latest schema.""" + if entry.version > ENTRY_VERSION: + return False + + if entry.version < ENTRY_VERSION: + data = dict(entry.data) + options = dict(entry.options) + + merged = {**data, **options} + normalized_options = { + CONF_DATE_FORMAT: str(merged.get(CONF_DATE_FORMAT, DEFAULT_DATE_FORMAT)), + CONF_LOCAL_TIME: bool(merged.get(CONF_LOCAL_TIME, DEFAULT_LOCAL_TIME)), + CONF_SCAN_INTERVAL: _normalize_scan_interval( + merged.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), + ), + CONF_SHOW_TOPN: int(merged.get(CONF_SHOW_TOPN, DEFAULT_TOPN)), + CONF_REMOVE_SUMMARY_IMAGE: bool( + merged.get( + CONF_REMOVE_SUMMARY_IMAGE, + DEFAULT_REMOVE_SUMMARY_IMAGE, + ), + ), + CONF_INCLUSIONS: _normalize_list(merged.get(CONF_INCLUSIONS, [])), + CONF_EXCLUSIONS: _normalize_list(merged.get(CONF_EXCLUSIONS, [])), + } + + for key in OPTION_KEYS: + data.pop(key, None) + + hass.config_entries.async_update_entry( + entry, + data=data, + options=normalized_options, + version=ENTRY_VERSION, + ) + + return True diff --git a/custom_components/feedparser/config_flow.py b/custom_components/feedparser/config_flow.py new file mode 100644 index 0000000..98e2e58 --- /dev/null +++ b/custom_components/feedparser/config_flow.py @@ -0,0 +1,292 @@ +"""Config flow for Feedparser.""" + +from __future__ import annotations + +from collections.abc import Mapping +from datetime import timedelta +from typing import Any + +import requests +import voluptuous as vol +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, + OptionsFlowWithReload, +) +from homeassistant.const import CONF_NAME +from requests_file import FileAdapter +from yarl import URL + +from .const import ( + CONF_DATE_FORMAT, + CONF_EXCLUSIONS, + CONF_FEED_URL, + CONF_INCLUSIONS, + CONF_LOCAL_TIME, + CONF_REMOVE_SUMMARY_IMAGE, + CONF_SCAN_INTERVAL, + CONF_SHOW_TOPN, + DEFAULT_DATE_FORMAT, + DEFAULT_LOCAL_TIME, + DEFAULT_NAME, + DEFAULT_REMOVE_SUMMARY_IMAGE, + DEFAULT_SCAN_INTERVAL, + DEFAULT_TOPN, + DOMAIN, + ENTRY_VERSION, +) + +CONF_SCAN_INTERVAL_HOURS = "scan_interval_hours" +CONF_SCAN_INTERVAL_MINUTES = "scan_interval_minutes" + + +def _split_csv(value: str) -> list[str]: + """Split a comma-separated string into a normalized list.""" + if not value: + return [] + return [item.strip() for item in value.split(",") if item.strip()] + + +def _join_csv(value: object) -> str: + """Convert list value to comma-separated text.""" + if not isinstance(value, list): + return "" + return ", ".join(item for item in value if isinstance(item, str)) + + +def _to_int(value: object, default: int) -> int: + """Convert arbitrary value to int with default fallback.""" + if isinstance(value, int): + return value + if isinstance(value, str): + try: + return int(value) + except ValueError: + return default + return default + + +def _scan_interval_to_dict(value: object) -> dict[str, int]: + """Normalize scan interval to {'hours': int, 'minutes': int}.""" + if isinstance(value, timedelta): + total_minutes = max(1, int(value.total_seconds() // 60)) + return { + "hours": total_minutes // 60, + "minutes": total_minutes % 60, + } + + if isinstance(value, Mapping): + days = _to_int(value.get("days"), 0) + hours = _to_int(value.get("hours"), 0) + minutes = _to_int(value.get("minutes"), 0) + seconds = _to_int(value.get("seconds"), 0) + total_minutes = max( + 1, + (days * 24 * 60) + (hours * 60) + minutes + (seconds // 60), + ) + return { + "hours": total_minutes // 60, + "minutes": total_minutes % 60, + } + + default_minutes = int(DEFAULT_SCAN_INTERVAL.total_seconds() // 60) + return { + "hours": default_minutes // 60, + "minutes": default_minutes % 60, + } + + +def _scan_interval_from_input(user_input: Mapping[str, object]) -> dict[str, int]: + """Build normalized scan interval dict from flow input.""" + if CONF_SCAN_INTERVAL in user_input: + return _scan_interval_to_dict(user_input[CONF_SCAN_INTERVAL]) + + hours = _to_int(user_input.get(CONF_SCAN_INTERVAL_HOURS), 0) + minutes = _to_int(user_input.get(CONF_SCAN_INTERVAL_MINUTES), 0) + total_minutes = max(1, (hours * 60) + minutes) + return { + "hours": total_minutes // 60, + "minutes": total_minutes % 60, + } + + +def _schema_with_defaults( + *, + name: str = DEFAULT_NAME, + feed_url: str = "", + date_format: str = DEFAULT_DATE_FORMAT, + local_time: bool = DEFAULT_LOCAL_TIME, + show_topn: int = DEFAULT_TOPN, + scan_interval: dict[str, int] | None = None, + remove_summary_image: bool = DEFAULT_REMOVE_SUMMARY_IMAGE, + inclusions: str = "", + exclusions: str = "", + include_feed_identity: bool, +) -> vol.Schema: + """Build schema for user/options forms.""" + normalized_scan_interval = scan_interval or _scan_interval_to_dict( + DEFAULT_SCAN_INTERVAL, + ) + schema: dict[Any, Any] = { + vol.Required(CONF_DATE_FORMAT, default=date_format): str, + vol.Required(CONF_LOCAL_TIME, default=local_time): bool, + vol.Required( + CONF_SCAN_INTERVAL_HOURS, + default=normalized_scan_interval["hours"], + ): vol.All(vol.Coerce(int), vol.Range(min=0)), + vol.Required( + CONF_SCAN_INTERVAL_MINUTES, + default=normalized_scan_interval["minutes"], + ): vol.All(vol.Coerce(int), vol.Range(min=0, max=59)), + vol.Required(CONF_SHOW_TOPN, default=show_topn): vol.All( + vol.Coerce(int), + vol.Range(min=1), + ), + vol.Required( + CONF_REMOVE_SUMMARY_IMAGE, + default=remove_summary_image, + ): bool, + vol.Optional(CONF_INCLUSIONS, default=inclusions): str, + vol.Optional(CONF_EXCLUSIONS, default=exclusions): str, + } + + if include_feed_identity: + schema = { + vol.Required(CONF_NAME, default=name): str, + vol.Required(CONF_FEED_URL, default=feed_url): str, + **schema, + } + + return vol.Schema(schema) + + +class FeedparserConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Feedparser.""" + + VERSION = ENTRY_VERSION + + @staticmethod + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Get the options flow for this handler.""" + return FeedparserOptionsFlow(config_entry) + + async def async_step_user( + self, + user_input: Mapping[str, object] | None = None, + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + feed_url = str(user_input[CONF_FEED_URL]).strip() + try: + parsed_url = URL(feed_url) + except ValueError: + errors[CONF_FEED_URL] = "invalid_url" + else: + if parsed_url.scheme not in ("http", "https", "file"): + errors[CONF_FEED_URL] = "invalid_url" + else: + await self.async_set_unique_id(feed_url) + self._abort_if_unique_id_configured(error="already_configured") + + try: + await self.hass.async_add_executor_job( + self._validate_feed_url, + feed_url, + ) + except requests.RequestException: + errors["base"] = "cannot_connect" + else: + data = { + CONF_NAME: str(user_input[CONF_NAME]).strip(), + CONF_FEED_URL: feed_url, + } + options = { + CONF_DATE_FORMAT: str(user_input[CONF_DATE_FORMAT]).strip(), + CONF_LOCAL_TIME: bool(user_input[CONF_LOCAL_TIME]), + CONF_SCAN_INTERVAL: _scan_interval_from_input(user_input), + CONF_SHOW_TOPN: _to_int( + user_input[CONF_SHOW_TOPN], + DEFAULT_TOPN, + ), + CONF_REMOVE_SUMMARY_IMAGE: bool( + user_input[CONF_REMOVE_SUMMARY_IMAGE], + ), + CONF_INCLUSIONS: _split_csv( + str(user_input[CONF_INCLUSIONS]).strip(), + ), + CONF_EXCLUSIONS: _split_csv( + str(user_input[CONF_EXCLUSIONS]).strip(), + ), + } + return self.async_create_entry( + title=data[CONF_NAME], + data=data, + options=options, + ) + + data_schema = _schema_with_defaults( + include_feed_identity=True, + ) + + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) + + @staticmethod + def _validate_feed_url(feed_url: str) -> None: + """Validate that the URL can be fetched.""" + session = requests.Session() + session.mount("file://", FileAdapter()) + response = session.get(feed_url, timeout=20) + response.raise_for_status() + + +class FeedparserOptionsFlow(OptionsFlowWithReload): + """Handle options for Feedparser.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self._config_entry = config_entry + + async def async_step_init( + self, + user_input: Mapping[str, object] | None = None, + ) -> ConfigFlowResult: + """Manage Feedparser options.""" + if user_input is not None: + options = { + CONF_DATE_FORMAT: str(user_input[CONF_DATE_FORMAT]).strip(), + CONF_LOCAL_TIME: bool(user_input[CONF_LOCAL_TIME]), + CONF_SCAN_INTERVAL: _scan_interval_from_input(user_input), + CONF_SHOW_TOPN: _to_int(user_input[CONF_SHOW_TOPN], DEFAULT_TOPN), + CONF_REMOVE_SUMMARY_IMAGE: bool(user_input[CONF_REMOVE_SUMMARY_IMAGE]), + CONF_INCLUSIONS: _split_csv(str(user_input[CONF_INCLUSIONS]).strip()), + CONF_EXCLUSIONS: _split_csv(str(user_input[CONF_EXCLUSIONS]).strip()), + } + return self.async_create_entry(title="", data=options) + + merged = {**self._config_entry.data, **self._config_entry.options} + data_schema = _schema_with_defaults( + include_feed_identity=False, + date_format=str(merged.get(CONF_DATE_FORMAT, DEFAULT_DATE_FORMAT)), + local_time=bool(merged.get(CONF_LOCAL_TIME, DEFAULT_LOCAL_TIME)), + scan_interval=_scan_interval_to_dict( + merged.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), + ), + show_topn=int(merged.get(CONF_SHOW_TOPN, DEFAULT_TOPN)), + remove_summary_image=bool( + merged.get( + CONF_REMOVE_SUMMARY_IMAGE, + DEFAULT_REMOVE_SUMMARY_IMAGE, + ), + ), + inclusions=_join_csv(merged.get(CONF_INCLUSIONS, [])), + exclusions=_join_csv(merged.get(CONF_EXCLUSIONS, [])), + ) + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/custom_components/feedparser/const.py b/custom_components/feedparser/const.py new file mode 100644 index 0000000..92d687c --- /dev/null +++ b/custom_components/feedparser/const.py @@ -0,0 +1,37 @@ +"""Constants for the Feedparser integration.""" + +from datetime import timedelta + +from homeassistant.const import Platform + +DOMAIN = "feedparser" +PLATFORMS: list[Platform] = [Platform.SENSOR] + +CONF_FEED_URL = "feed_url" +CONF_DATE_FORMAT = "date_format" +CONF_LOCAL_TIME = "local_time" +CONF_INCLUSIONS = "inclusions" +CONF_EXCLUSIONS = "exclusions" +CONF_SHOW_TOPN = "show_topn" +CONF_SCAN_INTERVAL = "scan_interval" +CONF_REMOVE_SUMMARY_IMAGE = "remove_summary_image" + +DEFAULT_DATE_FORMAT = "%a, %b %d %I:%M %p" +DEFAULT_SCAN_INTERVAL = timedelta(hours=1) +DEFAULT_TOPN = 9999 +DEFAULT_LOCAL_TIME = False +DEFAULT_REMOVE_SUMMARY_IMAGE = False + +DEFAULT_NAME = "Feed" + +ENTRY_VERSION = 2 + +OPTION_KEYS: list[str] = [ + CONF_DATE_FORMAT, + CONF_LOCAL_TIME, + CONF_SCAN_INTERVAL, + CONF_SHOW_TOPN, + CONF_REMOVE_SUMMARY_IMAGE, + CONF_INCLUSIONS, + CONF_EXCLUSIONS, +] diff --git a/custom_components/feedparser/manifest.json b/custom_components/feedparser/manifest.json index 635f717..5b77b0e 100644 --- a/custom_components/feedparser/manifest.json +++ b/custom_components/feedparser/manifest.json @@ -1,11 +1,20 @@ { "domain": "feedparser", "name": "Feedparser", - "codeowners": ["@iantrich", "@ogajduse"], + "config_flow": true, + "codeowners": [ + "@iantrich", + "@ogajduse" + ], "dependencies": [], "documentation": "https://github.com/custom-components/feedparser/blob/master/README.md", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/custom-components/feedparser/issues", - "requirements": ["feedparser==6.0.11", "python-dateutil", "requests-file", "requests"], - "version": "0.1.11" -} + "requirements": [ + "feedparser==6.0.11", + "python-dateutil", + "requests-file", + "requests" + ], + "version": "1.0.0" +} \ No newline at end of file diff --git a/custom_components/feedparser/sensor.py b/custom_components/feedparser/sensor.py index 4477bbe..63d3b19 100644 --- a/custom_components/feedparser/sensor.py +++ b/custom_components/feedparser/sensor.py @@ -1,46 +1,54 @@ """Feedparser sensor.""" + from __future__ import annotations import email.utils import logging import re +from collections.abc import Mapping from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Protocol, cast import feedparser # type: ignore[import] import homeassistant.helpers.config_validation as cv import requests import voluptuous as vol from dateutil import parser -from feedparser import FeedParserDict from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import CONF_NAME, CONF_SCAN_INTERVAL +from homeassistant.const import CONF_NAME +from homeassistant.helpers.entity_platform import async_get_current_platform from homeassistant.util import dt from requests_file import FileAdapter +from .const import ( + CONF_DATE_FORMAT, + CONF_EXCLUSIONS, + CONF_FEED_URL, + CONF_INCLUSIONS, + CONF_LOCAL_TIME, + CONF_REMOVE_SUMMARY_IMAGE, + CONF_SCAN_INTERVAL, + CONF_SHOW_TOPN, + DEFAULT_DATE_FORMAT, + DEFAULT_LOCAL_TIME, + DEFAULT_REMOVE_SUMMARY_IMAGE, + DEFAULT_SCAN_INTERVAL, + DEFAULT_TOPN, +) + if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -__version__ = "0.1.11" +__version__ = "1.0.0" COMPONENT_REPO = "https://github.com/custom-components/feedparser/" REQUIREMENTS = ["feedparser"] -CONF_FEED_URL = "feed_url" -CONF_DATE_FORMAT = "date_format" -CONF_LOCAL_TIME = "local_time" -CONF_INCLUSIONS = "inclusions" -CONF_EXCLUSIONS = "exclusions" -CONF_SHOW_TOPN = "show_topn" -CONF_REMOVE_SUMMARY_IMG = "remove_summary_image" - -DEFAULT_DATE_FORMAT = "%a, %b %d %I:%M %p" -DEFAULT_SCAN_INTERVAL = timedelta(hours=1) DEFAULT_THUMBNAIL = "https://www.home-assistant.io/images/favicon-192x192-full.png" -DEFAULT_TOPN = 9999 USER_AGENT = f"Home Assistant Feed-parser Integration {__version__}" IMAGE_REGEX = r"" @@ -49,9 +57,12 @@ vol.Required(CONF_NAME): cv.string, vol.Required(CONF_FEED_URL): cv.string, vol.Required(CONF_DATE_FORMAT, default=DEFAULT_DATE_FORMAT): cv.string, - vol.Optional(CONF_LOCAL_TIME, default=False): cv.boolean, + vol.Optional(CONF_LOCAL_TIME, default=DEFAULT_LOCAL_TIME): cv.boolean, vol.Optional(CONF_SHOW_TOPN, default=DEFAULT_TOPN): cv.positive_int, - vol.Optional(CONF_REMOVE_SUMMARY_IMG, default=False): cv.boolean, + vol.Optional( + CONF_REMOVE_SUMMARY_IMAGE, + default=DEFAULT_REMOVE_SUMMARY_IMAGE, + ): cv.boolean, vol.Optional(CONF_INCLUSIONS, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_EXCLUSIONS, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): cv.time_period, @@ -61,6 +72,28 @@ _LOGGER: logging.Logger = logging.getLogger(__name__) +class ParsedFeed(Protocol): + """Protocol for parsed feed object.""" + + entries: list[Mapping[str, object]] + + +def _scan_interval_to_timedelta(value: object) -> timedelta: + """Convert stored scan interval values to timedelta.""" + if isinstance(value, timedelta): + return value + + if isinstance(value, Mapping): + raw_hours = value.get("hours") + raw_minutes = value.get("minutes") + hours = raw_hours if isinstance(raw_hours, int) else 0 + minutes = raw_minutes if isinstance(raw_minutes, int) else 0 + total_minutes = max(1, (hours * 60) + minutes) + return timedelta(minutes=total_minutes) + + return DEFAULT_SCAN_INTERVAL + + async def async_setup_platform( hass: HomeAssistant, # noqa: ARG001 config: ConfigType, @@ -75,7 +108,7 @@ async def async_setup_platform( name=config[CONF_NAME], date_format=config[CONF_DATE_FORMAT], show_topn=config[CONF_SHOW_TOPN], - remove_summary_image=config[CONF_REMOVE_SUMMARY_IMG], + remove_summary_image=config[CONF_REMOVE_SUMMARY_IMAGE], inclusions=config[CONF_INCLUSIONS], exclusions=config[CONF_EXCLUSIONS], scan_interval=config[CONF_SCAN_INTERVAL], @@ -86,6 +119,43 @@ async def async_setup_platform( ) +async def async_setup_entry( + hass: HomeAssistant, # noqa: ARG001 + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Feedparser sensor from a config entry.""" + data = {**entry.data, **entry.options} + scan_interval = _scan_interval_to_timedelta( + data.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), + ) + + current_platform = async_get_current_platform() + current_platform.scan_interval = scan_interval + current_platform.scan_interval_seconds = scan_interval.total_seconds() + + async_add_entities( + [ + FeedParserSensor( + feed=data[CONF_FEED_URL], + name=data[CONF_NAME], + date_format=data.get(CONF_DATE_FORMAT, DEFAULT_DATE_FORMAT), + show_topn=data.get(CONF_SHOW_TOPN, DEFAULT_TOPN), + remove_summary_image=data.get( + CONF_REMOVE_SUMMARY_IMAGE, + DEFAULT_REMOVE_SUMMARY_IMAGE, + ), + inclusions=data.get(CONF_INCLUSIONS, []), + exclusions=data.get(CONF_EXCLUSIONS, []), + scan_interval=scan_interval, + local_time=data.get(CONF_LOCAL_TIME, DEFAULT_LOCAL_TIME), + unique_id=entry.entry_id, + ), + ], + update_before_add=True, + ) + + class FeedParserSensor(SensorEntity): """Representation of a Feedparser sensor.""" @@ -104,10 +174,12 @@ def __init__( inclusions: list[str | None], scan_interval: timedelta, local_time: bool, + unique_id: str | None = None, ) -> None: """Initialize the Feedparser sensor.""" self._feed = feed self._attr_name = name + self._attr_unique_id = unique_id self._attr_icon = "mdi:rss" self._date_format = date_format self._show_topn: int = show_topn @@ -121,6 +193,11 @@ def __init__( self._attr_attribution = "Data retrieved using RSS feedparser" _LOGGER.debug("Feed %s: FeedParserSensor initialized - %s", self.name, self) + @property + def scan_interval(self: FeedParserSensor) -> timedelta: + """Return polling interval.""" + return self._scan_interval + def __repr__(self: FeedParserSensor) -> str: """Return the representation.""" return ( @@ -140,7 +217,7 @@ def update(self: FeedParserSensor) -> None: s.headers.update({"User-Agent": USER_AGENT}) res: requests.Response = s.get(self._feed) res.raise_for_status() - parsed_feed: FeedParserDict = feedparser.parse(res.text) + parsed_feed = cast("ParsedFeed", feedparser.parse(res.text)) if not parsed_feed.entries: self._attr_native_value = None @@ -169,7 +246,7 @@ def update(self: FeedParserSensor) -> None: def _generate_entries( self: FeedParserSensor, - parsed_feed: FeedParserDict, + parsed_feed: ParsedFeed, ) -> list[dict[str, str]]: return [ self._generate_sensor_entry(feed_entry) @@ -180,11 +257,13 @@ def _generate_entries( def _generate_sensor_entry( self: FeedParserSensor, - feed_entry: FeedParserDict, + feed_entry: Mapping[str, object], ) -> dict[str, str]: _LOGGER.debug("Feed %s: Generating sensor entry for %s", self.name, feed_entry) sensor_entry = {} for key, value in feed_entry.items(): + if not isinstance(key, str): + continue if ( (self._inclusions and key not in self._inclusions) or ("parsed" in key) @@ -192,10 +271,14 @@ def _generate_sensor_entry( ): continue if key in ["published", "updated", "created", "expired"]: - parsed_date: datetime = self._parse_date(value) - sensor_entry[key] = parsed_date.strftime(self._date_format) + if isinstance(value, str): + parsed_date: datetime = self._parse_date(value) + sensor_entry[key] = parsed_date.strftime(self._date_format) elif key == "image": - sensor_entry["image"] = value.get("href") + if isinstance(value, Mapping): + href = value.get("href") + if isinstance(href, str): + sensor_entry["image"] = href else: sensor_entry[key] = value @@ -252,18 +335,26 @@ def _parse_date(self: FeedParserSensor, date: str) -> datetime: _LOGGER.debug("Feed %s: Parsed date: %s", self.name, parsed_time) return parsed_time - def _process_image(self: FeedParserSensor, feed_entry: FeedParserDict) -> str: - if feed_entry.get("enclosures"): - images = [ - enc for enc in feed_entry["enclosures"] if enc.type.startswith("image/") - ] - if images: - # pick the first image found - return images[0]["href"] - elif "summary" in feed_entry: + def _process_image(self: FeedParserSensor, feed_entry: Mapping[str, object]) -> str: + enclosures = feed_entry.get("enclosures") + if isinstance(enclosures, list): + for enclosure in enclosures: + if not isinstance(enclosure, dict): + continue + enclosure_type = enclosure.get("type") + href = enclosure.get("href") + if ( + isinstance(enclosure_type, str) + and enclosure_type.startswith("image/") + and isinstance(href, str) + ): + return href + + summary = feed_entry.get("summary") + if isinstance(summary, str): images = re.findall( IMAGE_REGEX, - feed_entry["summary"], + summary, ) if images: # pick the first image found @@ -275,16 +366,21 @@ def _process_image(self: FeedParserSensor, feed_entry: FeedParserDict) -> str: ) return DEFAULT_THUMBNAIL # use default image if no image found - def _process_link(self: FeedParserSensor, feed_entry: FeedParserDict) -> str: + def _process_link(self: FeedParserSensor, feed_entry: Mapping[str, object]) -> str: """Return link from feed entry.""" - if "links" in feed_entry: - if len(feed_entry["links"]) > 1: + links = feed_entry.get("links") + if isinstance(links, list) and links: + if len(links) > 1: _LOGGER.debug( "Feed %s: More than one link found for %s. Using the first link.", self.name, feed_entry, ) - return feed_entry["links"][0]["href"] + first_link = links[0] + if isinstance(first_link, dict): + href = first_link.get("href") + if isinstance(href, str): + return href return "" @property diff --git a/custom_components/feedparser/strings.json b/custom_components/feedparser/strings.json new file mode 100644 index 0000000..cff6b92 --- /dev/null +++ b/custom_components/feedparser/strings.json @@ -0,0 +1,48 @@ +{ + "config": { + "step": { + "user": { + "title": "Feedparser", + "description": "Create a sensor from an RSS/Atom feed.", + "data": { + "name": "Name", + "feed_url": "Feed URL", + "date_format": "Date format", + "local_time": "Convert dates to local time", + "scan_interval_hours": "Scan interval hours", + "scan_interval_minutes": "Scan interval minutes", + "show_topn": "Number of entries", + "remove_summary_image": "Remove image tags from summary", + "inclusions": "Included fields (comma-separated)", + "exclusions": "Excluded fields (comma-separated)" + } + } + }, + "error": { + "already_configured": "This feed is already configured.", + "cannot_connect": "Failed to download the feed.", + "invalid_url": "URL is invalid." + }, + "abort": { + "already_configured": "This feed is already configured." + } + }, + "options": { + "step": { + "init": { + "title": "Feedparser options", + "description": "Update feed parsing behavior.", + "data": { + "date_format": "Date format", + "local_time": "Convert dates to local time", + "scan_interval_hours": "Scan interval hours", + "scan_interval_minutes": "Scan interval minutes", + "show_topn": "Number of entries", + "remove_summary_image": "Remove image tags from summary", + "inclusions": "Included fields (comma-separated)", + "exclusions": "Excluded fields (comma-separated)" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/feedparser/translations/en.json b/custom_components/feedparser/translations/en.json new file mode 100644 index 0000000..cff6b92 --- /dev/null +++ b/custom_components/feedparser/translations/en.json @@ -0,0 +1,48 @@ +{ + "config": { + "step": { + "user": { + "title": "Feedparser", + "description": "Create a sensor from an RSS/Atom feed.", + "data": { + "name": "Name", + "feed_url": "Feed URL", + "date_format": "Date format", + "local_time": "Convert dates to local time", + "scan_interval_hours": "Scan interval hours", + "scan_interval_minutes": "Scan interval minutes", + "show_topn": "Number of entries", + "remove_summary_image": "Remove image tags from summary", + "inclusions": "Included fields (comma-separated)", + "exclusions": "Excluded fields (comma-separated)" + } + } + }, + "error": { + "already_configured": "This feed is already configured.", + "cannot_connect": "Failed to download the feed.", + "invalid_url": "URL is invalid." + }, + "abort": { + "already_configured": "This feed is already configured." + } + }, + "options": { + "step": { + "init": { + "title": "Feedparser options", + "description": "Update feed parsing behavior.", + "data": { + "date_format": "Date format", + "local_time": "Convert dates to local time", + "scan_interval_hours": "Scan interval hours", + "scan_interval_minutes": "Scan interval minutes", + "show_topn": "Number of entries", + "remove_summary_image": "Remove image tags from summary", + "inclusions": "Included fields (comma-separated)", + "exclusions": "Excluded fields (comma-separated)" + } + } + } + } +} \ No newline at end of file diff --git a/scripts/develop b/scripts/develop new file mode 100644 index 0000000..3acd91b --- /dev/null +++ b/scripts/develop @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +mkdir -p "${PWD}/test_hass" +if [ ! -L "${PWD}/test_hass/custom_components" ]; then + ( + cd "${PWD}/test_hass" + ln -s "../custom_components" "custom_components" + ) +fi + +DEV_USER="${HASS_DEV_USER:-dev}" +DEV_PASS="${HASS_DEV_PASS:-dev}" + +if ! hass --script auth -c "${PWD}/test_hass" list 2>/dev/null | grep -Fxq "${DEV_USER}"; then + hass --script auth -c "${PWD}/test_hass" add "${DEV_USER}" "${DEV_PASS}" >/dev/null +fi + +cat >"${PWD}/test_hass/.storage/onboarding" <<'EOF' +{ + "version": 4, + "minor_version": 1, + "key": "onboarding", + "data": { + "done": [ + "user", + "core_config", + "analytics", + "integration" + ] + } +} +EOF + +hass --config "${PWD}/test_hass" --debug diff --git a/scripts/setup b/scripts/setup new file mode 100644 index 0000000..d36af8d --- /dev/null +++ b/scripts/setup @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." +python3 -m pip install --upgrade pip +python3 -m pip install -e .[dev] diff --git a/test_hass/configuration.yaml b/test_hass/configuration.yaml index 2e845c1..31f4413 100644 --- a/test_hass/configuration.yaml +++ b/test_hass/configuration.yaml @@ -6,10 +6,9 @@ logger: # Loads default set of integrations. Do not remove. default_config: +demo: debugpy: http: server_port: 9123 - -sensor: !include sensors.yaml diff --git a/tests/conftest.py b/tests/conftest.py index 6d0f582..7ed512c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,19 +33,19 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: ) -@pytest.fixture() +@pytest.fixture def feed(request: pytest.FixtureRequest) -> FeedSource: """Return feed file source.""" return request.param -@pytest.fixture() +@pytest.fixture def feed_sensor(feed: FeedSource) -> FeedParserSensor: """Return feed sensor initialized with the local RSS feed.""" return FeedParserSensor(**feed.sensor_config_local_feed) -@pytest.fixture() +@pytest.fixture def feed_with_image_in_summary( request: pytest.FixtureRequest, ) -> FeedSource: diff --git a/tests/constants.py b/tests/constants.py index 0221d19..77b0537 100644 --- a/tests/constants.py +++ b/tests/constants.py @@ -1,4 +1,5 @@ """Constants for tests.""" + from pathlib import Path TESTS_PATH = Path(__file__).parent diff --git a/tests/data/CTK.json b/tests/data/CTK.json index 9fd252f..a8fa8a9 100644 --- a/tests/data/CTK.json +++ b/tests/data/CTK.json @@ -10,4 +10,4 @@ } }, "download_date": "2024-02-23T21:08:09.183484+00:00" -} +} \ No newline at end of file diff --git a/tests/data/alle_meldungen.json b/tests/data/alle_meldungen.json index 9b1c045..055ad8d 100644 --- a/tests/data/alle_meldungen.json +++ b/tests/data/alle_meldungen.json @@ -16,4 +16,4 @@ "remove_summary_image": true }, "download_date": "2024-02-23T21:08:09.183484+00:00" -} +} \ No newline at end of file diff --git a/tests/data/anp_nieuws.json b/tests/data/anp_nieuws.json index b7b1ad7..71098c0 100644 --- a/tests/data/anp_nieuws.json +++ b/tests/data/anp_nieuws.json @@ -14,4 +14,4 @@ ] }, "download_date": "2024-02-23T21:08:09.183484+00:00" -} +} \ No newline at end of file diff --git a/tests/data/api_met_no_metalerts.json b/tests/data/api_met_no_metalerts.json index e576e4a..7b2ae04 100644 --- a/tests/data/api_met_no_metalerts.json +++ b/tests/data/api_met_no_metalerts.json @@ -12,4 +12,4 @@ ] }, "download_date": "2024-02-23T21:08:09.183484+00:00" -} +} \ No newline at end of file diff --git a/tests/data/bbc_europe.json b/tests/data/bbc_europe.json index 2ee8534..b986585 100644 --- a/tests/data/bbc_europe.json +++ b/tests/data/bbc_europe.json @@ -9,4 +9,4 @@ "date_format": "%a, %d %b %Y %H:%M:%S %z" }, "download_date": "2024-02-25T21:09:08.348208+00:00" -} +} \ No newline at end of file diff --git a/tests/data/buienradar_nl.json b/tests/data/buienradar_nl.json index c04647b..1a23894 100644 --- a/tests/data/buienradar_nl.json +++ b/tests/data/buienradar_nl.json @@ -9,4 +9,4 @@ "date_format": "%Y-%m-%d %H:%M:%S.%f" }, "download_date": "2024-02-23T21:08:09.183484+00:00" -} +} \ No newline at end of file diff --git a/tests/data/ct24.json b/tests/data/ct24.json index b56f66b..1a87b5a 100644 --- a/tests/data/ct24.json +++ b/tests/data/ct24.json @@ -5,4 +5,4 @@ "feed_url": "https://ct24.ceskatelevize.cz/rss/hlavni-zpravy" }, "download_date": "2023-07-30T13:48:50.563494" -} +} \ No newline at end of file diff --git a/tests/data/nu_nl.json b/tests/data/nu_nl.json index ce5b695..86cfadb 100644 --- a/tests/data/nu_nl.json +++ b/tests/data/nu_nl.json @@ -5,4 +5,4 @@ "feed_url": "https://www.nu.nl/rss" }, "download_date": "2023-07-30T13:48:50.563494" -} +} \ No newline at end of file diff --git a/tests/data/nu_nl_algemeen.json b/tests/data/nu_nl_algemeen.json index ecc751c..f2f181d 100644 --- a/tests/data/nu_nl_algemeen.json +++ b/tests/data/nu_nl_algemeen.json @@ -5,4 +5,4 @@ "feed_url": "https://www.nu.nl/rss/Algemeen" }, "download_date": "2023-07-30T13:48:50.563494" -} +} \ No newline at end of file diff --git a/tests/data/skolmaten_se_ede_skola.json b/tests/data/skolmaten_se_ede_skola.json index 4840252..e2cdc29 100644 --- a/tests/data/skolmaten_se_ede_skola.json +++ b/tests/data/skolmaten_se_ede_skola.json @@ -12,4 +12,4 @@ ] }, "download_date": "2023-08-18T09:22:14.164244+00:00" -} +} \ No newline at end of file diff --git a/tests/data/stern_auto.json b/tests/data/stern_auto.json index f706821..d9931b1 100644 --- a/tests/data/stern_auto.json +++ b/tests/data/stern_auto.json @@ -13,4 +13,4 @@ ] }, "download_date": "2024-02-23T21:08:09.183484+00:00" -} +} \ No newline at end of file diff --git a/tests/data/zive.json b/tests/data/zive.json index 44a506f..5e80770 100644 --- a/tests/data/zive.json +++ b/tests/data/zive.json @@ -6,4 +6,4 @@ "show_topn": 1 }, "download_date": "2023-07-30T13:48:50.563494" -} +} \ No newline at end of file diff --git a/tests/download.py b/tests/download.py index a8937fc..9dbff37 100644 --- a/tests/download.py +++ b/tests/download.py @@ -1,4 +1,5 @@ """Download RSS feeds for testing.""" + import asyncio import datetime import json diff --git a/tests/feedsource.py b/tests/feedsource.py index 78b881a..d4f72f1 100644 --- a/tests/feedsource.py +++ b/tests/feedsource.py @@ -1,4 +1,5 @@ """Feed source class to be used in tests.""" + import json from datetime import datetime, timedelta from functools import cached_property diff --git a/tests/generate_ha_config.py b/tests/generate_ha_config.py index 875a725..857cc6f 100644 --- a/tests/generate_ha_config.py +++ b/tests/generate_ha_config.py @@ -1,4 +1,5 @@ """Generate Home Assistant sensors config from test feeds.""" + from constants import TEST_FEEDS from feedsource import FeedSource diff --git a/tests/test_sensors.py b/tests/test_sensors.py index 07fad38..032d1ff 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -1,4 +1,4 @@ -""""Tests the feedparser sensor.""" +""" "Tests the feedparser sensor.""" import re from contextlib import nullcontext, suppress From c6583331b793435f953cbb7460a25e566d988769 Mon Sep 17 00:00:00 2001 From: Ian Richardson Date: Tue, 3 Mar 2026 23:43:51 -0600 Subject: [PATCH 2/8] Add platform-only CONFIG_SCHEMA and tidy manifest Import Home Assistant config validation and add CONFIG_SCHEMA = cv.platform_only_config_schema(DOMAIN) to declare platform-only YAML configuration and enable proper config validation. Also import DOMAIN from consts and tidy manifest.json key ordering (move config_flow, domain, name) for consistency; no functional logic changes. --- custom_components/feedparser/__init__.py | 6 ++++++ custom_components/feedparser/manifest.json | 6 +++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/custom_components/feedparser/__init__.py b/custom_components/feedparser/__init__.py index c6e553c..3e39065 100644 --- a/custom_components/feedparser/__init__.py +++ b/custom_components/feedparser/__init__.py @@ -5,6 +5,8 @@ from datetime import timedelta from typing import TYPE_CHECKING +import homeassistant.helpers.config_validation as cv + from .const import ( CONF_DATE_FORMAT, CONF_EXCLUSIONS, @@ -18,6 +20,7 @@ DEFAULT_REMOVE_SUMMARY_IMAGE, DEFAULT_SCAN_INTERVAL, DEFAULT_TOPN, + DOMAIN, ENTRY_VERSION, OPTION_KEYS, PLATFORMS, @@ -28,6 +31,9 @@ from homeassistant.core import HomeAssistant +CONFIG_SCHEMA = cv.platform_only_config_schema(DOMAIN) + + def _normalize_list(value: object) -> list[str]: """Normalize option values to list[str].""" if isinstance(value, list): diff --git a/custom_components/feedparser/manifest.json b/custom_components/feedparser/manifest.json index 5b77b0e..4805b05 100644 --- a/custom_components/feedparser/manifest.json +++ b/custom_components/feedparser/manifest.json @@ -1,15 +1,15 @@ { - "domain": "feedparser", - "name": "Feedparser", - "config_flow": true, "codeowners": [ "@iantrich", "@ogajduse" ], + "config_flow": true, "dependencies": [], "documentation": "https://github.com/custom-components/feedparser/blob/master/README.md", + "domain": "feedparser", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/custom-components/feedparser/issues", + "name": "Feedparser", "requirements": [ "feedparser==6.0.11", "python-dateutil", From af84c31eaaa12597e340216db5838ac09f42f611 Mon Sep 17 00:00:00 2001 From: Ian Richardson Date: Wed, 4 Mar 2026 05:45:01 +0000 Subject: [PATCH 3/8] Fix manifest.json by removing duplicate domain and name entries --- custom_components/feedparser/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/feedparser/manifest.json b/custom_components/feedparser/manifest.json index 4805b05..81e9e94 100644 --- a/custom_components/feedparser/manifest.json +++ b/custom_components/feedparser/manifest.json @@ -1,4 +1,6 @@ { + "domain": "feedparser", + "name": "Feedparser", "codeowners": [ "@iantrich", "@ogajduse" @@ -6,10 +8,8 @@ "config_flow": true, "dependencies": [], "documentation": "https://github.com/custom-components/feedparser/blob/master/README.md", - "domain": "feedparser", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/custom-components/feedparser/issues", - "name": "Feedparser", "requirements": [ "feedparser==6.0.11", "python-dateutil", From 4a9ba7e09028b400790fb9e6f74660d00959a448 Mon Sep 17 00:00:00 2001 From: Ian Richardson Date: Wed, 4 Mar 2026 05:52:28 +0000 Subject: [PATCH 4/8] Update workflow actions and dependencies for modernization --- .github/workflows/codeql.yml | 8 ++++---- .github/workflows/hassfest.yml | 2 +- .github/workflows/pull_request.yml | 4 ++-- .pre-commit-config.yaml | 14 +++++++------- pyproject.toml | 6 +++--- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e5ac63b..108c80f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -46,11 +46,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -64,7 +64,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@v4 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -77,6 +77,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/hassfest.yml b/.github/workflows/hassfest.yml index 1e840a7..3906572 100644 --- a/.github/workflows/hassfest.yml +++ b/.github/workflows/hassfest.yml @@ -8,5 +8,5 @@ jobs: validate: runs-on: "ubuntu-latest" steps: - - uses: "actions/checkout@v4" + - uses: "actions/checkout@v6" - uses: home-assistant/actions/hassfest@master diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 14508af..f66b90b 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -16,10 +16,10 @@ jobs: run: sudo apt -y install libxml2-utils - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set Up Python-${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a6ac815..5bb33f0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -11,21 +11,21 @@ repos: - id: check-toml - id: debug-statements - repo: https://github.com/lsst-ts/pre-commit-xmllint - rev: v1.0.0 + rev: 6f36260b537bf9a42b6ea5262c915ae50786296e hooks: - id: format-xmllint - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks - rev: v2.12.0 + rev: v2.15.0 hooks: - id: pretty-format-toml args: ["--autofix", "--no-sort"] - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.16 + rev: v0.24.1 hooks: - id: validate-pyproject additional_dependencies: ["validate-pyproject-schema-store[all]"] - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v1.4.1" + rev: "v1.17.1" hooks: - id: mypy additional_dependencies: @@ -37,12 +37,12 @@ repos: types-requests, ] - repo: https://github.com/psf/black - rev: "22.10.0" + rev: "25.1.0" hooks: - id: black - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.0.278 + rev: v0.12.12 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/pyproject.toml b/pyproject.toml index 97409d9..8c93084 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "homeassistant.sensor.feedparser" -version = "0.1.11" +version = "1.0.0" description = "Home Assistant custom integration to parse RSS feeds" maintainers = [ {name = "Ian Richardson", email = "iantrich@gmail.com"}, @@ -24,7 +24,7 @@ classifiers = [ ] requires-python = ">=3.11.0" dependencies = [ - "feedparser==6.0.11", + "feedparser==6.0.12", "homeassistant", "python-dateutil", "requests-file", @@ -35,7 +35,7 @@ dependencies = [ dev = [ "black", "homeassistant-stubs", - "pytest==8.0.0", + "pytest==9.0.2", "mypy", "ruff", "types-python-dateutil", From d051557c4adb3c3989191ad9fd9ea24ebca40c85 Mon Sep 17 00:00:00 2001 From: Ian Richardson Date: Wed, 4 Mar 2026 06:00:42 +0000 Subject: [PATCH 5/8] Refactor config flow and sensor code to improve type handling and clarity --- custom_components/feedparser/config_flow.py | 10 +++---- custom_components/feedparser/sensor.py | 31 +++++++++++++-------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/custom_components/feedparser/config_flow.py b/custom_components/feedparser/config_flow.py index 98e2e58..8df9f3e 100644 --- a/custom_components/feedparser/config_flow.py +++ b/custom_components/feedparser/config_flow.py @@ -11,9 +11,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, - ConfigFlowResult, OptionsFlow, - OptionsFlowWithReload, ) from homeassistant.const import CONF_NAME from requests_file import FileAdapter @@ -175,7 +173,7 @@ def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: async def async_step_user( self, user_input: Mapping[str, object] | None = None, - ) -> ConfigFlowResult: + ) -> object: """Handle the initial step.""" errors: dict[str, str] = {} @@ -247,9 +245,11 @@ def _validate_feed_url(feed_url: str) -> None: response.raise_for_status() -class FeedparserOptionsFlow(OptionsFlowWithReload): +class FeedparserOptionsFlow(OptionsFlow): """Handle options for Feedparser.""" + automatic_reload = True + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" self._config_entry = config_entry @@ -257,7 +257,7 @@ def __init__(self, config_entry: ConfigEntry) -> None: async def async_step_init( self, user_input: Mapping[str, object] | None = None, - ) -> ConfigFlowResult: + ) -> object: """Manage Feedparser options.""" if user_input is not None: options = { diff --git a/custom_components/feedparser/sensor.py b/custom_components/feedparser/sensor.py index 63d3b19..c45e6aa 100644 --- a/custom_components/feedparser/sensor.py +++ b/custom_components/feedparser/sensor.py @@ -78,6 +78,13 @@ class ParsedFeed(Protocol): entries: list[Mapping[str, object]] +class FeedparserEntityPlatform(Protocol): + """Protocol for entity platform poll interval fields used by this integration.""" + + scan_interval: timedelta + scan_interval_seconds: float + + def _scan_interval_to_timedelta(value: object) -> timedelta: """Convert stored scan interval values to timedelta.""" if isinstance(value, timedelta): @@ -130,7 +137,7 @@ async def async_setup_entry( data.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), ) - current_platform = async_get_current_platform() + current_platform = cast("FeedparserEntityPlatform", async_get_current_platform()) current_platform.scan_interval = scan_interval current_platform.scan_interval_seconds = scan_interval.total_seconds() @@ -188,7 +195,7 @@ def __init__( self._exclusions = exclusions self._scan_interval = scan_interval self._local_time = local_time - self._entries: list[dict[str, str]] = [] + self._entries: list[dict[str, object]] = [] self._attr_extra_state_attributes = {"entries": self._entries} self._attr_attribution = "Data retrieved using RSS feedparser" _LOGGER.debug("Feed %s: FeedParserSensor initialized - %s", self.name, self) @@ -247,7 +254,7 @@ def update(self: FeedParserSensor) -> None: def _generate_entries( self: FeedParserSensor, parsed_feed: ParsedFeed, - ) -> list[dict[str, str]]: + ) -> list[dict[str, object]]: return [ self._generate_sensor_entry(feed_entry) for feed_entry in parsed_feed.entries[ @@ -258,9 +265,9 @@ def _generate_entries( def _generate_sensor_entry( self: FeedParserSensor, feed_entry: Mapping[str, object], - ) -> dict[str, str]: + ) -> dict[str, object]: _LOGGER.debug("Feed %s: Generating sensor entry for %s", self.name, feed_entry) - sensor_entry = {} + sensor_entry: dict[str, object] = {} for key, value in feed_entry.items(): if not isinstance(key, str): continue @@ -291,11 +298,13 @@ def _generate_sensor_entry( ): sensor_entry["link"] = processed_link if self._remove_summary_image and "summary" in sensor_entry: - sensor_entry["summary"] = re.sub( - IMAGE_REGEX, - "", - sensor_entry["summary"], - ) + summary = sensor_entry.get("summary") + if isinstance(summary, str): + sensor_entry["summary"] = re.sub( + IMAGE_REGEX, + "", + summary, + ) _LOGGER.debug("Feed %s: Generated sensor entry: %s", self.name, sensor_entry) return sensor_entry @@ -384,7 +393,7 @@ def _process_link(self: FeedParserSensor, feed_entry: Mapping[str, object]) -> s return "" @property - def feed_entries(self: FeedParserSensor) -> list[dict[str, str]]: + def feed_entries(self: FeedParserSensor) -> list[dict[str, object]]: """Return feed entries.""" if hasattr(self, "_entries"): return self._entries From c6d8b5fb32c620d3cf7009a8e4703f175dd368cf Mon Sep 17 00:00:00 2001 From: Ian Richardson Date: Wed, 4 Mar 2026 06:08:23 +0000 Subject: [PATCH 6/8] Refactor config flow type hints and ensure JSON files end with a newline --- custom_components/feedparser/config_flow.py | 36 +++++++++++++-------- tests/data/CTK.json | 2 +- tests/data/alle_meldungen.json | 2 +- tests/data/anp_nieuws.json | 2 +- tests/data/api_met_no_metalerts.json | 2 +- tests/data/bbc_europe.json | 2 +- tests/data/buienradar_nl.json | 2 +- tests/data/ct24.json | 2 +- tests/data/nu_nl.json | 2 +- tests/data/nu_nl_algemeen.json | 2 +- tests/data/skolmaten_se_ede_skola.json | 2 +- tests/data/stern_auto.json | 2 +- tests/data/zive.json | 2 +- 13 files changed, 35 insertions(+), 25 deletions(-) diff --git a/custom_components/feedparser/config_flow.py b/custom_components/feedparser/config_flow.py index 8df9f3e..9bcc834 100644 --- a/custom_components/feedparser/config_flow.py +++ b/custom_components/feedparser/config_flow.py @@ -4,13 +4,14 @@ from collections.abc import Mapping from datetime import timedelta -from typing import Any +from typing import Any, cast import requests import voluptuous as vol from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, + FlowResult, OptionsFlow, ) from homeassistant.const import CONF_NAME @@ -173,7 +174,7 @@ def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: async def async_step_user( self, user_input: Mapping[str, object] | None = None, - ) -> object: + ) -> FlowResult: """Handle the initial step.""" errors: dict[str, str] = {} @@ -220,20 +221,26 @@ async def async_step_user( str(user_input[CONF_EXCLUSIONS]).strip(), ), } - return self.async_create_entry( - title=data[CONF_NAME], - data=data, - options=options, + return cast( + "FlowResult", + self.async_create_entry( + title=data[CONF_NAME], + data=data, + options=options, + ), ) data_schema = _schema_with_defaults( include_feed_identity=True, ) - return self.async_show_form( - step_id="user", - data_schema=data_schema, - errors=errors, + return cast( + "FlowResult", + self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ), ) @staticmethod @@ -257,7 +264,7 @@ def __init__(self, config_entry: ConfigEntry) -> None: async def async_step_init( self, user_input: Mapping[str, object] | None = None, - ) -> object: + ) -> FlowResult: """Manage Feedparser options.""" if user_input is not None: options = { @@ -269,7 +276,7 @@ async def async_step_init( CONF_INCLUSIONS: _split_csv(str(user_input[CONF_INCLUSIONS]).strip()), CONF_EXCLUSIONS: _split_csv(str(user_input[CONF_EXCLUSIONS]).strip()), } - return self.async_create_entry(title="", data=options) + return cast("FlowResult", self.async_create_entry(title="", data=options)) merged = {**self._config_entry.data, **self._config_entry.options} data_schema = _schema_with_defaults( @@ -289,4 +296,7 @@ async def async_step_init( inclusions=_join_csv(merged.get(CONF_INCLUSIONS, [])), exclusions=_join_csv(merged.get(CONF_EXCLUSIONS, [])), ) - return self.async_show_form(step_id="init", data_schema=data_schema) + return cast( + "FlowResult", + self.async_show_form(step_id="init", data_schema=data_schema), + ) diff --git a/tests/data/CTK.json b/tests/data/CTK.json index a8fa8a9..9fd252f 100644 --- a/tests/data/CTK.json +++ b/tests/data/CTK.json @@ -10,4 +10,4 @@ } }, "download_date": "2024-02-23T21:08:09.183484+00:00" -} \ No newline at end of file +} diff --git a/tests/data/alle_meldungen.json b/tests/data/alle_meldungen.json index 055ad8d..9b1c045 100644 --- a/tests/data/alle_meldungen.json +++ b/tests/data/alle_meldungen.json @@ -16,4 +16,4 @@ "remove_summary_image": true }, "download_date": "2024-02-23T21:08:09.183484+00:00" -} \ No newline at end of file +} diff --git a/tests/data/anp_nieuws.json b/tests/data/anp_nieuws.json index 71098c0..b7b1ad7 100644 --- a/tests/data/anp_nieuws.json +++ b/tests/data/anp_nieuws.json @@ -14,4 +14,4 @@ ] }, "download_date": "2024-02-23T21:08:09.183484+00:00" -} \ No newline at end of file +} diff --git a/tests/data/api_met_no_metalerts.json b/tests/data/api_met_no_metalerts.json index 7b2ae04..e576e4a 100644 --- a/tests/data/api_met_no_metalerts.json +++ b/tests/data/api_met_no_metalerts.json @@ -12,4 +12,4 @@ ] }, "download_date": "2024-02-23T21:08:09.183484+00:00" -} \ No newline at end of file +} diff --git a/tests/data/bbc_europe.json b/tests/data/bbc_europe.json index b986585..2ee8534 100644 --- a/tests/data/bbc_europe.json +++ b/tests/data/bbc_europe.json @@ -9,4 +9,4 @@ "date_format": "%a, %d %b %Y %H:%M:%S %z" }, "download_date": "2024-02-25T21:09:08.348208+00:00" -} \ No newline at end of file +} diff --git a/tests/data/buienradar_nl.json b/tests/data/buienradar_nl.json index 1a23894..c04647b 100644 --- a/tests/data/buienradar_nl.json +++ b/tests/data/buienradar_nl.json @@ -9,4 +9,4 @@ "date_format": "%Y-%m-%d %H:%M:%S.%f" }, "download_date": "2024-02-23T21:08:09.183484+00:00" -} \ No newline at end of file +} diff --git a/tests/data/ct24.json b/tests/data/ct24.json index 1a87b5a..b56f66b 100644 --- a/tests/data/ct24.json +++ b/tests/data/ct24.json @@ -5,4 +5,4 @@ "feed_url": "https://ct24.ceskatelevize.cz/rss/hlavni-zpravy" }, "download_date": "2023-07-30T13:48:50.563494" -} \ No newline at end of file +} diff --git a/tests/data/nu_nl.json b/tests/data/nu_nl.json index 86cfadb..ce5b695 100644 --- a/tests/data/nu_nl.json +++ b/tests/data/nu_nl.json @@ -5,4 +5,4 @@ "feed_url": "https://www.nu.nl/rss" }, "download_date": "2023-07-30T13:48:50.563494" -} \ No newline at end of file +} diff --git a/tests/data/nu_nl_algemeen.json b/tests/data/nu_nl_algemeen.json index f2f181d..ecc751c 100644 --- a/tests/data/nu_nl_algemeen.json +++ b/tests/data/nu_nl_algemeen.json @@ -5,4 +5,4 @@ "feed_url": "https://www.nu.nl/rss/Algemeen" }, "download_date": "2023-07-30T13:48:50.563494" -} \ No newline at end of file +} diff --git a/tests/data/skolmaten_se_ede_skola.json b/tests/data/skolmaten_se_ede_skola.json index e2cdc29..4840252 100644 --- a/tests/data/skolmaten_se_ede_skola.json +++ b/tests/data/skolmaten_se_ede_skola.json @@ -12,4 +12,4 @@ ] }, "download_date": "2023-08-18T09:22:14.164244+00:00" -} \ No newline at end of file +} diff --git a/tests/data/stern_auto.json b/tests/data/stern_auto.json index d9931b1..f706821 100644 --- a/tests/data/stern_auto.json +++ b/tests/data/stern_auto.json @@ -13,4 +13,4 @@ ] }, "download_date": "2024-02-23T21:08:09.183484+00:00" -} \ No newline at end of file +} diff --git a/tests/data/zive.json b/tests/data/zive.json index 5e80770..44a506f 100644 --- a/tests/data/zive.json +++ b/tests/data/zive.json @@ -6,4 +6,4 @@ "show_topn": 1 }, "download_date": "2023-07-30T13:48:50.563494" -} \ No newline at end of file +} From df8e48c43e293572a81b778940de9c8d89b5ba55 Mon Sep 17 00:00:00 2001 From: Ian Richardson Date: Wed, 4 Mar 2026 06:20:17 +0000 Subject: [PATCH 7/8] Fix JSON files by adding missing newlines and refactor sensor entry handling for improved clarity and functionality --- custom_components/feedparser/manifest.json | 2 +- custom_components/feedparser/sensor.py | 62 +++++++++++++------ custom_components/feedparser/strings.json | 2 +- .../feedparser/translations/en.json | 2 +- tests/test_sensors.py | 15 +++-- 5 files changed, 56 insertions(+), 27 deletions(-) diff --git a/custom_components/feedparser/manifest.json b/custom_components/feedparser/manifest.json index 81e9e94..7f7505c 100644 --- a/custom_components/feedparser/manifest.json +++ b/custom_components/feedparser/manifest.json @@ -17,4 +17,4 @@ "requests" ], "version": "1.0.0" -} \ No newline at end of file +} diff --git a/custom_components/feedparser/sensor.py b/custom_components/feedparser/sensor.py index c45e6aa..a862a1f 100644 --- a/custom_components/feedparser/sensor.py +++ b/custom_components/feedparser/sensor.py @@ -269,26 +269,50 @@ def _generate_sensor_entry( _LOGGER.debug("Feed %s: Generating sensor entry for %s", self.name, feed_entry) sensor_entry: dict[str, object] = {} for key, value in feed_entry.items(): - if not isinstance(key, str): + if not isinstance(key, str) or self._should_skip_key(key): continue - if ( - (self._inclusions and key not in self._inclusions) - or ("parsed" in key) - or (key in self._exclusions) - ): - continue - if key in ["published", "updated", "created", "expired"]: - if isinstance(value, str): - parsed_date: datetime = self._parse_date(value) - sensor_entry[key] = parsed_date.strftime(self._date_format) - elif key == "image": - if isinstance(value, Mapping): - href = value.get("href") - if isinstance(href, str): - sensor_entry["image"] = href - else: - sensor_entry[key] = value + self._store_sensor_entry_value(sensor_entry, key, value) + + self._add_derived_values(sensor_entry, feed_entry) + _LOGGER.debug("Feed %s: Generated sensor entry: %s", self.name, sensor_entry) + return sensor_entry + + def _should_skip_key(self: FeedParserSensor, key: str) -> bool: + """Return whether a feed key should be skipped.""" + return bool( + (self._inclusions and key not in self._inclusions) + or ("parsed" in key) + or (key in self._exclusions), + ) + + def _store_sensor_entry_value( + self: FeedParserSensor, + sensor_entry: dict[str, object], + key: str, + value: object, + ) -> None: + """Store a normalized entry value.""" + if key in ["published", "updated", "created", "expired"]: + if isinstance(value, str): + parsed_date: datetime = self._parse_date(value) + sensor_entry[key] = parsed_date.strftime(self._date_format) + return + if key == "image": + if isinstance(value, Mapping): + href = value.get("href") + if isinstance(href, str): + sensor_entry["image"] = href + return + + sensor_entry[key] = value + + def _add_derived_values( + self: FeedParserSensor, + sensor_entry: dict[str, object], + feed_entry: Mapping[str, object], + ) -> None: + """Add values derived from feed content and options.""" if "image" in self._inclusions and "image" not in sensor_entry: sensor_entry["image"] = self._process_image(feed_entry) if ( @@ -305,8 +329,6 @@ def _generate_sensor_entry( "", summary, ) - _LOGGER.debug("Feed %s: Generated sensor entry: %s", self.name, sensor_entry) - return sensor_entry def _parse_date(self: FeedParserSensor, date: str) -> datetime: try: diff --git a/custom_components/feedparser/strings.json b/custom_components/feedparser/strings.json index cff6b92..602f69d 100644 --- a/custom_components/feedparser/strings.json +++ b/custom_components/feedparser/strings.json @@ -45,4 +45,4 @@ } } } -} \ No newline at end of file +} diff --git a/custom_components/feedparser/translations/en.json b/custom_components/feedparser/translations/en.json index cff6b92..602f69d 100644 --- a/custom_components/feedparser/translations/en.json +++ b/custom_components/feedparser/translations/en.json @@ -45,4 +45,4 @@ } } } -} \ No newline at end of file +} diff --git a/tests/test_sensors.py b/tests/test_sensors.py index 032d1ff..1010db3 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -1,4 +1,4 @@ -""" "Tests the feedparser sensor.""" +"""Tests the feedparser sensor.""" import re from contextlib import nullcontext, suppress @@ -141,8 +141,10 @@ def test_update_sensor_entries_time( first_entry_time: datetime = datetime(*first_entry_struct_time[:6], tzinfo=UTC) # get the time of the first entry in the sensor + published = feed_sensor.feed_entries[0].get("published") + assert isinstance(published, str) first_sensor_entry_time: datetime = datetime.strptime( # noqa: DTZ007 - feed_sensor.feed_entries[0]["published"], + published, feed.sensor_config.date_format, ) @@ -199,8 +201,9 @@ def test_remove_summary_image( # assert that the sensor does not remove the image from the summary assert any( - re.search(IMAGE_REGEX, e["summary"]) is not None + isinstance(summary, str) and re.search(IMAGE_REGEX, summary) is not None for e in feed_sensor.feed_entries + for summary in [e.get("summary")] ) feed_sensor = FeedParserSensor( @@ -210,7 +213,11 @@ def test_remove_summary_image( assert feed_sensor.feed_entries with nullcontext() if feed.all_entries_have_summary else suppress(KeyError): - assert all("img" not in e["summary"] for e in feed_sensor.feed_entries) + assert all( + not isinstance(summary, str) or "img" not in summary + for e in feed_sensor.feed_entries + for summary in [e.get("summary")] + ) def test_image_not_in_entries(feed: FeedSource) -> None: From 1323e8c5df3401d98bc9892eb82f2c252cf495fb Mon Sep 17 00:00:00 2001 From: Ian Richardson Date: Wed, 4 Mar 2026 06:24:44 +0000 Subject: [PATCH 8/8] Refactor ruff configuration and clean up feedsource.py comments for clarity --- pyproject.toml | 57 ++++++++++++++++++++++----------------------- tests/feedsource.py | 2 +- 2 files changed, 29 insertions(+), 30 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8c93084..f484807 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,32 @@ exclude = ''' # Enable the pycodestyle (`E`) and Pyflakes (`F`) rules by default. # Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or # McCabe complexity (`C901`) by default. +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".mypy_cache", + ".pytype", + ".ruff_cache", + ".tox", + ".venv", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "venv" +] +# Same as Black. +line-length = 88 +# Assume Python 3.11. +target-version = "py311" + +[tool.ruff.lint] select = [ "ANN", "ARG", @@ -98,7 +124,6 @@ select = [ "RUF", "SIM", "SLF", - "TCH", "TRY", "UP" ] @@ -110,40 +135,14 @@ ignore = [ "D213", # Multi-line docstring summary should start at the first line "FBT001" # Boolean positional argument in function definition ] -# Allow autofix for all enabled rules (when `--fix`) is provided. fixable = ["ALL"] unfixable = [] -# Exclude a variety of commonly ignored directories. -exclude = [ - ".bzr", - ".direnv", - ".eggs", - ".git", - ".git-rewrite", - ".hg", - ".mypy_cache", - ".pytype", - ".ruff_cache", - ".tox", - ".venv", - "__pypackages__", - "_build", - "buck-out", - "build", - "dist", - "venv" -] -# Same as Black. -line-length = 88 -# Allow unused variables when underscore-prefixed. dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" -# Assume Python 3.11. -target-version = "py311" -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "tests/**" = ["S101"] -[tool.ruff.pylint] +[tool.ruff.lint.pylint] max-args = 10 [[tool.mypy.overrides]] diff --git a/tests/feedsource.py b/tests/feedsource.py index d4f72f1..991bfa6 100644 --- a/tests/feedsource.py +++ b/tests/feedsource.py @@ -15,7 +15,7 @@ TEST_HASS_PATH, ) -yaml.Dumper.ignore_aliases = lambda *args: True # type: ignore[method-assign] # noqa: ARG005, E501 +yaml.Dumper.ignore_aliases = lambda *args: True # type: ignore[method-assign] # noqa: ARG005 class FeedSource: