wyoming-piper 1.5.3__tar.gz → 2.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/PKG-INFO +16 -14
- {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/pyproject.toml +16 -14
- {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/tests/test_piper.py +1 -21
- {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/wyoming_piper/__init__.py +1 -0
- {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/wyoming_piper/__main__.py +50 -24
- {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/wyoming_piper/download.py +41 -38
- wyoming_piper-2.1.2/wyoming_piper/handler.py +275 -0
- {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/wyoming_piper/voices.json +420 -0
- {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/wyoming_piper.egg-info/PKG-INFO +16 -14
- {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/wyoming_piper.egg-info/SOURCES.txt +0 -1
- wyoming_piper-2.1.2/wyoming_piper.egg-info/requires.txt +16 -0
- wyoming_piper-1.5.3/wyoming_piper/handler.py +0 -146
- wyoming_piper-1.5.3/wyoming_piper/process.py +0 -171
- wyoming_piper-1.5.3/wyoming_piper.egg-info/requires.txt +0 -14
- {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/LICENSE.md +0 -0
- {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/README.md +0 -0
- {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/setup.cfg +0 -0
- {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/wyoming_piper/const.py +0 -0
- {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/wyoming_piper/file_hash.py +0 -0
- {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/wyoming_piper.egg-info/dependency_links.txt +0 -0
- {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/wyoming_piper.egg-info/entry_points.txt +0 -0
- {wyoming_piper-1.5.3 → wyoming_piper-2.1.2}/wyoming_piper.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wyoming-piper
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 2.1.2
|
|
4
4
|
Summary: Wyoming Server for Piper
|
|
5
5
|
Author-email: Michael Hansen <mike@rhasspy.org>
|
|
6
6
|
License: MIT
|
|
@@ -8,28 +8,30 @@ Project-URL: Homepage, http://github.com/rhasspy/wyoming-piper
|
|
|
8
8
|
Keywords: rhasspy,wyoming,piper,tts
|
|
9
9
|
Classifier: Development Status :: 3 - Alpha
|
|
10
10
|
Classifier: Intended Audience :: Developers
|
|
11
|
-
Classifier: Topic ::
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
11
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
13
12
|
Classifier: Programming Language :: Python :: 3.9
|
|
14
13
|
Classifier: Programming Language :: Python :: 3.10
|
|
15
14
|
Classifier: Programming Language :: Python :: 3.11
|
|
16
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
-
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
19
|
License-File: LICENSE.md
|
|
20
|
-
Requires-Dist: wyoming
|
|
20
|
+
Requires-Dist: wyoming<2,>=1.8
|
|
21
|
+
Requires-Dist: regex>=2024.11.6
|
|
22
|
+
Requires-Dist: piper-tts<2,>=1.3.0
|
|
23
|
+
Requires-Dist: sentence-stream<2,>=1.2.0
|
|
21
24
|
Provides-Extra: dev
|
|
22
|
-
Requires-Dist: black
|
|
23
|
-
Requires-Dist: flake8
|
|
24
|
-
Requires-Dist:
|
|
25
|
-
Requires-Dist:
|
|
26
|
-
Requires-Dist:
|
|
27
|
-
Requires-Dist: pytest
|
|
28
|
-
Requires-Dist:
|
|
29
|
-
Requires-Dist: build==1.2.2.post1; extra == "dev"
|
|
25
|
+
Requires-Dist: black; extra == "dev"
|
|
26
|
+
Requires-Dist: flake8; extra == "dev"
|
|
27
|
+
Requires-Dist: mypy; extra == "dev"
|
|
28
|
+
Requires-Dist: pylint; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
31
|
+
Requires-Dist: build; extra == "dev"
|
|
30
32
|
Requires-Dist: scipy<2,>=1.10; extra == "dev"
|
|
31
33
|
Requires-Dist: numpy<2,>=1.20; extra == "dev"
|
|
32
|
-
Requires-Dist: python-speech-features
|
|
34
|
+
Requires-Dist: python-speech-features<1,>=0.6; extra == "dev"
|
|
33
35
|
Dynamic: license-file
|
|
34
36
|
|
|
35
37
|
# Wyoming Piper
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "wyoming-piper"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "2.1.2"
|
|
4
4
|
description = "Wyoming Server for Piper"
|
|
5
5
|
readme = "README.md"
|
|
6
|
-
requires-python = ">=3.
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
7
|
license = {text = "MIT"}
|
|
8
8
|
authors = [
|
|
9
9
|
{name = "Michael Hansen", email = "mike@rhasspy.org"}
|
|
@@ -12,15 +12,18 @@ keywords = ["rhasspy", "wyoming", "piper", "tts"]
|
|
|
12
12
|
classifiers = [
|
|
13
13
|
"Development Status :: 3 - Alpha",
|
|
14
14
|
"Intended Audience :: Developers",
|
|
15
|
-
"Topic ::
|
|
16
|
-
"Programming Language :: Python :: 3.8",
|
|
15
|
+
"Topic :: Multimedia :: Sound/Audio :: Speech",
|
|
17
16
|
"Programming Language :: Python :: 3.9",
|
|
18
17
|
"Programming Language :: Python :: 3.10",
|
|
19
18
|
"Programming Language :: Python :: 3.11",
|
|
20
19
|
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
21
|
]
|
|
22
22
|
dependencies = [
|
|
23
|
-
"wyoming>=1.
|
|
23
|
+
"wyoming>=1.8,<2",
|
|
24
|
+
"regex>=2024.11.6",
|
|
25
|
+
"piper-tts>=1.3.0,<2",
|
|
26
|
+
"sentence-stream>=1.2.0,<2",
|
|
24
27
|
]
|
|
25
28
|
|
|
26
29
|
[project.urls]
|
|
@@ -55,15 +58,14 @@ disallow_untyped_defs = true
|
|
|
55
58
|
|
|
56
59
|
[project.optional-dependencies]
|
|
57
60
|
dev = [
|
|
58
|
-
"black
|
|
59
|
-
"flake8
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"pytest
|
|
64
|
-
"
|
|
65
|
-
"build==1.2.2.post1",
|
|
61
|
+
"black",
|
|
62
|
+
"flake8",
|
|
63
|
+
"mypy",
|
|
64
|
+
"pylint",
|
|
65
|
+
"pytest",
|
|
66
|
+
"pytest-asyncio",
|
|
67
|
+
"build",
|
|
66
68
|
"scipy>=1.10,<2",
|
|
67
69
|
"numpy>=1.20,<2",
|
|
68
|
-
"python-speech-features
|
|
70
|
+
"python-speech-features>=0.6,<1",
|
|
69
71
|
]
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
"""Tests for wyoming-piper"""
|
|
2
|
+
|
|
2
3
|
import asyncio
|
|
3
4
|
import sys
|
|
4
|
-
import tarfile
|
|
5
5
|
import wave
|
|
6
6
|
from asyncio.subprocess import PIPE
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from urllib.request import urlopen
|
|
9
8
|
|
|
10
9
|
import numpy as np
|
|
11
10
|
import pytest
|
|
@@ -19,36 +18,17 @@ from .dtw import compute_optimal_path
|
|
|
19
18
|
|
|
20
19
|
_DIR = Path(__file__).parent
|
|
21
20
|
_LOCAL_DIR = _DIR.parent / "local"
|
|
22
|
-
_PIPER_URL = (
|
|
23
|
-
"https://github.com/rhasspy/piper/releases/download/v1.2.0/piper_amd64.tar.gz"
|
|
24
|
-
)
|
|
25
21
|
_TIMEOUT = 60
|
|
26
22
|
|
|
27
23
|
|
|
28
|
-
def download_piper() -> None:
|
|
29
|
-
"""Downloads a binary version of Piper."""
|
|
30
|
-
piper_path = _LOCAL_DIR / "piper"
|
|
31
|
-
if piper_path.exists():
|
|
32
|
-
return
|
|
33
|
-
|
|
34
|
-
_LOCAL_DIR.mkdir(parents=True, exist_ok=True)
|
|
35
|
-
with urlopen(_PIPER_URL) as response:
|
|
36
|
-
with tarfile.open(fileobj=response, mode="r|*") as piper_file:
|
|
37
|
-
piper_file.extractall(_LOCAL_DIR)
|
|
38
|
-
|
|
39
|
-
|
|
40
24
|
@pytest.mark.asyncio
|
|
41
25
|
async def test_piper() -> None:
|
|
42
|
-
download_piper()
|
|
43
|
-
|
|
44
26
|
proc = await asyncio.create_subprocess_exec(
|
|
45
27
|
sys.executable,
|
|
46
28
|
"-m",
|
|
47
29
|
"wyoming_piper",
|
|
48
30
|
"--uri",
|
|
49
31
|
"stdio://",
|
|
50
|
-
"--piper",
|
|
51
|
-
str(_LOCAL_DIR / "piper" / "piper"),
|
|
52
32
|
"--voice",
|
|
53
33
|
"en_US-ryan-low",
|
|
54
34
|
"--data-dir",
|
|
@@ -8,12 +8,11 @@ from pathlib import Path
|
|
|
8
8
|
from typing import Any, Dict, Set
|
|
9
9
|
|
|
10
10
|
from wyoming.info import Attribution, Info, TtsProgram, TtsVoice, TtsVoiceSpeaker
|
|
11
|
-
from wyoming.server import AsyncServer
|
|
11
|
+
from wyoming.server import AsyncServer, AsyncTcpServer
|
|
12
12
|
|
|
13
13
|
from . import __version__
|
|
14
|
-
from .download import find_voice, get_voices
|
|
14
|
+
from .download import ensure_voice_exists, find_voice, get_voices
|
|
15
15
|
from .handler import PiperEventHandler
|
|
16
|
-
from .process import PiperProcessManager
|
|
17
16
|
|
|
18
17
|
_LOGGER = logging.getLogger(__name__)
|
|
19
18
|
|
|
@@ -21,17 +20,20 @@ _LOGGER = logging.getLogger(__name__)
|
|
|
21
20
|
async def main() -> None:
|
|
22
21
|
"""Main entry point."""
|
|
23
22
|
parser = argparse.ArgumentParser()
|
|
24
|
-
parser.add_argument(
|
|
25
|
-
"--piper",
|
|
26
|
-
required=True,
|
|
27
|
-
help="Path to piper executable",
|
|
28
|
-
)
|
|
29
23
|
parser.add_argument(
|
|
30
24
|
"--voice",
|
|
31
25
|
required=True,
|
|
32
26
|
help="Default Piper voice to use (e.g., en_US-lessac-medium)",
|
|
33
27
|
)
|
|
34
28
|
parser.add_argument("--uri", default="stdio://", help="unix:// or tcp://")
|
|
29
|
+
#
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--zeroconf",
|
|
32
|
+
nargs="?",
|
|
33
|
+
const="piper",
|
|
34
|
+
help="Enable discovery over zeroconf with optional name (default: piper)",
|
|
35
|
+
)
|
|
36
|
+
#
|
|
35
37
|
parser.add_argument(
|
|
36
38
|
"--data-dir",
|
|
37
39
|
required=True,
|
|
@@ -48,17 +50,18 @@ async def main() -> None:
|
|
|
48
50
|
)
|
|
49
51
|
parser.add_argument("--noise-scale", type=float, help="Generator noise")
|
|
50
52
|
parser.add_argument("--length-scale", type=float, help="Phoneme length")
|
|
51
|
-
parser.add_argument(
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--noise-w-scale", "--noise-w", type=float, help="Phoneme width noise"
|
|
55
|
+
)
|
|
52
56
|
#
|
|
53
57
|
parser.add_argument(
|
|
54
58
|
"--auto-punctuation", default=".?!", help="Automatically add punctuation"
|
|
55
59
|
)
|
|
56
60
|
parser.add_argument("--samples-per-chunk", type=int, default=1024)
|
|
57
61
|
parser.add_argument(
|
|
58
|
-
"--
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
help="Maximum number of piper process to run simultaneously (default: 1)",
|
|
62
|
+
"--no-streaming",
|
|
63
|
+
action="store_true",
|
|
64
|
+
help="Disable audio streaming on sentence boundaries",
|
|
62
65
|
)
|
|
63
66
|
#
|
|
64
67
|
parser.add_argument(
|
|
@@ -67,6 +70,12 @@ async def main() -> None:
|
|
|
67
70
|
help="Download latest voices.json during startup",
|
|
68
71
|
)
|
|
69
72
|
#
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--use-cuda",
|
|
75
|
+
action="store_true",
|
|
76
|
+
help="Use CUDA if available (requires onnxruntime-gpu)",
|
|
77
|
+
)
|
|
78
|
+
#
|
|
70
79
|
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
|
|
71
80
|
parser.add_argument(
|
|
72
81
|
"--log-format", default=logging.BASIC_FORMAT, help="Format for log messages"
|
|
@@ -113,12 +122,14 @@ async def main() -> None:
|
|
|
113
122
|
voice_info.get("espeak", {}).get("voice", voice_name.split("_")[0]),
|
|
114
123
|
)
|
|
115
124
|
],
|
|
116
|
-
speakers=
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
125
|
+
speakers=(
|
|
126
|
+
[
|
|
127
|
+
TtsVoiceSpeaker(name=speaker_name)
|
|
128
|
+
for speaker_name in voice_info["speaker_id_map"]
|
|
129
|
+
]
|
|
130
|
+
if voice_info.get("speaker_id_map")
|
|
131
|
+
else None
|
|
132
|
+
),
|
|
122
133
|
)
|
|
123
134
|
for voice_name, voice_info in voices_info.items()
|
|
124
135
|
if not voice_info.get("_is_alias", False)
|
|
@@ -180,26 +191,41 @@ async def main() -> None:
|
|
|
180
191
|
installed=True,
|
|
181
192
|
voices=sorted(voices, key=lambda v: v.name),
|
|
182
193
|
version=__version__,
|
|
194
|
+
supports_synthesize_streaming=(not args.no_streaming),
|
|
183
195
|
)
|
|
184
196
|
],
|
|
185
197
|
)
|
|
186
198
|
|
|
187
|
-
|
|
199
|
+
# Ensure default voice is downloaded
|
|
200
|
+
voice_info = voices_info.get(args.voice, {})
|
|
201
|
+
voice_name = voice_info.get("key", args.voice)
|
|
202
|
+
assert voice_name is not None
|
|
188
203
|
|
|
189
|
-
|
|
190
|
-
# Other voices will be loaded on-demand.
|
|
191
|
-
await process_manager.get_process()
|
|
204
|
+
ensure_voice_exists(voice_name, args.data_dir, args.download_dir, voices_info)
|
|
192
205
|
|
|
193
206
|
# Start server
|
|
194
207
|
server = AsyncServer.from_uri(args.uri)
|
|
195
208
|
|
|
209
|
+
if args.zeroconf:
|
|
210
|
+
if not isinstance(server, AsyncTcpServer):
|
|
211
|
+
raise ValueError("Zeroconf requires tcp:// uri")
|
|
212
|
+
|
|
213
|
+
from wyoming.zeroconf import HomeAssistantZeroconf
|
|
214
|
+
|
|
215
|
+
tcp_server: AsyncTcpServer = server
|
|
216
|
+
hass_zeroconf = HomeAssistantZeroconf(
|
|
217
|
+
name=args.zeroconf, port=tcp_server.port, host=tcp_server.host
|
|
218
|
+
)
|
|
219
|
+
await hass_zeroconf.register_server()
|
|
220
|
+
_LOGGER.debug("Zeroconf discovery enabled")
|
|
221
|
+
|
|
196
222
|
_LOGGER.info("Ready")
|
|
197
223
|
await server.run(
|
|
198
224
|
partial(
|
|
199
225
|
PiperEventHandler,
|
|
200
226
|
wyoming_info,
|
|
201
227
|
args,
|
|
202
|
-
|
|
228
|
+
voices_info,
|
|
203
229
|
)
|
|
204
230
|
)
|
|
205
231
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"""Utility for downloading Piper voices."""
|
|
2
|
+
|
|
2
3
|
import json
|
|
3
4
|
import logging
|
|
4
5
|
import shutil
|
|
@@ -8,9 +9,7 @@ from urllib.error import URLError
|
|
|
8
9
|
from urllib.parse import quote, urlsplit, urlunsplit
|
|
9
10
|
from urllib.request import urlopen
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
URL_FORMAT = "https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/{file}"
|
|
12
|
+
URL_FORMAT = "https://huggingface.co/rhasspy/piper-voices/resolve/main/{file}"
|
|
14
13
|
|
|
15
14
|
_DIR = Path(__file__).parent
|
|
16
15
|
_LOGGER = logging.getLogger(__name__)
|
|
@@ -47,20 +46,21 @@ def get_voices(
|
|
|
47
46
|
except Exception:
|
|
48
47
|
_LOGGER.exception("Failed to update voices list")
|
|
49
48
|
|
|
49
|
+
voices_embedded = _DIR / "voices.json"
|
|
50
|
+
_LOGGER.debug("Loading %s", voices_embedded)
|
|
51
|
+
with open(voices_embedded, "r", encoding="utf-8") as voices_file:
|
|
52
|
+
voices = json.load(voices_file)
|
|
53
|
+
|
|
50
54
|
# Prefer downloaded file to embedded
|
|
51
55
|
if voices_download.exists():
|
|
52
56
|
try:
|
|
53
57
|
_LOGGER.debug("Loading %s", voices_download)
|
|
54
58
|
with open(voices_download, "r", encoding="utf-8") as voices_file:
|
|
55
|
-
|
|
59
|
+
voices.update(json.load(voices_file))
|
|
56
60
|
except Exception:
|
|
57
61
|
_LOGGER.exception("Failed to load %s", voices_download)
|
|
58
62
|
|
|
59
|
-
|
|
60
|
-
voices_embedded = _DIR / "voices.json"
|
|
61
|
-
_LOGGER.debug("Loading %s", voices_embedded)
|
|
62
|
-
with open(voices_embedded, "r", encoding="utf-8") as voices_file:
|
|
63
|
-
return json.load(voices_file)
|
|
63
|
+
return voices
|
|
64
64
|
|
|
65
65
|
|
|
66
66
|
def ensure_voice_exists(
|
|
@@ -87,8 +87,7 @@ def ensure_voice_exists(
|
|
|
87
87
|
for data_dir in data_dirs:
|
|
88
88
|
data_dir = Path(data_dir)
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
for file_path, file_info in voice_files.items():
|
|
90
|
+
for file_path, _file_info in voice_files.items():
|
|
92
91
|
if file_path in verified_files:
|
|
93
92
|
# Already verified this file in a different data directory
|
|
94
93
|
continue
|
|
@@ -99,34 +98,37 @@ def ensure_voice_exists(
|
|
|
99
98
|
|
|
100
99
|
data_file_path = data_dir / file_name
|
|
101
100
|
_LOGGER.debug("Checking %s", data_file_path)
|
|
102
|
-
if not data_file_path.exists():
|
|
101
|
+
if (not data_file_path.exists()) or (data_file_path.stat().st_size == 0):
|
|
103
102
|
_LOGGER.debug("Missing %s", data_file_path)
|
|
104
103
|
files_to_download.add(file_path)
|
|
105
104
|
continue
|
|
106
105
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
106
|
+
# Don't bother validating sizes or hashes.
|
|
107
|
+
# This causes more problems than its worth.
|
|
108
|
+
#
|
|
109
|
+
# expected_size = file_info["size_bytes"]
|
|
110
|
+
# actual_size = data_file_path.stat().st_size
|
|
111
|
+
# if expected_size != actual_size:
|
|
112
|
+
# _LOGGER.warning(
|
|
113
|
+
# "Wrong size (expected=%s, actual=%s) for %s",
|
|
114
|
+
# expected_size,
|
|
115
|
+
# actual_size,
|
|
116
|
+
# data_file_path,
|
|
117
|
+
# )
|
|
118
|
+
# files_to_download.add(file_path)
|
|
119
|
+
# continue
|
|
120
|
+
|
|
121
|
+
# expected_hash = file_info["md5_digest"]
|
|
122
|
+
# actual_hash = get_file_hash(data_file_path)
|
|
123
|
+
# if expected_hash != actual_hash:
|
|
124
|
+
# _LOGGER.warning(
|
|
125
|
+
# "Wrong hash (expected=%s, actual=%s) for %s",
|
|
126
|
+
# expected_hash,
|
|
127
|
+
# actual_hash,
|
|
128
|
+
# data_file_path,
|
|
129
|
+
# )
|
|
130
|
+
# files_to_download.add(file_path)
|
|
131
|
+
# continue
|
|
130
132
|
|
|
131
133
|
# File exists and has been verified
|
|
132
134
|
verified_files.add(file_path)
|
|
@@ -149,9 +151,10 @@ def ensure_voice_exists(
|
|
|
149
151
|
download_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
150
152
|
|
|
151
153
|
_LOGGER.debug("Downloading %s to %s", file_url, download_file_path)
|
|
152
|
-
with
|
|
153
|
-
|
|
154
|
-
|
|
154
|
+
with (
|
|
155
|
+
urlopen(_quote_url(file_url)) as response,
|
|
156
|
+
open(download_file_path, "wb") as download_file,
|
|
157
|
+
):
|
|
155
158
|
shutil.copyfileobj(response, download_file)
|
|
156
159
|
|
|
157
160
|
_LOGGER.info("Downloaded %s (%s)", download_file_path, file_url)
|