Skip to content

Commit

Permalink
Conda-Store client (#327)
Browse files Browse the repository at this point in the history
* Initial commit towards creating a conda-store client

* Working client library allong with run execution

* Black and Flake8 formatting

* Flake8 and Black conda-store-server

* Remove parts of cli interface that have not been discussed

* Adding documentation around commands
  • Loading branch information
costrouc authored Jun 24, 2022
1 parent 10b67ab commit fbfa678
Show file tree
Hide file tree
Showing 19 changed files with 624 additions and 105 deletions.
39 changes: 38 additions & 1 deletion conda-store-server/conda_store_server/server/views/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import pydantic
import yaml
from fastapi import APIRouter, Request, Depends, HTTPException, Query, Body
from fastapi.responses import RedirectResponse
from fastapi.responses import RedirectResponse, PlainTextResponse

from conda_store_server import api, orm, schema, utils, __version__
from conda_store_server.server import dependencies
Expand Down Expand Up @@ -684,3 +684,40 @@ def api_get_build_yaml(
require=True,
)
return RedirectResponse(conda_store.storage.get_url(build.conda_env_export_key))


@router_api.get("/build/{build_id}/lockfile/", response_class=PlainTextResponse)
def api_get_build_lockfile(
build_id: int,
request: Request,
conda_store=Depends(dependencies.get_conda_store),
auth=Depends(dependencies.get_auth),
):
build = api.get_build(conda_store.db, build_id)
auth.authorize_request(
request,
f"{build.environment.namespace.name}/{build.environment.name}",
{Permissions.ENVIRONMENT_READ},
require=True,
)

lockfile = api.get_build_lockfile(conda_store.db, build_id)
return lockfile


@router_api.get("/build/{build_id}/archive/")
def api_get_build_archive(
build_id: int,
request: Request,
conda_store=Depends(dependencies.get_conda_store),
auth=Depends(dependencies.get_auth),
):
build = api.get_build(conda_store.db, build_id)
auth.authorize_request(
request,
f"{build.environment.namespace.name}/{build.environment.name}",
{Permissions.ENVIRONMENT_READ},
require=True,
)

return RedirectResponse(conda_store.storage.get_url(build.conda_pack_key))
57 changes: 1 addition & 56 deletions conda-store-server/conda_store_server/server/views/ui.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from typing import Optional

from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse, PlainTextResponse
from fastapi.responses import RedirectResponse
import yaml

from conda_store_server import api
Expand Down Expand Up @@ -231,58 +231,3 @@ def ui_get_user(
"entity_binding_permissions": entity_binding_permissions,
}
return templates.TemplateResponse("user.html", context)


@router_ui.get("/build/{build_id}/logs/")
def api_get_build_logs(
build_id: int,
request: Request,
conda_store=Depends(dependencies.get_conda_store),
auth=Depends(dependencies.get_auth),
):
build = api.get_build(conda_store.db, build_id)
auth.authorize_request(
request,
f"{build.environment.namespace.name}/{build.environment.name}",
{Permissions.ENVIRONMENT_READ},
require=True,
)

return RedirectResponse(conda_store.storage.get_url(build.log_key))


@router_ui.get("/build/{build_id}/lockfile/", response_class=PlainTextResponse)
def api_get_build_lockfile(
build_id: int,
request: Request,
conda_store=Depends(dependencies.get_conda_store),
auth=Depends(dependencies.get_auth),
):
build = api.get_build(conda_store.db, build_id)
auth.authorize_request(
request,
f"{build.environment.namespace.name}/{build.environment.name}",
{Permissions.ENVIRONMENT_READ},
require=True,
)

lockfile = api.get_build_lockfile(conda_store.db, build_id)
return lockfile


@router_ui.get("/build/{build_id}/archive/")
def api_get_build_archive(
build_id: int,
request: Request,
conda_store=Depends(dependencies.get_conda_store),
auth=Depends(dependencies.get_auth),
):
build = api.get_build(conda_store.db, build_id)
auth.authorize_request(
request,
f"{build.environment.namespace.name}/{build.environment.name}",
{Permissions.ENVIRONMENT_READ},
require=True,
)

return RedirectResponse(conda_store.storage.get_url(build.conda_pack_key))
6 changes: 5 additions & 1 deletion conda-store-server/setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ classifiers =
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3 :: Only",
Programming Language :: Python :: 3 :: Only
project_urls =
Bug Reports = https://github.com/quansight/conda-store
Documentation = https://conda-store.readthedocs.io/
Source = https://github.com/quansight/conda-store

[options]
zip_safe = False
Expand Down
14 changes: 7 additions & 7 deletions conda-store/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,19 @@ COPY environment.yaml /opt/conda-store/environment.yaml

RUN mamba env create -f /opt/conda-store/environment.yaml

COPY ./ /opt/conda-store/

ENV PATH=/opt/conda/condabin:/opt/conda/envs/conda-store/bin:/opt/conda/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH}

RUN cd /opt/conda-store && \
pip install . && \
jupyter labextension install jupyterlab-launcher-shortcuts

RUN git clone ${GATOR_GIT_URL} && \
cd gator && \
git checkout origin/${GATOR_GIT_BRANCH} && \
pip install -e . && \
jupyter server extension enable mamba_gator --sys-prefix
jupyter server extension enable mamba_gator --sys-prefix && \
jupyter labextension install jupyterlab-launcher-shortcuts

COPY ./ /opt/conda-store/

RUN cd /opt/conda-store && \
pip install -e .

RUN mkdir -p /opt/jupyterhub && \
chown -R 1000:1000 /opt/jupyterhub
Expand Down
16 changes: 15 additions & 1 deletion conda-store/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,18 @@ A client library for interacting with a Conda-Store server. See the
more information. The client library provides a CLI for interacting
with conda-store.

Currently this part of conda-store is not well developed.
```shell
$ conda_store --help
Usage: conda_store [OPTIONS] COMMAND [ARGS]...

Options:
--conda-store-url TEXT Conda-Store base url including prefix
--auth [none|token|basic] Conda-Store authentication to use
--no-verify-ssl Disable tls verification on API requests
--help Show this message and exit.

Commands:
download Download artifacts for given build
info Get current permissions and default namespace
run Execute given environment specified as a URI with COMMAND
```
1 change: 1 addition & 0 deletions conda-store/conda_store/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__version__ = "0.4.0"
5 changes: 5 additions & 0 deletions conda-store/conda_store/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from conda_store.cli import cli


if __name__ == "__main__":
cli()
138 changes: 138 additions & 0 deletions conda-store/conda_store/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import os
import math

import yarl

from conda_store import auth, exception


class CondaStoreAPIError(exception.CondaStoreError):
pass


class CondaStoreAPI:
def __init__(
self, conda_store_url: str, auth_type: str = "none", verify_ssl=True, **kwargs
):
self.conda_store_url = yarl.URL(conda_store_url)
self.api_url = self.conda_store_url / "api/v1"
self.auth_type = auth_type
self.verify_ssl = verify_ssl

if auth_type == "token":
self.api_token = kwargs.get("api_token", os.environ["CONDA_STORE_TOKEN"])
elif auth_type == "basic":
self.username = kwargs.get("username", os.environ["CONDA_STORE_USERNAME"])
self.password = kwargs.get("password", os.environ["CONDA_STORE_PASSWORD"])

async def __aenter__(self):
if self.auth_type == "none":
self.session = await auth.none_authentication(verify_ssl=self.verify_ssl)
if self.auth_type == "token":
self.session = await auth.token_authentication(
self.api_token, verify_ssl=self.verify_ssl
)
elif self.auth_type == "basic":
self.session = await auth.basic_authentication(
self.hub_url, self.username, self.password, verify_ssl=self.verify_ssl
)
return self

async def __aexit__(self, exc_type, exc, tb):
await self.session.close()

async def get_paginated_request(self, url: yarl.URL, max_pages=None, **kwargs):
data = []

async with self.session.get(url) as response:
response_data = await response.json()
num_pages = math.ceil(response_data["count"] / response_data["size"])
data.extend(response_data["data"])

if max_pages is not None:
num_pages = min(max_pages, num_pages)

for page in range(2, num_pages + 1):
async with self.session.get(url % {"page": page}) as response:
data.extend((await response.json())["data"])

return data

async def get_permissions(self):
async with self.session.get(self.api_url / "permission") as response:
return (await response.json())["data"]

async def list_namespaces(self):
return await self.get_paginated_request(self.api_url / "namespace")

async def create_namespace(self, namespace: str):
async with self.session.post(
self.api_url / "namespace" / namespace
) as response:
if response.status != 200:
raise CondaStoreAPIError(f"Error creating namespace {namespace}")

async def delete_namespace(self, namespace: str):
async with self.session.delete(
self.api_url / "namespace" / namespace
) as response:
if response.status != 200:
raise CondaStoreAPIError(f"Error deleting namespace {namespace}")

async def list_environments(self):
return await self.get_paginated_request(self.api_url / "environment")

async def delete_environment(self, namespace: str, name: str):
async with self.session.delete(
self.api_url / "environment" / namespace / name
) as response:
if response.status != 200:
raise CondaStoreAPIError(
f"Error deleting environment {namespace}/{name}"
)

async def create_environment(self, namespace: str, specification: str):
async with self.session.post(
self.api_url / "specification",
json={
"namespace": namespace,
"specification": specification,
},
) as response:
data = await response.json()
if response.status != 200:
message = data["message"]
raise CondaStoreAPIError(
f"Error creating environment in namespace {namespace}\nReason {message}"
)

return data["data"]["build_id"]

async def get_environment(self, namespace: str, name: str):
async with self.session.get(
self.api_url / "environment" / namespace / name
) as response:
if response.status != 200:
raise CondaStoreAPIError(
f"Error getting environment {namespace}/{name}"
)

return (await response.json())["data"]

async def list_builds(self):
return await self.get_paginated_request(self.api_url / "build")

async def get_build(self, build_id: int):
async with self.session.get(self.api_url / "build" / str(build_id)) as response:
if response.status != 200:
raise CondaStoreAPIError(f"Error getting build {build_id}")

return (await response.json())["data"]

async def download(self, build_id: int, artifact: str) -> bytes:
url = self.api_url / "build" / str(build_id) / artifact
async with self.session.get(url) as response:
if response.status != 200:
raise CondaStoreAPIError(f"Error downloading build {build_id}")

return await response.content.read()
33 changes: 33 additions & 0 deletions conda-store/conda_store/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import aiohttp
import yarl


async def none_authentication(verify_ssl: bool = True):
return aiohttp.ClientSession(
connector=aiohttp.TCPConnector(ssl=None if verify_ssl else False),
)


async def token_authentication(api_token: str, verify_ssl: bool = True):
return aiohttp.ClientSession(
headers={"Authorization": f"token {api_token}"},
connector=aiohttp.TCPConnector(ssl=None if verify_ssl else False),
)


async def basic_authentication(
conda_store_url, username, password, verify_ssl: bool = True
):
session = aiohttp.ClientSession(
connector=aiohttp.TCPConnector(ssl=None if verify_ssl else False),
)

await session.post(
yarl.URL(conda_store_url) / "login",
data={
"username": username,
"password": password,
},
)

return session
Loading

0 comments on commit fbfa678

Please sign in to comment.