diff --git a/docs/cli.rst b/docs/cli.rst new file mode 100644 index 0000000000..f91d462bb5 --- /dev/null +++ b/docs/cli.rst @@ -0,0 +1,37 @@ +.. + Copyright 2017 - Swiss Data Science Center (SDSC) + A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + Eidgenössische Technische Hochschule Zürich (ETHZ). + + 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. + +Renga Command Line +================== + +.. automodule:: renga.cli + + +``renga login`` +--------------- + +.. automodule:: renga.cli.login + +``renga init`` +-------------- + +.. automodule:: renga.cli.init + +``renga add`` +------------- + +.. automodule:: renga.cli.add diff --git a/docs/client.rst b/docs/client.rst index 510ed9726c..4d6767c04f 100644 --- a/docs/client.rst +++ b/docs/client.rst @@ -27,8 +27,8 @@ There are several ways to instantiate a client used for communication with the Renga platform. 1. The easiest way is by calling the function :py:func:`~renga.client.from_env` - when running in environment created by Renga platform itself. -2. The client can be created from local configuration file by calling + when running in an environment created by the Renga platform itself. +2. The client can be created from a local configuration file by calling :py:func:`~renga.cli._client.from_config`. 3. Lastly, it can also be configured manually by instantiating a :py:class:`~renga.client.RengaClient` class. diff --git a/docs/index.rst b/docs/index.rst index 6c265e087a..9442ff1177 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -52,6 +52,33 @@ You can access files from a bucket: For more details and examples have a look at :doc:`the reference `. +Use the Renga command line +-------------------------- + +Interaction with the platform can also take place via the command-line +interface (CLI). + +First, you need to authenticate with an existing instance of the Renga +platform. The example shows a case when you have the platform running on +``localhost``. + +.. code-block:: console + + $ renga login http://localhost + Username: demo + Password: **** + Access token has been stored in: ... + +Following the above example you can create a first bucket and upload a file. + +.. code-block:: console + + $ export BUCKET_ID=$(renga io buckets create first-bucket) + $ echo "hello world" | renga io buckets $BUCKET_ID upload --name greeting.txt + 9876 + +For more information about using `renga`, refer to the :doc:`Renga command +line ` instructions. .. toctree:: :hidden: @@ -62,6 +89,7 @@ For more details and examples have a look at :doc:`the reference buckets contexts api + cli contributing changes license diff --git a/renga/api/storage.py b/renga/api/storage.py index 541087cca2..ed23df9727 100644 --- a/renga/api/storage.py +++ b/renga/api/storage.py @@ -57,6 +57,13 @@ def storage_authorize(self, resource_id=None, request_type=None): 'request_type': request_type}) return resp.json() + def storage_file_metadata_replace(self, resource_id, data): + """Replace resource metadata.""" + return self.put( + self._url('api/storage/file/{0}', resource_id), + json=data, + ).json() + def storage_io_write(self, data): """Write data to the file. diff --git a/renga/cli/__init__.py b/renga/cli/__init__.py index 7b8837fd46..58adcb5bcc 100644 --- a/renga/cli/__init__.py +++ b/renga/cli/__init__.py @@ -15,19 +15,71 @@ # 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. -"""CLI for the Renga platform.""" +r"""The base command for interacting with the Renga platform. + +``renga`` (base command) +------------------------ + +To list the available commands, either run ``renga`` with no parameters or +execute ``renga help``: + +.. code-block:: console + + $ renga help + Usage: renga [OPTIONS] COMMAND [ARGS]... + + Check common Renga commands used in various situations. + + Options: + --version Print version number. + --config FILENAME Location of client config files. + --config-path Print application config path. + --no-project Run command outside project context. + -h, --help Show this message and exit. + + Commands: + # [...] + +Configuration files +~~~~~~~~~~~~~~~~~~~ + +Depending on your system, you may find the configuration files used by Renga +command line in a different folder. By default, the following rules are used: + +MacOS: + ``~/Library/Application Support/Renga`` +Unix: + ``~/.config/renga`` +Windows: + ``C:\Users\\AppData\Roaming\Renga`` + +If in doubt where to look for the configuration file, you can display its path +by running ``renga --config-path``. + +You can specify a different location via the ``RENGA_CONFIG`` environment +variable or the ``--config`` command line option. If both are specified, then +the ``--config`` option value is used. For example: + +.. code-block:: console + + $ renga --config ~/renga/config/ login + +instructs Renga to store the configuration files in your ``~/renga/config/`` +directory when running the ``login`` command. +""" import click from click_plugins import with_plugins from pkg_resources import iter_entry_points -from ._config import print_app_config_path, with_config +from ._config import config_load, default_config_dir, print_app_config_path from ._version import print_version @with_plugins(iter_entry_points('renga.cli')) @click.group(context_settings={ 'auto_envvar_prefix': 'RENGA', + 'help_option_names': ['-h', '--help'], }) @click.option( '--version', @@ -36,6 +88,14 @@ expose_value=False, is_eager=True, help=print_version.__doc__) +@click.option( + '--config', + envvar='RENGA_CONFIG', + default=default_config_dir, + type=click.Path(), + callback=config_load, + expose_value=False, + help='Location of client config files.') @click.option( '--config-path', is_flag=True, @@ -46,9 +106,16 @@ @click.option( '--no-project', is_flag=True, - default=False) -@with_config + default=False, + help='Run command outside project context.') @click.pass_context -def cli(ctx, config, no_project): +def cli(ctx, no_project): """Check common Renga commands used in various situations.""" ctx.obj['no_project'] = no_project + + +@cli.command() +@click.pass_context +def help(ctx): + """Show help message and exit.""" + click.echo(ctx.parent.get_help()) diff --git a/renga/cli/_client.py b/renga/cli/_client.py index b95c399c9a..a17319e974 100644 --- a/renga/cli/_client.py +++ b/renga/cli/_client.py @@ -24,7 +24,7 @@ def from_config(config=None, endpoint=None): - """Create new client for endpoint in the config. + """Create a new client for endpoint in the config. Use ``renga`` command-line interface to manage multiple configurations. diff --git a/renga/cli/_config.py b/renga/cli/_config.py index 75a359c912..36acb6916f 100644 --- a/renga/cli/_config.py +++ b/renga/cli/_config.py @@ -47,33 +47,48 @@ lambda dumper, data: dumper.represent_str(str(data))) +def default_config_dir(): + """Return default config directory.""" + return click.get_app_dir(APP_NAME) + + def config_path(path=None): """Return config path.""" if path is None: - path = os.environ.get('RENGA_CONFIG', click.get_app_dir(APP_NAME)) - try: - os.makedirs(path) - except OSError as e: # pragma: no cover - if e.errno != errno.EEXIST: - raise + path = default_config_dir() + try: + os.makedirs(path) + except OSError as e: # pragma: no cover + if e.errno != errno.EEXIST: + raise return os.path.join(path, 'config.yml') -def read_config(path=None): +def read_config(path): """Read Renga configuration.""" try: - with open(config_path(path=path), 'r') as configfile: + with open(config_path(path), 'r') as configfile: return yaml.load(configfile) or {} except FileNotFoundError: return {} -def write_config(config, path=None): +def write_config(config, path): """Write Renga configuration.""" - with open(config_path(path=path), 'w+') as configfile: + with open(config_path(path), 'w+') as configfile: yaml.dump(config, configfile, default_flow_style=False) +def config_load(ctx, param, value): + """Print application config path.""" + if ctx.obj is None: + ctx.obj = {} + + ctx.obj['config_path'] = value + ctx.obj['config'] = read_config(value) + return value + + def with_config(f): """Add config to function.""" # keep it. @@ -87,10 +102,7 @@ def new_func(ctx, *args, **kwargs): if ctx.obj is None: ctx.obj = {} - if 'config' in ctx.obj: - config = ctx.obj['config'] - else: - config = ctx.obj['config'] = read_config() + config = ctx.obj['config'] project_enabled = not ctx.obj.get('no_project', False) project_config_path = get_project_config_path() @@ -104,7 +116,7 @@ def new_func(ctx, *args, **kwargs): if not project_config_path: raise RuntimeError('Invalid config update') write_config(project_config, path=project_config_path) - write_config(config) + write_config(config, path=ctx.obj['config_path']) if project_config is not None: config['project'] = project_config return result @@ -116,7 +128,7 @@ def print_app_config_path(ctx, param, value): """Print application config path.""" if not value or ctx.resilient_parsing: return - click.echo(config_path()) + click.echo(config_path(os.environ.get('RENGA_CONFIG'))) ctx.exit() diff --git a/renga/cli/_options.py b/renga/cli/_options.py index ce84c39ec4..d1424fba30 100644 --- a/renga/cli/_options.py +++ b/renga/cli/_options.py @@ -44,6 +44,22 @@ def default_endpoint_from_config(config, option=None): option=option) +def password_prompt(ctx, param, value): + """Prompt for password if ``--password-stdin`` is not used.""" + if ctx.resilient_parsing: + return + + if not value: + if 'password_stdin' in ctx.params: + with click.open_file('-') as fp: + value = fp.read().strip('\n') + else: + value = click.prompt('Password', hide_input=True) + + click.echo(value) + return value + + def default_endpoint(ctx, param, value): """Return default endpoint if specified.""" if ctx.resilient_parsing: diff --git a/renga/cli/add.py b/renga/cli/add.py index 873dff2a86..01a266bbfe 100644 --- a/renga/cli/add.py +++ b/renga/cli/add.py @@ -15,7 +15,21 @@ # 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. -"""Manage files.""" +"""Add files to a project. + +Adding data to a project +~~~~~~~~~~~~~~~~~~~~~~~~ + +In a newly-initialized project directory, nothing is tracked yet. You can +start tracking files by adding them to Renga with e.g.: + +.. code-block:: console + + $ renga add input.csv + +If you want to add a file to a specific bucket, you can do so by using the +``--bucket-id`` option. +""" import datetime import os @@ -28,39 +42,37 @@ @click.command() -@click.argument('pathspec', type=click.File('rb')) +@click.argument('path', type=click.File('rb')) @option_endpoint @click.option('--bucket-id', required=False, default=None, type=int) @with_config -def add(config, pathspec, endpoint, bucket_id): - """Add a resource to the project.""" +def add(config, path, endpoint, bucket_id): + """Add a file to the project.""" config['project'].setdefault('resources', {}) resources = config['project']['resources'] - # TODO check that the pathspec is relative to project directory + # TODO check that the path is relative to project directory - if pathspec.name in resources: + if path.name in resources: raise click.UsageError('Resource already exists.') resource = { 'added': datetime.datetime.utcnow().isoformat(), } - autosync = config['project']['core']['autosync'] - if autosync: - bucket_id = bucket_id or \ - config['project']['endpoints'][endpoint]['default_bucket'] - resource.setdefault('endpoints', {}) + bucket_id = bucket_id or \ + config['project']['endpoints'][endpoint]['default_bucket'] + resource.setdefault('endpoints', {}) - client = from_config(config, endpoint=endpoint) - bucket = client.buckets[bucket_id] + client = from_config(config, endpoint=endpoint) + bucket = client.buckets[bucket_id] - with bucket.files.open(pathspec.name, 'w') as fp: - fp.write(pathspec) + with bucket.files.open(path.name, 'w') as fp: + fp.write(path) - resource['endpoints'][endpoint] = { - 'vertex_id': fp.id, - } - click.echo(fp.id) + resource['endpoints'][endpoint] = { + 'vertex_id': fp.id, + } + click.echo(fp.id) - config['project']['resources'][pathspec.name] = resource + config['project']['resources'][path.name] = resource diff --git a/renga/cli/init.py b/renga/cli/init.py index 5f5e66820d..9a8a2c9810 100644 --- a/renga/cli/init.py +++ b/renga/cli/init.py @@ -15,7 +15,35 @@ # 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. -"""Initialize a Renga project.""" +"""Create an empty Renga project or reinitialize an existing one. + +Starting a Renga project +~~~~~~~~~~~~~~~~~~~~~~~~ + +If you have an existing directory which you want to turn into a Renga project, +you can type: + +.. code-block:: console + + $ cd ~/my_project + $ renga init + +or: + +.. code-block:: console + + $ renga init ~/my_project + +This creates a new subdirectory named ``.renga`` that contains all the +necessary files for managing the project configuration. + +Storing related data +~~~~~~~~~~~~~~~~~~~~ + +Each newly created project can get an automatically created storage space +(bucket) for its related data. This feature can be controlled with the +``--bucket/--no-bucket`` flag. +""" import datetime import os @@ -44,21 +72,18 @@ def validate_name(ctx, param, value): default='.', type=click.Path( exists=True, writable=True, file_okay=False, resolve_path=True)) -@click.option('--autosync', is_flag=True) +@click.option('--autosync', is_flag=True, help='DEPRECATED') @click.option('--name', callback=validate_name) @click.option('--force', is_flag=True) @option_endpoint @click.option( '--bucket/--no-bucket', default=False, - help='Initialize with/without new bucket') + help='Initialize with/without a new bucket') @with_config @click.pass_context def init(ctx, config, directory, autosync, name, force, endpoint, bucket): """Initialize a project.""" - if not autosync: - raise click.UsageError('You must specify the --autosync option.') - # 1. create the directory try: project_config_path = create_project_config_path( @@ -68,7 +93,6 @@ def init(ctx, config, directory, autosync, name, force, endpoint, bucket): project_config = read_config(project_config_path) project_config.setdefault('core', {}) - project_config['core']['autosync'] = autosync project_config['core']['name'] = name project_config['core'].setdefault('generated', datetime.datetime.utcnow().isoformat()) @@ -76,12 +100,11 @@ def init(ctx, config, directory, autosync, name, force, endpoint, bucket): if endpoint.option is not None: project_config['core']['default'] = endpoint - if autosync: - client = from_config(config, endpoint=endpoint) - project = client.projects.create(name=name) - project_config.setdefault('endpoints', {}) - project_config['endpoints'].setdefault(endpoint, {}) - project_config['endpoints'][endpoint]['vertex_id'] = project.id + client = from_config(config, endpoint=endpoint) + project = client.projects.create(name=name) + project_config.setdefault('endpoints', {}) + project_config['endpoints'].setdefault(endpoint, {}) + project_config['endpoints'][endpoint]['vertex_id'] = project.id write_config(project_config, path=project_config_path) diff --git a/renga/cli/io.py b/renga/cli/io.py index fd052c0ee8..bb1af9ed7e 100644 --- a/renga/cli/io.py +++ b/renga/cli/io.py @@ -116,6 +116,36 @@ def files(ctx, config, endpoint): click.echo(tabulate(bucket.files, headers=bucket.files.Meta.headers)) +@buckets.command() +@click.argument('input', default='-', type=click.File('wb')) +@click.option('--name', default=None, type=click.STRING) +@option_endpoint +@with_config +@click.pass_context +def upload(ctx, config, input, name, endpoint): + """Create file from an input in the bucket.""" + bucket_id = ctx.obj.get('bucket_id') + + if bucket_id is None: + raise click.MissingParameter( + 'bucket has to be defined', ctx=ctx, param_hint='bucket_id') + + client = from_config(config, endpoint=endpoint) + bucket = client.buckets[bucket_id] + try: + name = name or input.name + except AttributeError: + raise click.MissingParameter( + 'name has to be define when using STDIN', + ctx=ctx, + param_hint='name') + + with bucket.files.open(name, 'w') as fp: + fp.write(input.read()) + + click.echo(fp.id) + + @buckets.command() @click.argument('file_id', required=True, type=int) @click.argument('output', default='-', type=click.File('wb')) diff --git a/renga/cli/login.py b/renga/cli/login.py index f36337e22b..4a8c81ef1f 100644 --- a/renga/cli/login.py +++ b/renga/cli/login.py @@ -15,7 +15,42 @@ # 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. -"""Login to the Renga platform.""" +"""Logging in to the Renga platform. + +There is no central Renga instance, hence a platform URL **must** be +specified. Please contact your institution administrator to obtain the URL of +a running platform and necessary credentials. + +Log in to a self-hosted platform +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you want to log in to a self-hosted platform you can specify this by adding +the platform endpoint. + +.. code-block:: console + + $ renga login http://localhost/ + +.. note:: + + The warning will be shown when an unsecure protocol is used. + +Non-interactive login +~~~~~~~~~~~~~~~~~~~~~ + +In some environments, you might need to run the ``renga login`` command +non-interactively. Using the ``--password-stdin`` flag, you can provide a +password through ``STDIN``, which also prevents the password from ending up in +the shell's history or log-files. + +The following example reads a password from a file, and passes it to the +``renga login`` command using ``STDIN``: + +.. code-block:: console + + $ cat ~/my_secret.txt | renga login --username demo --password-stdin + +""" import click import requests @@ -25,7 +60,7 @@ from ._client import from_config from ._config import config_path, with_config -from ._options import argument_endpoint, default_endpoint +from ._options import argument_endpoint, default_endpoint, password_prompt @click.command() @@ -35,10 +70,13 @@ default='{endpoint}/auth/realms/Renga/protocol/openid-connect/token') @click.option('--client-id', default='demo-client') @click.option('--username', prompt=True) -@click.option('--password', prompt=True, hide_input=True) +@click.option('--password', callback=password_prompt) +@click.option('--password-stdin', is_flag=True) @click.option('--default', is_flag=True) @with_config -def login(config, endpoint, url, client_id, username, password, default): +@click.pass_context +def login(ctx, config, endpoint, url, client_id, username, password, + password_stdin, default): """Initialize tokens for access to the platform.""" url = url.format(endpoint=endpoint, client_id=client_id) scope = ['offline_access', 'openid'] @@ -63,7 +101,8 @@ def login(config, endpoint, url, client_id, username, password, default): config.setdefault('core', {}) config['core']['default'] = endpoint - click.echo('Access token has been stored in: {0}'.format(config_path())) + click.echo('Access token has been stored in: {0}'.format( + config_path(ctx.obj['config_path']))) @click.group(invoke_without_command=True) diff --git a/renga/models/_datastructures.py b/renga/models/_datastructures.py index 4216927adc..ec5eaae588 100644 --- a/renga/models/_datastructures.py +++ b/renga/models/_datastructures.py @@ -24,7 +24,7 @@ class Model(object): IDENTIFIER_KEY = 'identifier' def __init__(self, response=None, client=None, collection=None): - """Create representation of object on the server.""" + """Create a representation of an object on the server.""" self._response = response if response is not None else {} self._client = client self._collection = collection @@ -45,20 +45,20 @@ class Collection(object): """Abstract response of multiple objects.""" class Meta: - """Store information about model.""" + """Store information about the model.""" model = None - """Define type of object this collection represents.""" + """Define the type of object this collection represents.""" headers = ('id') """Which fields to use as headers when printing the collection.""" def __init__(self, client=None): - """Create representation of objects on the server.""" + """Create a representation of objects on the server.""" self._client = client def list(self): - """Return list if the collection is iterable.""" + """Return a list if the collection is iterable.""" if not hasattr(self, '__iter__'): raise NotImplemented('The collection is not iterable.') return list(self) diff --git a/renga/models/deployer.py b/renga/models/deployer.py index 74d6fee6d4..a219b7d8ae 100644 --- a/renga/models/deployer.py +++ b/renga/models/deployer.py @@ -47,7 +47,7 @@ def _names(self): } def __init__(self, context, prefix=None, env_tpl=None, **kwargs): - """Initialize collection of context inputs.""" + """Initialize a collection of context inputs.""" self._context = context self._prefix = prefix or 'renga.context.inputs.' self._env_tpl = env_tpl or 'RENGA_CONTEXT_INPUTS_{0}' @@ -150,10 +150,10 @@ def lineage(self): class ContextCollection(Collection): - """Represent projects on the server.""" + """Represent a collection of contexts.""" class Meta: - """Information about individual projects.""" + """Information about an individual context.""" model = Context @@ -172,7 +172,7 @@ def __getitem__(self, context_id): collection=self) def create(self, spec=None, **kwargs): - """Create new project.""" + """Create a new context.""" data = self._client.api.create_context(spec) return self.Meta.model(data, client=self._client, collection=self) @@ -238,15 +238,15 @@ def logs(self, **kwargs): **kwargs) def stop(self): - """Stop running execution.""" + """Stop a running execution.""" return self._client.api.stop_execution(self.context_id, self.id) class ExecutionCollection(Collection): - """Represent projects on the server.""" + """Represent a collection of executions.""" class Meta: - """Information about individual projects.""" + """Information about an individual execution.""" model = Execution @@ -258,7 +258,7 @@ def __init__(self, context_id, **kwargs): self.id = context_id def __iter__(self): - """Return all contexts.""" + """Return all executions.""" return (self.Meta.model(data, client=self._client, collection=self) for data in self._client.api.list_executions(self.id)) diff --git a/renga/models/projects.py b/renga/models/projects.py index fbd4999c45..b96eab047a 100644 --- a/renga/models/projects.py +++ b/renga/models/projects.py @@ -48,17 +48,17 @@ class Meta: model = Project def create(self, name=None, **kwargs): - """Create new project. + """Create a new project. :param name: The name of the project. - :returns: An instance of newly create project. + :returns: An instance of the newly create project. :rtype: .Project """ data = self._client.api.create_project({'name': name}) return self.Meta.model(data, client=self._client, collection=self) def __getitem__(self, project_id): - """Get existing project by its id.""" + """Get an existing project by its id.""" return self.Meta.model( self._client.api.get_project(project_id), client=self._client, diff --git a/renga/models/storage.py b/renga/models/storage.py index 30857c7af9..46eaf8cdf3 100644 --- a/renga/models/storage.py +++ b/renga/models/storage.py @@ -68,7 +68,7 @@ def backends(self): return self._client.api.storage_info() def create(self, name=None, backend='local', **kwargs): - """Create new :class:`~renga.models.storage.Bucket` instance.""" + """Create a new :class:`~renga.models.storage.Bucket` instance.""" data = self._client.api.create_bucket(name=name, backend=backend) return self.Meta.model(data, client=self._client, collection=self) @@ -107,6 +107,18 @@ def filename(self): """Filename of the file.""" return self._properties.get('resource:file_name') + @filename.setter + def filename(self, value): + """Modify the filename value.""" + labels = self._properties.get('resource:labels', []) + self._client.api.storage_file_metadata_replace(self.id, { + 'file_name': value, + 'labels': labels, + }) + + # Update if the service replace works + self._properties['resource:file_name'] = value + def open(self, mode='r'): """Return the :class:`~renga.models.storage.FileHandle` instance.""" file_handle = { diff --git a/renga/notebook.py b/renga/notebook.py index 220132e583..eaf5a5e995 100644 --- a/renga/notebook.py +++ b/renga/notebook.py @@ -339,3 +339,28 @@ def save(self, model, path): model['content'] = None return model + + def rename_file(self, old_path, path): + """Rename object from old_path with suffix of path.""" + old_sections = old_path.split('/') + sections = path.split('/') + + if old_sections[:-1] != sections[:-1]: + raise RuntimeError('Can not move file between buckets') + + resource = self._resolve_path(old_path) + model = resource.to_model() + + if model['type'] in {'notebook', 'file'}: + resource._obj.filename = sections[-1] + + return resource.to_model() + + def update(self, model, path): + """Update the file's path.""" + path = path.strip('/') + new_path = model.get('path', path).strip('/') + if path != new_path: + # FIXME model = self.rename(path, new_path) + model = self.rename_file(path, new_path) + return model diff --git a/tests/conftest.py b/tests/conftest.py index 53babec1e0..04a3575fc1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,7 @@ from __future__ import absolute_import, print_function import io +import json import os import shutil import tempfile @@ -224,7 +225,8 @@ def deployer_responses(auth_responses, renga_client): context = { 'identifier': 'abcd', 'spec': { - 'image': 'hello-world', + 'image': + 'hello-world', 'labels': [ 'renga.context.inputs.notebook=9876', 'renga.context.inputs.no_default', @@ -485,12 +487,25 @@ def explorer_responses(auth_responses, renga_client): status=200, json=files) - rsps.add( + rsps.add_callback( responses.GET, renga_client.api._url('/api/explorer/storage/file/9876'), - status=200, - json={'data': files[0], - 'bucket': buckets[0]}) + callback=lambda request: (200, {}, json.dumps( + {'data': files[0], 'bucket': buckets[0]})), + content_type='application/json', + ) + + def rename_file(request): + """Rename a file.""" + payload = json.loads(request.body.decode('utf-8')) + files[0]['properties'][0]['values'][0]['value'] = payload['file_name'] + return (200, {}, '{}') + + rsps.add_callback( + responses.PUT, + renga_client.api._url('/api/storage/file/9876'), + callback=rename_file, + content_type='application/json', ) @pytest.fixture(autouse=True) diff --git a/tests/test_cli.py b/tests/test_cli.py index be9261fc40..1904ac4c2c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -21,10 +21,11 @@ import os +import pytest import responses from renga import __version__, cli -from renga.cli._config import APP_NAME, read_config, write_config +from renga.cli._config import read_config, write_config def test_version(base_runner): @@ -33,10 +34,20 @@ def test_version(base_runner): assert __version__ in result.output.split('\n') -def test_config_path(base_runner): +@pytest.mark.parametrize('arg', (('help', ), ('-h', ), ('--help', ))) +def test_help(arg, base_runner): + """Test cli help.""" + result = base_runner.invoke(cli.cli, [arg]) + assert result.exit_code == 0 + assert 'Show this message and exit.' in result.output + + +def test_config_path(instance_path, base_runner): """Test config path.""" result = base_runner.invoke(cli.cli, ['--config-path']) - assert APP_NAME.lower() in result.output.split('\n')[0].lower() + output = result.output.split('\n')[0] + assert 'config.yml' in output + assert instance_path in output def test_login(base_runner, auth_responses): @@ -49,6 +60,29 @@ def test_login(base_runner, auth_responses): assert result.exit_code == 0 assert 'stored' in result.output + result = runner.invoke( + cli.cli, [ + 'login', + 'https://example.com', + '--username', + 'demo', + ], + input='demo') + assert result.exit_code == 0 + assert 'stored' in result.output + + result = runner.invoke( + cli.cli, [ + 'login', + 'https://example.com', + '--username', + 'demo', + '--password-stdin', + ], + input='demo') + assert result.exit_code == 0 + assert 'stored' in result.output + result = runner.invoke(cli.cli, ['tokens']) assert result.exit_code == 0 assert 'https://example.com: demodemo' in result.output.split('\n') @@ -67,28 +101,23 @@ def test_env(runner): def test_init(runner, auth_responses, projects_responses): """Test project initialization.""" - # 0. must autosync - result = runner.invoke(cli.cli, ['init']) - assert result.exit_code == 2 - # 1. the directory must exist - result = runner.invoke(cli.cli, ['init', '--autosync', 'test-project']) + result = runner.invoke(cli.cli, ['init', 'test-project']) assert result.exit_code == 2 # 2. test project directory creation os.mkdir('test-project') - result = runner.invoke(cli.cli, ['init', '--autosync', 'test-project']) + result = runner.invoke(cli.cli, ['init', 'test-project']) assert result.exit_code == 0 assert os.stat(os.path.join('test-project', '.renga')) # 3. test project init from directory os.chdir('test-project') - result = runner.invoke(cli.cli, ['init', '--autosync']) + result = runner.invoke(cli.cli, ['init']) assert result.exit_code == 2 - result = runner.invoke(cli.cli, [ - 'init', '--autosync', '--force', '--endpoint', 'https://example.com' - ]) + result = runner.invoke( + cli.cli, ['init', '--force', '--endpoint', 'https://example.com']) assert result.exit_code == 0 assert os.stat(os.path.join('.renga')) @@ -99,7 +128,7 @@ def test_init_with_buckets(runner, auth_responses, projects_responses, os.mkdir('test-project') os.chdir('test-project') - result = runner.invoke(cli.cli, ['init', '--autosync', '--bucket']) + result = runner.invoke(cli.cli, ['init', '--bucket']) assert result.exit_code == 0 assert os.stat(os.path.join('.renga')) @@ -120,6 +149,20 @@ def test_storage_buckets(runner, storage_responses): assert result.exit_code == 0 assert '1234' in result.output + result = runner.invoke( + cli.cli, ['io', 'buckets', 'upload'], input='hello world') + assert result.exit_code == 2 + + result = runner.invoke( + cli.cli, ['io', 'buckets', '1234', 'upload'], input='hello world') + assert result.exit_code == 2 + + result = runner.invoke( + cli.cli, ['io', 'buckets', '1234', 'upload', '--name', 'hello'], + input='hello world') + assert result.exit_code == 0 + assert '9876' in result.output + def test_storage_buckets_in_project(runner, projects_responses, storage_responses, explorer_responses): @@ -127,7 +170,7 @@ def test_storage_buckets_in_project(runner, projects_responses, os.mkdir('test-project') os.chdir('test-project') - result = runner.invoke(cli.cli, ['init', '--autosync']) + result = runner.invoke(cli.cli, ['init']) assert result.exit_code == 0 result = runner.invoke(cli.cli, ['io', 'buckets', 'create', 'bucket1']) @@ -186,14 +229,14 @@ def test_deployer(runner, deployer_responses): def test_notebooks(runner, deployer_responses): """Test notebook launch.""" - config = read_config() + config = read_config(os.environ['RENGA_CONFIG']) assert 'notebooks' not in config['endpoints']['https://example.com'] result = runner.invoke(cli.cli, ['notebooks', 'launch']) assert result.exit_code == 0 # The notebook context is filled - config = read_config() + config = read_config(os.environ['RENGA_CONFIG']) assert 'abcd' in config['endpoints']['https://example.com'][ 'notebooks'].values() @@ -204,7 +247,7 @@ def test_notebooks(runner, deployer_responses): assert result.exit_code == 0 # The notebook context is reused - config = read_config() + config = read_config(os.environ['RENGA_CONFIG']) assert 'my-image' not in config['endpoints']['https://example.com'][ 'notebooks'] @@ -214,19 +257,19 @@ def test_notebooks(runner, deployer_responses): ]) assert result.exit_code == 0 - config = read_config() + config = read_config(os.environ['RENGA_CONFIG']) assert 'my-image:latest' in config['endpoints']['https://example.com'][ 'notebooks'] # Should fail on an unknown context config['endpoints']['https://example.com']['notebooks'][ 'my-image:latest'] = 'deadbeef' - write_config(config) + write_config(config, path=os.environ['RENGA_CONFIG']) result = runner.invoke(cli.cli, ['notebooks', 'launch', '--image', 'my-image']) assert result.exit_code == 0 - config = read_config() + config = read_config(os.environ['RENGA_CONFIG']) assert 'abcd' in config['endpoints']['https://example.com'][ 'notebooks'].values() diff --git a/tests/test_client.py b/tests/test_client.py index b2299c634a..2c302ac52a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -154,3 +154,19 @@ def test_bucket_listing(renga_client, explorer_responses): assert buckets[1].id == 5678 assert renga_client.buckets[1234].id == 1234 + + +def test_file_renaming(renga_client, storage_responses): + """Test file renaming.""" + bucket = renga_client.buckets.create(name='world', backend='local') + assert bucket.id == 1234 + + file_ = bucket.files.create(file_name='hello') + assert file_.id == 9876 + assert file_.filename == 'hello' + + file_.filename = 'hello-2' + assert file_.filename == 'hello-2' + + file_ = bucket.files[9876] + assert file_.filename == 'hello-2'