Add a Python package for fetch_artifact.
We keep duplicating this function all over the place. Factor it out and modernize it. Test: all the test commands named in the readme Bug: None Change-Id: I8d90a9c101b9cf83d62d02141f40c6c3666bdf94
This commit is contained in:
1
python-packages/fetchartifact/OWNERS
Normal file
1
python-packages/fetchartifact/OWNERS
Normal file
@@ -0,0 +1 @@
|
|||||||
|
danalbert@google.com
|
||||||
64
python-packages/fetchartifact/README.md
Normal file
64
python-packages/fetchartifact/README.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# fetchartifact
|
||||||
|
|
||||||
|
This is a Python interface to http://go/fetchartifact, which is used for
|
||||||
|
fetching artifacts from http://go/ab.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fetchartifact import fetchartifact
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
artifacts = await fetch_artifact(
|
||||||
|
branch="aosp-master-ndk",
|
||||||
|
target="linux",
|
||||||
|
build="1234",
|
||||||
|
pattern="android-ndk-*.zip",
|
||||||
|
)
|
||||||
|
for artifact in artifacts:
|
||||||
|
print(f"Downloaded {artifact}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
For first time set-up, install https://python-poetry.org/, then run
|
||||||
|
`poetry install` to install the project's dependencies.
|
||||||
|
|
||||||
|
This project uses mypy and pylint for linting, black and isort for
|
||||||
|
auto-formatting, and pytest for testing. All of these tools will be installed
|
||||||
|
automatically, but you may want to configure editor integration for them.
|
||||||
|
|
||||||
|
To run any of the tools poetry installed, you can either prefix all your
|
||||||
|
commands with `poetry run` (as in `poetry run pytest`), or you can run
|
||||||
|
`poetry shell` to enter a shell with all the tools on the `PATH`. The following
|
||||||
|
instructions assume you've run `poetry shell` first.
|
||||||
|
|
||||||
|
To run the linters:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mypy fetchartifact tests
|
||||||
|
pylint fetchartifact tests
|
||||||
|
```
|
||||||
|
|
||||||
|
To auto-format the code (though I recommend configuring your editor to do this
|
||||||
|
on save):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
isort .
|
||||||
|
black .
|
||||||
|
```
|
||||||
|
|
||||||
|
To run the tests and generate coverage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest --cov=fetchartifact
|
||||||
|
```
|
||||||
|
|
||||||
|
Optionally, pass `--cov-report=html` to generate an HTML report, or
|
||||||
|
`--cov-report=xml` to generate an XML report for your editor.
|
||||||
|
|
||||||
|
Some tests require network access. If you need to run the tests in an
|
||||||
|
environment that cannot access the Android build servers, add
|
||||||
|
`-m "not requires_network"` to skip those tests. Only a mock service can be
|
||||||
|
tested without network access.
|
||||||
112
python-packages/fetchartifact/fetchartifact/__init__.py
Normal file
112
python-packages/fetchartifact/fetchartifact/__init__.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
#
|
||||||
|
# Copyright (C) 2023 The Android Open Source Project
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
"""A Python interface to https://android.googlesource.com/tools/fetch_artifact/."""
|
||||||
|
import logging
|
||||||
|
import urllib
|
||||||
|
from collections.abc import AsyncIterable
|
||||||
|
from logging import Logger
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from aiohttp import ClientSession
|
||||||
|
|
||||||
|
_DEFAULT_QUERY_URL_BASE = "https://androidbuildinternal.googleapis.com"
|
||||||
|
|
||||||
|
|
||||||
|
def _logger() -> Logger:
|
||||||
|
return logging.getLogger("fetchartifact")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_download_url(
|
||||||
|
target: str,
|
||||||
|
build_id: str,
|
||||||
|
artifact_name: str,
|
||||||
|
query_url_base: str,
|
||||||
|
) -> str:
|
||||||
|
"""Constructs the download URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target: Name of the build target from which to fetch the artifact.
|
||||||
|
build_id: ID of the build from which to fetch the artifact.
|
||||||
|
artifact_name: Name of the artifact to fetch.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
URL for the given artifact.
|
||||||
|
"""
|
||||||
|
# The Android build API does not handle / in artifact names, but urllib.parse.quote
|
||||||
|
# thinks those are safe by default. We need to escape them.
|
||||||
|
artifact_name = urllib.parse.quote(artifact_name, safe="")
|
||||||
|
return (
|
||||||
|
f"{query_url_base}/android/internal/build/v3/builds/{build_id}/{target}/"
|
||||||
|
f"attempts/latest/artifacts/{artifact_name}/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_artifact(
|
||||||
|
target: str,
|
||||||
|
build_id: str,
|
||||||
|
artifact_name: str,
|
||||||
|
session: ClientSession,
|
||||||
|
query_url_base: str = _DEFAULT_QUERY_URL_BASE,
|
||||||
|
) -> bytes:
|
||||||
|
"""Fetches an artifact from the build server.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target: Name of the build target from which to fetch the artifact.
|
||||||
|
build_id: ID of the build from which to fetch the artifact.
|
||||||
|
artifact_name: Name of the artifact to fetch.
|
||||||
|
session: The aiohttp ClientSession to use. If omitted, one will be created and
|
||||||
|
destroyed for every call.
|
||||||
|
query_url_base: The base of the endpoint used for querying download URLs. Uses
|
||||||
|
the android build service by default, but can be replaced for testing.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The bytes of the downloaded artifact.
|
||||||
|
"""
|
||||||
|
download_url = _make_download_url(target, build_id, artifact_name, query_url_base)
|
||||||
|
_logger().debug("Beginning download from %s", download_url)
|
||||||
|
async with session.get(download_url) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
return await response.read()
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_artifact_chunked(
|
||||||
|
target: str,
|
||||||
|
build_id: str,
|
||||||
|
artifact_name: str,
|
||||||
|
session: ClientSession,
|
||||||
|
chunk_size: int = 16 * 1024 * 1024,
|
||||||
|
query_url_base: str = _DEFAULT_QUERY_URL_BASE,
|
||||||
|
) -> AsyncIterable[bytes]:
|
||||||
|
"""Fetches an artifact from the build server.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target: Name of the build target from which to fetch the artifact.
|
||||||
|
build_id: ID of the build from which to fetch the artifact.
|
||||||
|
artifact_name: Name of the artifact to fetch.
|
||||||
|
session: The aiohttp ClientSession to use. If omitted, one will be created and
|
||||||
|
destroyed for every call.
|
||||||
|
query_url_base: The base of the endpoint used for querying download URLs. Uses
|
||||||
|
the android build service by default, but can be replaced for testing.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Async iterable bytes of the artifact contents.
|
||||||
|
"""
|
||||||
|
download_url = _make_download_url(target, build_id, artifact_name, query_url_base)
|
||||||
|
_logger().debug("Beginning download from %s", download_url)
|
||||||
|
async with session.get(download_url) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
async for chunk in response.content.iter_chunked(chunk_size):
|
||||||
|
yield chunk
|
||||||
1142
python-packages/fetchartifact/poetry.lock
generated
Normal file
1142
python-packages/fetchartifact/poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
60
python-packages/fetchartifact/pyproject.toml
Normal file
60
python-packages/fetchartifact/pyproject.toml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
[tool.poetry]
|
||||||
|
name = "fetchartifact"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Python library for https://android.googlesource.com/tools/fetch_artifact/."
|
||||||
|
authors = ["The Android Open Source Project"]
|
||||||
|
license = "Apache-2.0"
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = "^3.10"
|
||||||
|
aiohttp = "^3.8.4"
|
||||||
|
|
||||||
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
mypy = "^1.2.0"
|
||||||
|
pylint = "^2.17.2"
|
||||||
|
black = "^23.3.0"
|
||||||
|
pytest = "^7.2.2"
|
||||||
|
pytest-asyncio = "^0.21.0"
|
||||||
|
isort = "^5.12.0"
|
||||||
|
pytest-aiohttp = "^1.0.4"
|
||||||
|
pytest-cov = "^4.0.0"
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
fail_under = 100
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
addopts = "--strict-markers"
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
markers = [
|
||||||
|
"requires_network: marks a test that requires network access"
|
||||||
|
]
|
||||||
|
xfail_strict = true
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
check_untyped_defs = true
|
||||||
|
disallow_any_generics = true
|
||||||
|
disallow_any_unimported = true
|
||||||
|
disallow_subclassing_any = true
|
||||||
|
disallow_untyped_decorators = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
follow_imports = "silent"
|
||||||
|
implicit_reexport = false
|
||||||
|
namespace_packages = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
show_error_codes = true
|
||||||
|
strict_equality = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unreachable = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
|
||||||
|
[tool.pylint."MESSAGES CONTROL"]
|
||||||
|
disable = "duplicate-code,too-many-arguments"
|
||||||
|
|
||||||
|
[tool.isort]
|
||||||
|
profile = "black"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core>=1.0.0"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
15
python-packages/fetchartifact/tests/__init__.py
Normal file
15
python-packages/fetchartifact/tests/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#
|
||||||
|
# Copyright (C) 2023 The Android Open Source Project
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
101
python-packages/fetchartifact/tests/test_fetchartifact.py
Normal file
101
python-packages/fetchartifact/tests/test_fetchartifact.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
#
|
||||||
|
# Copyright (C) 2023 The Android Open Source Project
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
"""Tests for fetchartifact."""
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from aiohttp import ClientResponseError, ClientSession
|
||||||
|
from aiohttp.test_utils import TestClient
|
||||||
|
from aiohttp.web import Application, Request, Response
|
||||||
|
|
||||||
|
from fetchartifact import fetch_artifact, fetch_artifact_chunked
|
||||||
|
|
||||||
|
TEST_BUILD_ID = "1234"
|
||||||
|
TEST_TARGET = "linux"
|
||||||
|
TEST_ARTIFACT_NAME = "output.zip"
|
||||||
|
TEST_DOWNLOAD_URL = (
|
||||||
|
f"/android/internal/build/v3/builds/{TEST_BUILD_ID}/{TEST_TARGET}/"
|
||||||
|
f"attempts/latest/artifacts/{TEST_ARTIFACT_NAME}/url"
|
||||||
|
)
|
||||||
|
TEST_RESPONSE = b"Hello, world!"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="android_ci_client")
|
||||||
|
async def fixture_android_ci_client(aiohttp_client: type[TestClient]) -> TestClient:
|
||||||
|
"""Fixture for mocking the Android CI APIs."""
|
||||||
|
|
||||||
|
async def download(_request: Request) -> Response:
|
||||||
|
return Response(text=TEST_RESPONSE.decode("utf-8"))
|
||||||
|
|
||||||
|
app = Application()
|
||||||
|
app.router.add_get(TEST_DOWNLOAD_URL, download)
|
||||||
|
return await aiohttp_client(app) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fetch_artifact(android_ci_client: TestClient) -> None:
|
||||||
|
"""Tests that the download URL is queried."""
|
||||||
|
assert TEST_RESPONSE == await fetch_artifact(
|
||||||
|
TEST_TARGET,
|
||||||
|
TEST_BUILD_ID,
|
||||||
|
TEST_ARTIFACT_NAME,
|
||||||
|
cast(ClientSession, android_ci_client),
|
||||||
|
query_url_base="",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fetch_artifact_chunked(android_ci_client: TestClient) -> None:
|
||||||
|
"""Tests that the full file contents are downloaded."""
|
||||||
|
assert [c.encode("utf-8") for c in TEST_RESPONSE.decode("utf-8")] == [
|
||||||
|
chunk
|
||||||
|
async for chunk in fetch_artifact_chunked(
|
||||||
|
TEST_TARGET,
|
||||||
|
TEST_BUILD_ID,
|
||||||
|
TEST_ARTIFACT_NAME,
|
||||||
|
cast(ClientSession, android_ci_client),
|
||||||
|
chunk_size=1,
|
||||||
|
query_url_base="",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_failure_raises(android_ci_client: TestClient) -> None:
|
||||||
|
"""Tests that fetch failure raises an exception."""
|
||||||
|
with pytest.raises(ClientResponseError):
|
||||||
|
await fetch_artifact(
|
||||||
|
TEST_TARGET,
|
||||||
|
TEST_BUILD_ID,
|
||||||
|
TEST_ARTIFACT_NAME,
|
||||||
|
cast(ClientSession, android_ci_client),
|
||||||
|
query_url_base="/bad",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ClientResponseError):
|
||||||
|
async for _chunk in fetch_artifact_chunked(
|
||||||
|
TEST_TARGET,
|
||||||
|
TEST_BUILD_ID,
|
||||||
|
TEST_ARTIFACT_NAME,
|
||||||
|
cast(ClientSession, android_ci_client),
|
||||||
|
query_url_base="/bad",
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.requires_network
|
||||||
|
async def test_real_artifact() -> None:
|
||||||
|
"""Tests with a real artifact. Requires an internet connection."""
|
||||||
|
async with ClientSession() as session:
|
||||||
|
contents = await fetch_artifact("linux", "9945621", "logs/SUCCEEDED", session)
|
||||||
|
assert contents == b"1681499053\n"
|
||||||
Reference in New Issue
Block a user