New command line tool for working with tasks (#732)
* Adding new command line tool for performing common task related operations (create, list, delete, etc.) * Replaced @exception decorator with try/except in main() * Replaced optional --name with positional name and removed default * Added license text to files * Added django units to cover future API changes * Refactored into submodules to better support testsmain
parent
8e7a75847e
commit
db19cbfe4b
@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
import sys
|
||||||
|
from http.client import HTTPConnection
|
||||||
|
from core.core import CLI, CVAT_API_V1
|
||||||
|
from core.definition import parser
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def config_log(level):
|
||||||
|
log = logging.getLogger('core')
|
||||||
|
log.addHandler(logging.StreamHandler(sys.stdout))
|
||||||
|
log.setLevel(level)
|
||||||
|
if level <= logging.DEBUG:
|
||||||
|
HTTPConnection.debuglevel = 1
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
actions = {'create': CLI.tasks_create,
|
||||||
|
'delete': CLI.tasks_delete,
|
||||||
|
'ls': CLI.tasks_list,
|
||||||
|
'frames': CLI.tasks_frame,
|
||||||
|
'dump': CLI.tasks_dump}
|
||||||
|
args = parser.parse_args()
|
||||||
|
config_log(args.loglevel)
|
||||||
|
with requests.Session() as session:
|
||||||
|
session.auth = args.auth
|
||||||
|
api = CVAT_API_V1(args.server_host, args.server_port)
|
||||||
|
cli = CLI(session, api)
|
||||||
|
try:
|
||||||
|
actions[args.action](cli, **args.__dict__)
|
||||||
|
except (requests.exceptions.HTTPError,
|
||||||
|
requests.exceptions.ConnectionError,
|
||||||
|
requests.exceptions.RequestException) as e:
|
||||||
|
log.info(e)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
from .definition import parser, ResourceType # noqa
|
||||||
|
from .core import CLI, CVAT_API_V1 # noqa
|
||||||
@ -0,0 +1,140 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
from io import BytesIO
|
||||||
|
from PIL import Image
|
||||||
|
from .definition import ResourceType
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CLI():
|
||||||
|
|
||||||
|
def __init__(self, session, api):
|
||||||
|
self.api = api
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
def tasks_data(self, task_id, resource_type, resources):
|
||||||
|
""" Add local, remote, or shared files to an existing task. """
|
||||||
|
url = self.api.tasks_id_data(task_id)
|
||||||
|
data = None
|
||||||
|
files = None
|
||||||
|
if resource_type == ResourceType.LOCAL:
|
||||||
|
files = {f'client_files[{i}]': open(f, 'rb') for i, f in enumerate(resources)}
|
||||||
|
elif resource_type == ResourceType.REMOTE:
|
||||||
|
data = {f'remote_files[{i}]': f for i, f in enumerate(resources)}
|
||||||
|
elif resource_type == ResourceType.SHARE:
|
||||||
|
data = {f'server_files[{i}]': f for i, f in enumerate(resources)}
|
||||||
|
response = self.session.post(url, data=data, files=files)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
def tasks_list(self, use_json_output, **kwargs):
|
||||||
|
""" List all tasks in either basic or JSON format. """
|
||||||
|
url = self.api.tasks
|
||||||
|
response = self.session.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
response_json = response.json()
|
||||||
|
for r in response_json['results']:
|
||||||
|
if use_json_output:
|
||||||
|
log.info(json.dumps(r, indent=4))
|
||||||
|
else:
|
||||||
|
log.info(f'{r["id"]},{r["name"]},{r["status"]}')
|
||||||
|
if not response_json['next']:
|
||||||
|
return
|
||||||
|
page += 1
|
||||||
|
url = self.api.tasks_page(page)
|
||||||
|
response = self.session.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
def tasks_create(self, name, labels, bug, resource_type, resources, **kwargs):
|
||||||
|
""" Create a new task with the given name and labels JSON and
|
||||||
|
add the files to it. """
|
||||||
|
url = self.api.tasks
|
||||||
|
data = {'name': name,
|
||||||
|
'labels': labels,
|
||||||
|
'bug_tracker': bug,
|
||||||
|
'image_quality': 50}
|
||||||
|
response = self.session.post(url, json=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
response_json = response.json()
|
||||||
|
log.info(f'Created task ID: {response_json["id"]} '
|
||||||
|
f'NAME: {response_json["name"]}')
|
||||||
|
self.tasks_data(response_json['id'], resource_type, resources)
|
||||||
|
|
||||||
|
def tasks_delete(self, task_ids, **kwargs):
|
||||||
|
""" Delete a list of tasks, ignoring those which don't exist. """
|
||||||
|
for task_id in task_ids:
|
||||||
|
url = self.api.tasks_id(task_id)
|
||||||
|
response = self.session.delete(url)
|
||||||
|
try:
|
||||||
|
response.raise_for_status()
|
||||||
|
log.info(f'Task ID {task_id} deleted')
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
if response.status_code == 404:
|
||||||
|
log.info(f'Task ID {task_id} not found')
|
||||||
|
else:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def tasks_frame(self, task_id, frame_ids, outdir='', **kwargs):
|
||||||
|
""" Download the requested frame numbers for a task and save images as
|
||||||
|
task_<ID>_frame_<FRAME>.jpg."""
|
||||||
|
for frame_id in frame_ids:
|
||||||
|
url = self.api.tasks_id_frame_id(task_id, frame_id)
|
||||||
|
response = self.session.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
im = Image.open(BytesIO(response.content))
|
||||||
|
outfile = f'task_{task_id}_frame_{frame_id:06d}.jpg'
|
||||||
|
im.save(os.path.join(outdir, outfile))
|
||||||
|
|
||||||
|
def tasks_dump(self, task_id, fileformat, filename, **kwargs):
|
||||||
|
""" Download annotations for a task in the specified format
|
||||||
|
(e.g. 'YOLO ZIP 1.0')."""
|
||||||
|
url = self.api.tasks_id(task_id)
|
||||||
|
response = self.session.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
response_json = response.json()
|
||||||
|
|
||||||
|
url = self.api.tasks_id_annotations_filename(task_id,
|
||||||
|
response_json['name'],
|
||||||
|
fileformat)
|
||||||
|
while True:
|
||||||
|
response = self.session.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
log.info(f'STATUS {response.status_code}')
|
||||||
|
if response.status_code == 201:
|
||||||
|
break
|
||||||
|
|
||||||
|
response = self.session.get(url + '&action=download')
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
with open(filename, 'wb') as fp:
|
||||||
|
fp.write(response.content)
|
||||||
|
|
||||||
|
|
||||||
|
class CVAT_API_V1():
|
||||||
|
""" Build parameterized API URLs """
|
||||||
|
|
||||||
|
def __init__(self, host, port):
|
||||||
|
self.base = f'http://{host}:{port}/api/v1/'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tasks(self):
|
||||||
|
return f'{self.base}tasks'
|
||||||
|
|
||||||
|
def tasks_page(self, page_id):
|
||||||
|
return f'{self.tasks}?page={page_id}'
|
||||||
|
|
||||||
|
def tasks_id(self, task_id):
|
||||||
|
return f'{self.tasks}/{task_id}'
|
||||||
|
|
||||||
|
def tasks_id_data(self, task_id):
|
||||||
|
return f'{self.tasks}/{task_id}/data'
|
||||||
|
|
||||||
|
def tasks_id_frame_id(self, task_id, frame_id):
|
||||||
|
return f'{self.tasks}/{task_id}/frames/{frame_id}'
|
||||||
|
|
||||||
|
def tasks_id_annotations_filename(self, task_id, name, fileformat):
|
||||||
|
return f'{self.tasks}/{task_id}/annotations/{name}?format={fileformat}'
|
||||||
@ -0,0 +1,210 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
import argparse
|
||||||
|
import getpass
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth(s):
|
||||||
|
""" Parse USER[:PASS] strings and prompt for password if none was
|
||||||
|
supplied. """
|
||||||
|
user, _, password = s.partition(':')
|
||||||
|
password = password or os.environ.get('PASS') or getpass.getpass()
|
||||||
|
return user, password
|
||||||
|
|
||||||
|
|
||||||
|
def parse_label_arg(s):
|
||||||
|
""" If s is a file load it as JSON, otherwise parse s as JSON."""
|
||||||
|
if os.path.exists(s):
|
||||||
|
fp = open(s, 'r')
|
||||||
|
return json.load(fp)
|
||||||
|
else:
|
||||||
|
return json.loads(s)
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceType(Enum):
|
||||||
|
|
||||||
|
LOCAL = 0
|
||||||
|
SHARE = 1
|
||||||
|
REMOTE = 2
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name.lower()
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return str(self)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def argparse(s):
|
||||||
|
try:
|
||||||
|
return ResourceType[s.upper()]
|
||||||
|
except KeyError:
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
#######################################################################
|
||||||
|
# Command line interface definition
|
||||||
|
#######################################################################
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Perform common operations related to CVAT tasks.\n\n'
|
||||||
|
)
|
||||||
|
task_subparser = parser.add_subparsers(dest='action')
|
||||||
|
|
||||||
|
#######################################################################
|
||||||
|
# Positional arguments
|
||||||
|
#######################################################################
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--auth',
|
||||||
|
type=get_auth,
|
||||||
|
metavar='USER:[PASS]',
|
||||||
|
default=getpass.getuser(),
|
||||||
|
help='''defaults to the current user and supports the PASS
|
||||||
|
environment variable or password prompt
|
||||||
|
(default user: %(default)s).'''
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--server-host',
|
||||||
|
type=str,
|
||||||
|
default='localhost',
|
||||||
|
help='host (default: %(default)s)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--server-port',
|
||||||
|
type=int,
|
||||||
|
default='8080',
|
||||||
|
help='port (default: %(default)s)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--debug',
|
||||||
|
action='store_const',
|
||||||
|
dest='loglevel',
|
||||||
|
const=logging.DEBUG,
|
||||||
|
default=logging.INFO,
|
||||||
|
help='show debug output'
|
||||||
|
)
|
||||||
|
|
||||||
|
#######################################################################
|
||||||
|
# Create
|
||||||
|
#######################################################################
|
||||||
|
|
||||||
|
task_create_parser = task_subparser.add_parser(
|
||||||
|
'create',
|
||||||
|
description='Create a new CVAT task.'
|
||||||
|
)
|
||||||
|
task_create_parser.add_argument(
|
||||||
|
'name',
|
||||||
|
type=str,
|
||||||
|
help='name of the task'
|
||||||
|
)
|
||||||
|
task_create_parser.add_argument(
|
||||||
|
'--labels',
|
||||||
|
default='[]',
|
||||||
|
type=parse_label_arg,
|
||||||
|
help='string or file containing JSON labels specification'
|
||||||
|
)
|
||||||
|
task_create_parser.add_argument(
|
||||||
|
'--bug',
|
||||||
|
default='',
|
||||||
|
type=str,
|
||||||
|
help='bug tracker URL'
|
||||||
|
)
|
||||||
|
task_create_parser.add_argument(
|
||||||
|
'resource_type',
|
||||||
|
default='local',
|
||||||
|
choices=list(ResourceType),
|
||||||
|
type=ResourceType.argparse,
|
||||||
|
help='type of files specified'
|
||||||
|
)
|
||||||
|
task_create_parser.add_argument(
|
||||||
|
'resources',
|
||||||
|
type=str,
|
||||||
|
help='list of paths or URLs',
|
||||||
|
nargs='+'
|
||||||
|
)
|
||||||
|
|
||||||
|
#######################################################################
|
||||||
|
# Delete
|
||||||
|
#######################################################################
|
||||||
|
|
||||||
|
delete_parser = task_subparser.add_parser(
|
||||||
|
'delete',
|
||||||
|
description='Delete a CVAT task.'
|
||||||
|
)
|
||||||
|
delete_parser.add_argument(
|
||||||
|
'task_ids',
|
||||||
|
type=int,
|
||||||
|
help='list of task IDs',
|
||||||
|
nargs='+'
|
||||||
|
)
|
||||||
|
|
||||||
|
#######################################################################
|
||||||
|
# List
|
||||||
|
#######################################################################
|
||||||
|
|
||||||
|
ls_parser = task_subparser.add_parser(
|
||||||
|
'ls',
|
||||||
|
description='List all CVAT tasks in simple or JSON format.'
|
||||||
|
)
|
||||||
|
ls_parser.add_argument(
|
||||||
|
'--json',
|
||||||
|
dest='use_json_output',
|
||||||
|
default=False,
|
||||||
|
action='store_true',
|
||||||
|
help='output JSON data'
|
||||||
|
)
|
||||||
|
|
||||||
|
#######################################################################
|
||||||
|
# Frames
|
||||||
|
#######################################################################
|
||||||
|
|
||||||
|
frames_parser = task_subparser.add_parser(
|
||||||
|
'frames',
|
||||||
|
description='Download all frame images for a CVAT task.'
|
||||||
|
)
|
||||||
|
frames_parser.add_argument(
|
||||||
|
'task_id',
|
||||||
|
type=int,
|
||||||
|
help='task ID'
|
||||||
|
)
|
||||||
|
frames_parser.add_argument(
|
||||||
|
'frame_ids',
|
||||||
|
type=int,
|
||||||
|
help='list of frame IDs to download',
|
||||||
|
nargs='+'
|
||||||
|
)
|
||||||
|
frames_parser.add_argument(
|
||||||
|
'--outdir',
|
||||||
|
type=str,
|
||||||
|
default='',
|
||||||
|
help='directory to save images'
|
||||||
|
)
|
||||||
|
|
||||||
|
#######################################################################
|
||||||
|
# Dump
|
||||||
|
#######################################################################
|
||||||
|
|
||||||
|
dump_parser = task_subparser.add_parser(
|
||||||
|
'dump',
|
||||||
|
description='Download annotations for a CVAT task.'
|
||||||
|
)
|
||||||
|
dump_parser.add_argument(
|
||||||
|
'task_id',
|
||||||
|
type=int,
|
||||||
|
help='task ID'
|
||||||
|
)
|
||||||
|
dump_parser.add_argument(
|
||||||
|
'filename',
|
||||||
|
type=str,
|
||||||
|
help='output file'
|
||||||
|
)
|
||||||
|
dump_parser.add_argument(
|
||||||
|
'--format',
|
||||||
|
dest='fileformat',
|
||||||
|
type=str,
|
||||||
|
default='CVAT XML 1.1 for images',
|
||||||
|
help='annotation format (default: %(default)s)'
|
||||||
|
)
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
Pillow==5.3.0
|
||||||
|
requests==2.20.1
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
import logging
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from django.conf import settings
|
||||||
|
from requests.auth import HTTPBasicAuth
|
||||||
|
from utils.cli.core import CLI, CVAT_API_V1, ResourceType
|
||||||
|
from rest_framework.test import APITestCase, RequestsClient
|
||||||
|
from cvat.apps.engine.tests.test_rest_api import create_db_users
|
||||||
|
from cvat.apps.engine.tests.test_rest_api import generate_image_file
|
||||||
|
|
||||||
|
|
||||||
|
class TestCLI(APITestCase):
|
||||||
|
|
||||||
|
@unittest.mock.patch('sys.stdout', new_callable=io.StringIO)
|
||||||
|
def setUp(self, mock_stdout):
|
||||||
|
self.client = RequestsClient()
|
||||||
|
self.client.auth = HTTPBasicAuth('admin', 'admin')
|
||||||
|
self.api = CVAT_API_V1('testserver', '')
|
||||||
|
self.cli = CLI(self.client, self.api)
|
||||||
|
self.taskname = 'test_task'
|
||||||
|
self.cli.tasks_create(self.taskname,
|
||||||
|
[],
|
||||||
|
'',
|
||||||
|
ResourceType.LOCAL,
|
||||||
|
[self.img_file])
|
||||||
|
# redirect logging to mocked stdout to test program output
|
||||||
|
self.mock_stdout = mock_stdout
|
||||||
|
log = logging.getLogger('utils.cli.core')
|
||||||
|
log.setLevel(logging.INFO)
|
||||||
|
log.addHandler(logging.StreamHandler(sys.stdout))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.img_file = os.path.join(settings.SHARE_ROOT, 'test_cli.jpg')
|
||||||
|
data = generate_image_file(cls.img_file)
|
||||||
|
with open(cls.img_file, 'wb') as image:
|
||||||
|
image.write(data.read())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
super().tearDownClass()
|
||||||
|
os.remove(cls.img_file)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
create_db_users(cls)
|
||||||
|
|
||||||
|
def test_tasks_list(self):
|
||||||
|
self.cli.tasks_list(False)
|
||||||
|
self.assertRegex(self.mock_stdout.getvalue(), f'.*{self.taskname}.*')
|
||||||
|
|
||||||
|
def test_tasks_delete(self):
|
||||||
|
self.cli.tasks_delete([1])
|
||||||
|
self.cli.tasks_list(False)
|
||||||
|
self.assertNotRegex(self.mock_stdout.getvalue(), f'.*{self.taskname}.*')
|
||||||
|
|
||||||
|
def test_tasks_dump(self):
|
||||||
|
path = os.path.join(settings.SHARE_ROOT, 'test_cli.xml')
|
||||||
|
self.cli.tasks_dump(1, 'CVAT XML 1.1 for images', path)
|
||||||
|
self.assertTrue(os.path.exists(path))
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
def test_tasks_frame(self):
|
||||||
|
path = os.path.join(settings.SHARE_ROOT, 'task_1_frame_000000.jpg')
|
||||||
|
self.cli.tasks_frame(1, [0], outdir=settings.SHARE_ROOT)
|
||||||
|
self.assertTrue(os.path.exists(path))
|
||||||
|
os.remove(path)
|
||||||
Loading…
Reference in New Issue