Skip to content

Commit

Permalink
Merge branch 'main' of github.com:latchbio/latch
Browse files Browse the repository at this point in the history
  • Loading branch information
kennyworkman committed Mar 11, 2022
2 parents a7fe4d1 + 48461d7 commit 34dc99b
Show file tree
Hide file tree
Showing 8 changed files with 87 additions and 61 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ jobs:
run: |
python3 -m pip install -e .
- name: Test
env:
TEST_TOKEN: ${{ secrets.TEST_TOKEN }}
run: |
eval $(docker-machine env default)
cd tests; python3 -m pytest -s .
Expand All @@ -68,6 +70,8 @@ jobs:
run: |
python3 -m pip install -e .
- name: Test
env:
TEST_TOKEN: ${{ secrets.TEST_TOKEN }}
run: |
eval $(docker-machine env default)
cd tests; python3 -m pytest -s .
4 changes: 4 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ build-docs:
rm -rf docs/build
make -C docs html

test:
export TEST_TOKEN=$(cat ~/.latch/token) &&\
pytest -s tests

#
# Docs build.

Expand Down
40 changes: 37 additions & 3 deletions latch/services/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import webbrowser

import requests
from latch.auth import PKCE, CSRFState, OAuth2
from latch.config import UserConfig
from latch.constants import OAuth2Constants
Expand Down Expand Up @@ -32,11 +33,15 @@ def login() -> str:

oauth2_flow = OAuth2(pkce, csrf_state, OAuth2Constants)
auth_code = oauth2_flow.authorization_request()
token = oauth2_flow.access_token_request(auth_code)
jwt = oauth2_flow.access_token_request(auth_code)

# Exchange JWT from Auth0 for a persistent token issued by
# LatchBio.
access_jwt = _auth0_jwt_for_access_jwt(jwt)

config = UserConfig()
config.update_token(token)
return token
config.update_token(access_jwt)
return access_jwt


def _browser_available() -> bool:
Expand All @@ -55,3 +60,32 @@ def _browser_available() -> bool:
except Exception:
pass
return False


def _auth0_jwt_for_access_jwt(token) -> str:
"""Requests a LatchBio minted (long-lived) acccess JWT.
Uses an Auth0 token to authenticate the user.
"""

headers = {
"Authorization": f"Bearer {token}",
}

url = "https://nucleus.latch.bio/sdk/access-jwt"

response = requests.post(url, headers=headers)
if response.status_code == 403:
raise PermissionError(
"You need access to the Latch SDK beta ~ join the waitlist @ https://latch.bio/sdk"
)

try:
resp = response.json()
jwt = resp["jwt"]
except KeyError as e:
raise ValueError(
f"Malformed response from request for access token {resp}"
) from e

return jwt
11 changes: 10 additions & 1 deletion latch/services/register/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,16 @@ def image(self):
"""The image to be registered."""
if self.account_id is None:
raise ValueError("You need to log in before you can register a workflow.")
return f"{self.account_id}_{self.pkg_root.name}"

# CAUTION ~ this weird formatting is maintained indepedently in the
# nucleus endpoint and here.
# Name for federated token request has minimum of 2 characters.
if int(self.account_id) < 10:
account_id = f"x{self.account_id}"
else:
account_id = self.account_id

return f"{account_id}_{self.pkg_root.name}"

@property
def image_tagged(self):
Expand Down
47 changes: 6 additions & 41 deletions latch/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def retrieve_or_login() -> str:

user_conf = UserConfig()
token = user_conf.token
if token == "" or not token_is_valid(token):
if token == "":
token = login()
return token

Expand All @@ -42,32 +42,6 @@ def sub_from_jwt(token: str) -> str:
return sub


def token_is_valid(token: str) -> bool:
"""Checks if passed token is authenticated with Latch.
Queries a protected endpoint within the Latch API.
Args:
token: JWT
Returns:
True if valid.
"""

headers = {"Authorization": f"Bearer {token}"}
response = requests.get(
"https://nucleus.latch.bio/api/protected-sdk-ping", headers=headers
)

if response.status_code == 403:
raise PermissionError(
"You need access to the latch sdk beta ~ join the waitlist @ https://latch.bio/sdk"
)
if response.status_code == 200:
return True
return False


def account_id_from_token(token: str) -> str:
"""Exchanges a valid JWT for a Latch account ID.
Expand All @@ -81,17 +55,8 @@ def account_id_from_token(token: str) -> str:
A Latch account ID (UUID).
"""

headers = {"Authorization": f"Bearer {token}"}
response = requests.post(
"https://nucleus.latch.bio/sdk/get-account-id", headers=headers
)

if response.status_code == 403:
raise PermissionError(
"You need access to the latch sdk beta ~ join the waitlist @ https://latch.bio/sdk"
)
if response.status_code != 200:
raise Exception(
f"Could not retrieve id from token - {token}.\n\t{response.text}"
)
return response.json()["id"]
decoded_jwt = jwt.decode(token, options={"verify_signature": False})
try:
return decoded_jwt.get("id")
except KeyError as e:
raise ValueError("Your Latch access token is malformed") from e
4 changes: 2 additions & 2 deletions tests/fixtures.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import secrets
import string
from pathlib import Path
Expand All @@ -8,8 +9,7 @@
@pytest.fixture(scope="session")
def test_account_jwt():

# TODO: env variable
tmp_token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IlNMdVluMm1jTDN5UXByWjRRQ0pMdiJ9.eyJnaXZlbl9uYW1lIjoiQXl1c2giLCJmYW1pbHlfbmFtZSI6IkthbWF0Iiwibmlja25hbWUiOiJheXVzaCIsIm5hbWUiOiJBeXVzaCBLYW1hdCIsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQVRYQUp5a3JjWnFKZmpJZjdJbnFvbGo4enlXU3V2UVBDUDFRa3F5c2pqej1zOTYtYyIsImxvY2FsZSI6ImVuIiwidXBkYXRlZF9hdCI6IjIwMjItMDMtMDVUMDA6NTM6MjMuNzM1WiIsImVtYWlsIjoiYXl1c2hAbGF0Y2guYmlvIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImlzcyI6Imh0dHBzOi8vbGF0Y2hhaS51cy5hdXRoMC5jb20vIiwic3ViIjoiZ29vZ2xlLW9hdXRoMnwxMDE0OTQ5MDA1MTY1ODkzMjM0MjkiLCJhdWQiOiJqekZCT2hJYmZwNEVQUllaOHdteDRZeXZMMjdMRkRlQiIsImlhdCI6MTY0NjY5ODExMSwiZXhwIjoxNjQ2NzM0MTExfQ.OEYoUAJ2RAZjXxbbAcIzvIbiZ8yQuLHxjNYeobULw49GO0MEsvD1iLmFWahC3srH652f-MdO4FO2RJ8pfYxgsLK-ysF3PccucfOR_v5RHC4Ge8AMlMMgRQYcSyLLRo0pwsoUgkTJewyD7NzTGLydDyDSndlOWnF4vB9TKxx2AIt7M3sASemTPmErbVeDBI1-eBBpt5U3xggCYs_ogPlIYd0W9qvrHrazxGaETYK2T2vI9-ob0f7pWSKNDzMkfb4e5dw5DYJfas6rTqSxGHg0D2r3aF5GH7U0Z1tNgNSg_ncBgedVy-LkJTetcsda4SUmnep6C1odSr-B1WJ6uNhTdw"
tmp_token = os.environ["TEST_TOKEN"]
token_dir = Path.home().joinpath(".latch")
token_dir.mkdir(exist_ok=True)
token_file = token_dir.joinpath("token")
Expand Down
28 changes: 18 additions & 10 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from .fixtures import project_name, test_account_jwt


def _run_and_verify(cmd: List[str], does_exist: str):
output = subprocess.run(cmd, capture_output=True, check=True)
stdout = output.stdout.decode("utf-8")
Expand All @@ -14,7 +15,7 @@ def _run_and_verify(cmd: List[str], does_exist: str):

def test_init_and_register(test_account_jwt, project_name):
# Originally two separate tests: test_init and test_register.

# Combined into one test because pytest randomizes the order of tests, meaning
# half the time test_register would fail because the project had not been created
# by test_init yet.
Expand All @@ -34,12 +35,16 @@ def test_cp(test_account_jwt):
_cmd = ["latch", "cp", "foo.txt", "latch:///foo.txt"]
_run_and_verify(_cmd, "Successfully copied foo.txt to latch:///foo.txt.")
_cmd = ["latch", "cp", "foo.txt", "latch:///fake_dir_that_doesnt_exist/foo.txt"]
_run_and_verify(_cmd, "Successfully copied foo.txt to latch:///fake_dir_that_doesnt_exist/foo.txt.") # doesn't do what we expected
# doesn't do what we expected
_run_and_verify(
_cmd,
"Successfully copied foo.txt to latch:///fake_dir_that_doesnt_exist/foo.txt.",
)
_cmd = ["latch", "cp", "foo.txt", "latch:///oof.txt"]
_run_and_verify(_cmd, "Successfully copied foo.txt to latch:///oof.txt.")
_cmd = ["latch", "cp", "foo.txt", "latch:///welcome/"]
_run_and_verify(_cmd, "Successfully copied foo.txt to latch:///welcome/")

_cmd = ["latch", "cp", "latch:///foo.txt", "bar.txt"]
_run_and_verify(_cmd, "Successfully copied latch:///foo.txt to bar.txt.")
_cmd = ["latch", "cp", "latch:///foo.txt", "stuff.txt"]
Expand All @@ -56,7 +61,9 @@ def test_ls(test_account_jwt):
def test_execute(test_account_jwt):

with open("foo.py", "w") as f:
f.write(textwrap.dedent("""
f.write(
textwrap.dedent(
"""
from latch.types import LatchFile
params = {
Expand All @@ -65,19 +72,20 @@ def test_execute(test_account_jwt):
"read2": LatchFile("latch:///read2"),
}
"""
))
)
)

_cmd = ["latch", "execute", "foo.py"]
_run_and_verify(
_cmd, "Successfully launched workflow named wf.assemble_and_sort with version latest.")
_cmd,
"Successfully launched workflow named wf.assemble_and_sort with version latest.",
)


def test_get_wf(test_account_jwt):

_cmd = ["latch", "get-wf"]
_run_and_verify(
_cmd, "latch.crispresso2_wf")
_run_and_verify(_cmd, "latch.crispresso2_wf")

_cmd = ["latch", "get-wf", "--name", "latch.crispresso2_wf"]
_run_and_verify(
_cmd, "v0.1.11")
_run_and_verify(_cmd, "v0.1.11")
10 changes: 6 additions & 4 deletions tests/test_register.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ def _setup_and_build_wo_dockerfile(jwt, pkg_name, requirements=None):

pkg_dir = Path(tmpdir).joinpath(pkg_name)
pkg_dir.mkdir()
pkg_dir.joinpath("wf").mkdir()

with open(pkg_dir.joinpath("__init__.py"), "w") as f:
with open(pkg_dir.joinpath("wf/__init__.py"), "w") as f:
f.write(_gen__init__(pkg_name))

with open(pkg_dir.joinpath("version"), "w") as f:
Expand All @@ -67,20 +68,21 @@ def _setup_and_build_w_dockerfile(jwt, pkg_name):

pkg_dir = Path(tmpdir).joinpath(pkg_name)
pkg_dir.mkdir()
pkg_dir.joinpath("wf").mkdir()

with open(pkg_dir.joinpath("__init__.py"), "w") as f:
with open(pkg_dir.joinpath("wf/__init__.py"), "w") as f:
f.write(_gen__init__(pkg_name))

with open(pkg_dir.joinpath("version"), "w") as f:
f.write(_VERSION_0)

dockerfile = Path(tmpdir).joinpath("Dockerfile")
dockerfile = Path(pkg_dir).joinpath("Dockerfile")
with open(dockerfile, "w") as df:
df.write(
"\n".join(
[
"FROM busybox",
f"COPY {pkg_name} /src/{pkg_name}",
"COPY wf /src/wf",
"WORKDIR /src",
]
)
Expand Down

0 comments on commit 34dc99b

Please sign in to comment.