Skip to content

Commit

Permalink
Project restructure to make it easier to implement more backends (#32)
Browse files Browse the repository at this point in the history
  • Loading branch information
Sam Ritchie authored Jul 13, 2020
1 parent 85c0bc3 commit 6edb9b5
Show file tree
Hide file tree
Showing 55 changed files with 2,888 additions and 2,722 deletions.
32 changes: 17 additions & 15 deletions caliban/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,16 @@
from absl.flags import argparse_flags
from blessings import Terminal

import caliban.cloud.types as ct
import caliban.config as conf
import caliban.docker as docker
import caliban.gke as gke
import caliban.gke.constants as gke_k
import caliban.gke.types as gke_t
import caliban.gke.utils as gke_u
import caliban.config.experiment as ce
import caliban.docker.build as b
import caliban.platform.cloud.types as ct
import caliban.platform.gke as gke
import caliban.platform.gke.constants as gke_k
import caliban.platform.gke.types as gke_t
import caliban.platform.gke.util as gke_u
import caliban.util as u
import caliban.util.argparse as ua
from caliban import __version__

t = Terminal()
Expand Down Expand Up @@ -126,7 +128,7 @@ def add_script_args(parser):
def require_module(parser):
parser.add_argument(
"module",
type=u.validated_package,
type=ua.validated_package,
help=
"Code to execute, in either trainer.train' or 'trainer/train.py' format. "
"Accepts python scripts, modules or a path to an arbitrary script.")
Expand Down Expand Up @@ -157,7 +159,7 @@ def extra_dirs(parser):
"-d",
"--dir",
action="append",
type=u.validated_directory,
type=ua.validated_directory,
help="Extra directories to include. List these from large to small "
"to take full advantage of Docker's build cache.")

Expand Down Expand Up @@ -189,7 +191,7 @@ def region_arg(parser):

def cloud_key_arg(parser):
parser.add_argument("--cloud_key",
type=u.validated_file,
type=ua.validated_file,
help="Path to GCloud service account key. "
"(Defaults to $GOOGLE_APPLICATION_CREDENTIALS.)")

Expand Down Expand Up @@ -263,8 +265,8 @@ def shell_parser(base):
docker_run_arg(parser)
parser.add_argument(
"--shell",
choices=docker.Shell,
type=docker.Shell,
choices=b.Shell,
type=b.Shell,
help=
"""This argument sets the shell used inside the container to one of Caliban's
supported shells. Defaults to the shell specified by the $SHELL environment
Expand Down Expand Up @@ -352,7 +354,7 @@ def job_name_arg(parser):
def experiment_config_arg(parser):
parser.add_argument(
"--experiment_config",
type=conf.load_experiment_config,
type=ce.load_experiment_config,
help="Path to an experiment config, or 'stdin' to read from stdin.")


Expand All @@ -361,7 +363,7 @@ def label_arg(parser):
"--label",
metavar="KEY=VALUE",
action="append",
type=u.parse_kv_pair,
type=ua.parse_kv_pair,
help="Extra label k=v pair to submit to Cloud.")


Expand Down Expand Up @@ -539,7 +541,7 @@ def generate_docker_args(job_mode: conf.JobMode,

# Get extra dependencies in case you want to install your requirements via a
# setup.py file.
setup_extras = docker.base_extras(job_mode, "setup.py", args.get("extras"))
setup_extras = b.base_extras(job_mode, "setup.py", args.get("extras"))

# Google application credentials, from the CLI or from an env variable.
creds_path = conf.extract_cloud_key(args)
Expand All @@ -552,7 +554,7 @@ def generate_docker_args(job_mode: conf.JobMode,
reqs = "requirements.txt"
conda_env = "environment.yml"

# Arguments that make their way down to caliban.docker.build_image.
# Arguments that make their way down to caliban.docker.build.build_image.
docker_args = {
"extra_dirs": args.get("dir"),
"requirements_path": reqs if os.path.exists(reqs) else None,
Expand Down
193 changes: 193 additions & 0 deletions caliban/config/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
#!/usr/bin/python
#
# Copyright 2020 Google LLC
#
# 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.
"""
Utilities for our job runner, for working with configs.
"""

from __future__ import absolute_import, division, print_function

import argparse
import os
import sys
from enum import Enum
from typing import Any, Dict, List, Optional

import commentjson
import yaml

import caliban.platform.cloud.types as ct


class JobMode(str, Enum):
CPU = 'CPU'
GPU = 'GPU'


# Special config for Caliban.
CalibanConfig = Dict[str, Any]

DRY_RUN_FLAG = "--dry_run"
CALIBAN_CONFIG = ".calibanconfig.json"

# Defaults for various input values that we can supply given some partial set
# of info from the CLI.
DEFAULT_REGION = ct.US.central1

# : Dict[JobMode, ct.MachineType]
DEFAULT_MACHINE_TYPE = {
JobMode.CPU: ct.MachineType.highcpu_32,
JobMode.GPU: ct.MachineType.standard_8
}
DEFAULT_GPU = ct.GPU.P100

# Config to supply for CPU jobs.
DEFAULT_ACCELERATOR_CONFIG = {
"count": 0,
"type": "ACCELERATOR_TYPE_UNSPECIFIED"
}


def gpu(job_mode: JobMode) -> bool:
"""Returns True if the supplied JobMode is JobMode.GPU, False otherwise.
"""
return job_mode == JobMode.GPU


def load_yaml_config(path):
"""returns the config parsed based on the info in the flags.
Grabs the config file, written in yaml, slurps it in.
"""
with open(path) as f:
config = yaml.load(f, Loader=yaml.FullLoader)

return config


def load_config(path, mode='yaml'):
"""Load a JSON or YAML config.
"""
if mode == 'json':
with open(path) as f:
return commentjson.load(f)

return load_yaml_config(path)


def valid_json(path: str) -> Dict[str, Any]:
"""Loads JSON if the path points to a valid JSON file; otherwise, throws an
exception that's picked up by argparse.
"""
try:
return load_config(path, mode='json')
except commentjson.JSONLibraryException:
raise argparse.ArgumentTypeError(
"""File '{}' doesn't seem to contain valid JSON. Try again!""".format(
path))


def extract_script_args(m: Dict[str, Any]) -> List[str]:
"""Strip off the "--" argument if it was passed in as a separator."""
script_args = m.get("script_args")
if script_args is None or script_args == []:
return script_args

head, *tail = script_args

return tail if head == "--" else script_args


def extract_project_id(m: Dict[str, Any]) -> str:
"""Attempts to extract the project_id from the args; falls back to an
environment variable, or exits if this isn't available. There's no sensible
default available.
"""
project_id = m.get("project_id") or os.environ.get("PROJECT_ID")

if project_id is None:
print()
print(
"\nNo project_id found. 'caliban cloud' requires that you either set a \n\
$PROJECT_ID environment variable with the ID of your Cloud project, or pass one \n\
explicitly via --project_id. Try again, please!")
print()

sys.exit(1)

return project_id


def extract_region(m: Dict[str, Any]) -> ct.Region:
"""Returns the region specified in the args; defaults to an environment
variable. If that's not supplied defaults to the default cloud provider from
caliban.platform.cloud.
"""
region = m.get("region") or os.environ.get("REGION")

if region:
return ct.parse_region(region)

return DEFAULT_REGION


def extract_zone(m: Dict[str, Any]) -> str:
return "{}-a".format(extract_region(m))


def extract_cloud_key(m: Dict[str, Any]) -> Optional[str]:
"""Returns the Google service account key filepath specified in the args;
defaults to the $GOOGLE_APPLICATION_CREDENTIALS variable.
"""
return m.get("cloud_key") or \
os.environ.get("GOOGLE_APPLICATION_CREDENTIALS")


def apt_packages(conf: CalibanConfig, mode: JobMode) -> List[str]:
"""Returns the list of aptitude packages that should be installed to satisfy
the requests in the config.
"""
packages = conf.get("apt_packages") or {}

if isinstance(packages, dict):
k = "gpu" if gpu(mode) else "cpu"
return packages.get(k, [])

elif isinstance(packages, list):
return packages

else:
raise argparse.ArgumentTypeError(
"""{}'s "apt_packages" entry must be a dictionary or list, not '{}'""".
format(CALIBAN_CONFIG, packages))


def caliban_config() -> CalibanConfig:
"""Returns a dict that represents a `.calibanconfig.json` file if present,
empty dictionary otherwise.
"""
if not os.path.isfile(CALIBAN_CONFIG):
return {}

with open(CALIBAN_CONFIG) as f:
conf = commentjson.load(f)
return conf
Loading

0 comments on commit 6edb9b5

Please sign in to comment.