From ccc879db93cf378d89b17bd11b4dfd8488aba48a Mon Sep 17 00:00:00 2001 From: Markus Scheidgen <markus.scheidgen@gmail.com> Date: Sat, 22 Dec 2018 17:40:26 +0100 Subject: [PATCH] Addd bravado client. Added method names to swagger api. Refactored cli. --- nomad/api/admin.py | 1 + nomad/api/archive.py | 2 + nomad/api/auth.py | 1 + nomad/api/common.py | 2 +- nomad/api/raw.py | 3 + nomad/api/repo.py | 2 + nomad/api/upload.py | 17 +++++- nomad/client.py | 136 +++++++++++++++++++++---------------------- requirements.txt | 3 +- 9 files changed, 95 insertions(+), 72 deletions(-) diff --git a/nomad/api/admin.py b/nomad/api/admin.py index 91a7f1bb29..5a6eefdafd 100644 --- a/nomad/api/admin.py +++ b/nomad/api/admin.py @@ -28,6 +28,7 @@ ns = api.namespace('admin', description='Administrative operations') @api.doc(params={'operation': 'The operation to perform.'}) class AdminOperationsResource(Resource): # TODO in production this requires authorization + @api.doc('exec') @api.response(200, 'Operation performed') @api.response(404, 'Operation does not exist') @api.response(400, 'Operation not available/disabled') diff --git a/nomad/api/archive.py b/nomad/api/archive.py index 465ec092f6..93a767c481 100644 --- a/nomad/api/archive.py +++ b/nomad/api/archive.py @@ -37,6 +37,7 @@ ns = api.namespace( @calc_route(ns, '/logs') class ArchiveCalcLogResource(Resource): + @api.doc('get_logs') @api.response(404, 'The upload or calculation does not exist') @api.response(200, 'Archive data send') @login_if_available @@ -74,6 +75,7 @@ class ArchiveCalcLogResource(Resource): @calc_route(ns) class ArchiveCalcResource(Resource): + @api.doc('get_calc') @api.response(404, 'The upload or calculation does not exist') @api.response(200, 'Archive data send') @login_if_available diff --git a/nomad/api/auth.py b/nomad/api/auth.py index b46d1fba14..b1ec55914d 100644 --- a/nomad/api/auth.py +++ b/nomad/api/auth.py @@ -127,6 +127,7 @@ ns = api.namespace( @ns.route('/token') class TokenResource(Resource): + @api.doc('get_token') @api.response(200, 'Token send', headers={'Content-Type': 'text/plain; charset=utf-8'}) @login_really_required def get(self): diff --git a/nomad/api/common.py b/nomad/api/common.py index 6ef240fbd8..7e8b4f5264 100644 --- a/nomad/api/common.py +++ b/nomad/api/common.py @@ -46,7 +46,7 @@ def calc_route(ns, prefix: str = ''): ns.route('%s/<string:upload_hash>/<string:calc_hash>' % prefix)( api.doc(params={ 'upload_hash': 'The unique hash for the requested upload.', - 'path': 'The path to a file or directory.' + 'calc_hash': 'The unique hash for the requested calculation.' })(func) ) return decorator diff --git a/nomad/api/raw.py b/nomad/api/raw.py index 0052ed137b..9cf75d2fb3 100644 --- a/nomad/api/raw.py +++ b/nomad/api/raw.py @@ -55,6 +55,7 @@ raw_file_from_path_parser.add_argument(**raw_file_compress_argument) }) @api.header('Content-Type', 'application/gz') class RawFileFromPathResource(Resource): + @api.doc('get') @api.response(404, 'The upload or path does not exist') @api.response(200, 'File(s) send', headers={'Content-Type': 'application/gz'}) @api.expect(raw_file_from_path_parser, validate=True) @@ -127,6 +128,7 @@ raw_files_request_parser.add_argument( 'upload_hash': 'The unique hash for the requested upload.' }) class RawFilesResource(Resource): + @api.doc('get_files') @api.response(404, 'The upload or path does not exist') @api.response(200, 'File(s) send', headers={'Content-Type': 'application/gz'}) @api.expect(raw_files_request_model, validate=True) @@ -139,6 +141,7 @@ class RawFilesResource(Resource): return respond_to_get_raw_files(upload_hash, files, compress) + @api.doc('get_files_alternate') @api.response(404, 'The upload or path does not exist') @api.response(200, 'File(s) send', headers={'Content-Type': 'application/gz'}) @api.expect(raw_files_request_parser, validate=True) diff --git a/nomad/api/repo.py b/nomad/api/repo.py index 79d096c0fa..6c534746db 100644 --- a/nomad/api/repo.py +++ b/nomad/api/repo.py @@ -34,6 +34,7 @@ ns = api.namespace('repo', description='Access repository metadata, edit user me class RepoCalcResource(Resource): @api.response(404, 'The upload or calculation does not exist') @api.response(200, 'Metadata send') + @api.doc('get_calc') def get(self, upload_hash, calc_hash): """ Get calculation metadata in repository form. @@ -64,6 +65,7 @@ repo_request_parser.add_argument( @ns.route('/') class RepoCalcsResource(Resource): + @api.doc('get_calcs') @api.response(400, 'Invalid requests, e.g. wrong owner type') @api.expect(repo_request_parser, validate=True) @api.marshal_with(repo_calcs_model, skip_none=True, code=200, description='Metadata send') diff --git a/nomad/api/upload.py b/nomad/api/upload.py index 35d5c52b62..354128df6e 100644 --- a/nomad/api/upload.py +++ b/nomad/api/upload.py @@ -20,6 +20,8 @@ files, and retrieve the processing status of uploads. from flask import g, request from flask_restplus import Resource, fields, abort from datetime import datetime +from werkzeug.datastructures import FileStorage +import os.path from nomad import config from nomad.processing import Upload @@ -94,17 +96,20 @@ upload_operation_model = api.model('UploadOperation', { upload_metadata_parser = api.parser() upload_metadata_parser.add_argument('name', type=str, help='An optional name for the upload.', location='args') upload_metadata_parser.add_argument('local_path', type=str, help='Use a local file on the server.', location='args') +upload_metadata_parser.add_argument('file', type=FileStorage, help='The file to upload.', location='files') @ns.route('/') class UploadListResource(Resource): + @api.doc('get_uploads') @api.marshal_list_with(upload_model, skip_none=True, code=200, description='Uploads send') @login_really_required def get(self): """ Get the list of all uploads from the authenticated user. """ return [upload for upload in Upload.user_uploads(g.user)], 200 - @api.marshal_list_with(upload_model, skip_none=True, code=200, description='Upload received') + @api.doc('upload') + @api.marshal_with(upload_model, skip_none=True, code=200, description='Upload received') @api.expect(upload_metadata_parser) @login_really_required def put(self): @@ -124,6 +129,10 @@ class UploadListResource(Resource): curl ".../nomad/api/uploads/" --upload-file local_file """ local_path = request.args.get('local_path') + if local_path: + if not os.path.exists(local_path): + abort(404, message='The given local_path was not found.') + # create upload upload = Upload.create( user=g.user, @@ -183,6 +192,7 @@ class ProxyUpload: @ns.route('/<string:upload_id>') @api.doc(params={'upload_id': 'The unique id for the requested upload.'}) class UploadResource(Resource): + @api.doc('get_upload') @api.response(404, 'Upload does not exist') @api.marshal_with(upload_with_calcs_model, skip_none=True, code=200, description='Upload send') @api.expect(pagination_request_parser) @@ -232,6 +242,7 @@ class UploadResource(Resource): return result, 200 + @api.doc('delete_upload') @api.response(404, 'Upload does not exist') @api.response(400, 'Not allowed during processing or when not in staging') @api.marshal_with(upload_model, skip_none=True, code=200, description='Upload deleted') @@ -260,6 +271,7 @@ class UploadResource(Resource): except NotAllowedDuringProcessing: abort(400, message='You must not delete an upload during processing.') + @api.doc('exec') @api.response(404, 'Upload does not exist or is not allowed') @api.response(400, 'Operation is not supported') @api.marshal_with(upload_model, skip_none=True, code=200, description='Upload unstaged successfully') @@ -307,6 +319,7 @@ upload_command_model = api.model('UploadCommand', { @ns.route('/command') class UploadCommandResource(Resource): + @api.doc('get_upload_command') @api.marshal_with(upload_command_model, code=200, description='Upload command send') @login_really_required def get(self): @@ -316,7 +329,7 @@ class UploadCommandResource(Resource): config.services.api_port, config.services.api_base_path) - upload_command = 'curl -H "X-Token: "%s" "%s" --upload-file <local_file>' % ( + upload_command = 'curl -H "X-Token: %s" "%s" --upload-file <local_file>' % ( g.user.get_auth_token().decode('utf-8'), upload_url) return dict(upload_url=upload_url, upload_command=upload_command), 200 diff --git a/nomad/client.py b/nomad/client.py index b23a7fe7a6..69d4452738 100644 --- a/nomad/client.py +++ b/nomad/client.py @@ -13,20 +13,19 @@ # limitations under the License. """ -Simple client library for the nomad api that allows to bulk upload files via shell command. +Swagger/bravado based python client library for the API and various usefull shell commands. """ import os.path import os import sys -import subprocess -import shlex import time import requests -from requests.auth import HTTPBasicAuth import click from typing import Union, Callable, cast import logging +from bravado.requests_client import RequestsClient +from bravado.client import SwaggerClient from nomad import config, utils from nomad.files import UploadFile @@ -34,11 +33,35 @@ from nomad.parsing import parsers, parser_dict, LocalBackend from nomad.normalizing import normalizers -api_base = 'http://localhost/nomad/api' +api_base = 'http://%s:%d/%s' % (config.services.api_host, config.services.api_port, config.services.api_base_path) user = 'leonard.hofstadter@nomad-fairdi.tests.de' pw = 'password' +def _cli_client(): + return create_client() + + +def create_client( + host: str = config.services.api_host, + port: int = config.services.api_port, + base_path: str = config.services.api_base_path, + user: str = user, password: str = None): + """ A factory method to create the client. """ + + if user is not None: + http_client = RequestsClient() + http_client.set_basic_auth(host, user, pw) + else: + http_client = None + + client = SwaggerClient.from_url( + 'http://%s:%d%s/swagger.json' % (host, port, base_path), + http_client=http_client) + + return client + + def handle_common_errors(func): def wrapper(*args, **kwargs): try: @@ -51,7 +74,6 @@ def handle_common_errors(func): return wrapper -@handle_common_errors def upload_file(file_path: str, name: str = None, offline: bool = False, unstage: bool = False): """ Upload a file to nomad. @@ -62,70 +84,38 @@ def upload_file(file_path: str, name: str = None, offline: bool = False, unstage offline: allows to process data without upload, requires client to be run on the server unstage: automatically unstage after successful processing """ - auth = HTTPBasicAuth(user, pw) - - if name is None: - name = os.path.basename(file_path) - - post_data = dict(name=name) + client = _cli_client() if offline: - post_data.update(dict(local_path=os.path.abspath(file_path))) + upload = client.uploads.upload( + local_path=os.path.abspath(file_path), name=name).reponse().result click.echo('process offline: %s' % file_path) - - upload = requests.post('%s/uploads' % api_base, json=post_data, auth=auth).json() - - if not offline: - upload_cmd = upload['upload_command'] - upload_cmd = upload_cmd.replace('local_file', file_path) - - subprocess.call(shlex.split(upload_cmd)) - - click.echo('uploaded: %s' % file_path) - - while True: - upload = requests.get('%s/uploads/%s' % (api_base, upload['upload_id']), auth=auth).json() - status = upload['status'] - calcs_pagination = upload['calcs'].get('pagination') - if calcs_pagination is None: + else: + with open(file_path, 'rb') as f: + upload = client.uploads.upload(file=f, name=name).response().result + click.echo('process online: %s' % file_path) + + while upload.status not in ['SUCCESS', 'FAILURE']: + upload = client.uploads.get_upload(upload_id=upload.upload_id).response().result + calcs = upload.calcs.pagination + if calcs is None: total, successes, failures = 0, 0, 0 else: - total, successes, failures = ( - calcs_pagination[key] for key in ('total', 'successes', 'failures')) + total, successes, failures = (calcs.total, calcs.successes, calcs.failures) - ret = '\n' if status in ('SUCCESS', 'FAILURE') else '\r' + ret = '\n' if upload.status in ('SUCCESS', 'FAILURE') else '\r' print( 'status: %s; task: %s; parsing: %d/%d/%d %s' % - (status, upload['current_task'], successes, failures, total, ret), end='') - - if status in ('SUCCESS', 'FAILURE'): - break + (upload.status, upload.current_task, successes, failures, total, ret), end='') time.sleep(3) - if status == 'FAILURE': + if upload.status == 'FAILURE': click.echo('There have been errors:') - for error in upload['errors']: + for error in upload.errors: click.echo(' %s' % error) elif unstage: - post_data = dict(operation='unstage') - requests.post('%s/uploads/%s' % (api_base, upload['upload_id']), json=post_data, auth=auth).json() - - -def walk_through_files(path, extension='.zip'): - """ - Returns all abs path of all files in a sub tree of the given path that match - the given extension. - - Arguments: - path (str): the directory - extension (str): the extension, incl. '.', e.g. '.zip' (default) - """ - - for (dirpath, _, filenames) in os.walk(path): - for filename in filenames: - if filename.endswith(extension): - yield os.path.abspath(os.path.join(dirpath, filename)) + client.uploads.exec(upload_id=upload.upload_id, operation='unstage').reponse() class CalcProcReproduction: @@ -158,9 +148,10 @@ class CalcProcReproduction: os.makedirs(os.path.dirname(local_path)) if not os.path.exists(local_path) or override: # download raw if not already downloaded or if override is set + # download with request, since bravado does not support streaming # TODO currently only downloads mainfile self.logger.info('Downloading calc.') - req = requests.get('%s/raw/%s?files=%s' % (api_base, self.upload_hash, self.mainfile), stream=True) + req = requests.get('%s/raw/%s/%s' % (api_base, self.upload_hash, os.path.dirname(self.mainfile)), stream=True) with open(local_path, 'wb') as f: for chunk in req.iter_content(chunk_size=1024): f.write(chunk) @@ -243,10 +234,12 @@ class CalcProcReproduction: @click.group() -@click.option('-h', '--host', default='localhost', help='The host nomad runs on, default is "localhost".') -@click.option('-p', '--port', default=80, help='the port nomad runs with, default is 80.') +@click.option('-h', '--host', default=config.services.api_host, help='The host nomad runs on, default is "%s".' % config.services.api_host) +@click.option('-p', '--port', default=config.services.api_port, help='the port nomad runs with, default is %d.' % config.services.api_port) +@click.option('-u', '--user', default=None, help='the user name to login, default no login.') +@click.option('-w', '--password', default=None, help='the password use to login.') @click.option('-v', '--verbose', help='sets log level to debug', is_flag=True) -def cli(host: str, port: int, verbose: bool): +def cli(host: str, port: int, verbose: bool, user: str, password: str): if verbose: config.console_log_level = logging.DEBUG else: @@ -255,6 +248,14 @@ def cli(host: str, port: int, verbose: bool): global api_base api_base = 'http://%s:%d/nomad/api' % (host, port) + global _cli_client + + def _cli_client(): # pylint: disable=W0612 + if user is not None: + return create_client(host=host, port=port, user=user, password=password) + else: + return create_client(host=host, port=port) + @cli.command( help='Upload files to nomad. The given path can be a single file or a directory. ' @@ -281,9 +282,12 @@ def upload(path, name: str, offline: bool, unstage: bool): upload_file(path, name, offline, unstage) elif os.path.isdir(path): - for file_path in walk_through_files(path): - name = os.path.basename(file_path) - upload_file(file_path, name, offline, unstage) + for (dirpath, _, filenames) in os.walk(path): + for filename in filenames: + if filename.endswith('.zip'): + file_path = os.path.abspath(os.path.join(dirpath, filename)) + name = os.path.basename(file_path) + upload_file(file_path, name, offline, unstage) else: click.echo('Unknown path type %s.' % path) @@ -291,11 +295,7 @@ def upload(path, name: str, offline: bool, unstage: bool): @cli.command(help='Attempts to reset the nomad.') def reset(): - response = requests.post('%s/admin/reset' % api_base, auth=HTTPBasicAuth(user, pw)) - if response.status_code != 200: - click.echo('API return %s' % str(response.status_code)) - click.echo(response.text) - sys.exit(1) + _cli_client().admin.exec(operation='reset').reponse() @cli.command(help='Run processing locally.') diff --git a/requirements.txt b/requirements.txt index 7779d00eb2..7fdc8d6c0d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,4 +25,5 @@ psycopg2-binary sqlalchemy bcrypt filelock -ujson \ No newline at end of file +ujson +bravado \ No newline at end of file -- GitLab