Skip to content

Commit 239ebea

Browse files
authored
Merge pull request #70 from britive/develop
v1.2.1
2 parents 1ae6d0b + be20658 commit 239ebea

File tree

8 files changed

+216
-5
lines changed

8 files changed

+216
-5
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ testing-variables.txt
1010
.DS_Store
1111
site/
1212
lock-test.py
13+
test.py
1314

1415
# C extensions
1516
*.so

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,22 @@
22

33
All changes to the package starting with v0.3.1 will be logged here.
44

5+
## v1.2.1 [2023-03-14]
6+
#### What's New
7+
* None
8+
9+
#### Enhancements
10+
* None
11+
12+
#### Bug Fixes
13+
* `api` command shell completion fixed - dynamic sourcing of `method` values and options from the Britive Python SDK.
14+
15+
#### Dependencies
16+
* `britive>=2.17.0`
17+
18+
#### Other
19+
* None
20+
521
## v1.2.0 [2023-03-03]
622
#### What's New
723
* Support for Azure Managed Identities as federation providers.

requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
britive>=2.16.0
1+
britive>=2.17.0
22
certifi>=2022.12.7
33
charset-normalizer==2.1.0
44
click==8.1.3
@@ -11,7 +11,7 @@ tabulate==0.8.10
1111
toml==0.10.2
1212
urllib3==1.26.9
1313
cryptography~=39.0.1
14-
pytest==7.1.2
14+
pytest
1515
mkdocs==1.3.1
1616
mkdocs-click==0.8.0
1717
twine~=4.0.1

setup.cfg

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[metadata]
22
name = pybritive
3-
version = 1.2.0
3+
version = 1.2.1
44
author = Britive Inc.
55
author_email = support@britive.com
66
description = A pure Python CLI for Britive
@@ -26,7 +26,7 @@ install_requires =
2626
toml
2727
cryptography~=39.0.1
2828
python-dateutil
29-
britive>=2.16.0
29+
britive>=2.17.0
3030
jmespath
3131
pyjwt
3232

src/pybritive/commands/api.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import click
2+
from click import Command
23
from ..helpers.build_britive import build_britive
34
from ..options.britive_options import britive_options
5+
from ..completers.api_command import command_api_patch_shell_complete
6+
from ..helpers.api_method_argument_dectorator import click_smart_api_method_argument
7+
8+
9+
# this holds all the click version logic to gracefully degrade functionality
10+
# depending on the click version
11+
command_api_patch_shell_complete(Command)
412

513

614
@click.command(
@@ -11,7 +19,7 @@
1119
)
1220
@build_britive
1321
@britive_options(names='query,output_format,tenant,token,passphrase,federation_provider')
14-
@click.argument('method')
22+
@click_smart_api_method_argument # need to gracefully handle older version of click
1523
def api(ctx, query, output_format, tenant, token, passphrase, federation_provider, method):
1624
"""Exposes the Britive Python SDK methods to the CLI.
1725

src/pybritive/completers/api.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from britive.britive import Britive
2+
import json
3+
4+
5+
def api_completer(ctx, param, incomplete):
6+
# create an instance of the Britive class, so we can inspect it
7+
# this doesn't need to actually connect to any tenant, and we couldn't even if we
8+
# wanted to since when performing shell completion we have no tenant/token
9+
# context in order to properly establish a connection.
10+
b = Britive(token='ignore', tenant='britive.com', query_features=False)
11+
12+
# parse the incomplete command, so we can determine where in the "hierarchy" we are
13+
# and what commands/subcommands the user should be presented with
14+
parts = incomplete.split('.')[:-1]
15+
not_base_level = len(parts) > 0
16+
for part in parts:
17+
b = getattr(b, part)
18+
19+
options = []
20+
21+
# vars happen at all levels
22+
options += [var for var, value in vars(b).items() if str(value).startswith('<britive.') and var != 'britive']
23+
24+
# dir only happens at non "base" levels
25+
if not_base_level:
26+
options += [func for func in dir(b) if callable(getattr(b, func)) and not func.startswith("_")]
27+
28+
# pull it all back together and make it look nice
29+
existing = '.'.join(parts)
30+
options = [f'{existing}.{o}' if not_base_level else o for o in options]
31+
32+
return [o for o in options if o.lower().startswith(incomplete.lower())]
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import typing as t
2+
import inspect
3+
from britive.britive import Britive
4+
import pkg_resources
5+
6+
7+
def get_dynamic_method_parameters(method):
8+
try:
9+
# create an instance of the Britive class, so we can inspect it
10+
# this doesn't need to actually connect to any tenant, and we couldn't even if we
11+
# wanted to since when performing shell completion we have no tenant/token
12+
# context in order to properly establish a connection.
13+
b = Britive(token='ignore', tenant='britive.com', query_features=False)
14+
15+
# parse the method, so we can determine where in the "hierarchy" we are
16+
# and what commands/subcommands the user should be presented with
17+
for part in method.split('.'):
18+
b = getattr(b, part)
19+
20+
params = {}
21+
spec = inspect.getfullargspec(b)
22+
23+
# reformat parameters into a more consumable dict while holds all the required details
24+
helper = spec[6]
25+
helper.pop('return', None)
26+
for param, param_type in helper.items():
27+
params[param] = {
28+
'type': str(param_type).split("'")[1]
29+
}
30+
31+
defaults = list(spec[3])
32+
names = list(spec[0])
33+
34+
if len(defaults) > 0:
35+
for i in range(1, len(defaults) + 1):
36+
name = names[-1 * i]
37+
default = defaults[-1 * i]
38+
params[name]['default'] = default
39+
40+
try: # we don't REALLY need the doc string so if there are errors just eat them and move on
41+
doc_lines = inspect.getdoc(b)
42+
doc_lines = doc_lines.replace(':returns:', 'RETURNSPLIT')
43+
doc_lines = doc_lines.replace(':return:', 'RETURNSPLIT')
44+
doc_lines = doc_lines.split('RETURNSPLIT')[0].split(':param ')[1:]
45+
46+
for line in doc_lines:
47+
helper = line.split(':')
48+
name = helper[0].strip()
49+
help_text = ''.join(helper[1].strip().splitlines()).replace(' ', ' ')
50+
params[name]['help'] = help_text
51+
except:
52+
pass
53+
54+
param_list = []
55+
56+
for name, values in params.items():
57+
help_text = values.get('help') or ''
58+
59+
if 'default' in values.keys(): # cannot do a .get('default') as the default value could be False/None/etc.
60+
preamble = f'[optional: default = {values["default"]}]'
61+
if help_text == '':
62+
help_text = preamble
63+
else:
64+
help_text = f'{preamble} - {help_text}'
65+
66+
param = {
67+
'flag': f'--{name.replace("_", "-")}',
68+
'help': help_text
69+
}
70+
71+
param_list.append(param)
72+
73+
return param_list
74+
except Exception as e:
75+
return []
76+
77+
78+
def command_api_patch_shell_complete(cls):
79+
# click < 8.0.0 does shell completion different...
80+
# not all the classes/decorators are available, so we cannot
81+
# create custom shell completions like we can with click > 8.0.0
82+
major, minor, patch = pkg_resources.get_distribution('click').version.split('.')[0:3]
83+
84+
# we cannot patch the shell_complete method because it does not exist (click 7.x doesn't have it)
85+
# future proofing this as well in case click 9.x changes things up a lot
86+
if int(major) != 8:
87+
return
88+
89+
# we could potentially patch but there could be changes to shell_complete method which are not
90+
# accounted for in this patch - we will have to manually review any changes and ensure they are
91+
# backwards compatible.
92+
if int(minor) != 1:
93+
return
94+
95+
from click.shell_completion import CompletionItem
96+
from click.core import ParameterSource
97+
from click import Context, Option
98+
99+
# https://stackoverflow.com/questions/43778914/python3-using-super-in-eq-methods-raises-runtimeerror-super-class
100+
__class__ = cls # provide closure cell for super()
101+
102+
def shell_complete(self, ctx: Context, incomplete: str) -> t.List["CompletionItem"]:
103+
from click.shell_completion import CompletionItem
104+
105+
results: t.List["CompletionItem"] = []
106+
107+
if incomplete and not incomplete[0].isalnum():
108+
method = ctx.params.get('method')
109+
if method:
110+
dynamic_params = get_dynamic_method_parameters(method)
111+
112+
results.extend(
113+
CompletionItem(p['flag'], help=p['help'])
114+
for p in dynamic_params if p['flag'].startswith(incomplete)
115+
)
116+
117+
for param in self.get_params(ctx):
118+
if (
119+
not isinstance(param, Option)
120+
or param.hidden
121+
or (
122+
not param.multiple
123+
and ctx.get_parameter_source(param.name) # type: ignore
124+
is ParameterSource.COMMANDLINE
125+
)
126+
):
127+
continue
128+
129+
results.extend(
130+
CompletionItem(name, help=param.help)
131+
for name in [*param.opts, *param.secondary_opts]
132+
if name.startswith(incomplete)
133+
)
134+
135+
results.extend(super().shell_complete(ctx, incomplete))
136+
137+
return results
138+
139+
cls.shell_complete = shell_complete
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import click
2+
import pkg_resources
3+
from ..completers.api import api_completer
4+
5+
click_major_version = int(pkg_resources.get_distribution('click').version.split('.')[0])
6+
7+
8+
def click_smart_api_method_argument(func):
9+
if click_major_version >= 8:
10+
dec = click.argument('method', shell_complete=api_completer)
11+
else:
12+
dec = click.argument('method')
13+
return dec(func)
14+
15+

0 commit comments

Comments
 (0)