Skip to content

Commit e025359

Browse files
committed
URL support
1 parent 7d7626a commit e025359

4 files changed

Lines changed: 84 additions & 18 deletions

File tree

.cspell.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,21 @@
22
"version": "0.2",
33
"language": "en",
44
"words": [
5+
"aioclient",
56
"aiofiles",
67
"amitfin",
78
"autouse",
89
"caplog",
10+
"clientsession",
911
"codeowners",
12+
"coolmasternet",
1013
"fileserver",
1114
"freezegun",
1215
"HACS",
1316
"hass",
1417
"homeassistant",
1518
"mypy",
19+
"pycoolmasternet",
1620
"pysiaalarm",
1721
"pytest",
1822
"PYTHONPATH",

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ patch:
2525
delay: 60
2626
restart: true
2727
files:
28+
- name: coolmasternet.py
29+
base: https://raw.githubusercontent.com/OnFreund/pycoolmasternet-async/b463ac6101c25b027ecfb62c3d4edcc5bfbf4379/pycoolmasternet_async
30+
destination: "{site-packages}/pycoolmasternet_async"
31+
patch: https://raw.githubusercontent.com/amitfin/pycoolmasternet-async/wait-for-prompt/pycoolmasternet_async
2832
- name: adm_mapping.json
2933
base: /share/fileserver/pysiaalarm/base/data
3034
destination: "{site-packages}/pysiaalarm/data"
@@ -38,11 +42,11 @@ patch:
3842
`files` is a list of patches to apply. Each item on the list has the following properties (all are mandatory):
3943

4044
- `name`: the file name
41-
- `base`: the directory containing an original copy of the file (before the patch). The patch happens only if the content of the file to be patched (`destination/name`) is identical to the content of the base file (`base/name`). Otherwise, a repair issue is raised. In such a case, a rebase of the patch is required along with updating the content of the files `base/name` and `patch/name`.
45+
- `base`: the directory containing an original copy of the file (before the patch). The patch happens only if the content of the file to be patched (`destination/name`) is identical to the content of the base file (`base/name`). Otherwise, a repair issue is raised. In such a case, a rebase of the patch is required along with updating the content of the files `base/name` and `patch/name`. This parameter can be provided as a local path or as a URL.
4246
- `destination`: the local directory with the file to be patched.
43-
- `patch`: the directory containing the file with the change.
47+
- `patch`: the directory containing the file with the change. It can be provided as a local path or as a URL.
4448

45-
All files must exist (e.g. `base/name`, etc') inside the Home Assistant Core environment. It’s convenient to mount `base` and `patch` directories as [network shares](https://www.home-assistant.io/common-tasks/os#network-storage).
49+
All files must exist (e.g. `base/name`, etc') inside the Home Assistant Core environment. It’s convenient to mount `base` and `patch` directories as [network shares](https://www.home-assistant.io/common-tasks/os#network-storage), or provide them as URLs.
4650

4751
The `destination` directory can use the following variables as a prefix:
4852

custom_components/patch/__init__.py

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
from __future__ import annotations
44

5+
import asyncio
56
import datetime
67
from enum import StrEnum
78
from pathlib import Path
89
from typing import TYPE_CHECKING
10+
from urllib.parse import ParseResult, urlparse
911

1012
import aiofiles
1113
import homeassistant
@@ -24,6 +26,7 @@
2426
from homeassistant.helpers import config_validation as cv
2527
from homeassistant.helpers import event, recorder
2628
from homeassistant.helpers import issue_registry as ir
29+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
2730

2831
from .const import (
2932
CONF_DESTINATION,
@@ -53,9 +56,13 @@ def expand_path(path: str) -> str:
5356
CONFIG_FILE_SCHEMA = vol.Schema(
5457
{
5558
vol.Required(CONF_NAME): cv.string,
56-
vol.Required(CONF_BASE): vol.All(cv.string, expand_path, cv.isdir),
59+
vol.Required(CONF_BASE): vol.Any(
60+
vol.All(cv.url, urlparse), vol.All(cv.string, expand_path, cv.isdir)
61+
),
5762
vol.Required(CONF_DESTINATION): vol.All(cv.string, expand_path, cv.isdir),
58-
vol.Required(CONF_PATCH): vol.All(cv.string, expand_path, cv.isdir),
63+
vol.Required(CONF_PATCH): vol.Any(
64+
vol.All(cv.url, urlparse), vol.All(cv.string, expand_path, cv.isdir)
65+
),
5966
},
6067
extra=vol.ALLOW_EXTRA,
6168
)
@@ -64,7 +71,8 @@ def expand_path(path: str) -> str:
6471
def validate_files(single_patch: dict[str, str]) -> dict[str, str]:
6572
"""Validate all files of a patch configuration."""
6673
for dir_property in (CONF_BASE, CONF_DESTINATION, CONF_PATCH):
67-
cv.isfile(Path(single_patch[dir_property]) / single_patch[CONF_NAME])
74+
if not isinstance(single_patch[dir_property], ParseResult):
75+
cv.isfile(Path(single_patch[dir_property]) / single_patch[CONF_NAME])
6876
return single_patch
6977

7078

@@ -124,6 +132,7 @@ def __init__(self, hass: HomeAssistant, config: ConfigType) -> None:
124132
"""Initialize the object."""
125133
self._hass = hass
126134
self._config = config
135+
self._http_client = async_get_clientsession(hass)
127136

128137
@callback
129138
async def run_after_migration(self, _: datetime.datetime | None = None) -> None:
@@ -166,23 +175,34 @@ async def run(self) -> None:
166175
HA_DOMAIN, SERVICE_HOMEASSISTANT_RESTART
167176
)
168177

178+
async def _read(self, directory: str | ParseResult, name: str) -> tuple[str, str]:
179+
"""Read file content."""
180+
if isinstance(directory, ParseResult):
181+
url = f"{directory.geturl()}/{name}"
182+
async with self._http_client.get(url) as response:
183+
return url, await response.text()
184+
185+
path = Path(directory) / name
186+
async with aiofiles.open(path) as file:
187+
return str(path), await file.read()
188+
169189
async def _patch(
170190
self,
171191
name: str,
172-
base_directory: str,
192+
base_directory: str | ParseResult,
173193
destination_directory: str,
174-
patch_directory: str,
194+
patch_directory: str | ParseResult,
175195
) -> PatchResult:
176196
"""Check if identical files and update the destination if needed."""
177-
base = Path(base_directory) / name
178-
destination = Path(destination_directory) / name
179-
patch = Path(patch_directory) / name
180-
async with aiofiles.open(base) as file:
181-
base_content = await file.read()
182-
async with aiofiles.open(destination) as file:
183-
destination_content = await file.read()
184-
async with aiofiles.open(patch) as file:
185-
patch_content = await file.read()
197+
(
198+
(base, base_content),
199+
(destination, destination_content),
200+
(patch, patch_content),
201+
) = await asyncio.gather(
202+
self._read(base_directory, name),
203+
self._read(destination_directory, name),
204+
self._read(patch_directory, name),
205+
)
186206
if destination_content == patch_content:
187207
LOGGER.debug(
188208
"Destination file '%s' is identical to the patch file '%s'.",

tests/test_init.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@
4444
if TYPE_CHECKING:
4545
from freezegun.api import FrozenDateTimeFactory
4646
from homeassistant.helpers.typing import ConfigType
47+
from pytest_homeassistant_custom_component.test_util.aiohttp import (
48+
AiohttpClientMocker,
49+
)
4750

4851

4952
async def async_setup(hass: HomeAssistant, config: ConfigType | None = None) -> None:
@@ -182,6 +185,41 @@ async def test_patch( # noqa: PLR0913
182185
assert repairs[0].data["issue_id"].startswith("patch_file_base_mismatch")
183186

184187

188+
@patch("homeassistant.core.ServiceRegistry.async_call")
189+
async def test_patch_url(
190+
async_call_mock: AsyncMock,
191+
hass: HomeAssistant,
192+
aioclient_mock: AiohttpClientMocker,
193+
freezer: FrozenDateTimeFactory,
194+
caplog: pytest.LogCaptureFixture,
195+
) -> None:
196+
"""Test updating a file using URLs."""
197+
aioclient_mock.get("https://test.com/base/file", text="old")
198+
aioclient_mock.get("https://test.com/patch/file", text="new")
199+
with tempfile.TemporaryDirectory() as destination:
200+
with (Path(destination) / "file").open("w", encoding="ascii") as file:
201+
file.write("old")
202+
await async_setup(
203+
hass,
204+
{
205+
CONF_FILES: [
206+
{
207+
CONF_NAME: "file",
208+
CONF_BASE: "https://test.com/base",
209+
CONF_DESTINATION: destination,
210+
CONF_PATCH: "https://test.com/patch",
211+
}
212+
],
213+
},
214+
)
215+
await async_next_day(hass, freezer)
216+
with (Path(destination) / "file").open(encoding="ascii") as file:
217+
assert file.read() == "new"
218+
assert async_call_mock.call_count == 1
219+
assert "was updated by the patch file" in caplog.text
220+
assert "1 core file was patched." in caplog.text
221+
222+
185223
async def test_reload(
186224
hass: HomeAssistant,
187225
freezer: FrozenDateTimeFactory,
@@ -257,7 +295,7 @@ async def test_reload_no_config(
257295
("name", "base", "destination", "patch_dir", "error"),
258296
[
259297
("test", ".", ".", ".", "not a file"),
260-
("test", "dummy", ".", ".", "not a directory"),
298+
("test", "dummy", ".", ".", "invalid url"),
261299
],
262300
ids=["no file", "no directory"],
263301
)

0 commit comments

Comments
 (0)