Merge ~tonysimpson/snapstore-client:snapstore-library-integration into snapstore-client:master
- Git
- lp:~tonysimpson/snapstore-client
- snapstore-library-integration
- Merge into master
Status: | Work in progress |
---|---|
Proposed branch: | ~tonysimpson/snapstore-client:snapstore-library-integration |
Merge into: | snapstore-client:master |
Diff against target: |
3582 lines (+1951/-1049) 19 files modified
Makefile (+32/-12) dev/null (+0/-185) requirements.txt (+1/-0) snap/plugins/_python/__init__.py (+19/-0) snap/plugins/_python/_pip.py (+575/-0) snap/plugins/_python/_python_finder.py (+104/-0) snap/plugins/_python/_sitecustomize.py (+124/-0) snap/plugins/_python/errors.py (+79/-0) snap/plugins/python_ols.py (+499/-0) snap/snapcraft.yaml (+15/-8) snapstore (+1/-126) snapstore_client/__main__.py (+136/-0) snapstore_client/logic/login.py (+36/-59) snapstore_client/logic/overrides.py (+79/-71) snapstore_client/logic/push.py (+0/-5) snapstore_client/logic/tests/test_login.py (+103/-265) snapstore_client/logic/tests/test_overrides.py (+106/-313) snapstore_client/logic/tests/utils.py (+42/-0) snapstore_client/utils.py (+0/-5) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Simon Davy (community) | Approve | ||
Review via email: mp+371039@code.launchpad.net |
Commit message
Integrate snapstore_library auth and overrides
Description of the change
Integrates the snapstore-library authentication and override
Tests have been simplified because snapstore-library has test coverage. Updated tests just cover functionality in CLI code.
snap/plugins/* should be ignore as it is just a copy of code from lp:snapstore-snap
There is a snapstore-library branch in lp:~siab/+git/siab-dependencies that can be used for testing.
CI integration has not been considered yet - a setup-jenkaas would need to be added to the Makefile and would need to pull siab-dependencies
snapstore-library review here https:/
Unmerged commits
- 9038b0a... by Tony Simpson
-
Removed dead code
- e980150... by Tony Simpson
-
Make build process more like snap-store-proxy
Borrow approach from snapstore-snap (snap-store-proxy).
snap/plugins/* is a copy of plugin from snap-store-proxy. - 54341a0... by Tony Simpson
-
Integrate snapstore_library auth and overrides
* Test have been rewritten, still requires a few more I think
* Dead code has not been removed yet
* Has not been user tested
Preview Diff
1 | diff --git a/Makefile b/Makefile | |||
2 | index d8f1bd4..2266b98 100644 | |||
3 | --- a/Makefile | |||
4 | +++ b/Makefile | |||
5 | @@ -1,15 +1,22 @@ | |||
10 | 1 | # In a global tmp dir for now so snapcraft doesn't make copies (see | 1 | FILE_DEPS = Makefile snap/snapcraft.yaml snapstore snapstore_client/**/*.py dependencies/*.* |
7 | 2 | # https://forum.snapcraft.io/t/customizable-ignores/230/12). | ||
8 | 3 | TMPDIR = /tmp/snapstore-client.tmp | ||
9 | 4 | |||
11 | 5 | SERVICE_PACKAGE = snapstore_client | 2 | SERVICE_PACKAGE = snapstore_client |
13 | 6 | ENV = $(TMPDIR)/env | 3 | ENV = env |
14 | 7 | PYTHON3 = $(ENV)/bin/python3 | 4 | PYTHON3 = $(ENV)/bin/python3 |
15 | 8 | PIP = $(PYTHON3) -m pip | 5 | PIP = $(PYTHON3) -m pip |
16 | 9 | FLAKE8 = $(ENV)/bin/flake8 | 6 | FLAKE8 = $(ENV)/bin/flake8 |
17 | 10 | 7 | ||
18 | 11 | DEPENDENCY_REPO ?= lp:~siab/+git/siab-dependencies | 8 | DEPENDENCY_REPO ?= lp:~siab/+git/siab-dependencies |
20 | 12 | SNAPSTORE_DEPENDENCY_DIR ?= $(TMPDIR)/dependencies | 9 | SNAPSTORE_DEPENDENCY_DIR = dependencies |
21 | 10 | |||
22 | 11 | SNAPCRAFT ?= snapcraft | ||
23 | 12 | VIRT := $(shell systemd-detect-virt) | ||
24 | 13 | ifeq ($(VIRT),lxc) | ||
25 | 14 | SNAPCRAFT_FLAGS ?= --destructive-mode | ||
26 | 15 | else | ||
27 | 16 | SNAPCRAFT_FLAGS ?= --use-lxd | ||
28 | 17 | endif | ||
29 | 18 | SNAPCRAFT_BUILD_ENVIRONMENT_CHANNEL_SNAPCRAFT ?= edge | ||
30 | 19 | export PATH := $(PATH):/snap/bin | ||
31 | 13 | 20 | ||
32 | 14 | 21 | ||
33 | 15 | $(SNAPSTORE_DEPENDENCY_DIR): | 22 | $(SNAPSTORE_DEPENDENCY_DIR): |
34 | @@ -26,8 +33,13 @@ $(ENV)/dev: $(ENV)/prod | |||
35 | 26 | 33 | ||
36 | 27 | bootstrap: $(ENV)/prod | 34 | bootstrap: $(ENV)/prod |
37 | 28 | 35 | ||
40 | 29 | snap: | 36 | snap-store-proxy-client.snap: $(SNAPSTORE_DEPENDENCY_DIR) $(FILE_DEPS) |
41 | 30 | snapcraft cleanbuild | 37 | SNAPCRAFT_BUILD_ENVIRONMENT_CHANNEL_SNAPCRAFT=${SNAPCRAFT_BUILD_ENVIRONMENT_CHANNEL_SNAPCRAFT} SNAPCRAFT_ENABLE_ERROR_REPORTING=0 $(SNAPCRAFT) snap $(SNAPCRAFT_FLAGS) --output snap-store-proxy-client.snap |
42 | 38 | |||
43 | 39 | snap: snap-store-proxy-client.snap | ||
44 | 40 | |||
45 | 41 | install: snap | ||
46 | 42 | sudo snap install snap-store-proxy-client.snap --dangerous | ||
47 | 31 | 43 | ||
48 | 32 | test: $(ENV)/dev | 44 | test: $(ENV)/dev |
49 | 33 | $(PYTHON3) -m unittest $(TESTS) 2>&1 | 45 | $(PYTHON3) -m unittest $(TESTS) 2>&1 |
50 | @@ -49,11 +61,19 @@ coverage: $(ENV)/dev | |||
51 | 49 | $(PYTHON3) -m coverage html | 61 | $(PYTHON3) -m coverage html |
52 | 50 | 62 | ||
53 | 51 | clean: | 63 | clean: |
55 | 52 | rm -rf $(TMPDIR) | 64 | ifneq (,$(findstring --destructive-mode, $(SNAPCRAFT_FLAGS))) |
56 | 65 | rm -fr parts | ||
57 | 66 | rm -fr stage | ||
58 | 67 | else | ||
59 | 68 | $(SNAPCRAFT) clean $(SNAPCRAFT_FLAGS) | ||
60 | 69 | endif | ||
61 | 70 | rm -rf env | ||
62 | 53 | rm -rf dist docs/build | 71 | rm -rf dist docs/build |
63 | 54 | rm -rf .coverage htmlcov | 72 | rm -rf .coverage htmlcov |
66 | 55 | find -name '__pycache__' -print0 | xargs -0 rm -rf | 73 | snapcraft clean |
67 | 56 | find -name '*.~*' -delete | 74 | |
68 | 75 | fullclean: clean | ||
69 | 76 | rm -rf dependencies | ||
70 | 57 | 77 | ||
71 | 58 | 78 | ||
73 | 59 | .PHONY: bootstrap test lint coverage clean snap docs | 79 | .PHONY: bootstrap test lint coverage clean fullclean snap install docs |
74 | diff --git a/requirements.txt b/requirements.txt | |||
75 | index 09ae15a..19a551f 100644 | |||
76 | --- a/requirements.txt | |||
77 | +++ b/requirements.txt | |||
78 | @@ -2,3 +2,4 @@ pymacaroons | |||
79 | 2 | pysha3 | 2 | pysha3 |
80 | 3 | pyxdg | 3 | pyxdg |
81 | 4 | requests | 4 | requests |
82 | 5 | snapstore_library | ||
83 | 5 | \ No newline at end of file | 6 | \ No newline at end of file |
84 | diff --git a/snap/plugins/_python/__init__.py b/snap/plugins/_python/__init__.py | |||
85 | 6 | new file mode 100644 | 7 | new file mode 100644 |
86 | index 0000000..6d6ad33 | |||
87 | --- /dev/null | |||
88 | +++ b/snap/plugins/_python/__init__.py | |||
89 | @@ -0,0 +1,19 @@ | |||
90 | 1 | # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- | ||
91 | 2 | # | ||
92 | 3 | # Copyright (C) 2017 Canonical Ltd | ||
93 | 4 | # | ||
94 | 5 | # This program is free software: you can redistribute it and/or modify | ||
95 | 6 | # it under the terms of the GNU General Public License version 3 as | ||
96 | 7 | # published by the Free Software Foundation. | ||
97 | 8 | # | ||
98 | 9 | # This program is distributed in the hope that it will be useful, | ||
99 | 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
100 | 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
101 | 12 | # GNU General Public License for more details. | ||
102 | 13 | # | ||
103 | 14 | # You should have received a copy of the GNU General Public License | ||
104 | 15 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
105 | 16 | |||
106 | 17 | from ._pip import Pip # noqa | ||
107 | 18 | from ._python_finder import get_python_command # noqa | ||
108 | 19 | from ._sitecustomize import generate_sitecustomize # noqa | ||
109 | diff --git a/snap/plugins/_python/_pip.py b/snap/plugins/_python/_pip.py | |||
110 | 0 | new file mode 100644 | 20 | new file mode 100644 |
111 | index 0000000..9acf3ba | |||
112 | --- /dev/null | |||
113 | +++ b/snap/plugins/_python/_pip.py | |||
114 | @@ -0,0 +1,575 @@ | |||
115 | 1 | # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- | ||
116 | 2 | # | ||
117 | 3 | # Copyright (C) 2016-2019 Canonical Ltd | ||
118 | 4 | # | ||
119 | 5 | # This program is free software: you can redistribute it and/or modify | ||
120 | 6 | # it under the terms of the GNU General Public License version 3 as | ||
121 | 7 | # published by the Free Software Foundation. | ||
122 | 8 | # | ||
123 | 9 | # This program is distributed in the hope that it will be useful, | ||
124 | 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
125 | 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
126 | 12 | # GNU General Public License for more details. | ||
127 | 13 | # | ||
128 | 14 | # You should have received a copy of the GNU General Public License | ||
129 | 15 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
130 | 16 | |||
131 | 17 | import collections | ||
132 | 18 | import contextlib | ||
133 | 19 | import json | ||
134 | 20 | import logging | ||
135 | 21 | import os | ||
136 | 22 | import re | ||
137 | 23 | import shutil | ||
138 | 24 | import stat | ||
139 | 25 | import subprocess | ||
140 | 26 | import sys | ||
141 | 27 | import tempfile | ||
142 | 28 | from typing import List, Optional, Sequence, Set | ||
143 | 29 | |||
144 | 30 | import snapcraft | ||
145 | 31 | from snapcraft import file_utils | ||
146 | 32 | from snapcraft.internal import mangling | ||
147 | 33 | from ._python_finder import get_python_command, get_python_headers, get_python_home | ||
148 | 34 | from . import errors | ||
149 | 35 | |||
150 | 36 | logger = logging.getLogger(__name__) | ||
151 | 37 | |||
152 | 38 | |||
153 | 39 | def _process_common_args( | ||
154 | 40 | *, constraints: Optional[Set[str]] = None, process_dependency_links: bool = False | ||
155 | 41 | ) -> List[str]: | ||
156 | 42 | args = [] | ||
157 | 43 | if constraints: | ||
158 | 44 | for constraint in constraints: | ||
159 | 45 | args.extend(["--constraint", constraint]) | ||
160 | 46 | |||
161 | 47 | if process_dependency_links: | ||
162 | 48 | args.append("--process-dependency-links") | ||
163 | 49 | |||
164 | 50 | return args | ||
165 | 51 | |||
166 | 52 | |||
167 | 53 | def _process_package_args( | ||
168 | 54 | *, packages: Sequence[str], requirements: Sequence[str], setup_py_dir: str | ||
169 | 55 | ) -> List[str]: | ||
170 | 56 | args = [] | ||
171 | 57 | if requirements: | ||
172 | 58 | for requirement in requirements: | ||
173 | 59 | args.extend(["--requirement", requirement]) | ||
174 | 60 | |||
175 | 61 | if packages: | ||
176 | 62 | args.extend(packages) | ||
177 | 63 | |||
178 | 64 | if setup_py_dir: | ||
179 | 65 | args.append(".") | ||
180 | 66 | |||
181 | 67 | return args | ||
182 | 68 | |||
183 | 69 | |||
184 | 70 | def _replicate_owner_mode(path): | ||
185 | 71 | # Don't bother with a path that doesn't exist or is a symlink. The target | ||
186 | 72 | # of the symlink will either be updated anyway, or we won't have permission | ||
187 | 73 | # to change it. | ||
188 | 74 | if not os.path.exists(path) or os.path.islink(path): | ||
189 | 75 | return | ||
190 | 76 | |||
191 | 77 | file_mode = os.stat(path).st_mode | ||
192 | 78 | |||
193 | 79 | # We at least need to write to it to fix shebangs later | ||
194 | 80 | new_mode = file_mode | stat.S_IWUSR | ||
195 | 81 | |||
196 | 82 | # If the owner can execute it, so should everyone else. | ||
197 | 83 | if file_mode & stat.S_IXUSR: | ||
198 | 84 | new_mode |= stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH | ||
199 | 85 | |||
200 | 86 | # If the owner can read it, so should everyone else | ||
201 | 87 | if file_mode & stat.S_IRUSR: | ||
202 | 88 | new_mode |= stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH | ||
203 | 89 | |||
204 | 90 | os.chmod(path, new_mode) | ||
205 | 91 | |||
206 | 92 | |||
207 | 93 | def _fix_permissions(path): | ||
208 | 94 | for root, dirs, files in os.walk(path): | ||
209 | 95 | for filename in files: | ||
210 | 96 | _replicate_owner_mode(os.path.join(root, filename)) | ||
211 | 97 | for dirname in dirs: | ||
212 | 98 | _replicate_owner_mode(os.path.join(root, dirname)) | ||
213 | 99 | |||
214 | 100 | |||
215 | 101 | def _expand_vars(text, project_dir): | ||
216 | 102 | text = text.replace('$SNAPCRAFT_PROJECT_DIR', project_dir) | ||
217 | 103 | text = text.replace('${SNAPCRAFT_PROJECT_DIR}', project_dir) | ||
218 | 104 | return text | ||
219 | 105 | |||
220 | 106 | |||
221 | 107 | class Pip: | ||
222 | 108 | """Wrapper for pip abstracting the args necessary for use in a part. | ||
223 | 109 | |||
224 | 110 | This class takes care of fetching pip, setuptools, and wheel, and then | ||
225 | 111 | simply shells out to pip with the magical arguments necessary to install | ||
226 | 112 | packages into a part. | ||
227 | 113 | |||
228 | 114 | Of particular importance: packages must be downloaded (via download()) | ||
229 | 115 | before they can be installed or have wheels built. | ||
230 | 116 | """ | ||
231 | 117 | |||
232 | 118 | def __init__(self, *, python_major_version, part_dir, install_dir, stage_dir, project_dir): | ||
233 | 119 | """Initialize pip. | ||
234 | 120 | |||
235 | 121 | You must call setup() before you can actually use pip. | ||
236 | 122 | |||
237 | 123 | :param str python_major_version: The python major version to find (2 or | ||
238 | 124 | 3) | ||
239 | 125 | :param str part_dir: Path to the part's working area | ||
240 | 126 | :param str install_dir: Path to the part's install area | ||
241 | 127 | :param str stage_dir: Path to the staging area | ||
242 | 128 | :param str project_dir: Path to the project area | ||
243 | 129 | |||
244 | 130 | :raises MissingPythonCommandError: If no python could be found in the | ||
245 | 131 | staging or part's install area. | ||
246 | 132 | """ | ||
247 | 133 | self._python_major_version = python_major_version | ||
248 | 134 | self._install_dir = install_dir | ||
249 | 135 | self._stage_dir = stage_dir | ||
250 | 136 | self._project_dir = project_dir | ||
251 | 137 | |||
252 | 138 | self._python_package_dir = os.path.join(part_dir, "python-packages") | ||
253 | 139 | os.makedirs(self._python_package_dir, exist_ok=True) | ||
254 | 140 | |||
255 | 141 | self.__python_command = None # type:str | ||
256 | 142 | self.__python_home = None # type: str | ||
257 | 143 | |||
258 | 144 | @property | ||
259 | 145 | def _python_command(self): | ||
260 | 146 | """Lazily determine the python command required.""" | ||
261 | 147 | if not self.__python_command: | ||
262 | 148 | self.__python_command = get_python_command( | ||
263 | 149 | self._python_major_version, | ||
264 | 150 | stage_dir=self._stage_dir, | ||
265 | 151 | install_dir=self._install_dir, | ||
266 | 152 | ) | ||
267 | 153 | return self.__python_command | ||
268 | 154 | |||
269 | 155 | @property | ||
270 | 156 | def _python_home(self): | ||
271 | 157 | """Lazily determine the correct python home.""" | ||
272 | 158 | if not self.__python_home: | ||
273 | 159 | self.__python_home = get_python_home( | ||
274 | 160 | self._python_major_version, | ||
275 | 161 | stage_dir=self._stage_dir, | ||
276 | 162 | install_dir=self._install_dir, | ||
277 | 163 | ) | ||
278 | 164 | return self.__python_home | ||
279 | 165 | |||
280 | 166 | def setup( | ||
281 | 167 | self, | ||
282 | 168 | no_index: bool = False, | ||
283 | 169 | find_links: Optional[Sequence[str]] = None | ||
284 | 170 | ): | ||
285 | 171 | """Install pip and dependencies. | ||
286 | 172 | |||
287 | 173 | Check to see if pip has already been installed. If not, fetch pip, | ||
288 | 174 | setuptools, and wheel, and install them so they can be used. | ||
289 | 175 | """ | ||
290 | 176 | |||
291 | 177 | self._ensure_pip_installed( | ||
292 | 178 | no_index=no_index, | ||
293 | 179 | find_links=find_links | ||
294 | 180 | ) | ||
295 | 181 | self._ensure_wheel_installed( | ||
296 | 182 | no_index=no_index, | ||
297 | 183 | find_links=find_links | ||
298 | 184 | ) | ||
299 | 185 | self._ensure_setuptools_installed( | ||
300 | 186 | no_index=no_index, | ||
301 | 187 | find_links=find_links | ||
302 | 188 | ) | ||
303 | 189 | |||
304 | 190 | def is_setup(self): | ||
305 | 191 | """Return true if this class has already been setup.""" | ||
306 | 192 | |||
307 | 193 | return ( | ||
308 | 194 | self._is_pip_installed() | ||
309 | 195 | and self._is_wheel_installed() | ||
310 | 196 | and self._is_setuptools_installed() | ||
311 | 197 | ) | ||
312 | 198 | |||
313 | 199 | def _ensure_pip_installed( | ||
314 | 200 | self, | ||
315 | 201 | no_index: bool = False, | ||
316 | 202 | find_links: Optional[Sequence[str]] = None | ||
317 | 203 | ): | ||
318 | 204 | # Check to see if we have our own pip. If not, we need to use the pip | ||
319 | 205 | # on the host (installed via build-packages) to grab our own. | ||
320 | 206 | if not self._is_pip_installed(): | ||
321 | 207 | logger.info("Fetching and installing pip...") | ||
322 | 208 | |||
323 | 209 | real_python_home = self.__python_home | ||
324 | 210 | |||
325 | 211 | # Make sure we're using pip from the host. Wrapping this operation | ||
326 | 212 | # in a try/finally to make sure we revert away from the host's | ||
327 | 213 | # python at the end. | ||
328 | 214 | try: | ||
329 | 215 | self.__python_home = os.path.join(os.path.sep, "usr") | ||
330 | 216 | |||
331 | 217 | # Using the host's pip, install our own pip | ||
332 | 218 | self.download( | ||
333 | 219 | {"pip"}, | ||
334 | 220 | no_index=no_index, | ||
335 | 221 | find_links=find_links | ||
336 | 222 | ) | ||
337 | 223 | self.install({"pip"}, ignore_installed=True) | ||
338 | 224 | finally: | ||
339 | 225 | # Now that we have our own pip, reset the python home | ||
340 | 226 | self.__python_home = real_python_home | ||
341 | 227 | |||
342 | 228 | def _ensure_wheel_installed( | ||
343 | 229 | self, | ||
344 | 230 | no_index: bool = False, | ||
345 | 231 | find_links: Optional[Sequence[str]] = None | ||
346 | 232 | ): | ||
347 | 233 | if not self._is_wheel_installed(): | ||
348 | 234 | logger.info("Fetching and installing wheel...") | ||
349 | 235 | self.download( | ||
350 | 236 | {"wheel"}, | ||
351 | 237 | no_index=no_index, | ||
352 | 238 | find_links=find_links | ||
353 | 239 | ) | ||
354 | 240 | self.install({"wheel"}, ignore_installed=True) | ||
355 | 241 | |||
356 | 242 | def _ensure_setuptools_installed( | ||
357 | 243 | self, | ||
358 | 244 | no_index: bool = False, | ||
359 | 245 | find_links: Optional[Sequence[str]] = None | ||
360 | 246 | ): | ||
361 | 247 | if not self._is_setuptools_installed(): | ||
362 | 248 | logger.info("Fetching and installing setuptools...") | ||
363 | 249 | self.download( | ||
364 | 250 | {"setuptools"}, | ||
365 | 251 | no_index=no_index, | ||
366 | 252 | find_links=find_links | ||
367 | 253 | ) | ||
368 | 254 | self.install({"setuptools"}, ignore_installed=True) | ||
369 | 255 | |||
370 | 256 | def _is_pip_installed(self): | ||
371 | 257 | try: | ||
372 | 258 | # We're expecting an error here at least once complaining about | ||
373 | 259 | # pip not being installed. In order to verify that the error is the | ||
374 | 260 | # one we think it is, we need to process the stderr. So we'll | ||
375 | 261 | # redirect it to stdout. If it's not the error we expect, something | ||
376 | 262 | # is wrong, so re-raise it. | ||
377 | 263 | # | ||
378 | 264 | # Using _run_output here so stdout doesn't get printed to the | ||
379 | 265 | # terminal. | ||
380 | 266 | self._run_output([], stderr=subprocess.STDOUT) | ||
381 | 267 | except subprocess.CalledProcessError as e: | ||
382 | 268 | output = e.output.decode(sys.getfilesystemencoding()).strip() | ||
383 | 269 | if "no module named pip" in output.lower(): | ||
384 | 270 | return False | ||
385 | 271 | else: | ||
386 | 272 | raise e | ||
387 | 273 | return True | ||
388 | 274 | |||
389 | 275 | def _is_wheel_installed(self): | ||
390 | 276 | return "wheel" in self.list() | ||
391 | 277 | |||
392 | 278 | def _is_setuptools_installed(self): | ||
393 | 279 | return "setuptools" in self.list() | ||
394 | 280 | |||
395 | 281 | def download( | ||
396 | 282 | self, | ||
397 | 283 | packages, | ||
398 | 284 | *, | ||
399 | 285 | setup_py_dir: Optional[str] = None, | ||
400 | 286 | constraints: Optional[Set[str]] = None, | ||
401 | 287 | requirements: Optional[Sequence[str]] = None, | ||
402 | 288 | process_dependency_links: bool = False, | ||
403 | 289 | no_index: bool = False, | ||
404 | 290 | find_links: Optional[Sequence[str]] = None | ||
405 | 291 | ): | ||
406 | 292 | """Download packages into cache, but do not install them. | ||
407 | 293 | |||
408 | 294 | :param iterable packages: Packages to download from index. | ||
409 | 295 | :param str setup_py_dir: Directory containing setup.py. | ||
410 | 296 | :param iterable constraints: Collection of paths to constraints files. | ||
411 | 297 | :param iterable requirements: Collection of paths to requirements | ||
412 | 298 | files. | ||
413 | 299 | :param boolean process_dependency_links: Enable the processing of | ||
414 | 300 | dependency links. | ||
415 | 301 | :param boolean no_index: Don't hit PyPI | ||
416 | 302 | :param iterable find_links: Locations of additional repositories | ||
417 | 303 | """ | ||
418 | 304 | package_args = _process_package_args( | ||
419 | 305 | packages=packages, requirements=requirements, setup_py_dir=setup_py_dir | ||
420 | 306 | ) | ||
421 | 307 | |||
422 | 308 | if not package_args: | ||
423 | 309 | return # No operation was requested | ||
424 | 310 | |||
425 | 311 | args = _process_common_args( | ||
426 | 312 | process_dependency_links=process_dependency_links, constraints=constraints | ||
427 | 313 | ) | ||
428 | 314 | if no_index: | ||
429 | 315 | args.append('--no-index') | ||
430 | 316 | if find_links: | ||
431 | 317 | for fl_item in find_links: | ||
432 | 318 | args.append('--find-links') | ||
433 | 319 | args.append(_expand_vars(fl_item, self._project_dir)) | ||
434 | 320 | |||
435 | 321 | # Using pip with a few special parameters: | ||
436 | 322 | # | ||
437 | 323 | # --disable-pip-version-check: Don't whine if pip is out-of-date with | ||
438 | 324 | # the version on pypi. | ||
439 | 325 | # --dest: Download packages into the directory we've set aside for it. | ||
440 | 326 | # | ||
441 | 327 | # For cwd, setup_py_dir will be the actual directory we need to be in | ||
442 | 328 | # or None. | ||
443 | 329 | self._run( | ||
444 | 330 | [ | ||
445 | 331 | "download", | ||
446 | 332 | "--disable-pip-version-check", | ||
447 | 333 | "--dest", | ||
448 | 334 | self._python_package_dir, | ||
449 | 335 | ] | ||
450 | 336 | + args | ||
451 | 337 | + package_args, | ||
452 | 338 | cwd=setup_py_dir, | ||
453 | 339 | ) | ||
454 | 340 | |||
455 | 341 | def install( | ||
456 | 342 | self, | ||
457 | 343 | packages, | ||
458 | 344 | *, | ||
459 | 345 | setup_py_dir: Optional[str] = None, | ||
460 | 346 | constraints: Optional[Set[str]] = None, | ||
461 | 347 | requirements: Optional[Sequence[str]] = None, | ||
462 | 348 | process_dependency_links: bool = False, | ||
463 | 349 | upgrade: bool = False, | ||
464 | 350 | install_deps: bool = True, | ||
465 | 351 | ignore_installed: bool = False | ||
466 | 352 | ): | ||
467 | 353 | """Install packages from cache. | ||
468 | 354 | |||
469 | 355 | The packages should have already been downloaded via `download()`. | ||
470 | 356 | |||
471 | 357 | :param iterable packages: Packages to install from cache. | ||
472 | 358 | :param str setup_py_dir: Directory containing setup.py. | ||
473 | 359 | :param iterable constraints: Collection of paths to constraints files. | ||
474 | 360 | :param iterable requirements: Collection of paths to requirements | ||
475 | 361 | files. | ||
476 | 362 | :param boolean process_dependency_links: Enable the processing of | ||
477 | 363 | dependency links. | ||
478 | 364 | :param boolean upgrade: Recursively upgrade packages. | ||
479 | 365 | :param boolean install_deps: Install package dependencies. | ||
480 | 366 | :param boolean ignore_installed: Reinstall packages if they're already | ||
481 | 367 | installed | ||
482 | 368 | """ | ||
483 | 369 | package_args = _process_package_args( | ||
484 | 370 | packages=packages, requirements=requirements, setup_py_dir=setup_py_dir | ||
485 | 371 | ) | ||
486 | 372 | |||
487 | 373 | if not package_args: | ||
488 | 374 | return # No operation was requested | ||
489 | 375 | |||
490 | 376 | args = _process_common_args( | ||
491 | 377 | process_dependency_links=process_dependency_links, constraints=constraints | ||
492 | 378 | ) | ||
493 | 379 | |||
494 | 380 | if upgrade: | ||
495 | 381 | args.append("--upgrade") | ||
496 | 382 | |||
497 | 383 | if not install_deps: | ||
498 | 384 | args.append("--no-deps") | ||
499 | 385 | |||
500 | 386 | if ignore_installed: | ||
501 | 387 | args.append("--ignore-installed") | ||
502 | 388 | |||
503 | 389 | # Using pip with a few special parameters: | ||
504 | 390 | # | ||
505 | 391 | # --user: Install packages to PYTHONUSERBASE, which we've pointed to | ||
506 | 392 | # the installdir. | ||
507 | 393 | # --no-index: Don't hit pypi, assume the packages are already | ||
508 | 394 | # downloaded (i.e. by using `self.download()`) | ||
509 | 395 | # --find-links: Provide the directory into which the packages should | ||
510 | 396 | # have already been fetched | ||
511 | 397 | # | ||
512 | 398 | # For cwd, setup_py_dir will be the actual directory we need to be in | ||
513 | 399 | # or None. | ||
514 | 400 | self._run( | ||
515 | 401 | [ | ||
516 | 402 | "install", | ||
517 | 403 | "--user", | ||
518 | 404 | "--no-index", | ||
519 | 405 | "--find-links", | ||
520 | 406 | self._python_package_dir, | ||
521 | 407 | ] | ||
522 | 408 | + args | ||
523 | 409 | + package_args, | ||
524 | 410 | cwd=setup_py_dir, | ||
525 | 411 | ) | ||
526 | 412 | |||
527 | 413 | # Installing with --user results in a directory with 700 permissions. | ||
528 | 414 | # We need it a bit more open than that, so open it up. | ||
529 | 415 | _fix_permissions(self._install_dir) | ||
530 | 416 | |||
531 | 417 | # Fix all shebangs to use the in-snap python. | ||
532 | 418 | mangling.rewrite_python_shebangs(self._install_dir) | ||
533 | 419 | |||
534 | 420 | def wheel( | ||
535 | 421 | self, | ||
536 | 422 | packages, | ||
537 | 423 | *, | ||
538 | 424 | setup_py_dir: Optional[str] = None, | ||
539 | 425 | constraints: Optional[Set[str]] = None, | ||
540 | 426 | requirements: Optional[Sequence[str]] = None, | ||
541 | 427 | process_dependency_links: bool = False | ||
542 | 428 | ): | ||
543 | 429 | """Build wheels of packages in the cache. | ||
544 | 430 | |||
545 | 431 | The packages should have already been downloaded via `download()`. | ||
546 | 432 | |||
547 | 433 | :param iterable packages: Packages in cache for which to build wheels. | ||
548 | 434 | :param str setup_py_dir: Directory containing setup.py. | ||
549 | 435 | :param iterable constraints: Collection of paths to constraints files. | ||
550 | 436 | :param iterable requirements: Collection of paths to requirements | ||
551 | 437 | files. | ||
552 | 438 | :param boolean process_dependency_links: Enable the processing of | ||
553 | 439 | dependency links. | ||
554 | 440 | |||
555 | 441 | :return: List of paths to each wheel that was built. | ||
556 | 442 | :rtype: list | ||
557 | 443 | """ | ||
558 | 444 | package_args = _process_package_args( | ||
559 | 445 | packages=packages, requirements=requirements, setup_py_dir=setup_py_dir | ||
560 | 446 | ) | ||
561 | 447 | |||
562 | 448 | if not package_args: | ||
563 | 449 | return [] # No operation was requested | ||
564 | 450 | |||
565 | 451 | args = _process_common_args( | ||
566 | 452 | process_dependency_links=process_dependency_links, constraints=constraints | ||
567 | 453 | ) | ||
568 | 454 | |||
569 | 455 | wheels = [] # type: List[str] | ||
570 | 456 | with tempfile.TemporaryDirectory() as temp_dir: | ||
571 | 457 | |||
572 | 458 | # Using pip with a few special parameters: | ||
573 | 459 | # | ||
574 | 460 | # --no-index: Don't hit pypi, assume the packages are already | ||
575 | 461 | # downloaded (i.e. by using `self.download()`) | ||
576 | 462 | # --find-links: Provide the directory into which the packages | ||
577 | 463 | # should have already been fetched | ||
578 | 464 | # --wheel-dir: Build wheels into a temporary working area rather | ||
579 | 465 | # rather than cwd. We'll copy them over. FIXME: We can | ||
580 | 466 | # probably get away just building them in the package | ||
581 | 467 | # dir. Try that once this refactor has been validated. | ||
582 | 468 | self._run( | ||
583 | 469 | [ | ||
584 | 470 | "wheel", | ||
585 | 471 | "--no-index", | ||
586 | 472 | "--find-links", | ||
587 | 473 | self._python_package_dir, | ||
588 | 474 | "--wheel-dir", | ||
589 | 475 | temp_dir, | ||
590 | 476 | ] | ||
591 | 477 | + args | ||
592 | 478 | + package_args, | ||
593 | 479 | cwd=setup_py_dir, | ||
594 | 480 | ) | ||
595 | 481 | wheels = os.listdir(temp_dir) | ||
596 | 482 | for wheel in wheels: | ||
597 | 483 | file_utils.link_or_copy( | ||
598 | 484 | os.path.join(temp_dir, wheel), | ||
599 | 485 | os.path.join(self._python_package_dir, wheel), | ||
600 | 486 | ) | ||
601 | 487 | |||
602 | 488 | return [os.path.join(self._python_package_dir, wheel) for wheel in wheels] | ||
603 | 489 | |||
604 | 490 | def list(self, *, user=False): | ||
605 | 491 | """Determine which packages have been installed. | ||
606 | 492 | |||
607 | 493 | :param boolean user: Whether or not to limit results to user base. | ||
608 | 494 | |||
609 | 495 | :return: Dict of installed python packages and their versions | ||
610 | 496 | :rtype: dict | ||
611 | 497 | """ | ||
612 | 498 | command = ["list"] | ||
613 | 499 | if user: | ||
614 | 500 | command.append("--user") | ||
615 | 501 | |||
616 | 502 | packages = collections.OrderedDict() | ||
617 | 503 | try: | ||
618 | 504 | output = self._run_output(command + ["--format=json"]) | ||
619 | 505 | json_output = json.loads(output, object_pairs_hook=collections.OrderedDict) | ||
620 | 506 | except subprocess.CalledProcessError: | ||
621 | 507 | # --format requires a newer pip, so fall back to legacy output | ||
622 | 508 | output = self._run_output(command) | ||
623 | 509 | json_output = [] # type: List[Dict[str, str]] | ||
624 | 510 | version_regex = re.compile(r"\((.+)\)") | ||
625 | 511 | for line in output.splitlines(): | ||
626 | 512 | line = line.split() | ||
627 | 513 | m = version_regex.search(line[1]) | ||
628 | 514 | if not m: | ||
629 | 515 | raise errors.PipListInvalidLegacyFormatError(output) | ||
630 | 516 | json_output.append({"name": line[0], "version": m.group(1)}) | ||
631 | 517 | except json.decoder.JSONDecodeError as e: | ||
632 | 518 | raise errors.PipListInvalidJsonError(output) from e | ||
633 | 519 | |||
634 | 520 | for package in json_output: | ||
635 | 521 | if "name" not in package: | ||
636 | 522 | raise errors.PipListMissingFieldError("name", output) | ||
637 | 523 | if "version" not in package: | ||
638 | 524 | raise errors.PipListMissingFieldError("version", output) | ||
639 | 525 | packages[package["name"]] = package["version"] | ||
640 | 526 | return packages | ||
641 | 527 | |||
642 | 528 | def clean_packages(self): | ||
643 | 529 | """Remove the package cache.""" | ||
644 | 530 | with contextlib.suppress(FileNotFoundError): | ||
645 | 531 | shutil.rmtree(self._python_package_dir) | ||
646 | 532 | |||
647 | 533 | def env(self): | ||
648 | 534 | """The environment used by pip. | ||
649 | 535 | |||
650 | 536 | This function is only useful if you happen to need to call into pip's | ||
651 | 537 | environment without using the API otherwise made available here (e.g. | ||
652 | 538 | calling the setup.py directly instead of with pip). | ||
653 | 539 | |||
654 | 540 | :return: Dict of the environment necessary to use the pip contained | ||
655 | 541 | here. | ||
656 | 542 | :rtype: dict | ||
657 | 543 | """ | ||
658 | 544 | env = os.environ.copy() | ||
659 | 545 | env["PYTHONUSERBASE"] = self._install_dir | ||
660 | 546 | env["PYTHONHOME"] = self._python_home | ||
661 | 547 | |||
662 | 548 | env["PATH"] = "{}:{}".format( | ||
663 | 549 | os.path.join(self._install_dir, "usr", "bin"), os.path.expandvars("$PATH") | ||
664 | 550 | ) | ||
665 | 551 | |||
666 | 552 | headers = get_python_headers( | ||
667 | 553 | self._python_major_version, stage_dir=self._stage_dir | ||
668 | 554 | ) | ||
669 | 555 | if headers: | ||
670 | 556 | current_cppflags = env.get("CPPFLAGS", "") | ||
671 | 557 | env["CPPFLAGS"] = "-I{}".format(headers) | ||
672 | 558 | if current_cppflags: | ||
673 | 559 | env["CPPFLAGS"] = "{} {}".format(env["CPPFLAGS"], current_cppflags) | ||
674 | 560 | |||
675 | 561 | return env | ||
676 | 562 | |||
677 | 563 | def _run(self, args, runner=None, **kwargs): | ||
678 | 564 | env = self.env() | ||
679 | 565 | |||
680 | 566 | # Using None as the default value instead of common.run so we can mock | ||
681 | 567 | # common.run. | ||
682 | 568 | if runner is None: | ||
683 | 569 | runner = snapcraft.internal.common.run | ||
684 | 570 | return runner( | ||
685 | 571 | [self._python_command, "-m", "pip"] + list(args), env=env, **kwargs | ||
686 | 572 | ) | ||
687 | 573 | |||
688 | 574 | def _run_output(self, args, **kwargs): | ||
689 | 575 | return self._run(args, runner=snapcraft.internal.common.run_output, **kwargs) | ||
690 | diff --git a/snap/plugins/_python/_python_finder.py b/snap/plugins/_python/_python_finder.py | |||
691 | 0 | new file mode 100644 | 576 | new file mode 100644 |
692 | index 0000000..c677cd6 | |||
693 | --- /dev/null | |||
694 | +++ b/snap/plugins/_python/_python_finder.py | |||
695 | @@ -0,0 +1,104 @@ | |||
696 | 1 | # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- | ||
697 | 2 | # | ||
698 | 3 | # Copyright (C) 2017 Canonical Ltd | ||
699 | 4 | # | ||
700 | 5 | # This program is free software: you can redistribute it and/or modify | ||
701 | 6 | # it under the terms of the GNU General Public License version 3 as | ||
702 | 7 | # published by the Free Software Foundation. | ||
703 | 8 | # | ||
704 | 9 | # This program is distributed in the hope that it will be useful, | ||
705 | 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
706 | 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
707 | 12 | # GNU General Public License for more details. | ||
708 | 13 | # | ||
709 | 14 | # You should have received a copy of the GNU General Public License | ||
710 | 15 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
711 | 16 | |||
712 | 17 | import os | ||
713 | 18 | import glob | ||
714 | 19 | |||
715 | 20 | from . import errors | ||
716 | 21 | |||
717 | 22 | |||
718 | 23 | def get_python_command(python_major_version, *, stage_dir, install_dir): | ||
719 | 24 | """Find the python command to use, preferring staged over the part. | ||
720 | 25 | |||
721 | 26 | We prefer the staged python as opposed to the in-part python in order to | ||
722 | 27 | support one part that supplies python, with another part built `after` it | ||
723 | 28 | wanting to use its python. | ||
724 | 29 | |||
725 | 30 | :param str python_major_version: The python major version to find (2 or 3) | ||
726 | 31 | :param str stage_dir: Path to the staging area | ||
727 | 32 | :param str install_dir: Path to the part's install area | ||
728 | 33 | |||
729 | 34 | :return: Path to the python command that was found | ||
730 | 35 | :rtype: str | ||
731 | 36 | |||
732 | 37 | :raises MissingPythonCommandError: If no python could be found in the | ||
733 | 38 | staging or part's install area. | ||
734 | 39 | """ | ||
735 | 40 | python_command_name = "python{}".format(python_major_version) | ||
736 | 41 | python_command = os.path.join("usr", "bin", python_command_name) | ||
737 | 42 | staged_python = os.path.join(stage_dir, python_command) | ||
738 | 43 | part_python = os.path.join(install_dir, python_command) | ||
739 | 44 | |||
740 | 45 | if os.path.exists(staged_python): | ||
741 | 46 | return staged_python | ||
742 | 47 | elif os.path.exists(part_python): | ||
743 | 48 | return part_python | ||
744 | 49 | else: | ||
745 | 50 | raise errors.MissingPythonCommandError( | ||
746 | 51 | python_command_name, [stage_dir, install_dir] | ||
747 | 52 | ) | ||
748 | 53 | |||
749 | 54 | |||
750 | 55 | def get_python_headers(python_major_version, *, stage_dir): | ||
751 | 56 | """Find the python headers to use, if any, preferring staged over the host. | ||
752 | 57 | |||
753 | 58 | We want to make sure we use the headers from the staging area if available, | ||
754 | 59 | or we may end up building for an older python version than the one we | ||
755 | 60 | actually want to use. | ||
756 | 61 | |||
757 | 62 | :param str python_major_version: The python version to find (2 or 3) | ||
758 | 63 | :param str stage_dir: Path to the staging area | ||
759 | 64 | |||
760 | 65 | :return: Path to the python headers that were found ('' if none) | ||
761 | 66 | :rtype: str | ||
762 | 67 | """ | ||
763 | 68 | python_command_name = "python{}".format(python_major_version) | ||
764 | 69 | base_match = os.path.join("usr", "include", "{}*".format(python_command_name)) | ||
765 | 70 | staged_python = glob.glob(os.path.join(stage_dir, base_match)) | ||
766 | 71 | host_python = glob.glob(os.path.join(os.path.sep, base_match)) | ||
767 | 72 | |||
768 | 73 | if staged_python: | ||
769 | 74 | return staged_python[0] | ||
770 | 75 | elif host_python: | ||
771 | 76 | return host_python[0] | ||
772 | 77 | else: | ||
773 | 78 | return "" | ||
774 | 79 | |||
775 | 80 | |||
776 | 81 | def get_python_home(python_major_version, *, stage_dir, install_dir): | ||
777 | 82 | """Find the correct PYTHONHOME, preferring staged over the part. | ||
778 | 83 | |||
779 | 84 | We prefer the staged python as opposed to the in-part python in order to | ||
780 | 85 | support one part that supplies python, with another part built `after` it | ||
781 | 86 | wanting to use its python. | ||
782 | 87 | |||
783 | 88 | :param str python_major_version: The python major version to find (2 or 3) | ||
784 | 89 | :param str stage_dir: Path to the staging area | ||
785 | 90 | :param str install_dir: Path to the part's install area | ||
786 | 91 | |||
787 | 92 | :return: Path to the PYTHONHOME that was found | ||
788 | 93 | :rtype: str | ||
789 | 94 | |||
790 | 95 | :raises MissingPythonCommandError: If no python could be found in the | ||
791 | 96 | staging or part's install area. | ||
792 | 97 | """ | ||
793 | 98 | python_command = get_python_command( | ||
794 | 99 | python_major_version, stage_dir=stage_dir, install_dir=install_dir | ||
795 | 100 | ) | ||
796 | 101 | if python_command.startswith(stage_dir): | ||
797 | 102 | return os.path.join(stage_dir, "usr") | ||
798 | 103 | else: | ||
799 | 104 | return os.path.join(install_dir, "usr") | ||
800 | diff --git a/snap/plugins/_python/_sitecustomize.py b/snap/plugins/_python/_sitecustomize.py | |||
801 | 0 | new file mode 100644 | 105 | new file mode 100644 |
802 | index 0000000..aed89ea | |||
803 | --- /dev/null | |||
804 | +++ b/snap/plugins/_python/_sitecustomize.py | |||
805 | @@ -0,0 +1,124 @@ | |||
806 | 1 | # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- | ||
807 | 2 | # | ||
808 | 3 | # Copyright (C) 2017 Canonical Ltd | ||
809 | 4 | # | ||
810 | 5 | # This program is free software: you can redistribute it and/or modify | ||
811 | 6 | # it under the terms of the GNU General Public License version 3 as | ||
812 | 7 | # published by the Free Software Foundation. | ||
813 | 8 | # | ||
814 | 9 | # This program is distributed in the hope that it will be useful, | ||
815 | 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
816 | 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
817 | 12 | # GNU General Public License for more details. | ||
818 | 13 | # | ||
819 | 14 | # You should have received a copy of the GNU General Public License | ||
820 | 15 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
821 | 16 | |||
822 | 17 | import contextlib | ||
823 | 18 | import glob | ||
824 | 19 | import os | ||
825 | 20 | from textwrap import dedent | ||
826 | 21 | |||
827 | 22 | from ._python_finder import get_python_command | ||
828 | 23 | from . import errors | ||
829 | 24 | |||
830 | 25 | _SITECUSTOMIZE_TEMPLATE = dedent( | ||
831 | 26 | """\ | ||
832 | 27 | import site | ||
833 | 28 | import os | ||
834 | 29 | |||
835 | 30 | snap_dir = os.getenv("SNAP") | ||
836 | 31 | snapcraft_stage_dir = os.getenv("SNAPCRAFT_STAGE") | ||
837 | 32 | snapcraft_part_install = os.getenv("SNAPCRAFT_PART_INSTALL") | ||
838 | 33 | |||
839 | 34 | for d in (snap_dir, snapcraft_stage_dir, snapcraft_part_install): | ||
840 | 35 | if d: | ||
841 | 36 | site_dir = os.path.join(d, "{site_dir}") | ||
842 | 37 | site.addsitedir(site_dir) | ||
843 | 38 | |||
844 | 39 | if snap_dir: | ||
845 | 40 | site.ENABLE_USER_SITE = False""" | ||
846 | 41 | ) | ||
847 | 42 | |||
848 | 43 | |||
849 | 44 | def _get_user_site_dir(python_major_version, *, install_dir): | ||
850 | 45 | path_glob = os.path.join( | ||
851 | 46 | install_dir, "lib", "python{}*".format(python_major_version), "site-packages" | ||
852 | 47 | ) | ||
853 | 48 | user_site_dirs = glob.glob(path_glob) | ||
854 | 49 | if not user_site_dirs: | ||
855 | 50 | raise errors.MissingUserSitePackagesError(path_glob) | ||
856 | 51 | |||
857 | 52 | return user_site_dirs[0][len(install_dir) + 1 :] | ||
858 | 53 | |||
859 | 54 | |||
860 | 55 | def _get_sitecustomize_path(python_major_version, *, stage_dir, install_dir): | ||
861 | 56 | # Use the part's install_dir unless there's a python in the staging area | ||
862 | 57 | base_dir = install_dir | ||
863 | 58 | with contextlib.suppress(errors.MissingPythonCommandError): | ||
864 | 59 | python_command = get_python_command( | ||
865 | 60 | python_major_version, stage_dir=stage_dir, install_dir=install_dir | ||
866 | 61 | ) | ||
867 | 62 | if python_command.startswith(stage_dir): | ||
868 | 63 | base_dir = stage_dir | ||
869 | 64 | |||
870 | 65 | site_py_glob = os.path.join( | ||
871 | 66 | base_dir, "usr", "lib", "python{}*".format(python_major_version), "site.py" | ||
872 | 67 | ) | ||
873 | 68 | python_sites = glob.glob(site_py_glob) | ||
874 | 69 | if not python_sites: | ||
875 | 70 | raise errors.MissingSitePyError(site_py_glob) | ||
876 | 71 | |||
877 | 72 | python_site_dir = os.path.dirname(python_sites[0]) | ||
878 | 73 | |||
879 | 74 | return os.path.join( | ||
880 | 75 | install_dir, python_site_dir[len(base_dir) + 1 :], "sitecustomize.py" | ||
881 | 76 | ) | ||
882 | 77 | |||
883 | 78 | |||
884 | 79 | def generate_sitecustomize(python_major_version, *, stage_dir, install_dir): | ||
885 | 80 | """Generate a sitecustomize.py to look in staging, part install, and snap. | ||
886 | 81 | |||
887 | 82 | This is done by checking the values of the environment variables $SNAP, | ||
888 | 83 | $SNAPCRAFT_STAGE, and $SNAPCRAFT_PART_INSTALL. As a result, the same | ||
889 | 84 | sitecustomize.py works to find packages at both build- and run-time. | ||
890 | 85 | |||
891 | 86 | :param str python_major_version: The python major version to use (2 or 3) | ||
892 | 87 | :param str stage_dir: Path to the staging area | ||
893 | 88 | :param str install_dir: Path to the part's install area | ||
894 | 89 | |||
895 | 90 | :raises MissingUserSitePackagesError: If no user site packages are found in | ||
896 | 91 | install_dir/lib/pythonX* | ||
897 | 92 | :raises MissingSitePyError: If no site.py can be found in either the | ||
898 | 93 | staging area or the part install area. | ||
899 | 94 | """ | ||
900 | 95 | sitecustomize_path = _get_sitecustomize_path( | ||
901 | 96 | python_major_version, stage_dir=stage_dir, install_dir=install_dir | ||
902 | 97 | ) | ||
903 | 98 | os.makedirs(os.path.dirname(sitecustomize_path), exist_ok=True) | ||
904 | 99 | |||
905 | 100 | # There may very well already be a sitecustomize.py already there. If so, | ||
906 | 101 | # get rid of it. Is may be a symlink to another sitecustomize.py, in which | ||
907 | 102 | # case, we'll get rid of that one as well. | ||
908 | 103 | if os.path.islink(sitecustomize_path): | ||
909 | 104 | target_path = os.path.realpath(sitecustomize_path) | ||
910 | 105 | |||
911 | 106 | # Only remove the target if it's contained within the install directory | ||
912 | 107 | if target_path.startswith(os.path.abspath(install_dir) + os.sep): | ||
913 | 108 | with contextlib.suppress(FileNotFoundError): | ||
914 | 109 | os.remove(target_path) | ||
915 | 110 | |||
916 | 111 | with contextlib.suppress(FileNotFoundError): | ||
917 | 112 | os.remove(sitecustomize_path) | ||
918 | 113 | |||
919 | 114 | # Create our sitecustomize. Python from the archives already has one | ||
920 | 115 | # which is distro-specific and not needed here, so we truncate it if it's | ||
921 | 116 | # already there. | ||
922 | 117 | with open(sitecustomize_path, "w") as f: | ||
923 | 118 | f.write( | ||
924 | 119 | _SITECUSTOMIZE_TEMPLATE.format( | ||
925 | 120 | site_dir=_get_user_site_dir( | ||
926 | 121 | python_major_version, install_dir=install_dir | ||
927 | 122 | ) | ||
928 | 123 | ) | ||
929 | 124 | ) | ||
930 | diff --git a/snap/plugins/_python/errors.py b/snap/plugins/_python/errors.py | |||
931 | 0 | new file mode 100644 | 125 | new file mode 100644 |
932 | index 0000000..87fbcfb | |||
933 | --- /dev/null | |||
934 | +++ b/snap/plugins/_python/errors.py | |||
935 | @@ -0,0 +1,79 @@ | |||
936 | 1 | # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- | ||
937 | 2 | # | ||
938 | 3 | # Copyright (C) 2017 Canonical Ltd | ||
939 | 4 | # | ||
940 | 5 | # This program is free software: you can redistribute it and/or modify | ||
941 | 6 | # it under the terms of the GNU General Public License version 3 as | ||
942 | 7 | # published by the Free Software Foundation. | ||
943 | 8 | # | ||
944 | 9 | # This program is distributed in the hope that it will be useful, | ||
945 | 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
946 | 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
947 | 12 | # GNU General Public License for more details. | ||
948 | 13 | # | ||
949 | 14 | # You should have received a copy of the GNU General Public License | ||
950 | 15 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
951 | 16 | |||
952 | 17 | import snapcraft.internal.errors | ||
953 | 18 | import snapcraft.formatting_utils | ||
954 | 19 | |||
955 | 20 | |||
956 | 21 | class PythonPluginError(snapcraft.internal.errors.SnapcraftError): | ||
957 | 22 | pass | ||
958 | 23 | |||
959 | 24 | |||
960 | 25 | class MissingPythonCommandError(PythonPluginError): | ||
961 | 26 | |||
962 | 27 | fmt = "Unable to find {python_version}, searched: {search_paths}" | ||
963 | 28 | |||
964 | 29 | def __init__(self, python_version, search_paths): | ||
965 | 30 | super().__init__( | ||
966 | 31 | python_version=python_version, | ||
967 | 32 | search_paths=snapcraft.formatting_utils.combine_paths( | ||
968 | 33 | search_paths, "", ":" | ||
969 | 34 | ), | ||
970 | 35 | ) | ||
971 | 36 | |||
972 | 37 | |||
973 | 38 | class MissingUserSitePackagesError(PythonPluginError): | ||
974 | 39 | |||
975 | 40 | fmt = "Unable to find user site packages: {site_dir_glob}" | ||
976 | 41 | |||
977 | 42 | def __init__(self, site_dir_glob): | ||
978 | 43 | super().__init__(site_dir_glob=site_dir_glob) | ||
979 | 44 | |||
980 | 45 | |||
981 | 46 | class MissingSitePyError(PythonPluginError): | ||
982 | 47 | |||
983 | 48 | fmt = "Unable to find site.py: {site_py_glob}" | ||
984 | 49 | |||
985 | 50 | def __init__(self, site_py_glob): | ||
986 | 51 | super().__init__(site_py_glob=site_py_glob) | ||
987 | 52 | |||
988 | 53 | |||
989 | 54 | class PipListInvalidLegacyFormatError(PythonPluginError): | ||
990 | 55 | |||
991 | 56 | fmt = ( | ||
992 | 57 | "Failed to parse Python package list: " | ||
993 | 58 | "The returned output is not in the expected format:\n" | ||
994 | 59 | "{output}" | ||
995 | 60 | ) | ||
996 | 61 | |||
997 | 62 | def __init__(self, output): | ||
998 | 63 | super().__init__(output=output) | ||
999 | 64 | |||
1000 | 65 | |||
1001 | 66 | class PipListInvalidJsonError(PythonPluginError): | ||
1002 | 67 | |||
1003 | 68 | fmt = "Pip packages output isn't valid json: {json!r}" | ||
1004 | 69 | |||
1005 | 70 | def __init__(self, json): | ||
1006 | 71 | super().__init__(json=json) | ||
1007 | 72 | |||
1008 | 73 | |||
1009 | 74 | class PipListMissingFieldError(PythonPluginError): | ||
1010 | 75 | |||
1011 | 76 | fmt = "Pip packages json missing {field!r} field: {json!r}" | ||
1012 | 77 | |||
1013 | 78 | def __init__(self, field, json): | ||
1014 | 79 | super().__init__(field=field, json=json) | ||
1015 | diff --git a/snap/plugins/python_ols.py b/snap/plugins/python_ols.py | |||
1016 | 0 | new file mode 100644 | 80 | new file mode 100644 |
1017 | index 0000000..95598d1 | |||
1018 | --- /dev/null | |||
1019 | +++ b/snap/plugins/python_ols.py | |||
1020 | @@ -0,0 +1,499 @@ | |||
1021 | 1 | # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- | ||
1022 | 2 | # | ||
1023 | 3 | # Copyright (C) 2016-2018 Canonical Ltd | ||
1024 | 4 | # | ||
1025 | 5 | # This program is free software: you can redistribute it and/or modify | ||
1026 | 6 | # it under the terms of the GNU General Public License version 3 as | ||
1027 | 7 | # published by the Free Software Foundation. | ||
1028 | 8 | # | ||
1029 | 9 | # This program is distributed in the hope that it will be useful, | ||
1030 | 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
1031 | 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
1032 | 12 | # GNU General Public License for more details. | ||
1033 | 13 | # | ||
1034 | 14 | # You should have received a copy of the GNU General Public License | ||
1035 | 15 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
1036 | 16 | |||
1037 | 17 | """The python plugin can be used for python 2 or 3 based parts. | ||
1038 | 18 | |||
1039 | 19 | It can be used for python projects where you would want to do: | ||
1040 | 20 | |||
1041 | 21 | - import python modules with a requirements.txt | ||
1042 | 22 | - build a python project that has a setup.py | ||
1043 | 23 | - install packages straight from pip | ||
1044 | 24 | |||
1045 | 25 | This plugin uses the common plugin keywords as well as those for "sources". | ||
1046 | 26 | For more information check the 'plugins' topic for the former and the | ||
1047 | 27 | 'sources' topic for the latter. | ||
1048 | 28 | |||
1049 | 29 | Additionally, this plugin uses the following plugin-specific keywords: | ||
1050 | 30 | |||
1051 | 31 | - requirements: | ||
1052 | 32 | (list of strings) | ||
1053 | 33 | List of paths to requirements files. | ||
1054 | 34 | - constraints: | ||
1055 | 35 | (list of strings) | ||
1056 | 36 | List of paths to constraint files. | ||
1057 | 37 | - process-dependency-links: | ||
1058 | 38 | (bool; default: false) | ||
1059 | 39 | Enable the processing of dependency links in pip, which allow one | ||
1060 | 40 | project to provide places to look for another project | ||
1061 | 41 | - python-packages: | ||
1062 | 42 | (list) | ||
1063 | 43 | A list of dependencies to get from PyPI | ||
1064 | 44 | - python-version: | ||
1065 | 45 | (string; default: python3) | ||
1066 | 46 | The python version to use. Valid options are: python2 and python3 | ||
1067 | 47 | |||
1068 | 48 | If the plugin finds a python interpreter with a basename that matches | ||
1069 | 49 | `python-version` in the <stage> directory on the following fixed path: | ||
1070 | 50 | `<stage-dir>/usr/bin/<python-interpreter>` then this interpreter would | ||
1071 | 51 | be preferred instead and no interpreter would be brought in through | ||
1072 | 52 | `stage-packages` mechanisms. | ||
1073 | 53 | """ | ||
1074 | 54 | |||
1075 | 55 | import collections | ||
1076 | 56 | import contextlib | ||
1077 | 57 | import os | ||
1078 | 58 | import re | ||
1079 | 59 | from shutil import which | ||
1080 | 60 | from textwrap import dedent | ||
1081 | 61 | from typing import List, Set | ||
1082 | 62 | |||
1083 | 63 | import requests | ||
1084 | 64 | |||
1085 | 65 | import snapcraft | ||
1086 | 66 | from snapcraft.common import isurl | ||
1087 | 67 | from snapcraft.internal import errors, mangling | ||
1088 | 68 | from snapcraft.internal.errors import SnapcraftPluginCommandError | ||
1089 | 69 | import _python | ||
1090 | 70 | |||
1091 | 71 | |||
1092 | 72 | class UnsupportedPythonVersionError(snapcraft.internal.errors.SnapcraftError): | ||
1093 | 73 | |||
1094 | 74 | fmt = "Unsupported python version: {python_version!r}" | ||
1095 | 75 | |||
1096 | 76 | |||
1097 | 77 | class SnapcraftPluginPythonFileMissing(snapcraft.internal.errors.SnapcraftError): | ||
1098 | 78 | |||
1099 | 79 | fmt = ( | ||
1100 | 80 | "Failed to find the referred {plugin_property} file at the given " | ||
1101 | 81 | "path: {plugin_property_value!r}.\n" | ||
1102 | 82 | "Check the property and ensure the file exists." | ||
1103 | 83 | ) | ||
1104 | 84 | |||
1105 | 85 | def __init__(self, *, plugin_property, plugin_property_value): | ||
1106 | 86 | super().__init__( | ||
1107 | 87 | plugin_property=plugin_property, plugin_property_value=plugin_property_value | ||
1108 | 88 | ) | ||
1109 | 89 | |||
1110 | 90 | |||
1111 | 91 | class PythonPlugin(snapcraft.BasePlugin): | ||
1112 | 92 | @classmethod | ||
1113 | 93 | def schema(cls): | ||
1114 | 94 | schema = super().schema() | ||
1115 | 95 | schema["properties"]["requirements"] = { | ||
1116 | 96 | "type": "array", | ||
1117 | 97 | "minitems": 1, | ||
1118 | 98 | "uniqueItems": True, | ||
1119 | 99 | "items": {"type": "string"}, | ||
1120 | 100 | "default": [], | ||
1121 | 101 | } | ||
1122 | 102 | schema["properties"]["constraints"] = { | ||
1123 | 103 | "type": "array", | ||
1124 | 104 | "minitems": 1, | ||
1125 | 105 | "uniqueItems": True, | ||
1126 | 106 | "items": {"type": "string"}, | ||
1127 | 107 | "default": [], | ||
1128 | 108 | } | ||
1129 | 109 | schema["properties"]["python-packages"] = { | ||
1130 | 110 | "type": "array", | ||
1131 | 111 | "minitems": 1, | ||
1132 | 112 | "uniqueItems": True, | ||
1133 | 113 | "items": {"type": "string"}, | ||
1134 | 114 | "default": [], | ||
1135 | 115 | } | ||
1136 | 116 | schema["properties"]["process-dependency-links"] = { | ||
1137 | 117 | "type": "boolean", | ||
1138 | 118 | "default": False, | ||
1139 | 119 | } | ||
1140 | 120 | schema["properties"]["python-version"] = { | ||
1141 | 121 | "type": "string", | ||
1142 | 122 | "default": "python3", | ||
1143 | 123 | "enum": ["python2", "python3"], | ||
1144 | 124 | } | ||
1145 | 125 | schema["properties"]["pip-no-index"] = { | ||
1146 | 126 | "type": "boolean", | ||
1147 | 127 | "default": False, | ||
1148 | 128 | } | ||
1149 | 129 | schema["properties"]["pip-find-links"] = { | ||
1150 | 130 | "type": "array", | ||
1151 | 131 | "items": {"type": "string"}, | ||
1152 | 132 | "default": [], | ||
1153 | 133 | } | ||
1154 | 134 | schema["anyOf"] = [{"required": ["source"]}, {"required": ["python-packages"]}] | ||
1155 | 135 | |||
1156 | 136 | return schema | ||
1157 | 137 | |||
1158 | 138 | @classmethod | ||
1159 | 139 | def get_pull_properties(cls): | ||
1160 | 140 | # Inform Snapcraft of the properties associated with pulling. If these | ||
1161 | 141 | # change in the YAML Snapcraft will consider the pull step dirty. | ||
1162 | 142 | return [ | ||
1163 | 143 | "requirements", | ||
1164 | 144 | "constraints", | ||
1165 | 145 | "python-packages", | ||
1166 | 146 | "process-dependency-links", | ||
1167 | 147 | "python-version", | ||
1168 | 148 | "pip-no-index", | ||
1169 | 149 | "pip-find-links", | ||
1170 | 150 | ] | ||
1171 | 151 | |||
1172 | 152 | @property | ||
1173 | 153 | def plugin_stage_packages(self): | ||
1174 | 154 | if self.options.python_version == "python2": | ||
1175 | 155 | python_base = "python" | ||
1176 | 156 | elif self.options.python_version == "python3": | ||
1177 | 157 | python_base = "python3" | ||
1178 | 158 | |||
1179 | 159 | if self.project.info.base in ("core", "core16", "core18"): | ||
1180 | 160 | stage_packages = [python_base] | ||
1181 | 161 | else: | ||
1182 | 162 | stage_packages = [] | ||
1183 | 163 | |||
1184 | 164 | if self.project.info.base == "core18" and python_base == "python3": | ||
1185 | 165 | stage_packages.append("{}-distutils".format(python_base)) | ||
1186 | 166 | |||
1187 | 167 | return stage_packages | ||
1188 | 168 | |||
1189 | 169 | # ignore mypy error: Read-only property cannot override read-write property | ||
1190 | 170 | @property # type: ignore | ||
1191 | 171 | def stage_packages(self): | ||
1192 | 172 | try: | ||
1193 | 173 | _python.get_python_command( | ||
1194 | 174 | self._python_major_version, | ||
1195 | 175 | stage_dir=self.project.stage_dir, | ||
1196 | 176 | install_dir=self.installdir, | ||
1197 | 177 | ) | ||
1198 | 178 | except _python.errors.MissingPythonCommandError: | ||
1199 | 179 | return super().stage_packages + self.plugin_stage_packages | ||
1200 | 180 | else: | ||
1201 | 181 | return super().stage_packages | ||
1202 | 182 | |||
1203 | 183 | @property | ||
1204 | 184 | def _pip(self): | ||
1205 | 185 | if not self.__pip: | ||
1206 | 186 | self.__pip = _python.Pip( | ||
1207 | 187 | python_major_version=self._python_major_version, | ||
1208 | 188 | part_dir=self.partdir, | ||
1209 | 189 | install_dir=self.installdir, | ||
1210 | 190 | stage_dir=self.project.stage_dir, | ||
1211 | 191 | project_dir=self.project._project_dir, | ||
1212 | 192 | ) | ||
1213 | 193 | return self.__pip | ||
1214 | 194 | |||
1215 | 195 | def __init__(self, name, options, project): | ||
1216 | 196 | super().__init__(name, options, project) | ||
1217 | 197 | |||
1218 | 198 | self._setup_base_tools(project.info.base) | ||
1219 | 199 | |||
1220 | 200 | self._manifest = collections.OrderedDict() | ||
1221 | 201 | |||
1222 | 202 | # Pip requires only the major version of python rather than the command | ||
1223 | 203 | # name like our option requires. | ||
1224 | 204 | match = re.match(r"python(?P<major_version>\d).*", self.options.python_version) | ||
1225 | 205 | if not match: | ||
1226 | 206 | raise UnsupportedPythonVersionError( | ||
1227 | 207 | python_version=self.options.python_version | ||
1228 | 208 | ) | ||
1229 | 209 | |||
1230 | 210 | self._python_major_version = match.group("major_version") | ||
1231 | 211 | self.__pip = None | ||
1232 | 212 | |||
1233 | 213 | def _setup_base_tools(self, base): | ||
1234 | 214 | # NOTE: stage-packages are lazily loaded. | ||
1235 | 215 | if base in ("core", "core16", "core18"): | ||
1236 | 216 | if self.options.python_version == "python3": | ||
1237 | 217 | self.build_packages.extend( | ||
1238 | 218 | [ | ||
1239 | 219 | "python3-dev", | ||
1240 | 220 | "python3-pip", | ||
1241 | 221 | "python3-pkg-resources", | ||
1242 | 222 | "python3-setuptools", | ||
1243 | 223 | ] | ||
1244 | 224 | ) | ||
1245 | 225 | elif self.options.python_version == "python2": | ||
1246 | 226 | self.build_packages.extend( | ||
1247 | 227 | [ | ||
1248 | 228 | "python-dev", | ||
1249 | 229 | "python-pip", | ||
1250 | 230 | "python-pkg-resources", | ||
1251 | 231 | "python-setuptools", | ||
1252 | 232 | ] | ||
1253 | 233 | ) | ||
1254 | 234 | else: | ||
1255 | 235 | raise errors.PluginBaseError(part_name=self.name, base=base) | ||
1256 | 236 | |||
1257 | 237 | def pull(self): | ||
1258 | 238 | super().pull() | ||
1259 | 239 | |||
1260 | 240 | self._pip.setup( | ||
1261 | 241 | no_index=self.options.pip_no_index, | ||
1262 | 242 | find_links=self.options.pip_find_links | ||
1263 | 243 | ) | ||
1264 | 244 | |||
1265 | 245 | with simple_env_bzr(os.path.join(self.installdir, "bin")): | ||
1266 | 246 | # Download this project, using its setup.py if present. This will | ||
1267 | 247 | # also download any python-packages requested. | ||
1268 | 248 | self._download_project() | ||
1269 | 249 | |||
1270 | 250 | def clean_pull(self): | ||
1271 | 251 | super().clean_pull() | ||
1272 | 252 | self._pip.clean_packages() | ||
1273 | 253 | |||
1274 | 254 | def build(self): | ||
1275 | 255 | super().build() | ||
1276 | 256 | |||
1277 | 257 | with simple_env_bzr(os.path.join(self.installdir, "bin")): | ||
1278 | 258 | # Install the packages that have already been downloaded | ||
1279 | 259 | installed_pipy_packages = self._install_project() | ||
1280 | 260 | |||
1281 | 261 | requirements = self._get_list_of_packages_from_property( | ||
1282 | 262 | self.options.requirements | ||
1283 | 263 | ) | ||
1284 | 264 | if requirements: | ||
1285 | 265 | self._manifest["requirements-contents"] = requirements | ||
1286 | 266 | |||
1287 | 267 | constraints = self._get_list_of_packages_from_property(self.options.constraints) | ||
1288 | 268 | if constraints: | ||
1289 | 269 | self._manifest["constraints-contents"] = constraints | ||
1290 | 270 | |||
1291 | 271 | self._manifest["python-packages"] = [ | ||
1292 | 272 | "{}={}".format(name, installed_pipy_packages[name]) | ||
1293 | 273 | for name in installed_pipy_packages | ||
1294 | 274 | ] | ||
1295 | 275 | |||
1296 | 276 | _python.generate_sitecustomize( | ||
1297 | 277 | self._python_major_version, | ||
1298 | 278 | stage_dir=self.project.stage_dir, | ||
1299 | 279 | install_dir=self.installdir, | ||
1300 | 280 | ) | ||
1301 | 281 | |||
1302 | 282 | def _find_file(self, *, filename: str) -> str: | ||
1303 | 283 | # source-subdir defaults to '' | ||
1304 | 284 | for basepath in [self.builddir, self.sourcedir]: | ||
1305 | 285 | if basepath == self.sourcedir: | ||
1306 | 286 | # This is overwritten in the base plugin | ||
1307 | 287 | # TODO add consistency | ||
1308 | 288 | source_subdir = self.options.source_subdir | ||
1309 | 289 | else: | ||
1310 | 290 | source_subdir = "" | ||
1311 | 291 | filepath = os.path.join(basepath, source_subdir, filename) | ||
1312 | 292 | if os.path.exists(filepath): | ||
1313 | 293 | return filepath | ||
1314 | 294 | |||
1315 | 295 | return None | ||
1316 | 296 | |||
1317 | 297 | def _get_setup_py_dir(self): | ||
1318 | 298 | setup_py_dir = None | ||
1319 | 299 | setup_py_path = self._find_file(filename="setup.py") | ||
1320 | 300 | if setup_py_path: | ||
1321 | 301 | setup_py_dir = os.path.dirname(setup_py_path) | ||
1322 | 302 | |||
1323 | 303 | return setup_py_dir | ||
1324 | 304 | |||
1325 | 305 | def _get_list_of_packages_from_property(self, property_list: Set[str]) -> List[str]: | ||
1326 | 306 | """Return a sorted list of all packages found in property.""" | ||
1327 | 307 | package_list = list() # type: List[str] | ||
1328 | 308 | for entry in property_list: | ||
1329 | 309 | contents = self._get_file_contents(entry) | ||
1330 | 310 | package_list.extend(contents.splitlines()) | ||
1331 | 311 | return package_list | ||
1332 | 312 | |||
1333 | 313 | def _get_normalized_property_set( | ||
1334 | 314 | self, property_name, property_list: List[str] | ||
1335 | 315 | ) -> Set[str]: | ||
1336 | 316 | """Return a normalized set from a requirements or constraints list.""" | ||
1337 | 317 | normalized = set() # type: Set[str] | ||
1338 | 318 | for entry in property_list: | ||
1339 | 319 | if isurl(entry): | ||
1340 | 320 | normalized.add(entry) | ||
1341 | 321 | else: | ||
1342 | 322 | entry_file = self._find_file(filename=entry) | ||
1343 | 323 | if not entry_file: | ||
1344 | 324 | raise SnapcraftPluginPythonFileMissing( | ||
1345 | 325 | plugin_property=property_name, plugin_property_value=entry | ||
1346 | 326 | ) | ||
1347 | 327 | normalized.add(entry_file) | ||
1348 | 328 | |||
1349 | 329 | return normalized | ||
1350 | 330 | |||
1351 | 331 | def _install_wheels(self, wheels): | ||
1352 | 332 | installed = self._pip.list() | ||
1353 | 333 | wheel_names = [os.path.basename(w).split("-")[0] for w in wheels] | ||
1354 | 334 | # we want to avoid installing what is already provided in | ||
1355 | 335 | # stage-packages | ||
1356 | 336 | need_install = [k for k in wheel_names if k not in installed] | ||
1357 | 337 | self._pip.install( | ||
1358 | 338 | need_install, | ||
1359 | 339 | upgrade=True, | ||
1360 | 340 | install_deps=False, | ||
1361 | 341 | process_dependency_links=self.options.process_dependency_links, | ||
1362 | 342 | ) | ||
1363 | 343 | |||
1364 | 344 | def _download_project(self): | ||
1365 | 345 | constraints = self._get_normalized_property_set( | ||
1366 | 346 | "constraints", self.options.constraints | ||
1367 | 347 | ) | ||
1368 | 348 | requirements = self._get_normalized_property_set( | ||
1369 | 349 | "requirements", self.options.requirements | ||
1370 | 350 | ) | ||
1371 | 351 | |||
1372 | 352 | |||
1373 | 353 | self._pip.download( | ||
1374 | 354 | self.options.python_packages, | ||
1375 | 355 | setup_py_dir=None, | ||
1376 | 356 | constraints=constraints, | ||
1377 | 357 | requirements=requirements, | ||
1378 | 358 | process_dependency_links=self.options.process_dependency_links, | ||
1379 | 359 | no_index=self.options.pip_no_index, | ||
1380 | 360 | find_links=self.options.pip_find_links, | ||
1381 | 361 | ) | ||
1382 | 362 | |||
1383 | 363 | def _install_project(self): | ||
1384 | 364 | setup_py_dir = self._get_setup_py_dir() | ||
1385 | 365 | constraints = self._get_normalized_property_set( | ||
1386 | 366 | "constraints", self.options.constraints | ||
1387 | 367 | ) | ||
1388 | 368 | requirements = self._get_normalized_property_set( | ||
1389 | 369 | "requirements", self.options.requirements | ||
1390 | 370 | ) | ||
1391 | 371 | |||
1392 | 372 | # setup.py is handled in a different step as some projects may | ||
1393 | 373 | # need to satisfy dependencies for setup.py to be parsed. | ||
1394 | 374 | wheels = self._pip.wheel( | ||
1395 | 375 | self.options.python_packages, | ||
1396 | 376 | setup_py_dir=None, | ||
1397 | 377 | constraints=constraints, | ||
1398 | 378 | requirements=requirements, | ||
1399 | 379 | process_dependency_links=self.options.process_dependency_links, | ||
1400 | 380 | ) | ||
1401 | 381 | |||
1402 | 382 | if wheels: | ||
1403 | 383 | self._install_wheels(wheels) | ||
1404 | 384 | |||
1405 | 385 | if setup_py_dir is not None: | ||
1406 | 386 | self._pip.download( | ||
1407 | 387 | [], | ||
1408 | 388 | setup_py_dir=setup_py_dir, | ||
1409 | 389 | constraints=constraints, | ||
1410 | 390 | requirements=set(), | ||
1411 | 391 | process_dependency_links=self.options.process_dependency_links, | ||
1412 | 392 | no_index=self.options.pip_no_index, | ||
1413 | 393 | find_links=self.options.pip_find_links, | ||
1414 | 394 | ) | ||
1415 | 395 | wheels = self._pip.wheel( | ||
1416 | 396 | [], | ||
1417 | 397 | setup_py_dir=setup_py_dir, | ||
1418 | 398 | constraints=constraints, | ||
1419 | 399 | requirements=set(), | ||
1420 | 400 | process_dependency_links=self.options.process_dependency_links, | ||
1421 | 401 | ) | ||
1422 | 402 | |||
1423 | 403 | if wheels: | ||
1424 | 404 | self._install_wheels(wheels) | ||
1425 | 405 | |||
1426 | 406 | setup_py_path = os.path.join(setup_py_dir, "setup.py") | ||
1427 | 407 | if os.path.exists(setup_py_path): | ||
1428 | 408 | # pbr and others don't work using `pip install .` | ||
1429 | 409 | # LP: #1670852 | ||
1430 | 410 | # There is also a chance that this setup.py is distutils based | ||
1431 | 411 | # in which case we will rely on the `pip install .` ran before | ||
1432 | 412 | # this. | ||
1433 | 413 | with contextlib.suppress(SnapcraftPluginCommandError): | ||
1434 | 414 | self._setup_tools_install(setup_py_path) | ||
1435 | 415 | |||
1436 | 416 | return self._pip.list() | ||
1437 | 417 | |||
1438 | 418 | def _setup_tools_install(self, setup_file): | ||
1439 | 419 | command = [ | ||
1440 | 420 | _python.get_python_command( | ||
1441 | 421 | self._python_major_version, | ||
1442 | 422 | stage_dir=self.project.stage_dir, | ||
1443 | 423 | install_dir=self.installdir, | ||
1444 | 424 | ), | ||
1445 | 425 | os.path.basename(setup_file), | ||
1446 | 426 | "--no-user-cfg", | ||
1447 | 427 | "install", | ||
1448 | 428 | "--single-version-externally-managed", | ||
1449 | 429 | "--user", | ||
1450 | 430 | "--record", | ||
1451 | 431 | "install.txt", | ||
1452 | 432 | ] | ||
1453 | 433 | self.run(command, env=self._pip.env(), cwd=os.path.dirname(setup_file)) | ||
1454 | 434 | |||
1455 | 435 | # Fix all shebangs to use the in-snap python. The stuff installed from | ||
1456 | 436 | # pip has already been fixed, but anything done in this step has not. | ||
1457 | 437 | mangling.rewrite_python_shebangs(self.installdir) | ||
1458 | 438 | |||
1459 | 439 | def _get_file_contents(self, path): | ||
1460 | 440 | if isurl(path): | ||
1461 | 441 | return requests.get(path).text | ||
1462 | 442 | else: | ||
1463 | 443 | file_path = os.path.join(self.sourcedir, path) | ||
1464 | 444 | with open(file_path) as _file: | ||
1465 | 445 | return _file.read() | ||
1466 | 446 | |||
1467 | 447 | def get_manifest(self): | ||
1468 | 448 | return self._manifest | ||
1469 | 449 | |||
1470 | 450 | def snap_fileset(self): | ||
1471 | 451 | fileset = super().snap_fileset() | ||
1472 | 452 | fileset.append("-bin/pip") | ||
1473 | 453 | fileset.append("-bin/pip2") | ||
1474 | 454 | fileset.append("-bin/pip3") | ||
1475 | 455 | fileset.append("-bin/pip2.7") | ||
1476 | 456 | fileset.append("-bin/pip3.*") | ||
1477 | 457 | fileset.append("-bin/easy_install*") | ||
1478 | 458 | fileset.append("-bin/wheel") | ||
1479 | 459 | # The RECORD files include hashes useful when uninstalling packages. | ||
1480 | 460 | # In the snap they will cause conflicts when more than one part uses | ||
1481 | 461 | # the python plugin. | ||
1482 | 462 | fileset.append("-lib/python*/site-packages/*/RECORD") | ||
1483 | 463 | return fileset | ||
1484 | 464 | |||
1485 | 465 | |||
1486 | 466 | @contextlib.contextmanager | ||
1487 | 467 | def simple_env_bzr(bin_dir): | ||
1488 | 468 | """Create an appropriate environment to run bzr. | ||
1489 | 469 | |||
1490 | 470 | The python plugin sets up PYTHONUSERBASE and PYTHONHOME which | ||
1491 | 471 | conflicts with bzr when using python3 as those two environment | ||
1492 | 472 | variables will make bzr look for modules in the wrong location. | ||
1493 | 473 | """ | ||
1494 | 474 | os.makedirs(bin_dir, exist_ok=True) | ||
1495 | 475 | bzr_bin = os.path.join(bin_dir, "bzr") | ||
1496 | 476 | real_bzr_bin = which("bzr") | ||
1497 | 477 | if real_bzr_bin: | ||
1498 | 478 | exec_line = 'exec {} "$@"'.format(real_bzr_bin) | ||
1499 | 479 | else: | ||
1500 | 480 | exec_line = "echo bzr needs to be in PATH; exit 1" | ||
1501 | 481 | with open(bzr_bin, "w") as f: | ||
1502 | 482 | f.write( | ||
1503 | 483 | dedent( | ||
1504 | 484 | """#!/bin/sh | ||
1505 | 485 | unset PYTHONUSERBASE | ||
1506 | 486 | unset PYTHONHOME | ||
1507 | 487 | {} | ||
1508 | 488 | """.format( | ||
1509 | 489 | exec_line | ||
1510 | 490 | ) | ||
1511 | 491 | ) | ||
1512 | 492 | ) | ||
1513 | 493 | os.chmod(bzr_bin, 0o777) | ||
1514 | 494 | try: | ||
1515 | 495 | yield | ||
1516 | 496 | finally: | ||
1517 | 497 | os.remove(bzr_bin) | ||
1518 | 498 | if not os.listdir(bin_dir): | ||
1519 | 499 | os.rmdir(bin_dir) | ||
1520 | diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml | |||
1521 | index f234ee1..613affa 100644 | |||
1522 | --- a/snap/snapcraft.yaml | |||
1523 | +++ b/snap/snapcraft.yaml | |||
1524 | @@ -1,10 +1,10 @@ | |||
1525 | 1 | |||
1526 | 1 | name: snap-store-proxy-client | 2 | name: snap-store-proxy-client |
1528 | 2 | version: 1.0 | 3 | version: "1.0" |
1529 | 3 | summary: Canonical snap store proxy administration client. | 4 | summary: Canonical snap store proxy administration client. |
1530 | 4 | description: | | 5 | description: | |
1531 | 5 | The Canonical snapstore client is used to manage a snap store proxy. | 6 | The Canonical snapstore client is used to manage a snap store proxy. |
1534 | 6 | 7 | base: core | |
1533 | 7 | # Defaults, but snapcraft prints ugly yellow messages without them. | ||
1535 | 8 | confinement: strict | 8 | confinement: strict |
1536 | 9 | grade: stable | 9 | grade: stable |
1537 | 10 | 10 | ||
1538 | @@ -16,17 +16,24 @@ apps: | |||
1539 | 16 | 16 | ||
1540 | 17 | parts: | 17 | parts: |
1541 | 18 | deps: | 18 | deps: |
1544 | 19 | plugin: python | 19 | plugin: python-ols |
1545 | 20 | requirements: ./requirements.txt | 20 | source: . |
1546 | 21 | pip-find-links: | ||
1547 | 22 | - $SNAPCRAFT_PROJECT_DIR/dependencies | ||
1548 | 23 | pip-no-index: true | ||
1549 | 24 | requirements: | ||
1550 | 25 | - ./requirements.txt | ||
1551 | 21 | stage-packages: | 26 | stage-packages: |
1552 | 22 | - libsodium18 | 27 | - libsodium18 |
1553 | 23 | source: | 28 | source: |
1554 | 24 | plugin: dump | 29 | plugin: dump |
1555 | 25 | source: . | 30 | source: . |
1556 | 31 | override-build: | | ||
1557 | 32 | #TODO: Would be a lot simpler to make snapstore_client normal | ||
1558 | 33 | # Python package | ||
1559 | 34 | cp -a $SNAPCRAFT_PROJECT_DIR/snapstore $SNAPCRAFT_PART_INSTALL | ||
1560 | 35 | cp -a $SNAPCRAFT_PROJECT_DIR/snapstore_client $SNAPCRAFT_PART_INSTALL | ||
1561 | 26 | stage: | 36 | stage: |
1562 | 27 | - ./snapstore | 37 | - ./snapstore |
1563 | 28 | - ./snapstore_client/ | 38 | - ./snapstore_client/ |
1564 | 29 | override-prime: | | ||
1565 | 30 | snapcraftctl prime | ||
1566 | 31 | /snap/core/current/usr/bin/python3 -m compileall -q -j0 snapstore_client | ||
1567 | 32 | 39 | ||
1568 | diff --git a/snapstore b/snapstore | |||
1569 | index cd67b71..4550e58 100755 | |||
1570 | --- a/snapstore | |||
1571 | +++ b/snapstore | |||
1572 | @@ -1,132 +1,7 @@ | |||
1573 | 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
1574 | 2 | # -*- coding: utf-8 -*- | ||
1575 | 3 | # Copyright 2017 Canonical Ltd. This software is licensed under the | ||
1576 | 4 | # GNU General Public License version 3 (see the file LICENSE). | ||
1577 | 5 | |||
1578 | 6 | """CLI utility to manage a store-in-a-box installation.""" | ||
1579 | 7 | |||
1580 | 8 | import argparse | ||
1581 | 9 | import logging | ||
1582 | 10 | import os | ||
1583 | 11 | import sys | 2 | import sys |
1584 | 12 | 3 | ||
1702 | 13 | from snapstore_client.cli import configure_logging | 4 | from snapstore_client.__main__ import main |
1586 | 14 | from snapstore_client.logic.login import login | ||
1587 | 15 | from snapstore_client.logic.overrides import ( | ||
1588 | 16 | delete_override, | ||
1589 | 17 | list_overrides, | ||
1590 | 18 | override, | ||
1591 | 19 | ) | ||
1592 | 20 | from snapstore_client.logic.push import push_snap | ||
1593 | 21 | |||
1594 | 22 | |||
1595 | 23 | DEFAULT_SSO_URL = 'https://login.ubuntu.com/' | ||
1596 | 24 | |||
1597 | 25 | |||
1598 | 26 | def main(): | ||
1599 | 27 | try: | ||
1600 | 28 | configure_logging() | ||
1601 | 29 | args = parse_args() | ||
1602 | 30 | return args.func(args) or 0 | ||
1603 | 31 | except KeyboardInterrupt: | ||
1604 | 32 | # use default logger as we can't guarantee that configuration | ||
1605 | 33 | # has completed. | ||
1606 | 34 | logging.error("Operation cancelled") | ||
1607 | 35 | return 1 | ||
1608 | 36 | |||
1609 | 37 | |||
1610 | 38 | def parse_args(): | ||
1611 | 39 | parser = argparse.ArgumentParser() | ||
1612 | 40 | subparsers = parser.add_subparsers(help='sub-command help') | ||
1613 | 41 | |||
1614 | 42 | login_parser = subparsers.add_parser( | ||
1615 | 43 | 'login', help='Sign into a store.', | ||
1616 | 44 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) | ||
1617 | 45 | login_parser.add_argument('store_url', help='Store URL') | ||
1618 | 46 | login_parser.add_argument('email', help='Ubuntu One SSO email', nargs='?') | ||
1619 | 47 | login_parser.add_argument('--sso-url', help='Ubuntu One SSO URL', | ||
1620 | 48 | default=DEFAULT_SSO_URL) | ||
1621 | 49 | login_parser.add_argument('--offline', help="Use offline mode interaction", | ||
1622 | 50 | action='store_true') | ||
1623 | 51 | login_parser.set_defaults(func=login) | ||
1624 | 52 | |||
1625 | 53 | list_overrides_parser = subparsers.add_parser( | ||
1626 | 54 | 'list-overrides', help='List channel map overrides.', | ||
1627 | 55 | ) | ||
1628 | 56 | list_overrides_parser.add_argument( | ||
1629 | 57 | '--series', default='16', | ||
1630 | 58 | help='The series within which to list overrides.') | ||
1631 | 59 | list_overrides_parser.add_argument( | ||
1632 | 60 | 'snap_name', | ||
1633 | 61 | help='The name of the snap whose channel map should be listed.') | ||
1634 | 62 | list_overrides_parser.add_argument( | ||
1635 | 63 | '--password', | ||
1636 | 64 | help='Password for interacting with an offline proxy', | ||
1637 | 65 | default=os.environ.get('SNAP_PROXY_PASSWORD') | ||
1638 | 66 | ) | ||
1639 | 67 | list_overrides_parser.set_defaults(func=list_overrides) | ||
1640 | 68 | |||
1641 | 69 | override_parser = subparsers.add_parser( | ||
1642 | 70 | 'override', help='Set channel map overrides.', | ||
1643 | 71 | ) | ||
1644 | 72 | override_parser.add_argument( | ||
1645 | 73 | '--series', default='16', | ||
1646 | 74 | help='The series within which to set overrides.') | ||
1647 | 75 | override_parser.add_argument( | ||
1648 | 76 | 'snap_name', | ||
1649 | 77 | help='The name of the snap whose channel map should be modified.') | ||
1650 | 78 | override_parser.add_argument( | ||
1651 | 79 | 'channel_map_entries', nargs='+', metavar='channel_map_entry', | ||
1652 | 80 | help='A channel map override, in the form <channel>=<revision>.') | ||
1653 | 81 | override_parser.add_argument( | ||
1654 | 82 | '--password', | ||
1655 | 83 | help='Password for interacting with an offline proxy', | ||
1656 | 84 | default=os.environ.get('SNAP_PROXY_PASSWORD') | ||
1657 | 85 | ) | ||
1658 | 86 | override_parser.set_defaults(func=override) | ||
1659 | 87 | |||
1660 | 88 | delete_override_parser = subparsers.add_parser( | ||
1661 | 89 | 'delete-override', help='Delete channel map overrides.', | ||
1662 | 90 | ) | ||
1663 | 91 | delete_override_parser.add_argument( | ||
1664 | 92 | '--series', default='16', | ||
1665 | 93 | help='The series within which to delete overrides.') | ||
1666 | 94 | delete_override_parser.add_argument( | ||
1667 | 95 | 'snap_name', | ||
1668 | 96 | help='The name of the snap whose channel map should be modified.') | ||
1669 | 97 | delete_override_parser.add_argument( | ||
1670 | 98 | 'channels', nargs='+', metavar='channel', | ||
1671 | 99 | help='A channel whose overrides should be deleted.') | ||
1672 | 100 | delete_override_parser.add_argument( | ||
1673 | 101 | '--password', | ||
1674 | 102 | help='Password for interacting with an offline proxy', | ||
1675 | 103 | default=os.environ.get('SNAP_PROXY_PASSWORD') | ||
1676 | 104 | ) | ||
1677 | 105 | delete_override_parser.set_defaults(func=delete_override) | ||
1678 | 106 | |||
1679 | 107 | push_snap_parser = subparsers.add_parser( | ||
1680 | 108 | 'push-snap', help='push a snap to an offline proxy') | ||
1681 | 109 | push_snap_parser.add_argument( | ||
1682 | 110 | 'snap_tar_file', | ||
1683 | 111 | help='The .tar.gz file of a bundled downloaded snap') | ||
1684 | 112 | push_snap_parser.add_argument( | ||
1685 | 113 | '--push-channel-map', | ||
1686 | 114 | action='store_true', | ||
1687 | 115 | help="Force push of the channel map," | ||
1688 | 116 | " removing any existing overrides") | ||
1689 | 117 | push_snap_parser.add_argument( | ||
1690 | 118 | '--password', | ||
1691 | 119 | help='Password for interacting with an offline proxy', | ||
1692 | 120 | default=os.environ.get('SNAP_PROXY_PASSWORD') | ||
1693 | 121 | ) | ||
1694 | 122 | push_snap_parser.set_defaults(func=push_snap) | ||
1695 | 123 | |||
1696 | 124 | if len(sys.argv) == 1: | ||
1697 | 125 | # Display help if no arguments are provided. | ||
1698 | 126 | parser.print_help() | ||
1699 | 127 | sys.exit(1) | ||
1700 | 128 | |||
1701 | 129 | return parser.parse_args() | ||
1703 | 130 | 5 | ||
1704 | 131 | 6 | ||
1705 | 132 | if __name__ == '__main__': | 7 | if __name__ == '__main__': |
1706 | diff --git a/snapstore_client/__main__.py b/snapstore_client/__main__.py | |||
1707 | 133 | new file mode 100644 | 8 | new file mode 100644 |
1708 | index 0000000..be6029a | |||
1709 | --- /dev/null | |||
1710 | +++ b/snapstore_client/__main__.py | |||
1711 | @@ -0,0 +1,136 @@ | |||
1712 | 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the | ||
1713 | 2 | # GNU General Public License version 3 (see the file LICENSE). | ||
1714 | 3 | |||
1715 | 4 | """CLI utility to manage a store-in-a-box installation.""" | ||
1716 | 5 | |||
1717 | 6 | import argparse | ||
1718 | 7 | import logging | ||
1719 | 8 | import os | ||
1720 | 9 | import sys | ||
1721 | 10 | |||
1722 | 11 | from snapstore_client.cli import configure_logging | ||
1723 | 12 | from snapstore_client.logic.login import login | ||
1724 | 13 | from snapstore_client.logic.overrides import ( | ||
1725 | 14 | delete_override, | ||
1726 | 15 | list_overrides, | ||
1727 | 16 | override, | ||
1728 | 17 | ) | ||
1729 | 18 | from snapstore_client.logic.push import push_snap | ||
1730 | 19 | |||
1731 | 20 | |||
1732 | 21 | logger = logging.getLogger(__name__) | ||
1733 | 22 | |||
1734 | 23 | |||
1735 | 24 | DEFAULT_SSO_URL = 'https://login.ubuntu.com/' | ||
1736 | 25 | |||
1737 | 26 | |||
1738 | 27 | def main(): | ||
1739 | 28 | try: | ||
1740 | 29 | configure_logging() | ||
1741 | 30 | args = parse_args() | ||
1742 | 31 | return args.func(args) or 0 | ||
1743 | 32 | except Exception as e: | ||
1744 | 33 | logging.error("%s: %s", type(e).__name__, e) | ||
1745 | 34 | except KeyboardInterrupt: | ||
1746 | 35 | # use default logger as we can't guarantee that configuration | ||
1747 | 36 | # has completed. | ||
1748 | 37 | logging.error("Operation cancelled") | ||
1749 | 38 | return 1 | ||
1750 | 39 | |||
1751 | 40 | |||
1752 | 41 | def parse_args(): | ||
1753 | 42 | parser = argparse.ArgumentParser() | ||
1754 | 43 | subparsers = parser.add_subparsers(help='sub-command help') | ||
1755 | 44 | |||
1756 | 45 | login_parser = subparsers.add_parser( | ||
1757 | 46 | 'login', help='Sign into a store.', | ||
1758 | 47 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) | ||
1759 | 48 | login_parser.add_argument('store_url', help='Store URL') | ||
1760 | 49 | login_parser.add_argument('email', help='Ubuntu One SSO email', nargs='?') | ||
1761 | 50 | login_parser.add_argument('--sso-url', help='Ubuntu One SSO URL', | ||
1762 | 51 | default=DEFAULT_SSO_URL) | ||
1763 | 52 | login_parser.add_argument('--offline', help="Use offline mode interaction", | ||
1764 | 53 | action='store_true') | ||
1765 | 54 | login_parser.set_defaults(func=login) | ||
1766 | 55 | |||
1767 | 56 | list_overrides_parser = subparsers.add_parser( | ||
1768 | 57 | 'list-overrides', help='List channel map overrides.', | ||
1769 | 58 | ) | ||
1770 | 59 | list_overrides_parser.add_argument( | ||
1771 | 60 | '--series', default='16', | ||
1772 | 61 | help='The series within which to list overrides.') | ||
1773 | 62 | list_overrides_parser.add_argument( | ||
1774 | 63 | 'snap_name', | ||
1775 | 64 | help='The name of the snap whose channel map should be listed.') | ||
1776 | 65 | list_overrides_parser.add_argument( | ||
1777 | 66 | '--password', | ||
1778 | 67 | help='Password for interacting with an offline proxy', | ||
1779 | 68 | default=os.environ.get('SNAP_PROXY_PASSWORD') | ||
1780 | 69 | ) | ||
1781 | 70 | list_overrides_parser.set_defaults(func=list_overrides) | ||
1782 | 71 | |||
1783 | 72 | override_parser = subparsers.add_parser( | ||
1784 | 73 | 'override', help='Set channel map overrides.', | ||
1785 | 74 | ) | ||
1786 | 75 | override_parser.add_argument( | ||
1787 | 76 | '--series', default='16', | ||
1788 | 77 | help='The series within which to set overrides.') | ||
1789 | 78 | override_parser.add_argument( | ||
1790 | 79 | 'snap_name', | ||
1791 | 80 | help='The name of the snap whose channel map should be modified.') | ||
1792 | 81 | override_parser.add_argument( | ||
1793 | 82 | 'channel_map_entries', nargs='+', metavar='channel_map_entry', | ||
1794 | 83 | help='A channel map override, in the form <channel>=<revision>.') | ||
1795 | 84 | override_parser.add_argument( | ||
1796 | 85 | '--password', | ||
1797 | 86 | help='Password for interacting with an offline proxy', | ||
1798 | 87 | default=os.environ.get('SNAP_PROXY_PASSWORD') | ||
1799 | 88 | ) | ||
1800 | 89 | override_parser.set_defaults(func=override) | ||
1801 | 90 | |||
1802 | 91 | delete_override_parser = subparsers.add_parser( | ||
1803 | 92 | 'delete-override', help='Delete channel map overrides.', | ||
1804 | 93 | ) | ||
1805 | 94 | delete_override_parser.add_argument( | ||
1806 | 95 | '--series', default='16', | ||
1807 | 96 | help='The series within which to delete overrides.') | ||
1808 | 97 | delete_override_parser.add_argument( | ||
1809 | 98 | 'snap_name', | ||
1810 | 99 | help='The name of the snap whose channel map should be modified.') | ||
1811 | 100 | delete_override_parser.add_argument( | ||
1812 | 101 | 'channels', nargs='+', metavar='channel', | ||
1813 | 102 | help='A channel whose overrides should be deleted.') | ||
1814 | 103 | delete_override_parser.add_argument( | ||
1815 | 104 | '--password', | ||
1816 | 105 | help='Password for interacting with an offline proxy', | ||
1817 | 106 | default=os.environ.get('SNAP_PROXY_PASSWORD') | ||
1818 | 107 | ) | ||
1819 | 108 | delete_override_parser.set_defaults(func=delete_override) | ||
1820 | 109 | |||
1821 | 110 | push_snap_parser = subparsers.add_parser( | ||
1822 | 111 | 'push-snap', help='push a snap to an offline proxy') | ||
1823 | 112 | push_snap_parser.add_argument( | ||
1824 | 113 | 'snap_tar_file', | ||
1825 | 114 | help='The .tar.gz file of a bundled downloaded snap') | ||
1826 | 115 | push_snap_parser.add_argument( | ||
1827 | 116 | '--push-channel-map', | ||
1828 | 117 | action='store_true', | ||
1829 | 118 | help="Force push of the channel map," | ||
1830 | 119 | " removing any existing overrides") | ||
1831 | 120 | push_snap_parser.add_argument( | ||
1832 | 121 | '--password', | ||
1833 | 122 | help='Password for interacting with an offline proxy', | ||
1834 | 123 | default=os.environ.get('SNAP_PROXY_PASSWORD') | ||
1835 | 124 | ) | ||
1836 | 125 | push_snap_parser.set_defaults(func=push_snap) | ||
1837 | 126 | |||
1838 | 127 | if len(sys.argv) == 1: | ||
1839 | 128 | # Display help if no arguments are provided. | ||
1840 | 129 | parser.print_help() | ||
1841 | 130 | sys.exit(1) | ||
1842 | 131 | |||
1843 | 132 | return parser.parse_args() | ||
1844 | 133 | |||
1845 | 134 | |||
1846 | 135 | if __name__ == '__main__': | ||
1847 | 136 | sys.exit(main()) | ||
1848 | diff --git a/snapstore_client/exceptions.py b/snapstore_client/exceptions.py | |||
1849 | 0 | deleted file mode 100644 | 137 | deleted file mode 100644 |
1850 | index 061b5fe..0000000 | |||
1851 | --- a/snapstore_client/exceptions.py | |||
1852 | +++ /dev/null | |||
1853 | @@ -1,72 +0,0 @@ | |||
1854 | 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the | ||
1855 | 2 | # GNU General Public License version 3 (see the file LICENSE). | ||
1856 | 3 | |||
1857 | 4 | """Client exceptions.""" | ||
1858 | 5 | |||
1859 | 6 | |||
1860 | 7 | class ClientError(Exception): | ||
1861 | 8 | """The base class for all user-visible exceptions.""" | ||
1862 | 9 | |||
1863 | 10 | @property | ||
1864 | 11 | def fmt(self): | ||
1865 | 12 | raise NotImplementedError | ||
1866 | 13 | |||
1867 | 14 | def __init__(self, **kwargs): | ||
1868 | 15 | for key, value in kwargs.items(): | ||
1869 | 16 | setattr(self, key, value) | ||
1870 | 17 | |||
1871 | 18 | def __str__(self): | ||
1872 | 19 | return self.fmt.format(**self.__dict__) | ||
1873 | 20 | |||
1874 | 21 | |||
1875 | 22 | class InvalidCredentials(ClientError): | ||
1876 | 23 | |||
1877 | 24 | fmt = 'Invalid credentials: {message}.' | ||
1878 | 25 | |||
1879 | 26 | def __init__(self, message): | ||
1880 | 27 | super().__init__(message=message) | ||
1881 | 28 | |||
1882 | 29 | |||
1883 | 30 | class InvalidStoreURL(ClientError): | ||
1884 | 31 | |||
1885 | 32 | fmt = 'Invalid store url: {message}.' | ||
1886 | 33 | |||
1887 | 34 | def __init__(self, message): | ||
1888 | 35 | super().__init__(message=message) | ||
1889 | 36 | |||
1890 | 37 | |||
1891 | 38 | class StoreCommunicationError(ClientError): | ||
1892 | 39 | |||
1893 | 40 | fmt = 'Connection error with the store using: {message}.' | ||
1894 | 41 | |||
1895 | 42 | def __init__(self, message): | ||
1896 | 43 | super().__init__(message=message) | ||
1897 | 44 | |||
1898 | 45 | |||
1899 | 46 | class StoreMacaroonSSOMismatch(ClientError): | ||
1900 | 47 | |||
1901 | 48 | fmt = 'Root macaroon does not refer to expected SSO host: {sso_host}.' | ||
1902 | 49 | |||
1903 | 50 | def __init__(self, sso_host): | ||
1904 | 51 | super().__init__(sso_host=sso_host) | ||
1905 | 52 | |||
1906 | 53 | |||
1907 | 54 | class StoreAuthenticationError(ClientError): | ||
1908 | 55 | |||
1909 | 56 | # No terminating full stop because the message from SSO sometimes | ||
1910 | 57 | # (though not always!) includes one. | ||
1911 | 58 | fmt = 'Authentication error: {message}' | ||
1912 | 59 | |||
1913 | 60 | def __init__(self, message, extra=None): | ||
1914 | 61 | super().__init__(message=message, extra=extra) | ||
1915 | 62 | |||
1916 | 63 | |||
1917 | 64 | class StoreTwoFactorAuthenticationRequired(StoreAuthenticationError): | ||
1918 | 65 | |||
1919 | 66 | def __init__(self): | ||
1920 | 67 | super().__init__('Two-factor authentication required.') | ||
1921 | 68 | |||
1922 | 69 | |||
1923 | 70 | class StoreMacaroonNeedsRefresh(ClientError): | ||
1924 | 71 | |||
1925 | 72 | fmt = 'Authentication macaroon needs to be refreshed.' | ||
1926 | diff --git a/snapstore_client/logic/login.py b/snapstore_client/logic/login.py | |||
1927 | index 9550988..1eb92ef 100644 | |||
1928 | --- a/snapstore_client/logic/login.py | |||
1929 | +++ b/snapstore_client/logic/login.py | |||
1930 | @@ -2,86 +2,63 @@ | |||
1931 | 2 | 2 | ||
1932 | 3 | import getpass | 3 | import getpass |
1933 | 4 | import logging | 4 | import logging |
1935 | 5 | from urllib.parse import urlparse | 5 | from urllib.parse import urljoin |
1936 | 6 | 6 | ||
1938 | 7 | from pymacaroons import Macaroon | 7 | import requests |
1939 | 8 | 8 | ||
1945 | 9 | from snapstore_client import ( | 9 | from snapstore_client import config |
1946 | 10 | config, | 10 | from snapstore_library import exceptions, authentication |
1942 | 11 | exceptions, | ||
1943 | 12 | webservices as ws, | ||
1944 | 13 | ) | ||
1947 | 14 | 11 | ||
1948 | 15 | 12 | ||
1949 | 16 | logger = logging.getLogger(__name__) | 13 | logger = logging.getLogger(__name__) |
1950 | 17 | 14 | ||
1951 | 18 | 15 | ||
1960 | 19 | def _extract_caveat_id(sso_url, root_macaroon): | 16 | def set_offline(args): |
1961 | 20 | macaroon = Macaroon.deserialize(root_macaroon) | 17 | store_name = "default" |
1962 | 21 | sso_host = urlparse(sso_url).netloc | 18 | cfg = config.Config() |
1963 | 22 | for caveat in macaroon.caveats: | 19 | store = cfg.store_section(store_name) |
1964 | 23 | if caveat.location == sso_host: | 20 | store.set("gw_url", args.store_url) |
1965 | 24 | return caveat.caveat_id | 21 | cfg.save() |
1966 | 25 | else: | 22 | logger.info("Configured %s as the %s store", args.store_url, store_name) |
1967 | 26 | raise exceptions.StoreMacaroonSSOMismatch(sso_host) | 23 | return 0 |
1968 | 27 | 24 | ||
1969 | 28 | 25 | ||
1970 | 29 | def login(args): | 26 | def login(args): |
1971 | 30 | # TODO: validate these before using to avoid ugly errors. | ||
1972 | 31 | gw_url = args.store_url | ||
1973 | 32 | sso_url = args.sso_url | ||
1974 | 33 | |||
1975 | 34 | if args.offline: | 27 | if args.offline: |
1981 | 35 | cfg = config.Config() | 28 | return set_offline(args) |
1977 | 36 | store = cfg.store_section('default') | ||
1978 | 37 | store.set('gw_url', gw_url) | ||
1979 | 38 | cfg.save() | ||
1980 | 39 | return | ||
1982 | 40 | 29 | ||
1984 | 41 | logger.info('Enter your Ubuntu One SSO credentials.') | 30 | logger.info("Enter your Ubuntu One SSO credentials.") |
1985 | 42 | email = args.email | 31 | email = args.email |
1986 | 43 | if not email: | 32 | if not email: |
1989 | 44 | email = input('Email: ') | 33 | email = input("Email: ") |
1990 | 45 | password = getpass.getpass('Password: ') | 34 | password = getpass.getpass("Password: ") |
1991 | 46 | 35 | ||
1992 | 36 | session = requests.Session() | ||
1993 | 37 | auth_url = urljoin(args.store_url, "/v2/auth/issue-store-admin") | ||
1994 | 38 | sso_url = urljoin(args.sso_url, "/api/v2/tokens/discharge") | ||
1995 | 47 | try: | 39 | try: |
2021 | 48 | root = ws.issue_store_admin(gw_url) | 40 | root_macaroon, discharge_macaroon = authentication.login( |
2022 | 49 | except exceptions.ClientError as e: | 41 | session, auth_url, sso_url, email, password |
2023 | 50 | logger.error(str(e)) | 42 | ) |
2024 | 51 | return | 43 | except exceptions.TwoFactorAuthenticationRequired: |
2025 | 52 | caveat_id = _extract_caveat_id(sso_url, root) | 44 | one_time_password = input("Second-factor auth: ") |
2026 | 53 | try: | 45 | root_macaroon, discharge_macaroon = authentication.login( |
2027 | 54 | try: | 46 | session, auth_url, sso_url, email, password, otp=one_time_password |
2028 | 55 | unbound_discharge = ws.get_sso_discharge( | 47 | ) |
2029 | 56 | sso_url, email, password, caveat_id) | 48 | logger.info("Login successful") |
2005 | 57 | logger.info('Login successful') | ||
2006 | 58 | except exceptions.StoreTwoFactorAuthenticationRequired: | ||
2007 | 59 | one_time_password = input('Second-factor auth: ') | ||
2008 | 60 | unbound_discharge = ws.get_sso_discharge( | ||
2009 | 61 | sso_url, email, password, caveat_id, | ||
2010 | 62 | one_time_password=one_time_password) | ||
2011 | 63 | logger.info('Login successful') | ||
2012 | 64 | except exceptions.StoreAuthenticationError as e: | ||
2013 | 65 | logger.error('Login failed.') | ||
2014 | 66 | logger.error('%s', e) | ||
2015 | 67 | if e.extra: | ||
2016 | 68 | for key, value in e.extra.items(): | ||
2017 | 69 | if isinstance(value, list): | ||
2018 | 70 | value = ' '.join(value) | ||
2019 | 71 | logger.error('%s: %s', key, value) | ||
2020 | 72 | return 1 | ||
2030 | 73 | 49 | ||
2031 | 74 | cfg = config.Config() | 50 | cfg = config.Config() |
2032 | 75 | # For now, the store is always called "default". In the future we may want | 51 | # For now, the store is always called "default". In the future we may want |
2033 | 76 | # to support multiple stores by allowing the user to provide a nice name | 52 | # to support multiple stores by allowing the user to provide a nice name |
2034 | 77 | # for a store at login that can be used to select the store for later | 53 | # for a store at login that can be used to select the store for later |
2035 | 78 | # operations. | 54 | # operations. |
2042 | 79 | store = cfg.store_section('default') | 55 | store_name = "default" |
2043 | 80 | store.set('gw_url', gw_url) | 56 | store = cfg.store_section(store_name) |
2044 | 81 | store.set('sso_url', sso_url) | 57 | store.set("gw_url", args.store_url) |
2045 | 82 | store.set('root', root) | 58 | store.set("sso_url", args.sso_url) |
2046 | 83 | store.set('unbound_discharge', unbound_discharge) | 59 | store.set("root", root_macaroon.serialize()) |
2047 | 84 | store.set('email', email) | 60 | store.set("unbound_discharge", discharge_macaroon.serialize()) |
2048 | 61 | store.set("email", email) | ||
2049 | 85 | cfg.save() | 62 | cfg.save() |
2051 | 86 | 63 | logger.info("Configured %s as the %s store", args.store_url, store_name) | |
2052 | 87 | return 0 | 64 | return 0 |
2053 | diff --git a/snapstore_client/logic/overrides.py b/snapstore_client/logic/overrides.py | |||
2054 | index d3c8356..aa6d490 100644 | |||
2055 | --- a/snapstore_client/logic/overrides.py | |||
2056 | +++ b/snapstore_client/logic/overrides.py | |||
2057 | @@ -1,51 +1,73 @@ | |||
2058 | 1 | # Copyright 2017 Canonical Ltd. | 1 | # Copyright 2017 Canonical Ltd. |
2059 | 2 | 2 | ||
2060 | 3 | import logging | 3 | import logging |
2061 | 4 | from urllib.parse import urljoin | ||
2062 | 4 | 5 | ||
2064 | 5 | from requests.exceptions import HTTPError | 6 | import requests |
2065 | 6 | 7 | ||
2071 | 7 | from snapstore_client import ( | 8 | from snapstore_client import config |
2067 | 8 | config, | ||
2068 | 9 | exceptions, | ||
2069 | 10 | webservices as ws, | ||
2070 | 11 | ) | ||
2072 | 12 | from snapstore_client.presentation_helpers import ( | 9 | from snapstore_client.presentation_helpers import ( |
2073 | 13 | channel_map_string_to_tuple, | 10 | channel_map_string_to_tuple, |
2074 | 14 | override_to_string, | 11 | override_to_string, |
2075 | 15 | ) | 12 | ) |
2076 | 16 | from snapstore_client.utils import ( | 13 | from snapstore_client.utils import ( |
2077 | 17 | _check_default_store, | 14 | _check_default_store, |
2078 | 18 | _log_authorized_error, | ||
2079 | 19 | _log_credentials_error, | ||
2080 | 20 | ) | 15 | ) |
2081 | 16 | from snapstore_library import authentication, overrides | ||
2082 | 21 | 17 | ||
2083 | 22 | 18 | ||
2084 | 23 | logger = logging.getLogger(__name__) | 19 | logger = logging.getLogger(__name__) |
2085 | 24 | 20 | ||
2086 | 25 | 21 | ||
2087 | 22 | # Used by the offline store | ||
2088 | 23 | HARDCODED_ADMIN = "admin" | ||
2089 | 24 | |||
2090 | 25 | |||
2091 | 26 | def _get_macaroon_auth(session, store): | ||
2092 | 27 | refresh_url = urljoin(store.get("sso_url"), "/api/v2/tokens/refresh") | ||
2093 | 28 | refresh_handler = authentication.MacaroonRefreshHandler( | ||
2094 | 29 | session, refresh_url | ||
2095 | 30 | ) | ||
2096 | 31 | |||
2097 | 32 | def discharge_updater(discharge): | ||
2098 | 33 | store.set("unbound_discharge", discharge) | ||
2099 | 34 | |||
2100 | 35 | return authentication.HTTPMacaroonAuth( | ||
2101 | 36 | authentication.deserialize(store.get("root")), | ||
2102 | 37 | authentication.deserialize(store.get("unbound_discharge")), | ||
2103 | 38 | refresh_handler=refresh_handler, | ||
2104 | 39 | dischage_update_handler=discharge_updater, | ||
2105 | 40 | ) | ||
2106 | 41 | |||
2107 | 42 | |||
2108 | 43 | def _get_auth(session, args, store): | ||
2109 | 44 | if args.password: | ||
2110 | 45 | auth = requests.auth.HTTPBasicAuth(HARDCODED_ADMIN, args.password) | ||
2111 | 46 | else: | ||
2112 | 47 | auth = _get_macaroon_auth(session, store) | ||
2113 | 48 | return auth | ||
2114 | 49 | |||
2115 | 50 | |||
2116 | 51 | def _print_overrides(overrides_response): | ||
2117 | 52 | for override in overrides_response["overrides"]: | ||
2118 | 53 | logger.info(override_to_string(override)) | ||
2119 | 54 | |||
2120 | 55 | |||
2121 | 26 | def list_overrides(args): | 56 | def list_overrides(args): |
2122 | 27 | cfg = config.Config() | 57 | cfg = config.Config() |
2123 | 28 | store = _check_default_store(cfg) | 58 | store = _check_default_store(cfg) |
2124 | 29 | if not store: | 59 | if not store: |
2125 | 30 | return 1 | 60 | return 1 |
2126 | 31 | 61 | ||
2144 | 32 | try: | 62 | session = requests.Session() |
2145 | 33 | if args.password: | 63 | auth = _get_auth(session, args, store) |
2146 | 34 | response = ws.get_overrides( | 64 | url = urljoin( |
2147 | 35 | store, args.snap_name, | 65 | store.get("gw_url"), "/v2/metadata/overrides/{}".format(args.snap_name) |
2148 | 36 | series=args.series, password=args.password) | 66 | ) |
2149 | 37 | else: | 67 | overrides_response = overrides.get_overrides( |
2150 | 38 | response = ws.refresh_if_necessary( | 68 | session, url, args.snap_name, auth=auth |
2151 | 39 | store, ws.get_overrides, | 69 | ) |
2152 | 40 | store, args.snap_name, series=args.series) | 70 | _print_overrides(overrides_response) |
2136 | 41 | except exceptions.InvalidCredentials as e: | ||
2137 | 42 | _log_credentials_error(e) | ||
2138 | 43 | return 1 | ||
2139 | 44 | except HTTPError: | ||
2140 | 45 | _log_authorized_error() | ||
2141 | 46 | return 1 | ||
2142 | 47 | for override in response['overrides']: | ||
2143 | 48 | logger.info(override_to_string(override)) | ||
2153 | 49 | 71 | ||
2154 | 50 | 72 | ||
2155 | 51 | def override(args): | 73 | def override(args): |
2156 | @@ -54,31 +76,24 @@ def override(args): | |||
2157 | 54 | if not store: | 76 | if not store: |
2158 | 55 | return 1 | 77 | return 1 |
2159 | 56 | 78 | ||
2161 | 57 | overrides = [] | 79 | overrides_data = [] |
2162 | 58 | for channel_map_entry in args.channel_map_entries: | 80 | for channel_map_entry in args.channel_map_entries: |
2163 | 59 | channel, revision = channel_map_string_to_tuple(channel_map_entry) | 81 | channel, revision = channel_map_string_to_tuple(channel_map_entry) |
2186 | 60 | overrides.append({ | 82 | overrides_data.append( |
2187 | 61 | 'snap_name': args.snap_name, | 83 | { |
2188 | 62 | 'revision': revision, | 84 | "snap_name": args.snap_name, |
2189 | 63 | 'channel': channel, | 85 | "revision": revision, |
2190 | 64 | 'series': args.series, | 86 | "channel": channel, |
2191 | 65 | }) | 87 | "series": args.series, |
2192 | 66 | try: | 88 | } |
2193 | 67 | if args.password: | 89 | ) |
2194 | 68 | response = ws.set_overrides( | 90 | session = requests.Session() |
2195 | 69 | store, overrides, password=args.password) | 91 | auth = _get_auth(session, args, store) |
2196 | 70 | else: | 92 | url = urljoin(store.get("gw_url"), "/v2/metadata/overrides") |
2197 | 71 | response = ws.refresh_if_necessary( | 93 | overrides_response = overrides.set_overrides( |
2198 | 72 | store, ws.set_overrides, | 94 | session, url, overrides_data, auth=auth |
2199 | 73 | store, overrides) | 95 | ) |
2200 | 74 | except exceptions.InvalidCredentials as e: | 96 | _print_overrides(overrides_response) |
2179 | 75 | _log_credentials_error(e) | ||
2180 | 76 | return 1 | ||
2181 | 77 | except HTTPError: | ||
2182 | 78 | _log_authorized_error() | ||
2183 | 79 | return 1 | ||
2184 | 80 | for override in response['overrides']: | ||
2185 | 81 | logger.info(override_to_string(override)) | ||
2201 | 82 | 97 | ||
2202 | 83 | 98 | ||
2203 | 84 | def delete_override(args): | 99 | def delete_override(args): |
2204 | @@ -87,27 +102,20 @@ def delete_override(args): | |||
2205 | 87 | if not store: | 102 | if not store: |
2206 | 88 | return 1 | 103 | return 1 |
2207 | 89 | 104 | ||
2209 | 90 | overrides = [] | 105 | overrides_data = [] |
2210 | 91 | for channel in args.channels: | 106 | for channel in args.channels: |
2233 | 92 | overrides.append({ | 107 | overrides_data.append( |
2234 | 93 | 'snap_name': args.snap_name, | 108 | { |
2235 | 94 | 'revision': None, | 109 | "snap_name": args.snap_name, |
2236 | 95 | 'channel': channel, | 110 | "revision": None, |
2237 | 96 | 'series': args.series, | 111 | "channel": channel, |
2238 | 97 | }) | 112 | "series": args.series, |
2239 | 98 | try: | 113 | } |
2240 | 99 | if args.password: | 114 | ) |
2241 | 100 | response = ws.set_overrides( | 115 | session = requests.Session() |
2242 | 101 | store, overrides, password=args.password) | 116 | auth = _get_auth(session, args, store) |
2243 | 102 | else: | 117 | url = urljoin(store.get("gw_url"), "/v2/metadata/overrides") |
2244 | 103 | response = ws.refresh_if_necessary( | 118 | overrides_response = overrides.set_overrides( |
2245 | 104 | store, ws.set_overrides, | 119 | session, url, overrides_data, auth=auth |
2246 | 105 | store, overrides) | 120 | ) |
2247 | 106 | except exceptions.InvalidCredentials as e: | 121 | _print_overrides(overrides_response) |
2226 | 107 | _log_credentials_error(e) | ||
2227 | 108 | return 1 | ||
2228 | 109 | except HTTPError: | ||
2229 | 110 | _log_authorized_error() | ||
2230 | 111 | return 1 | ||
2231 | 112 | for override in response['overrides']: | ||
2232 | 113 | logger.info(override_to_string(override)) | ||
2248 | diff --git a/snapstore_client/logic/push.py b/snapstore_client/logic/push.py | |||
2249 | index abd06e8..be5fcd4 100644 | |||
2250 | --- a/snapstore_client/logic/push.py | |||
2251 | +++ b/snapstore_client/logic/push.py | |||
2252 | @@ -10,12 +10,10 @@ from requests.exceptions import HTTPError | |||
2253 | 10 | 10 | ||
2254 | 11 | from snapstore_client import ( | 11 | from snapstore_client import ( |
2255 | 12 | config, | 12 | config, |
2256 | 13 | exceptions, | ||
2257 | 14 | ) | 13 | ) |
2258 | 15 | from snapstore_client.utils import ( | 14 | from snapstore_client.utils import ( |
2259 | 16 | _check_default_store, | 15 | _check_default_store, |
2260 | 17 | _log_authorized_error, | 16 | _log_authorized_error, |
2261 | 18 | _log_credentials_error, | ||
2262 | 19 | ) | 17 | ) |
2263 | 20 | 18 | ||
2264 | 21 | logger = logging.getLogger(__name__) | 19 | logger = logging.getLogger(__name__) |
2265 | @@ -298,9 +296,6 @@ def push_snap(args): | |||
2266 | 298 | logger.error( | 296 | logger.error( |
2267 | 299 | "This command only works with a supplied password" | 297 | "This command only works with a supplied password" |
2268 | 300 | " and a proxy in offline mode") | 298 | " and a proxy in offline mode") |
2269 | 301 | except exceptions.InvalidCredentials as e: | ||
2270 | 302 | _log_credentials_error(e) | ||
2271 | 303 | return 1 | ||
2272 | 304 | except HTTPError: | 299 | except HTTPError: |
2273 | 305 | _log_authorized_error() | 300 | _log_authorized_error() |
2274 | 306 | return 1 | 301 | return 1 |
2275 | diff --git a/snapstore_client/logic/tests/test_login.py b/snapstore_client/logic/tests/test_login.py | |||
2276 | index d7854cc..f2fce6e 100644 | |||
2277 | --- a/snapstore_client/logic/tests/test_login.py | |||
2278 | +++ b/snapstore_client/logic/tests/test_login.py | |||
2279 | @@ -1,286 +1,124 @@ | |||
2280 | 1 | # Copyright 2017 Canonical Ltd. | 1 | # Copyright 2017 Canonical Ltd. |
2281 | 2 | from testtools.assertions import assert_that | ||
2282 | 3 | from testtools.matchers import Equals | ||
2283 | 4 | import pymacaroons | ||
2284 | 2 | 5 | ||
2288 | 3 | import json | 6 | from snapstore_client.logic.tests.utils import RunMainTestCase |
2289 | 4 | from unittest import mock | 7 | from snapstore_library import exceptions |
2287 | 5 | from urllib.parse import urljoin, urlparse | ||
2290 | 6 | 8 | ||
2291 | 7 | import fixtures | ||
2292 | 8 | from pymacaroons import Macaroon | ||
2293 | 9 | import responses | ||
2294 | 10 | from testtools import TestCase | ||
2295 | 11 | from testtools.matchers import ( | ||
2296 | 12 | ContainsDict, | ||
2297 | 13 | Equals, | ||
2298 | 14 | MatchesDict, | ||
2299 | 15 | ) | ||
2300 | 16 | 9 | ||
2348 | 17 | from snapstore_client import ( | 10 | class LoginTests(RunMainTestCase): |
2349 | 18 | config, | 11 | def test_offline_login(self): |
2350 | 19 | exceptions, | 12 | self.mock_with_side_effect( |
2351 | 20 | ) | 13 | "snapstore_library.authentication.login", |
2352 | 21 | from snapstore_client.logic.login import login | 14 | AssertionError("should not be called"), |
2306 | 22 | from snapstore_client.tests import factory | ||
2307 | 23 | |||
2308 | 24 | |||
2309 | 25 | class LoginTests(TestCase): | ||
2310 | 26 | |||
2311 | 27 | def setUp(self): | ||
2312 | 28 | super().setUp() | ||
2313 | 29 | self.default_gw_url = 'http://store.local/' | ||
2314 | 30 | self.default_sso_url = 'https://login.staging.ubuntu.com/' | ||
2315 | 31 | self.logger = self.useFixture(fixtures.FakeLogger()) | ||
2316 | 32 | self.config_path = self.useFixture(fixtures.TempDir()).path | ||
2317 | 33 | self.useFixture(fixtures.MonkeyPatch( | ||
2318 | 34 | 'xdg.BaseDirectory.xdg_config_home', self.config_path)) | ||
2319 | 35 | self.useFixture(fixtures.MonkeyPatch( | ||
2320 | 36 | 'xdg.BaseDirectory.xdg_config_dirs', [self.config_path])) | ||
2321 | 37 | self.mock_input = self.useFixture(fixtures.MockPatch( | ||
2322 | 38 | 'builtins.input')).mock | ||
2323 | 39 | self.mock_getpass = self.useFixture(fixtures.MockPatch( | ||
2324 | 40 | 'getpass.getpass')).mock | ||
2325 | 41 | |||
2326 | 42 | def make_responses_callback(self, response_templates): | ||
2327 | 43 | full_responses = [] | ||
2328 | 44 | for response in response_templates: | ||
2329 | 45 | status = response.get('status', 200) | ||
2330 | 46 | content_type = 'text/plain' | ||
2331 | 47 | if 'json' in response: | ||
2332 | 48 | content_type = 'application/json' | ||
2333 | 49 | body = json.dumps(response['json']) | ||
2334 | 50 | else: | ||
2335 | 51 | body = response.get('body') | ||
2336 | 52 | full_responses.append( | ||
2337 | 53 | (status, {'Content-Type': content_type}, body)) | ||
2338 | 54 | iter_responses = iter(full_responses) | ||
2339 | 55 | return lambda request: next(iter_responses) | ||
2340 | 56 | |||
2341 | 57 | def make_args(self, store_url=None, | ||
2342 | 58 | sso_url=None, email=None, offline=False): | ||
2343 | 59 | return factory.Args( | ||
2344 | 60 | store_url=store_url or self.default_gw_url, | ||
2345 | 61 | sso_url=sso_url or self.default_sso_url, | ||
2346 | 62 | email=email, | ||
2347 | 63 | offline=offline, | ||
2353 | 64 | ) | 15 | ) |
2354 | 65 | 16 | ||
2376 | 66 | def add_issue_store_admin_response(self, *response_templates, gw_url=None): | 17 | exit_status = self.run_main( |
2377 | 67 | gw_url = gw_url or self.default_gw_url | 18 | "login", "http://store.local/", "--offline" |
2378 | 68 | issue_store_admin_url = urljoin(gw_url, '/v2/auth/issue-store-admin') | 19 | ) |
2358 | 69 | responses.add_callback( | ||
2359 | 70 | 'POST', issue_store_admin_url, | ||
2360 | 71 | self.make_responses_callback(response_templates)) | ||
2361 | 72 | |||
2362 | 73 | def add_get_sso_discharge_response(self, *response_templates, | ||
2363 | 74 | sso_url=None): | ||
2364 | 75 | sso_url = sso_url or self.default_sso_url | ||
2365 | 76 | discharge_url = urljoin(sso_url, '/api/v2/tokens/discharge') | ||
2366 | 77 | responses.add_callback( | ||
2367 | 78 | 'POST', discharge_url, | ||
2368 | 79 | self.make_responses_callback(response_templates)) | ||
2369 | 80 | |||
2370 | 81 | def make_root_macaroon(self, sso_url=None): | ||
2371 | 82 | macaroon = Macaroon() | ||
2372 | 83 | sso_url = sso_url or self.default_sso_url | ||
2373 | 84 | sso_host = urlparse(sso_url).netloc | ||
2374 | 85 | macaroon.add_third_party_caveat(sso_host, 'key', 'payload') | ||
2375 | 86 | return macaroon.serialize() | ||
2379 | 87 | 20 | ||
2391 | 88 | @responses.activate | 21 | assert_that(exit_status, Equals(0)) |
2392 | 89 | def test_login_sso_mismatch(self): | 22 | assert_that( |
2393 | 90 | self.mock_input.return_value = 'user@example.org' | 23 | self.logger.output, |
2394 | 91 | self.mock_getpass.return_value = 'secret' | 24 | Equals("Configured http://store.local/ as the default store\n"), |
2395 | 92 | macaroon = Macaroon() | 25 | ) |
2385 | 93 | macaroon.add_third_party_caveat('another.example.com', '', '') | ||
2386 | 94 | self.add_issue_store_admin_response( | ||
2387 | 95 | {'status': 200, 'json': {'macaroon': macaroon.serialize()}}) | ||
2388 | 96 | self.add_get_sso_discharge_response({'status': 401}) | ||
2389 | 97 | self.assertRaises( | ||
2390 | 98 | exceptions.StoreMacaroonSSOMismatch, login, self.make_args()) | ||
2396 | 99 | 26 | ||
2416 | 100 | @responses.activate | 27 | def test_login(self): |
2417 | 101 | def test_login_sso_bad_email(self): | 28 | root_macaroon = pymacaroons.Macaroon(location="test1") |
2418 | 102 | self.mock_input.return_value = '' | 29 | discharge_macaroon = pymacaroons.Macaroon(location="test2") |
2419 | 103 | self.mock_getpass.return_value = '' | 30 | self.mock_with_return( |
2420 | 104 | self.add_issue_store_admin_response( | 31 | "snapstore_library.authentication.login", |
2421 | 105 | {'status': 200, 'json': {'macaroon': self.make_root_macaroon()}}) | 32 | (root_macaroon, discharge_macaroon), |
2422 | 106 | auth_error = { | 33 | ) |
2423 | 107 | 'message': 'Invalid request data', | 34 | self.mock_with_return("getpass.getpass", "secret") |
2405 | 108 | 'extra': {'email': ['Enter a valid email address.']}, | ||
2406 | 109 | } | ||
2407 | 110 | self.add_get_sso_discharge_response( | ||
2408 | 111 | {'status': 401, 'json': {'error_list': [auth_error]}}) | ||
2409 | 112 | self.assertEqual(1, login(self.make_args())) | ||
2410 | 113 | self.assertEqual( | ||
2411 | 114 | 'Enter your Ubuntu One SSO credentials.\n' | ||
2412 | 115 | 'Login failed.\n' | ||
2413 | 116 | 'Authentication error: Invalid request data\n' | ||
2414 | 117 | 'email: Enter a valid email address.\n', | ||
2415 | 118 | self.logger.output) | ||
2424 | 119 | 35 | ||
2440 | 120 | @responses.activate | 36 | exit_status = self.run_main( |
2441 | 121 | def test_login_sso_unauthorized(self): | 37 | "login", "http://store.local/", "test@example.com" |
2442 | 122 | self.mock_input.return_value = 'user@example.org' | 38 | ) |
2428 | 123 | self.mock_getpass.return_value = 'secret' | ||
2429 | 124 | self.add_issue_store_admin_response( | ||
2430 | 125 | {'status': 200, 'json': {'macaroon': self.make_root_macaroon()}}) | ||
2431 | 126 | auth_error = {'message': 'Provided email/password is not correct.'} | ||
2432 | 127 | self.add_get_sso_discharge_response( | ||
2433 | 128 | {'status': 401, 'json': {'error_list': [auth_error]}}) | ||
2434 | 129 | self.assertEqual(1, login(self.make_args())) | ||
2435 | 130 | self.assertEqual( | ||
2436 | 131 | 'Enter your Ubuntu One SSO credentials.\n' | ||
2437 | 132 | 'Login failed.\n' | ||
2438 | 133 | 'Authentication error: Provided email/password is not correct.\n', | ||
2439 | 134 | self.logger.output) | ||
2443 | 135 | 39 | ||
2456 | 136 | @responses.activate | 40 | assert_that(exit_status, Equals(0)) |
2457 | 137 | def test_login_twofactor_required(self): | 41 | assert_that( |
2458 | 138 | self.mock_input.side_effect = ('user@example.org', '123456') | 42 | self.logger.output, |
2459 | 139 | self.mock_getpass.return_value = 'secret' | 43 | Equals( |
2460 | 140 | root = self.make_root_macaroon() | 44 | "Enter your Ubuntu One SSO credentials.\n" |
2461 | 141 | self.add_issue_store_admin_response( | 45 | "Login successful\n" |
2462 | 142 | {'status': 200, 'json': {'macaroon': root}}) | 46 | "Configured http://store.local/ as the default store\n" |
2463 | 143 | self.add_get_sso_discharge_response( | 47 | ), |
2464 | 144 | {'status': 401, | 48 | ) |
2453 | 145 | 'json': {'error_list': [{'code': 'twofactor-required'}]}}, | ||
2454 | 146 | {'status': 200, 'json': {'discharge_macaroon': 'dummy'}}) | ||
2455 | 147 | self.assertEqual(0, login(self.make_args())) | ||
2465 | 148 | 49 | ||
2492 | 149 | self.assertIn( | 50 | def test_login_two_factor_required(self): |
2493 | 150 | 'Enter your Ubuntu One SSO credentials.', self.logger.output) | 51 | root_macaroon = pymacaroons.Macaroon(location="test1") |
2494 | 151 | self.mock_input.assert_has_calls([ | 52 | discharge_macaroon = pymacaroons.Macaroon(location="test2") |
2469 | 152 | mock.call('Email: '), mock.call('Second-factor auth: ')]) | ||
2470 | 153 | self.mock_getpass.assert_called_once_with('Password: ') | ||
2471 | 154 | self.assertEqual(3, len(responses.calls)) | ||
2472 | 155 | self.assertEqual({ | ||
2473 | 156 | 'email': 'user@example.org', | ||
2474 | 157 | 'password': 'secret', | ||
2475 | 158 | 'caveat_id': 'payload', | ||
2476 | 159 | }, json.loads(responses.calls[1].request.body.decode())) | ||
2477 | 160 | self.assertEqual({ | ||
2478 | 161 | 'email': 'user@example.org', | ||
2479 | 162 | 'password': 'secret', | ||
2480 | 163 | 'caveat_id': 'payload', | ||
2481 | 164 | 'otp': '123456', | ||
2482 | 165 | }, json.loads(responses.calls[2].request.body.decode())) | ||
2483 | 166 | self.assertThat(config.Config().parser, ContainsDict({ | ||
2484 | 167 | 'store:default': MatchesDict({ | ||
2485 | 168 | 'gw_url': Equals(self.default_gw_url), | ||
2486 | 169 | 'sso_url': Equals(self.default_sso_url), | ||
2487 | 170 | 'root': Equals(root), | ||
2488 | 171 | 'unbound_discharge': Equals('dummy'), | ||
2489 | 172 | 'email': Equals('user@example.org'), | ||
2490 | 173 | }), | ||
2491 | 174 | })) | ||
2495 | 175 | 53 | ||
2506 | 176 | @responses.activate | 54 | def _login(session, auth_url, sso_url, email, password, otp=None): |
2507 | 177 | def test_login_twofactor_not_required(self): | 55 | if otp is None: |
2508 | 178 | self.mock_input.return_value = 'user@example.org' | 56 | raise exceptions.TwoFactorAuthenticationRequired() |
2509 | 179 | self.mock_getpass.return_value = 'secret' | 57 | assert_that(otp, Equals("123456")) |
2510 | 180 | root = self.make_root_macaroon() | 58 | return root_macaroon, discharge_macaroon |
2501 | 181 | self.add_issue_store_admin_response( | ||
2502 | 182 | {'status': 200, 'json': {'macaroon': root}}) | ||
2503 | 183 | self.add_get_sso_discharge_response( | ||
2504 | 184 | {'status': 200, 'json': {'discharge_macaroon': 'dummy'}}) | ||
2505 | 185 | login(self.make_args()) | ||
2511 | 186 | 59 | ||
2531 | 187 | self.assertIn( | 60 | self.mock_with_side_effect( |
2532 | 188 | 'Enter your Ubuntu One SSO credentials.', self.logger.output) | 61 | "snapstore_library.authentication.login", _login |
2533 | 189 | self.mock_input.assert_called_once_with('Email: ') | 62 | ) |
2534 | 190 | self.mock_getpass.assert_called_once_with('Password: ') | 63 | self.mock_with_return("getpass.getpass", "secret") |
2535 | 191 | self.assertEqual(2, len(responses.calls)) | 64 | self.mock_with_return("builtins.input", "123456") |
2517 | 192 | self.assertEqual({ | ||
2518 | 193 | 'email': 'user@example.org', | ||
2519 | 194 | 'password': 'secret', | ||
2520 | 195 | 'caveat_id': 'payload', | ||
2521 | 196 | }, json.loads(responses.calls[1].request.body.decode())) | ||
2522 | 197 | self.assertThat(config.Config().parser, ContainsDict({ | ||
2523 | 198 | 'store:default': MatchesDict({ | ||
2524 | 199 | 'gw_url': Equals(self.default_gw_url), | ||
2525 | 200 | 'sso_url': Equals(self.default_sso_url), | ||
2526 | 201 | 'root': Equals(root), | ||
2527 | 202 | 'unbound_discharge': Equals('dummy'), | ||
2528 | 203 | 'email': Equals('user@example.org'), | ||
2529 | 204 | }), | ||
2530 | 205 | })) | ||
2536 | 206 | 65 | ||
2547 | 207 | @responses.activate | 66 | exit_status = self.run_main( |
2548 | 208 | def test_login_with_email(self): | 67 | "login", "http://store.local/", "test@example.com" |
2549 | 209 | self.mock_input.side_effect = Exception("shouldn't be called") | 68 | ) |
2540 | 210 | self.mock_getpass.return_value = 'secret' | ||
2541 | 211 | root = self.make_root_macaroon() | ||
2542 | 212 | self.add_issue_store_admin_response( | ||
2543 | 213 | {'status': 200, 'json': {'macaroon': root}}) | ||
2544 | 214 | self.add_get_sso_discharge_response( | ||
2545 | 215 | {'status': 200, 'json': {'discharge_macaroon': 'dummy'}}) | ||
2546 | 216 | login(self.make_args(email='user@example.org')) | ||
2550 | 217 | 69 | ||
2570 | 218 | self.assertIn( | 70 | assert_that(exit_status, Equals(0)) |
2571 | 219 | 'Enter your Ubuntu One SSO credentials.', self.logger.output) | 71 | assert_that( |
2572 | 220 | self.mock_input.assert_not_called() | 72 | self.logger.output, |
2573 | 221 | self.mock_getpass.assert_called_once_with('Password: ') | 73 | Equals( |
2574 | 222 | self.assertEqual(2, len(responses.calls)) | 74 | "Enter your Ubuntu One SSO credentials.\n" |
2575 | 223 | self.assertEqual({ | 75 | "Login successful\n" |
2576 | 224 | 'email': 'user@example.org', | 76 | "Configured http://store.local/ as the default store\n" |
2577 | 225 | 'password': 'secret', | 77 | ), |
2578 | 226 | 'caveat_id': 'payload', | 78 | ) |
2560 | 227 | }, json.loads(responses.calls[1].request.body.decode())) | ||
2561 | 228 | self.assertThat(config.Config().parser, ContainsDict({ | ||
2562 | 229 | 'store:default': MatchesDict({ | ||
2563 | 230 | 'gw_url': Equals(self.default_gw_url), | ||
2564 | 231 | 'sso_url': Equals(self.default_sso_url), | ||
2565 | 232 | 'root': Equals(root), | ||
2566 | 233 | 'unbound_discharge': Equals('dummy'), | ||
2567 | 234 | 'email': Equals('user@example.org'), | ||
2568 | 235 | }), | ||
2569 | 236 | })) | ||
2579 | 237 | 79 | ||
2583 | 238 | @responses.activate | 80 | def test_login_no_email_arg(self): |
2584 | 239 | def test_store_url(self): | 81 | root_macaroon = pymacaroons.Macaroon(location="test1") |
2585 | 240 | gw_url = 'http://otherstore.local:1234/' | 82 | discharge_macaroon = pymacaroons.Macaroon(location="test2") |
2586 | 241 | 83 | ||
2595 | 242 | self.mock_input.return_value = 'user@example.org' | 84 | def _login(session, auth_url, sso_url, email, password, otp=None): |
2596 | 243 | self.mock_getpass.return_value = 'secret' | 85 | assert_that(email, Equals("test@example.com")) |
2597 | 244 | root = self.make_root_macaroon() | 86 | return root_macaroon, discharge_macaroon |
2590 | 245 | self.add_issue_store_admin_response( | ||
2591 | 246 | {'status': 200, 'json': {'macaroon': root}}, gw_url=gw_url) | ||
2592 | 247 | self.add_get_sso_discharge_response( | ||
2593 | 248 | {'status': 200, 'json': {'discharge_macaroon': 'dummy'}}) | ||
2594 | 249 | login(self.make_args(store_url=gw_url)) | ||
2598 | 250 | 87 | ||
2609 | 251 | self.assertEqual(2, len(responses.calls)) | 88 | self.mock_with_side_effect( |
2610 | 252 | self.assertEqual(responses.calls[0].request.url[:len(gw_url)], gw_url) | 89 | "snapstore_library.authentication.login", _login |
2611 | 253 | self.assertTrue( | 90 | ) |
2612 | 254 | responses.calls[1].request.url.startswith(self.default_sso_url)) | 91 | self.mock_with_return("builtins.input", "test@example.com") |
2613 | 255 | self.assertThat(config.Config().parser, ContainsDict({ | 92 | self.mock_with_return("getpass.getpass", "secret") |
2614 | 256 | 'store:default': ContainsDict({ | 93 | |
2615 | 257 | 'gw_url': Equals(gw_url), | 94 | exit_status = self.run_main("login", "http://store.local/") |
2616 | 258 | 'sso_url': Equals(self.default_sso_url), | 95 | |
2617 | 259 | }), | 96 | assert_that(exit_status, Equals(0)) |
2618 | 260 | })) | 97 | assert_that( |
2619 | 98 | self.logger.output, | ||
2620 | 99 | Equals( | ||
2621 | 100 | "Enter your Ubuntu One SSO credentials.\n" | ||
2622 | 101 | "Login successful\n" | ||
2623 | 102 | "Configured http://store.local/ as the default store\n" | ||
2624 | 103 | ), | ||
2625 | 104 | ) | ||
2626 | 261 | 105 | ||
2630 | 262 | @responses.activate | 106 | def test_login_unhandled_error(self): |
2631 | 263 | def test_sso_url(self): | 107 | self.mock_with_side_effect( |
2632 | 264 | sso_url = 'https://othersso.local:1234/' | 108 | "snapstore_library.authentication.login", |
2633 | 109 | Exception("Some error happened"), | ||
2634 | 110 | ) | ||
2635 | 111 | self.mock_with_return("getpass.getpass", "secret") | ||
2636 | 265 | 112 | ||
2646 | 266 | self.mock_input.return_value = 'user@example.org' | 113 | exit_status = self.run_main( |
2647 | 267 | self.mock_getpass.return_value = 'secret' | 114 | "login", "http://store.local/", "test@example.com" |
2648 | 268 | root = self.make_root_macaroon(sso_url=sso_url) | 115 | ) |
2640 | 269 | self.add_issue_store_admin_response( | ||
2641 | 270 | {'status': 200, 'json': {'macaroon': root}}) | ||
2642 | 271 | self.add_get_sso_discharge_response( | ||
2643 | 272 | {'status': 200, 'json': {'discharge_macaroon': 'dummy'}}, | ||
2644 | 273 | sso_url=sso_url) | ||
2645 | 274 | login(self.make_args(sso_url=sso_url)) | ||
2649 | 275 | 116 | ||
2661 | 276 | self.assertEqual(2, len(responses.calls)) | 117 | assert_that(exit_status, Equals(1)) |
2662 | 277 | self.assertTrue( | 118 | assert_that( |
2663 | 278 | responses.calls[0].request.url.startswith(self.default_gw_url)) | 119 | self.logger.output, |
2664 | 279 | self.assertEqual( | 120 | Equals( |
2665 | 280 | responses.calls[1].request.url[:len(sso_url)], sso_url) | 121 | "Enter your Ubuntu One SSO credentials.\n" |
2666 | 281 | self.assertThat(config.Config().parser, ContainsDict({ | 122 | "Exception: Some error happened\n" |
2667 | 282 | 'store:default': ContainsDict({ | 123 | ), |
2668 | 283 | 'gw_url': Equals(self.default_gw_url), | 124 | ) |
2658 | 284 | 'sso_url': Equals(sso_url), | ||
2659 | 285 | }), | ||
2660 | 286 | })) | ||
2669 | diff --git a/snapstore_client/logic/tests/test_overrides.py b/snapstore_client/logic/tests/test_overrides.py | |||
2670 | index 05e32fc..5d53b48 100644 | |||
2671 | --- a/snapstore_client/logic/tests/test_overrides.py | |||
2672 | +++ b/snapstore_client/logic/tests/test_overrides.py | |||
2673 | @@ -1,314 +1,107 @@ | |||
2674 | 1 | # Copyright 2017 Canonical Ltd. | 1 | # Copyright 2017 Canonical Ltd. |
2988 | 2 | 2 | from testtools.assertions import assert_that | |
2989 | 3 | import json | 3 | from testtools.matchers import Equals |
2990 | 4 | from urllib.parse import urljoin | 4 | |
2991 | 5 | 5 | from snapstore_client.logic.tests.utils import RunMainTestCase | |
2992 | 6 | import fixtures | 6 | |
2993 | 7 | import responses | 7 | |
2994 | 8 | from testtools import TestCase | 8 | class OverridesTests(RunMainTestCase): |
2995 | 9 | 9 | def test_list_overrides(self): | |
2996 | 10 | from snapstore_client import config | 10 | overrides = { |
2997 | 11 | from snapstore_client.logic.overrides import ( | 11 | "overrides": [ |
2998 | 12 | delete_override, | 12 | { |
2999 | 13 | list_overrides, | 13 | "snap_id": "x" * 32, |
3000 | 14 | override, | 14 | "snap_name": "mysnap", |
3001 | 15 | ) | 15 | "revision": 1, |
3002 | 16 | from snapstore_client.tests import ( | 16 | "upstream_revision": 2, |
3003 | 17 | factory, | 17 | "channel": "stable", |
3004 | 18 | testfixtures, | 18 | "architecture": "amd64", |
3005 | 19 | ) | 19 | "series": 16, |
3006 | 20 | 20 | }, | |
3007 | 21 | 21 | { | |
3008 | 22 | class OverridesTests(TestCase): | 22 | "snap_id": "x" * 32, |
3009 | 23 | 23 | "snap_name": "mysnap", | |
3010 | 24 | def test_list_overrides_no_store_config(self): | 24 | "revision": 3, |
3011 | 25 | self.useFixture(testfixtures.ConfigFixture(empty=True)) | 25 | "upstream_revision": 5, |
3012 | 26 | logger = self.useFixture(fixtures.FakeLogger()) | 26 | "channel": "foo/stable", |
3013 | 27 | rc = list_overrides(factory.Args(snap_name='some-snap', series='16')) | 27 | "architecture": "i386", |
3014 | 28 | self.assertEqual(rc, 1) | 28 | "series": 16, |
3015 | 29 | self.assertEqual( | 29 | }, |
3016 | 30 | logger.output, | 30 | ] |
3017 | 31 | 'No store configuration found. ' | 31 | } |
3018 | 32 | 'Have you run "snap-store-proxy-client login"?\n') | 32 | self.mock_with_return( |
3019 | 33 | 33 | "snapstore_library.overrides.get_overrides", overrides | |
3020 | 34 | @responses.activate | 34 | ) |
3021 | 35 | def test_list_overrides_online(self): | 35 | |
3022 | 36 | self.useFixture(testfixtures.ConfigFixture()) | 36 | self.run_main_preconfigured("list-overrides", "mysnap") |
3023 | 37 | logger = self.useFixture(fixtures.FakeLogger()) | 37 | |
3024 | 38 | snap_id = factory.generate_snap_id() | 38 | assert_that( |
3025 | 39 | overrides = [ | 39 | self.logger.output, |
3026 | 40 | factory.SnapDeviceGateway.Override( | 40 | Equals( |
3027 | 41 | snap_id=snap_id, snap_name='mysnap'), | 41 | "mysnap stable amd64 1 (upstream 2)\n" |
3028 | 42 | factory.SnapDeviceGateway.Override( | 42 | "mysnap foo/stable i386 3 (upstream 5)\n" |
3029 | 43 | snap_id=snap_id, snap_name='mysnap', revision=3, | 43 | ), |
3030 | 44 | upstream_revision=4, channel='foo/stable', | 44 | ) |
3031 | 45 | architecture='i386'), | 45 | |
3032 | 46 | ] | 46 | def test_overrides(self): |
3033 | 47 | # XXX cjwatson 2017-06-26: Use acceptable-generated doubles once | 47 | overrides = { |
3034 | 48 | # they exist. | 48 | "overrides": [ |
3035 | 49 | overrides_url = urljoin( | 49 | { |
3036 | 50 | config.Config().store_section('default').get('gw_url'), | 50 | "snap_id": "x" * 32, |
3037 | 51 | '/v2/metadata/overrides/mysnap') | 51 | "snap_name": "mysnap", |
3038 | 52 | responses.add( | 52 | "revision": 1, |
3039 | 53 | 'GET', overrides_url, status=200, json={'overrides': overrides}) | 53 | "upstream_revision": 6, |
3040 | 54 | 54 | "channel": "stable", | |
3041 | 55 | list_overrides( | 55 | "architecture": "amd64", |
3042 | 56 | factory.Args(snap_name='mysnap', series='16', password=False)) | 56 | "series": 16, |
3043 | 57 | self.assertEqual( | 57 | }, |
3044 | 58 | 'mysnap stable amd64 1 (upstream 2)\n' | 58 | { |
3045 | 59 | 'mysnap foo/stable i386 3 (upstream 4)\n', | 59 | "snap_id": "x" * 32, |
3046 | 60 | logger.output) | 60 | "snap_name": "mysnap", |
3047 | 61 | # We shouldn't have Basic Authorization headers, but Macaroon | 61 | "revision": 3, |
3048 | 62 | self.assertNotIn( | 62 | "upstream_revision": 7, |
3049 | 63 | 'Basic', | 63 | "channel": "foo/stable", |
3050 | 64 | responses.calls[0].request.headers['Authorization']) | 64 | "architecture": "i386", |
3051 | 65 | 65 | "series": 16, | |
3052 | 66 | @responses.activate | 66 | }, |
3053 | 67 | def test_list_overrides_offline(self): | 67 | ] |
3054 | 68 | self.useFixture(testfixtures.ConfigFixture()) | 68 | } |
3055 | 69 | logger = self.useFixture(fixtures.FakeLogger()) | 69 | self.mock_with_return( |
3056 | 70 | snap_id = factory.generate_snap_id() | 70 | "snapstore_library.overrides.set_overrides", overrides |
3057 | 71 | overrides = [ | 71 | ) |
3058 | 72 | factory.SnapDeviceGateway.Override( | 72 | |
3059 | 73 | snap_id=snap_id, snap_name='mysnap'), | 73 | self.run_main_preconfigured( |
3060 | 74 | factory.SnapDeviceGateway.Override( | 74 | "override", "mysnap", "foo/stable=3", "stable=1" |
3061 | 75 | snap_id=snap_id, snap_name='mysnap', revision=3, | 75 | ) |
3062 | 76 | upstream_revision=4, channel='foo/stable', | 76 | |
3063 | 77 | architecture='i386'), | 77 | assert_that( |
3064 | 78 | ] | 78 | self.logger.output, |
3065 | 79 | # XXX cjwatson 2017-06-26: Use acceptable-generated doubles once | 79 | Equals( |
3066 | 80 | # they exist. | 80 | "mysnap stable amd64 1 (upstream 6)\n" |
3067 | 81 | overrides_url = urljoin( | 81 | "mysnap foo/stable i386 3 (upstream 7)\n" |
3068 | 82 | config.Config().store_section('default').get('gw_url'), | 82 | ), |
3069 | 83 | '/v2/metadata/overrides/mysnap') | 83 | ) |
3070 | 84 | responses.add( | 84 | |
3071 | 85 | 'GET', overrides_url, status=200, json={'overrides': overrides}) | 85 | def test_delete_overrides(self): |
3072 | 86 | 86 | overrides = { | |
3073 | 87 | list_overrides( | 87 | "overrides": [ |
3074 | 88 | factory.Args(snap_name='mysnap', series='16', password='test')) | 88 | { |
3075 | 89 | self.assertEqual( | 89 | "snap_id": "x" * 32, |
3076 | 90 | 'mysnap stable amd64 1 (upstream 2)\n' | 90 | "snap_name": "mysnap", |
3077 | 91 | 'mysnap foo/stable i386 3 (upstream 4)\n', | 91 | "revision": 1, |
3078 | 92 | logger.output) | 92 | "upstream_revision": 6, |
3079 | 93 | self.assertEqual( | 93 | "channel": "stable", |
3080 | 94 | 'Basic YWRtaW46dGVzdA==', | 94 | "architecture": "amd64", |
3081 | 95 | responses.calls[0].request.headers['Authorization']) | 95 | "series": 16, |
3082 | 96 | 96 | } | |
3083 | 97 | def test_override_no_store_config(self): | 97 | ] |
3084 | 98 | self.useFixture(testfixtures.ConfigFixture(empty=True)) | 98 | } |
3085 | 99 | logger = self.useFixture(fixtures.FakeLogger()) | 99 | self.mock_with_return( |
3086 | 100 | rc = override(factory.Args( | 100 | "snapstore_library.overrides.set_overrides", overrides |
3087 | 101 | snap_name='some-snap', channel_map_entries=['stable=1'], | 101 | ) |
3088 | 102 | series='16', | 102 | |
3089 | 103 | password=False)) | 103 | self.run_main_preconfigured("delete-override", "mysnap", "foo/stable") |
3090 | 104 | self.assertEqual(rc, 1) | 104 | |
3091 | 105 | self.assertEqual( | 105 | assert_that( |
3092 | 106 | logger.output, | 106 | self.logger.output, Equals("mysnap stable amd64 1 (upstream 6)\n") |
3093 | 107 | 'No store configuration found. ' | 107 | ) |
2781 | 108 | 'Have you run "snap-store-proxy-client login"?\n') | ||
2782 | 109 | |||
2783 | 110 | @responses.activate | ||
2784 | 111 | def test_override_online(self): | ||
2785 | 112 | self.useFixture(testfixtures.ConfigFixture()) | ||
2786 | 113 | logger = self.useFixture(fixtures.FakeLogger()) | ||
2787 | 114 | snap_id = factory.generate_snap_id() | ||
2788 | 115 | overrides = [ | ||
2789 | 116 | factory.SnapDeviceGateway.Override( | ||
2790 | 117 | snap_id=snap_id, snap_name='mysnap'), | ||
2791 | 118 | factory.SnapDeviceGateway.Override( | ||
2792 | 119 | snap_id=snap_id, snap_name='mysnap', revision=3, | ||
2793 | 120 | upstream_revision=4, channel='foo/stable', | ||
2794 | 121 | architecture='i386'), | ||
2795 | 122 | ] | ||
2796 | 123 | # XXX cjwatson 2017-06-26: Use acceptable-generated doubles once | ||
2797 | 124 | # they exist. | ||
2798 | 125 | overrides_url = urljoin( | ||
2799 | 126 | config.Config().store_section('default').get('gw_url'), | ||
2800 | 127 | '/v2/metadata/overrides') | ||
2801 | 128 | responses.add( | ||
2802 | 129 | 'POST', overrides_url, status=200, json={'overrides': overrides}) | ||
2803 | 130 | |||
2804 | 131 | override(factory.Args( | ||
2805 | 132 | snap_name='mysnap', | ||
2806 | 133 | channel_map_entries=['stable=1', 'foo/stable=3'], | ||
2807 | 134 | series='16', | ||
2808 | 135 | password=False)) | ||
2809 | 136 | self.assertEqual([ | ||
2810 | 137 | { | ||
2811 | 138 | 'snap_name': 'mysnap', | ||
2812 | 139 | 'revision': 1, | ||
2813 | 140 | 'channel': 'stable', | ||
2814 | 141 | 'series': '16', | ||
2815 | 142 | }, | ||
2816 | 143 | { | ||
2817 | 144 | 'snap_name': 'mysnap', | ||
2818 | 145 | 'revision': 3, | ||
2819 | 146 | 'channel': 'foo/stable', | ||
2820 | 147 | 'series': '16', | ||
2821 | 148 | }, | ||
2822 | 149 | ], json.loads(responses.calls[0].request.body.decode())) | ||
2823 | 150 | self.assertEqual( | ||
2824 | 151 | 'mysnap stable amd64 1 (upstream 2)\n' | ||
2825 | 152 | 'mysnap foo/stable i386 3 (upstream 4)\n', | ||
2826 | 153 | logger.output) | ||
2827 | 154 | # We shouldn't have Basic Authorization headers, but Macaroon | ||
2828 | 155 | self.assertNotIn( | ||
2829 | 156 | 'Basic', | ||
2830 | 157 | responses.calls[0].request.headers['Authorization']) | ||
2831 | 158 | |||
2832 | 159 | @responses.activate | ||
2833 | 160 | def test_override_offline(self): | ||
2834 | 161 | self.useFixture(testfixtures.ConfigFixture()) | ||
2835 | 162 | logger = self.useFixture(fixtures.FakeLogger()) | ||
2836 | 163 | snap_id = factory.generate_snap_id() | ||
2837 | 164 | overrides = [ | ||
2838 | 165 | factory.SnapDeviceGateway.Override( | ||
2839 | 166 | snap_id=snap_id, snap_name='mysnap'), | ||
2840 | 167 | factory.SnapDeviceGateway.Override( | ||
2841 | 168 | snap_id=snap_id, snap_name='mysnap', revision=3, | ||
2842 | 169 | upstream_revision=4, channel='foo/stable', | ||
2843 | 170 | architecture='i386'), | ||
2844 | 171 | ] | ||
2845 | 172 | # XXX cjwatson 2017-06-26: Use acceptable-generated doubles once | ||
2846 | 173 | # they exist. | ||
2847 | 174 | overrides_url = urljoin( | ||
2848 | 175 | config.Config().store_section('default').get('gw_url'), | ||
2849 | 176 | '/v2/metadata/overrides') | ||
2850 | 177 | responses.add( | ||
2851 | 178 | 'POST', overrides_url, status=200, json={'overrides': overrides}) | ||
2852 | 179 | |||
2853 | 180 | override(factory.Args( | ||
2854 | 181 | snap_name='mysnap', | ||
2855 | 182 | channel_map_entries=['stable=1', 'foo/stable=3'], | ||
2856 | 183 | series='16', | ||
2857 | 184 | password='test')) | ||
2858 | 185 | self.assertEqual([ | ||
2859 | 186 | { | ||
2860 | 187 | 'snap_name': 'mysnap', | ||
2861 | 188 | 'revision': 1, | ||
2862 | 189 | 'channel': 'stable', | ||
2863 | 190 | 'series': '16', | ||
2864 | 191 | }, | ||
2865 | 192 | { | ||
2866 | 193 | 'snap_name': 'mysnap', | ||
2867 | 194 | 'revision': 3, | ||
2868 | 195 | 'channel': 'foo/stable', | ||
2869 | 196 | 'series': '16', | ||
2870 | 197 | }, | ||
2871 | 198 | ], json.loads(responses.calls[0].request.body.decode())) | ||
2872 | 199 | self.assertEqual( | ||
2873 | 200 | 'mysnap stable amd64 1 (upstream 2)\n' | ||
2874 | 201 | 'mysnap foo/stable i386 3 (upstream 4)\n', | ||
2875 | 202 | logger.output) | ||
2876 | 203 | self.assertEqual( | ||
2877 | 204 | 'Basic YWRtaW46dGVzdA==', | ||
2878 | 205 | responses.calls[0].request.headers['Authorization']) | ||
2879 | 206 | |||
2880 | 207 | def test_delete_override_no_store_config(self): | ||
2881 | 208 | self.useFixture(testfixtures.ConfigFixture(empty=True)) | ||
2882 | 209 | logger = self.useFixture(fixtures.FakeLogger()) | ||
2883 | 210 | rc = delete_override(factory.Args( | ||
2884 | 211 | snap_name='some-snap', channels=['stable'], | ||
2885 | 212 | series='16', password=False)) | ||
2886 | 213 | self.assertEqual(rc, 1) | ||
2887 | 214 | self.assertEqual( | ||
2888 | 215 | logger.output, | ||
2889 | 216 | 'No store configuration found. ' | ||
2890 | 217 | 'Have you run "snap-store-proxy-client login"?\n') | ||
2891 | 218 | |||
2892 | 219 | @responses.activate | ||
2893 | 220 | def test_delete_override_online(self): | ||
2894 | 221 | self.useFixture(testfixtures.ConfigFixture()) | ||
2895 | 222 | logger = self.useFixture(fixtures.FakeLogger()) | ||
2896 | 223 | snap_id = factory.generate_snap_id() | ||
2897 | 224 | overrides = [ | ||
2898 | 225 | factory.SnapDeviceGateway.Override( | ||
2899 | 226 | snap_id=snap_id, snap_name='mysnap', revision=None), | ||
2900 | 227 | factory.SnapDeviceGateway.Override( | ||
2901 | 228 | snap_id=snap_id, snap_name='mysnap', revision=None, | ||
2902 | 229 | upstream_revision=4, channel='foo/stable', | ||
2903 | 230 | architecture='i386'), | ||
2904 | 231 | ] | ||
2905 | 232 | # XXX cjwatson 2017-06-26: Use acceptable-generated doubles once | ||
2906 | 233 | # they exist. | ||
2907 | 234 | overrides_url = urljoin( | ||
2908 | 235 | config.Config().store_section('default').get('gw_url'), | ||
2909 | 236 | '/v2/metadata/overrides') | ||
2910 | 237 | responses.add( | ||
2911 | 238 | 'POST', overrides_url, status=200, json={'overrides': overrides}) | ||
2912 | 239 | |||
2913 | 240 | delete_override(factory.Args( | ||
2914 | 241 | snap_name='mysnap', | ||
2915 | 242 | channels=['stable', 'foo/stable'], | ||
2916 | 243 | series='16', | ||
2917 | 244 | password=False)) | ||
2918 | 245 | self.assertEqual([ | ||
2919 | 246 | { | ||
2920 | 247 | 'snap_name': 'mysnap', | ||
2921 | 248 | 'revision': None, | ||
2922 | 249 | 'channel': 'stable', | ||
2923 | 250 | 'series': '16', | ||
2924 | 251 | }, | ||
2925 | 252 | { | ||
2926 | 253 | 'snap_name': 'mysnap', | ||
2927 | 254 | 'revision': None, | ||
2928 | 255 | 'channel': 'foo/stable', | ||
2929 | 256 | 'series': '16', | ||
2930 | 257 | }, | ||
2931 | 258 | ], json.loads(responses.calls[0].request.body.decode())) | ||
2932 | 259 | self.assertEqual( | ||
2933 | 260 | 'mysnap stable amd64 is tracking upstream (revision 2)\n' | ||
2934 | 261 | 'mysnap foo/stable i386 is tracking upstream (revision 4)\n', | ||
2935 | 262 | logger.output) | ||
2936 | 263 | # We shouldn't have Basic Authorization headers, but Macaroon | ||
2937 | 264 | self.assertNotIn( | ||
2938 | 265 | 'Basic', | ||
2939 | 266 | responses.calls[0].request.headers['Authorization']) | ||
2940 | 267 | |||
2941 | 268 | @responses.activate | ||
2942 | 269 | def test_delete_override_offline(self): | ||
2943 | 270 | self.useFixture(testfixtures.ConfigFixture()) | ||
2944 | 271 | logger = self.useFixture(fixtures.FakeLogger()) | ||
2945 | 272 | snap_id = factory.generate_snap_id() | ||
2946 | 273 | overrides = [ | ||
2947 | 274 | factory.SnapDeviceGateway.Override( | ||
2948 | 275 | snap_id=snap_id, snap_name='mysnap', revision=None), | ||
2949 | 276 | factory.SnapDeviceGateway.Override( | ||
2950 | 277 | snap_id=snap_id, snap_name='mysnap', revision=None, | ||
2951 | 278 | upstream_revision=4, channel='foo/stable', | ||
2952 | 279 | architecture='i386'), | ||
2953 | 280 | ] | ||
2954 | 281 | # XXX cjwatson 2017-06-26: Use acceptable-generated doubles once | ||
2955 | 282 | # they exist. | ||
2956 | 283 | overrides_url = urljoin( | ||
2957 | 284 | config.Config().store_section('default').get('gw_url'), | ||
2958 | 285 | '/v2/metadata/overrides') | ||
2959 | 286 | responses.add( | ||
2960 | 287 | 'POST', overrides_url, status=200, json={'overrides': overrides}) | ||
2961 | 288 | |||
2962 | 289 | delete_override(factory.Args( | ||
2963 | 290 | snap_name='mysnap', | ||
2964 | 291 | channels=['stable', 'foo/stable'], | ||
2965 | 292 | series='16', | ||
2966 | 293 | password='test')) | ||
2967 | 294 | self.assertEqual([ | ||
2968 | 295 | { | ||
2969 | 296 | 'snap_name': 'mysnap', | ||
2970 | 297 | 'revision': None, | ||
2971 | 298 | 'channel': 'stable', | ||
2972 | 299 | 'series': '16', | ||
2973 | 300 | }, | ||
2974 | 301 | { | ||
2975 | 302 | 'snap_name': 'mysnap', | ||
2976 | 303 | 'revision': None, | ||
2977 | 304 | 'channel': 'foo/stable', | ||
2978 | 305 | 'series': '16', | ||
2979 | 306 | }, | ||
2980 | 307 | ], json.loads(responses.calls[0].request.body.decode())) | ||
2981 | 308 | self.assertEqual( | ||
2982 | 309 | 'mysnap stable amd64 is tracking upstream (revision 2)\n' | ||
2983 | 310 | 'mysnap foo/stable i386 is tracking upstream (revision 4)\n', | ||
2984 | 311 | logger.output) | ||
2985 | 312 | self.assertEqual( | ||
2986 | 313 | 'Basic YWRtaW46dGVzdA==', | ||
2987 | 314 | responses.calls[0].request.headers['Authorization']) | ||
3094 | diff --git a/snapstore_client/logic/tests/utils.py b/snapstore_client/logic/tests/utils.py | |||
3095 | 315 | new file mode 100644 | 108 | new file mode 100644 |
3096 | index 0000000..fd2d596 | |||
3097 | --- /dev/null | |||
3098 | +++ b/snapstore_client/logic/tests/utils.py | |||
3099 | @@ -0,0 +1,42 @@ | |||
3100 | 1 | import sys | ||
3101 | 2 | |||
3102 | 3 | import testtools | ||
3103 | 4 | import fixtures | ||
3104 | 5 | |||
3105 | 6 | from snapstore_client.__main__ import main | ||
3106 | 7 | from snapstore_client.tests import testfixtures | ||
3107 | 8 | |||
3108 | 9 | |||
3109 | 10 | class RunMainTestCase(testtools.TestCase): | ||
3110 | 11 | def mock(self, function): | ||
3111 | 12 | return self.useFixture(fixtures.MockPatch(function)).mock | ||
3112 | 13 | |||
3113 | 14 | def mock_with_return(self, function, rtn): | ||
3114 | 15 | mock = self.mock(function) | ||
3115 | 16 | mock.return_value = rtn | ||
3116 | 17 | return mock | ||
3117 | 18 | |||
3118 | 19 | def mock_with_side_effect(self, function, side_effect): | ||
3119 | 20 | mock = self.mock(function) | ||
3120 | 21 | mock.side_effect = side_effect | ||
3121 | 22 | return mock | ||
3122 | 23 | |||
3123 | 24 | def _run_main(self, *args, empty_config=True): | ||
3124 | 25 | self.useFixture(testfixtures.ConfigFixture(empty=empty_config)) | ||
3125 | 26 | self.mock("snapstore_client.__main__.configure_logging") | ||
3126 | 27 | self.logger = self.useFixture(fixtures.FakeLogger()) | ||
3127 | 28 | |||
3128 | 29 | old_argv = sys.argv | ||
3129 | 30 | try: | ||
3130 | 31 | sys.argv = ["snap-store-proxy-client"] + list(args) | ||
3131 | 32 | return main() | ||
3132 | 33 | except SystemExit as se: | ||
3133 | 34 | raise AssertionError("Exited with {}".format(se.code)) | ||
3134 | 35 | finally: | ||
3135 | 36 | sys.argv = old_argv | ||
3136 | 37 | |||
3137 | 38 | def run_main(self, *args): | ||
3138 | 39 | return self._run_main(*args, empty_config=True) | ||
3139 | 40 | |||
3140 | 41 | def run_main_preconfigured(self, *args): | ||
3141 | 42 | return self._run_main(*args, empty_config=False) | ||
3142 | diff --git a/snapstore_client/tests/test_webservices.py b/snapstore_client/tests/test_webservices.py | |||
3143 | 0 | deleted file mode 100644 | 43 | deleted file mode 100644 |
3144 | index c9c743c..0000000 | |||
3145 | --- a/snapstore_client/tests/test_webservices.py | |||
3146 | +++ /dev/null | |||
3147 | @@ -1,228 +0,0 @@ | |||
3148 | 1 | # Copyright 2017 Canonical Ltd. | ||
3149 | 2 | |||
3150 | 3 | import json | ||
3151 | 4 | import sys | ||
3152 | 5 | from urllib.parse import urljoin | ||
3153 | 6 | |||
3154 | 7 | import fixtures | ||
3155 | 8 | from requests.exceptions import HTTPError | ||
3156 | 9 | import responses | ||
3157 | 10 | from testtools import TestCase | ||
3158 | 11 | |||
3159 | 12 | from snapstore_client import ( | ||
3160 | 13 | config, | ||
3161 | 14 | exceptions, | ||
3162 | 15 | webservices, | ||
3163 | 16 | ) | ||
3164 | 17 | from snapstore_client.tests import ( | ||
3165 | 18 | factory, | ||
3166 | 19 | matchers, | ||
3167 | 20 | testfixtures, | ||
3168 | 21 | ) | ||
3169 | 22 | |||
3170 | 23 | if sys.version < '3.6': | ||
3171 | 24 | import sha3 # noqa | ||
3172 | 25 | |||
3173 | 26 | |||
3174 | 27 | class WebservicesTests(TestCase): | ||
3175 | 28 | |||
3176 | 29 | def setUp(self): | ||
3177 | 30 | super().setUp() | ||
3178 | 31 | self.config = self.useFixture(testfixtures.ConfigFixture()) | ||
3179 | 32 | |||
3180 | 33 | @responses.activate | ||
3181 | 34 | def test_issue_store_admin_success(self): | ||
3182 | 35 | gw_url = 'http://store.local/' | ||
3183 | 36 | issue_store_admin_url = urljoin(gw_url, '/v2/auth/issue-store-admin') | ||
3184 | 37 | responses.add( | ||
3185 | 38 | 'POST', issue_store_admin_url, status=200, | ||
3186 | 39 | json={'macaroon': 'dummy'}) | ||
3187 | 40 | |||
3188 | 41 | self.assertEqual('dummy', webservices.issue_store_admin(gw_url)) | ||
3189 | 42 | |||
3190 | 43 | @responses.activate | ||
3191 | 44 | def test_issue_store_admin_error(self): | ||
3192 | 45 | logger = self.useFixture(fixtures.FakeLogger()) | ||
3193 | 46 | gw_url = 'http://store.local/' | ||
3194 | 47 | issue_store_admin_url = urljoin(gw_url, '/v2/auth/issue-store-admin') | ||
3195 | 48 | responses.add( | ||
3196 | 49 | 'POST', issue_store_admin_url, status=400, | ||
3197 | 50 | json=factory.APIError.single('Something went wrong').to_dict()) | ||
3198 | 51 | |||
3199 | 52 | self.assertRaises( | ||
3200 | 53 | HTTPError, webservices.issue_store_admin, gw_url) | ||
3201 | 54 | self.assertEqual( | ||
3202 | 55 | 'Failed to issue store_admin macaroon:\nSomething went wrong\n', | ||
3203 | 56 | logger.output) | ||
3204 | 57 | |||
3205 | 58 | @responses.activate | ||
3206 | 59 | def test_get_sso_discharge_success(self): | ||
3207 | 60 | sso_url = 'http://sso.local/' | ||
3208 | 61 | discharge_url = urljoin(sso_url, '/api/v2/tokens/discharge') | ||
3209 | 62 | responses.add( | ||
3210 | 63 | 'POST', discharge_url, status=200, | ||
3211 | 64 | json={'discharge_macaroon': 'dummy'}) | ||
3212 | 65 | |||
3213 | 66 | self.assertEqual( | ||
3214 | 67 | 'dummy', | ||
3215 | 68 | webservices.get_sso_discharge( | ||
3216 | 69 | sso_url, 'user@example.org', 'secret', 'caveat')) | ||
3217 | 70 | request = responses.calls[0].request | ||
3218 | 71 | self.assertEqual('application/json', request.headers['Content-Type']) | ||
3219 | 72 | self.assertEqual({ | ||
3220 | 73 | 'email': 'user@example.org', | ||
3221 | 74 | 'password': 'secret', | ||
3222 | 75 | 'caveat_id': 'caveat', | ||
3223 | 76 | }, json.loads(request.body.decode())) | ||
3224 | 77 | |||
3225 | 78 | @responses.activate | ||
3226 | 79 | def test_get_sso_discharge_success_with_otp(self): | ||
3227 | 80 | sso_url = 'http://sso.local/' | ||
3228 | 81 | discharge_url = urljoin(sso_url, '/api/v2/tokens/discharge') | ||
3229 | 82 | responses.add( | ||
3230 | 83 | 'POST', discharge_url, status=200, | ||
3231 | 84 | json={'discharge_macaroon': 'dummy'}) | ||
3232 | 85 | |||
3233 | 86 | self.assertEqual( | ||
3234 | 87 | 'dummy', | ||
3235 | 88 | webservices.get_sso_discharge( | ||
3236 | 89 | sso_url, 'user@example.org', 'secret', 'caveat', | ||
3237 | 90 | one_time_password='123456')) | ||
3238 | 91 | request = responses.calls[0].request | ||
3239 | 92 | self.assertEqual('application/json', request.headers['Content-Type']) | ||
3240 | 93 | self.assertEqual({ | ||
3241 | 94 | 'email': 'user@example.org', | ||
3242 | 95 | 'password': 'secret', | ||
3243 | 96 | 'caveat_id': 'caveat', | ||
3244 | 97 | 'otp': '123456', | ||
3245 | 98 | }, json.loads(request.body.decode())) | ||
3246 | 99 | |||
3247 | 100 | @responses.activate | ||
3248 | 101 | def test_get_sso_discharge_twofactor_required(self): | ||
3249 | 102 | sso_url = 'http://sso.local/' | ||
3250 | 103 | discharge_url = urljoin(sso_url, '/api/v2/tokens/discharge') | ||
3251 | 104 | responses.add( | ||
3252 | 105 | 'POST', discharge_url, status=401, | ||
3253 | 106 | json={'error_list': [{'code': 'twofactor-required'}]}) | ||
3254 | 107 | |||
3255 | 108 | self.assertRaises( | ||
3256 | 109 | exceptions.StoreTwoFactorAuthenticationRequired, | ||
3257 | 110 | webservices.get_sso_discharge, | ||
3258 | 111 | sso_url, 'user@example.org', 'secret', 'caveat') | ||
3259 | 112 | |||
3260 | 113 | @responses.activate | ||
3261 | 114 | def test_get_sso_discharge_structured_error(self): | ||
3262 | 115 | sso_url = 'http://sso.local/' | ||
3263 | 116 | discharge_url = urljoin(sso_url, '/api/v2/tokens/discharge') | ||
3264 | 117 | responses.add( | ||
3265 | 118 | 'POST', discharge_url, status=400, | ||
3266 | 119 | json={'error_list': [{'code': 'invalid-request', | ||
3267 | 120 | 'message': 'Something went wrong'}]}) | ||
3268 | 121 | |||
3269 | 122 | e = self.assertRaises( | ||
3270 | 123 | exceptions.StoreAuthenticationError, webservices.get_sso_discharge, | ||
3271 | 124 | sso_url, 'user@example.org', 'secret', 'caveat') | ||
3272 | 125 | self.assertEqual('Something went wrong', e.message) | ||
3273 | 126 | |||
3274 | 127 | @responses.activate | ||
3275 | 128 | def test_get_sso_discharge_unstructured_error(self): | ||
3276 | 129 | logger = self.useFixture(fixtures.FakeLogger()) | ||
3277 | 130 | sso_url = 'http://sso.local/' | ||
3278 | 131 | discharge_url = urljoin(sso_url, '/api/v2/tokens/discharge') | ||
3279 | 132 | responses.add( | ||
3280 | 133 | 'POST', discharge_url, status=503, body='Try again later.') | ||
3281 | 134 | |||
3282 | 135 | self.assertRaises( | ||
3283 | 136 | HTTPError, webservices.get_sso_discharge, | ||
3284 | 137 | sso_url, 'user@example.org', 'secret', 'caveat') | ||
3285 | 138 | self.assertEqual( | ||
3286 | 139 | 'Failed to get SSO discharge:\n' | ||
3287 | 140 | '====================\n' | ||
3288 | 141 | 'Try again later.\n' | ||
3289 | 142 | '====================\n', | ||
3290 | 143 | logger.output) | ||
3291 | 144 | |||
3292 | 145 | @responses.activate | ||
3293 | 146 | def test_get_overrides_success(self): | ||
3294 | 147 | logger = self.useFixture(fixtures.FakeLogger()) | ||
3295 | 148 | overrides = [factory.SnapDeviceGateway.Override()] | ||
3296 | 149 | # XXX cjwatson 2017-06-26: Use acceptable-generated double once it | ||
3297 | 150 | # exists. | ||
3298 | 151 | store = config.Config().store_section('default') | ||
3299 | 152 | overrides_url = urljoin( | ||
3300 | 153 | store.get('gw_url'), '/v2/metadata/overrides/mysnap') | ||
3301 | 154 | responses.add('GET', overrides_url, status=200, json=overrides) | ||
3302 | 155 | |||
3303 | 156 | self.assertEqual(overrides, webservices.get_overrides( | ||
3304 | 157 | store, 'mysnap')) | ||
3305 | 158 | request = responses.calls[0].request | ||
3306 | 159 | self.assertThat( | ||
3307 | 160 | request.headers['Authorization'], | ||
3308 | 161 | matchers.MacaroonHeaderVerifies(self.config.key)) | ||
3309 | 162 | self.assertNotIn('Failed to get overrides:', logger.output) | ||
3310 | 163 | |||
3311 | 164 | @responses.activate | ||
3312 | 165 | def test_get_overrides_error(self): | ||
3313 | 166 | logger = self.useFixture(fixtures.FakeLogger()) | ||
3314 | 167 | store = config.Config().store_section('default') | ||
3315 | 168 | overrides_url = urljoin( | ||
3316 | 169 | store.get('gw_url'), '/v2/metadata/overrides/mysnap') | ||
3317 | 170 | responses.add( | ||
3318 | 171 | 'GET', overrides_url, status=400, | ||
3319 | 172 | json=factory.APIError.single('Something went wrong').to_dict()) | ||
3320 | 173 | |||
3321 | 174 | self.assertRaises( | ||
3322 | 175 | HTTPError, webservices.get_overrides, store, 'mysnap') | ||
3323 | 176 | self.assertEqual( | ||
3324 | 177 | 'Failed to get overrides:\nSomething went wrong\n', logger.output) | ||
3325 | 178 | |||
3326 | 179 | @responses.activate | ||
3327 | 180 | def test_set_overrides_success(self): | ||
3328 | 181 | logger = self.useFixture(fixtures.FakeLogger()) | ||
3329 | 182 | override = factory.SnapDeviceGateway.Override() | ||
3330 | 183 | # XXX cjwatson 2017-06-26: Use acceptable-generated double once it | ||
3331 | 184 | # exists. | ||
3332 | 185 | store = config.Config().store_section('default') | ||
3333 | 186 | overrides_url = urljoin(store.get('gw_url'), '/v2/metadata/overrides') | ||
3334 | 187 | responses.add('POST', overrides_url, status=200, json=[override]) | ||
3335 | 188 | |||
3336 | 189 | self.assertEqual([override], webservices.set_overrides( | ||
3337 | 190 | store, [{ | ||
3338 | 191 | 'snap_name': override['snap_name'], | ||
3339 | 192 | 'revision': override['revision'], | ||
3340 | 193 | 'channel': override['channel'], | ||
3341 | 194 | 'series': override['series'], | ||
3342 | 195 | }])) | ||
3343 | 196 | request = responses.calls[0].request | ||
3344 | 197 | self.assertThat( | ||
3345 | 198 | request.headers['Authorization'], | ||
3346 | 199 | matchers.MacaroonHeaderVerifies(self.config.key)) | ||
3347 | 200 | self.assertEqual([{ | ||
3348 | 201 | 'snap_name': override['snap_name'], | ||
3349 | 202 | 'revision': override['revision'], | ||
3350 | 203 | 'channel': override['channel'], | ||
3351 | 204 | 'series': override['series'], | ||
3352 | 205 | }], json.loads(request.body.decode())) | ||
3353 | 206 | self.assertNotIn('Failed to set override:', logger.output) | ||
3354 | 207 | |||
3355 | 208 | @responses.activate | ||
3356 | 209 | def test_set_overrides_error(self): | ||
3357 | 210 | logger = self.useFixture(fixtures.FakeLogger()) | ||
3358 | 211 | override = factory.SnapDeviceGateway.Override() | ||
3359 | 212 | # XXX cjwatson 2017-06-26: Use acceptable-generated double once it | ||
3360 | 213 | # exists. | ||
3361 | 214 | store = config.Config().store_section('default') | ||
3362 | 215 | overrides_url = urljoin(store.get('gw_url'), '/v2/metadata/overrides') | ||
3363 | 216 | responses.add( | ||
3364 | 217 | 'POST', overrides_url, status=400, | ||
3365 | 218 | json=factory.APIError.single('Something went wrong').to_dict()) | ||
3366 | 219 | |||
3367 | 220 | self.assertRaises(HTTPError, lambda: webservices.set_overrides( | ||
3368 | 221 | store, { | ||
3369 | 222 | 'snap_name': override['snap_name'], | ||
3370 | 223 | 'revision': override['revision'], | ||
3371 | 224 | 'channel': override['channel'], | ||
3372 | 225 | 'series': override['series'], | ||
3373 | 226 | })) | ||
3374 | 227 | self.assertEqual( | ||
3375 | 228 | 'Failed to set override:\nSomething went wrong\n', logger.output) | ||
3376 | diff --git a/snapstore_client/utils.py b/snapstore_client/utils.py | |||
3377 | index 461a1c3..8b10606 100644 | |||
3378 | --- a/snapstore_client/utils.py | |||
3379 | +++ b/snapstore_client/utils.py | |||
3380 | @@ -3,11 +3,6 @@ import logging | |||
3381 | 3 | logger = logging.getLogger(__name__) | 3 | logger = logging.getLogger(__name__) |
3382 | 4 | 4 | ||
3383 | 5 | 5 | ||
3384 | 6 | def _log_credentials_error(e): | ||
3385 | 7 | logger.error('%s', e) | ||
3386 | 8 | logger.error('Try to "snap-store-proxy-client login" again.') | ||
3387 | 9 | |||
3388 | 10 | |||
3389 | 11 | def _log_authorized_error(): | 6 | def _log_authorized_error(): |
3390 | 12 | logger.error(("Perhaps you have not been registered as an " | 7 | logger.error(("Perhaps you have not been registered as an " |
3391 | 13 | "admin with the proxy.")) | 8 | "admin with the proxy.")) |
3392 | diff --git a/snapstore_client/webservices.py b/snapstore_client/webservices.py | |||
3393 | 14 | deleted file mode 100644 | 9 | deleted file mode 100644 |
3394 | index 3ebf149..0000000 | |||
3395 | --- a/snapstore_client/webservices.py | |||
3396 | +++ /dev/null | |||
3397 | @@ -1,185 +0,0 @@ | |||
3398 | 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the | ||
3399 | 2 | # GNU General Public License version 3 (see the file LICENSE). | ||
3400 | 3 | |||
3401 | 4 | import base64 | ||
3402 | 5 | import json | ||
3403 | 6 | import logging | ||
3404 | 7 | import urllib.parse | ||
3405 | 8 | |||
3406 | 9 | from pymacaroons import Macaroon | ||
3407 | 10 | import requests | ||
3408 | 11 | |||
3409 | 12 | from snapstore_client import exceptions | ||
3410 | 13 | |||
3411 | 14 | |||
3412 | 15 | logger = logging.getLogger(__name__) | ||
3413 | 16 | |||
3414 | 17 | |||
3415 | 18 | def issue_store_admin(gw_url): | ||
3416 | 19 | """Ask the store to issue a store_admin macaroon.""" | ||
3417 | 20 | issue_store_admin_url = urllib.parse.urljoin( | ||
3418 | 21 | gw_url, '/v2/auth/issue-store-admin') | ||
3419 | 22 | try: | ||
3420 | 23 | resp = requests.post(issue_store_admin_url) | ||
3421 | 24 | except requests.exceptions.ConnectionError: | ||
3422 | 25 | raise exceptions.StoreCommunicationError(gw_url) | ||
3423 | 26 | except requests.exceptions.RequestException: | ||
3424 | 27 | raise exceptions.InvalidStoreURL(gw_url) | ||
3425 | 28 | if resp.status_code == 404: | ||
3426 | 29 | _print_error_message('issue store_admin macaroon', resp) | ||
3427 | 30 | raise exceptions.InvalidStoreURL(gw_url) | ||
3428 | 31 | elif resp.status_code != 200: | ||
3429 | 32 | _print_error_message('issue store_admin macaroon', resp) | ||
3430 | 33 | resp.raise_for_status() | ||
3431 | 34 | return resp.json()['macaroon'] | ||
3432 | 35 | |||
3433 | 36 | |||
3434 | 37 | def get_sso_discharge(sso_url, email, password, caveat_id, | ||
3435 | 38 | one_time_password=None): | ||
3436 | 39 | discharge_url = urllib.parse.urljoin( | ||
3437 | 40 | sso_url, '/api/v2/tokens/discharge') | ||
3438 | 41 | data = {'email': email, 'password': password, 'caveat_id': caveat_id} | ||
3439 | 42 | if one_time_password is not None: | ||
3440 | 43 | data['otp'] = one_time_password | ||
3441 | 44 | resp = requests.post( | ||
3442 | 45 | discharge_url, headers={'Accept': 'application/json'}, json=data) | ||
3443 | 46 | if not resp.ok: | ||
3444 | 47 | try: | ||
3445 | 48 | error_list = resp.json().get('error_list', []) | ||
3446 | 49 | if resp.status_code == 401: | ||
3447 | 50 | for error in error_list: | ||
3448 | 51 | if error.get('code') == 'twofactor-required': | ||
3449 | 52 | raise exceptions.StoreTwoFactorAuthenticationRequired() | ||
3450 | 53 | if error_list: | ||
3451 | 54 | # Only bother about the first error. | ||
3452 | 55 | error = error_list[0] | ||
3453 | 56 | raise exceptions.StoreAuthenticationError( | ||
3454 | 57 | error['message'], extra=error.get('extra')) | ||
3455 | 58 | except json.JSONDecodeError: | ||
3456 | 59 | pass | ||
3457 | 60 | _print_error_message('get SSO discharge', resp) | ||
3458 | 61 | resp.raise_for_status() | ||
3459 | 62 | return resp.json()['discharge_macaroon'] | ||
3460 | 63 | |||
3461 | 64 | |||
3462 | 65 | def refresh_sso_discharge(store, unbound_discharge_raw): | ||
3463 | 66 | refresh_url = urllib.parse.urljoin( | ||
3464 | 67 | store.get('sso_url'), '/api/v2/tokens/refresh') | ||
3465 | 68 | data = {'discharge_macaroon': unbound_discharge_raw} | ||
3466 | 69 | resp = requests.post( | ||
3467 | 70 | refresh_url, headers={'Accept': 'application/json'}, json=data) | ||
3468 | 71 | if not resp.ok: | ||
3469 | 72 | _print_error_message('refresh SSO discharge', resp) | ||
3470 | 73 | resp.raise_for_status() | ||
3471 | 74 | return resp.json()['discharge_macaroon'] | ||
3472 | 75 | |||
3473 | 76 | |||
3474 | 77 | def _deserialize_macaroon(name, value): | ||
3475 | 78 | if value is None: | ||
3476 | 79 | raise exceptions.InvalidCredentials('no {} macaroon'.format(name)) | ||
3477 | 80 | try: | ||
3478 | 81 | return Macaroon.deserialize(value) | ||
3479 | 82 | except Exception: | ||
3480 | 83 | raise exceptions.InvalidCredentials( | ||
3481 | 84 | 'failed to deserialize {} macaroon'.format(name)) | ||
3482 | 85 | |||
3483 | 86 | |||
3484 | 87 | def _get_macaroon_auth(store): | ||
3485 | 88 | """Return an Authorization header containing store macaroons.""" | ||
3486 | 89 | root_raw = store.get('root') | ||
3487 | 90 | root = _deserialize_macaroon('root', root_raw) | ||
3488 | 91 | unbound_discharge_raw = store.get('unbound_discharge') | ||
3489 | 92 | unbound_discharge = _deserialize_macaroon( | ||
3490 | 93 | 'unbound discharge', unbound_discharge_raw) | ||
3491 | 94 | bound_discharge = root.prepare_for_request(unbound_discharge) | ||
3492 | 95 | bound_discharge_raw = bound_discharge.serialize() | ||
3493 | 96 | return 'Macaroon root="{}", discharge="{}"'.format( | ||
3494 | 97 | root_raw, bound_discharge_raw) | ||
3495 | 98 | |||
3496 | 99 | |||
3497 | 100 | def _get_basic_auth(password): | ||
3498 | 101 | """Build the basic auth for interacting with an offline proxy""" | ||
3499 | 102 | # XXX twom 2019-03-15 Hardcoded username, awaiting user management | ||
3500 | 103 | username = 'admin' | ||
3501 | 104 | credentials = '{}:{}'.format(username, password) | ||
3502 | 105 | try: | ||
3503 | 106 | encoded_credentials = base64.b64encode(credentials.encode('UTF-8')) | ||
3504 | 107 | except UnicodeEncodeError: | ||
3505 | 108 | logger.error('Unable to encode password to UTF-8') | ||
3506 | 109 | raise | ||
3507 | 110 | return 'Basic {}'.format(encoded_credentials.decode()) | ||
3508 | 111 | |||
3509 | 112 | |||
3510 | 113 | def _raise_needs_refresh(response): | ||
3511 | 114 | if (response.status_code == 401 and | ||
3512 | 115 | response.headers.get('WWW-Authenticate') == ( | ||
3513 | 116 | 'Macaroon needs_refresh=1')): | ||
3514 | 117 | raise exceptions.StoreMacaroonNeedsRefresh() | ||
3515 | 118 | |||
3516 | 119 | |||
3517 | 120 | def refresh_if_necessary(store, func, *args, **kwargs): | ||
3518 | 121 | """Make a request, refreshing macaroons if necessary.""" | ||
3519 | 122 | try: | ||
3520 | 123 | return func(*args, **kwargs) | ||
3521 | 124 | except exceptions.StoreMacaroonNeedsRefresh: | ||
3522 | 125 | unbound_discharge = refresh_sso_discharge( | ||
3523 | 126 | store.get('sso_url'), store.get('unbound_discharge')) | ||
3524 | 127 | store.set('unbound_discharge', unbound_discharge) | ||
3525 | 128 | store.save() | ||
3526 | 129 | return func(*args, **kwargs) | ||
3527 | 130 | |||
3528 | 131 | |||
3529 | 132 | def get_overrides(store, snap_name, series='16', password=None): | ||
3530 | 133 | """Get all overrides for a snap.""" | ||
3531 | 134 | overrides_url = urllib.parse.urljoin( | ||
3532 | 135 | store.get('gw_url'), | ||
3533 | 136 | '/v2/metadata/overrides/{}'.format(urllib.parse.quote_plus(snap_name))) | ||
3534 | 137 | headers = { | ||
3535 | 138 | 'X-Ubuntu-Series': series, | ||
3536 | 139 | } | ||
3537 | 140 | if password: | ||
3538 | 141 | headers['Authorization'] = _get_basic_auth(password) | ||
3539 | 142 | else: | ||
3540 | 143 | headers['Authorization'] = _get_macaroon_auth(store) | ||
3541 | 144 | resp = requests.get(overrides_url, headers=headers) | ||
3542 | 145 | _raise_needs_refresh(resp) | ||
3543 | 146 | if resp.status_code != 200: | ||
3544 | 147 | _print_error_message('get overrides', resp) | ||
3545 | 148 | resp.raise_for_status() | ||
3546 | 149 | return resp.json() | ||
3547 | 150 | |||
3548 | 151 | |||
3549 | 152 | def set_overrides(store, overrides, password=None): | ||
3550 | 153 | """Add or remove channel map overrides for a snap.""" | ||
3551 | 154 | overrides_url = urllib.parse.urljoin( | ||
3552 | 155 | store.get('gw_url'), '/v2/metadata/overrides') | ||
3553 | 156 | if password: | ||
3554 | 157 | headers = {'Authorization': _get_basic_auth(password)} | ||
3555 | 158 | else: | ||
3556 | 159 | headers = {'Authorization': _get_macaroon_auth(store)} | ||
3557 | 160 | resp = requests.post(overrides_url, headers=headers, json=overrides) | ||
3558 | 161 | _raise_needs_refresh(resp) | ||
3559 | 162 | if resp.status_code != 200: | ||
3560 | 163 | _print_error_message('set override', resp) | ||
3561 | 164 | resp.raise_for_status() | ||
3562 | 165 | return resp.json() | ||
3563 | 166 | |||
3564 | 167 | |||
3565 | 168 | def _print_error_message(action, response): | ||
3566 | 169 | """Print failure messages from other services in a standard way.""" | ||
3567 | 170 | logger.error("Failed to %s:", action) | ||
3568 | 171 | if response.status_code == 500: | ||
3569 | 172 | logger.error("Server sent 500 response.") | ||
3570 | 173 | elif response.status_code == 404: | ||
3571 | 174 | logger.error("Server sent 404 response") | ||
3572 | 175 | else: | ||
3573 | 176 | try: | ||
3574 | 177 | json_document = response.json() | ||
3575 | 178 | error_list = json_document.get( | ||
3576 | 179 | 'error-list', json_document.get('error_list', [])) | ||
3577 | 180 | for error in error_list: | ||
3578 | 181 | logger.error(error['message']) | ||
3579 | 182 | except json.JSONDecodeError: | ||
3580 | 183 | logger.error('=' * 20) | ||
3581 | 184 | logger.error(response.content.decode('UTF-8', errors='replace')) | ||
3582 | 185 | logger.error('=' * 20) |
Ok, so this is a big MP to review, which is not great
It might have have been better to split it into 2 or 3, 1 just adding the python-ols snapcraft plugin, one for overrides and one for authentication?
That said, I think the tests are much improved, and the use of the library makes this all much nicer.