[cli][tests] split cli __init__ module, added check for .halfapi/config

This commit is contained in:
Maxime Alves LIRMM 2020-08-05 13:59:46 +02:00
parent 155bab1e8f
commit 6a65aaeaef
8 changed files with 455 additions and 56 deletions

View File

@ -1,17 +1 @@
#!/usr/bin/env python3 #!/usr/bin/env
# builtins
import click
@click.group(invoke_without_command=True, context_settings=CONTEXT_SETTINGS)
@click.option('--version', is_flag=True)
@click.pass_context
def cli(ctx, version):
if version:
import halfapi
return click.echo(halfapi.version)
if ctx.invoked_subcommand is None:
return run()
if __name__ == '__main__':
cli()

23
halfapi/cli/cli.py Normal file
View File

@ -0,0 +1,23 @@
#!/usr/bin/env python3
# builtins
import click
from halfapi.conf import IS_PROJECT
@click.group(invoke_without_command=True)
@click.option('--version', is_flag=True)
@click.pass_context
def cli(ctx, version):
if version:
import halfapi
return click.echo(halfapi.version)
#if not IS_PROJECT:
# return init()
#if ctx.invoked_subcommand is None:
# return run()
if IS_PROJECT:
import halfapi.cli.domain
import halfapi.cli.run
else:
import halfapi.cli.init

274
halfapi/cli/domain.py Normal file
View File

@ -0,0 +1,274 @@
#!/usr/bin/env python3
# builtins
import sys
import logging
import click
import importlib
from .cli import cli
from halfapi.conf import DOMAINS, BASE_DIR
from halfapi.db import (
Domain,
APIRouter,
APIRoute,
AclFunction,
Acl)
logger = logging.getLogger('halfapi')
def delete_domain(domain):
d = Domain(name=domain)
if len(d) != 1:
return False
d.delete(delete_all=True)
return True
@click.option('--domain', '-d', default=None, multiple=True)
@click.option('--update', default=False, is_flag=True)
@cli.command()
def routes(domain, update):
"""
Lists routes for the specified domains, or update them in the database
Parameters:
domain (List[str]|None): The list of the domains to list/update
The parameter has a misleading name as it is a multiple option
but this would be strange to use it several times named as "domains"
update (boolean): If set, update the database for the selected domains
"""
if not domain:
domain = DOMAINS
else:
for domain_name in domain:
if domain_name in DOMAINS:
continue
click.echo(
f'Domain {domain}s is not activated in the configuration')
if update:
update_db(domain)
else:
list_routes(domain)
def list_routes(domain):
click.echo(f'\nDomain {domain}')
routes = Acl(domain=domain)
for route in routes.select():
click.echo('-', route)
def update_db(domains):
def add_domain(domain):
"""
Inserts Domain into database
Parameters:
- domain (str): The domain's name
"""
new_domain = Domain(name=domain)
if len(new_domain) == 0:
click.echo(f'New domain {domain}')
new_domain.insert()
def add_router(name, domain):
"""
Inserts Router into database
Parameters:
- name (str): The Router's name
"""
router = APIRouter()
router.name = name
router.domain = domain
if len(router) == 0:
router.insert()
def add_acl_fct(fct, domain):
"""
Inserts ACL function into database
Parameters:
- fct (Callable): The ACL function reference
- domain (str): The Domain's name
"""
acl = AclFunction()
acl.domain = domain
acl.name = fct.__name__
if len(acl) == 0:
acl.insert()
def add_acls(acls, **route):
"""
Inserts ACL into database
Parameters:
- acls (List[Callable]): List of the Route's ACL's
- route (dict): The Route
"""
route.pop('fct_name')
acl = Acl(**route)
for fct in acls:
acl.acl_fct_name = fct.__name__
if len(acl) == 0:
if fct is not None:
add_acl_fct(fct, route['domain'])
acl.insert()
elif fct is None:
acl.delete()
def get_fct_name(http_verb, path):
"""
Returns the predictable name of the function for a route
Parameters:
- http_verb (str): The Route's HTTP method (GET, POST, ...)
- path (str): A path beginning by '/' for the route
Returns:
str: The *unique* function name for a route and it's verb
Examples:
>>> get_fct_name('foo', 'bar')
Traceback (most recent call last):
...
Exception: Malformed path
>>> get_fct_name('get', '/')
'get_'
>>> get_fct_name('GET', '/')
'get_'
>>> get_fct_name('POST', '/foo')
'post_foo'
>>> get_fct_name('POST', '/foo/bar')
'post_foo_bar'
>>> get_fct_name('DEL', '/foo/{boo}/{far}/bar')
'del_foo_BOO_FAR_bar'
>>> get_fct_name('DEL', '/foo/{boo:zoo}')
'del_foo_BOO'
"""
if path[0] != '/':
raise Exception('Malformed path')
elts = path[1:].split('/')
fct_name = [http_verb.lower()]
for elt in elts:
if elt and elt[0] == '{':
fct_name.append(elt[1:-1].split(':')[0].upper())
else:
fct_name.append(elt)
return '_'.join(fct_name)
def add_route(http_verb, path, router, domain, acls):
"""
Inserts Route into database
Parameters:
- http_verb (str): The Route's HTTP method (GET, POST, ...)
- path (str): A path beginning by '/' for the route
- router (str): The Route's Router name
- domain (str): The Domain's name
- acls (List[Callable]): The list of ACL functions for this Route
"""
click.echo(f'Adding route /{domain}/{router}{path}')
route = APIRoute()
# Route definition
route.http_verb = http_verb
route.path = path
route.fct_name = get_fct_name(http_verb, path)
route.router = router
route.domain = domain
if len(route) == 0:
route.insert()
add_acls(acls, **route.to_dict())
sys.path.insert(0, BASE_DIR)
for domain in domains:
# Reset Domain relations
delete_domain(domain)
acl_set = set()
try:
# Module retrieval
dom_mod = importlib.import_module(domain)
except ImportError:
# Domain is not available in current PYTHONPATH
click.echo(f"Can't import *{domain}*", err=True)
continue
try:
add_domain(domain)
except Exception as e:
# Could not insert Domain
# @TODO : Insertion exception handling
click.echo(e)
click.echo(f"Could not insert *{domain}*", err=True)
continue
# add sub routers
try:
ROUTERS = dom_mod.ROUTERS
except AttributeError:
# No ROUTERS variable in current domain, check domain/__init__.py
click.echo(f'The domain {domain} has no *ROUTERS* variable', err=True)
for router_name in dom_mod.ROUTERS:
try:
router_mod = getattr(dom_mod.routers, router_name)
except AttributError:
# Missing router, continue
click.echo(f'The domain {domain} has no *{router_name}* router', err=True)
continue
try:
add_router(router_name, domain)
except Exception as e:
# Could not insert Router
# @TODO : Insertion exception handling
print(e)
continue
for route_path, route_params in router_mod.ROUTES.items():
for http_verb, acls in route_params.items():
try:
# Insert a route and it's ACLS
add_route(http_verb, route_path, router_name, domain, acls)
except Exception as e:
# Could not insert route
# @TODO : Insertion exception handling
print(e)
continue

66
halfapi/cli/init.py Normal file
View File

@ -0,0 +1,66 @@
#!/usr/bin/env python3
# builtins
import os
import sys
import re
import click
import logging
from halfapi import __version__
from halfapi.cli.lib.db import ProjectDB
from .cli import cli
logger = logging.getLogger('halfapi')
TMPL_HALFAPI_ETC = """Insert this into the HALFAPI_CONF_DIR/{project} file
[project]
host = 127.0.0.1
port = 8000
secret = /path/to/secret_file
production = False
base_dir = {base_dir}
"""
TMPL_HALFAPI_CONFIG = """[project]
name = {name}
halfapi_version = {halfapi_version}
"""
@click.argument('project')
@cli.command()
def init(project):
if not re.match('^[a-z0-9_]+$', project, re.I):
click.echo('Project name must match "^[a-z0-9_]+$", retry.', err=True)
sys.exit(1)
if os.path.exists(project):
click.echo(f'A file named {project} already exists, abort.', err=True)
sys.exit(1)
click.echo(f'create directory {project}')
os.mkdir(project)
try:
pdb = ProjectDB(project)
pdb.init()
except Exception as e:
logger.warning(e)
logger.debug(os.environ.get('HALFORM_CONF_DIR'))
raise e
os.mkdir(os.path.join(project, '.halfapi'))
open(os.path.join(project, '.halfapi', 'domains'), 'w').write('[domains]\n')
config_file = os.path.join(project, '.halfapi', 'config')
with open(config_file, 'w') as f:
f.write(TMPL_HALFAPI_CONFIG.format(
name=project,
halfapi_version=__version__
))
print(TMPL_HALFAPI_ETC.format(
project=project,
base_dir=os.path.abspath(project)
))

36
halfapi/cli/run.py Normal file
View File

@ -0,0 +1,36 @@
import sys
import click
import uvicorn
from .cli import cli
from halfapi.cli.domain import list_routes
from halfapi.conf import (HOST, PORT,
PRODUCTION, BASE_DIR, DOMAINS)
@click.option('--host', default=None)
@click.option('--port', default=None)
@cli.command()
def run(host, port):
if not host:
host = HOST
if not port:
port = PORT
port = int(port)
debug = reload = not PRODUCTION
log_level = 'info' if PRODUCTION else 'debug'
click.echo('Launching application')
sys.path.insert(0, BASE_DIR)
[ list_routes(domain) for domain in DOMAINS ]
uvicorn.run('halfapi.app:application',
host=host,
port=int(port),
log_level=log_level,
reload=reload)

View File

@ -4,45 +4,48 @@ from os import environ
import sys import sys
from configparser import ConfigParser from configparser import ConfigParser
default_config = { IS_PROJECT = os.path.isfile('.halfapi/config')
'project': {
'host': '127.0.0.1', if IS_PROJECT:
'port': '8000', default_config = {
'secret': '', 'project': {
'base_dir': '', 'host': '127.0.0.1',
'production': 'no' 'port': '8000',
'secret': '',
'base_dir': '',
'production': 'no'
}
} }
}
config = ConfigParser(allow_no_value=True) config = ConfigParser(allow_no_value=True)
config.read_dict(default_config) config.read_dict(default_config)
config.read(filenames=['.halfapi/config']) config.read(filenames=['.halfapi/config'])
PROJECT_NAME = config.get('project', 'name') PROJECT_NAME = config.get('project', 'name')
if len(PROJECT_NAME) == 0: if len(PROJECT_NAME) == 0:
raise Exception('Need a project name as argument') raise Exception('Need a project name as argument')
config = ConfigParser(allow_no_value=True) config = ConfigParser(allow_no_value=True)
config.read_dict(default_config) config.read_dict(default_config)
config.read(filenames=['.halfapi/domains']) config.read(filenames=['.halfapi/domains'])
DOMAINS = [domain for domain, _ in config.items('domains')] DOMAINS = [domain for domain, _ in config.items('domains')]
CONF_DIR = environ.get('HALFAPI_CONF_DIR', '/etc/half_api') CONF_DIR = environ.get('HALFAPI_CONF_DIR', '/etc/half_api')
config.read(filenames=[os.path.join( config.read(filenames=[os.path.join(
CONF_DIR, CONF_DIR,
PROJECT_NAME PROJECT_NAME
)]) )])
HOST = config.get('project', 'host') HOST = config.get('project', 'host')
PORT = config.getint('project', 'port') PORT = config.getint('project', 'port')
DB_NAME = f'halfapi_{PROJECT_NAME}' DB_NAME = f'halfapi_{PROJECT_NAME}'
with open(config.get('project', 'secret')) as secret_file: with open(config.get('project', 'secret')) as secret_file:
SECRET = secret_file.read() SECRET = secret_file.read()
PRODUCTION = config.getboolean('project', 'production') PRODUCTION = config.getboolean('project', 'production')
BASE_DIR = config.get('project', 'base_dir') BASE_DIR = config.get('project', 'base_dir')

View File

@ -57,7 +57,7 @@ setup(
}, },
entry_points={ entry_points={
"console_scripts":[ "console_scripts":[
"halfapi=halfapi.cli:cli" "halfapi=halfapi.cli.cli:cli"
] ]
} }
) )

View File

@ -53,14 +53,6 @@ class TestCli():
r = runner.invoke(cli, ['init', '--help']) r = runner.invoke(cli, ['init', '--help'])
assert r.exit_code == 0 assert r.exit_code == 0
with runner.isolated_filesystem():
r = runner.invoke(cli, ['run', '--help'])
assert r.exit_code == 0
with runner.isolated_filesystem():
r = runner.invoke(cli, ['domain', '--help'])
assert r.exit_code == 0
def test_init_project_fail(self, runner, dropdb): def test_init_project_fail(self, runner, dropdb):
# Missing argument (project) # Missing argument (project)
@ -117,25 +109,46 @@ class TestCli():
assert res.exit_code == 0 assert res.exit_code == 0
assert res.exception is None assert res.exception is None
def test_run_commands(self, runner, dropdb):
with runner.isolated_filesystem():
res = runner.invoke(cli, ['init', PROJNAME])
res = runner.invoke(cli, ['run', '--help'])
assert res.exit_code == 0
with runner.isolated_filesystem():
res = runner.invoke(cli, ['init', PROJNAME])
res = runner.invoke(cli, ['run', 'foobar'])
assert res.exit_code == 2
def test_domain_commands(self, runner, dropdb): def test_domain_commands(self, runner, dropdb):
with runner.isolated_filesystem(): with runner.isolated_filesystem():
res = runner.invoke(cli, ['init', PROJNAME])
res = runner.invoke(cli, ['domain', 'foobar']) res = runner.invoke(cli, ['domain', 'foobar'])
assert res.exit_code == 2 assert res.exit_code == 2
with runner.isolated_filesystem(): with runner.isolated_filesystem():
res = runner.invoke(cli, ['init', PROJNAME])
res = runner.invoke(cli, ['domain', '--help'])
assert r.exit_code == 0
with runner.isolated_filesystem():
res = runner.invoke(cli, ['init', PROJNAME])
res = runner.invoke(cli, ['domain', 'create', '--help']) res = runner.invoke(cli, ['domain', 'create', '--help'])
assert r.exit_code == 0 assert r.exit_code == 0
with runner.isolated_filesystem(): with runner.isolated_filesystem():
res = runner.invoke(cli, ['init', PROJNAME])
res = runner.invoke(cli, ['domain', 'read', '--help']) res = runner.invoke(cli, ['domain', 'read', '--help'])
assert r.exit_code == 0 assert r.exit_code == 0
with runner.isolated_filesystem(): with runner.isolated_filesystem():
res = runner.invoke(cli, ['init', PROJNAME])
res = runner.invoke(cli, ['domain', 'update', '--help']) res = runner.invoke(cli, ['domain', 'update', '--help'])
assert r.exit_code == 0 assert r.exit_code == 0
with runner.isolated_filesystem(): with runner.isolated_filesystem():
res = runner.invoke(cli, ['init', PROJNAME])
res = runner.invoke(cli, ['domain', 'delete', '--help']) res = runner.invoke(cli, ['domain', 'delete', '--help'])
assert r.exit_code == 0 assert r.exit_code == 0