Skip to content

Commit db7e49e

Browse files
authored
Feature/extension scripts (#224)
* initial work for extension scripts * handle cmd construction on Windows * use next(iter(dict)) instead of list(dict.keys)[0] * require --python to be first arg to trigger interpreter * adjust arg swap given we know it's arg[0] * remove cmd name if there's only one * rename --python option to --script, document option and add docstring to InterpreterGroup * alias sdk_options in extensions.py * style * allow single commands to execute when no args/options required * style * just print executable path instead of exec-ing directly * remove unused imports * test installable plugin * delete test plugin folder * add required lib, style * style * exit if --python is passed to not allow subcommands * make extensions a module * add unused import ignore to setup.cfg, document import to prevent future removal * Add docs and redefine sdk_options to be regular decorator instead of returning a decorator * style * update changelog * Add note on extensions to README * style * code42cli > Code42 CLI * remove ignore since we're using all imports now * comma * make readable w linebreaks
1 parent 65c5426 commit db7e49e

File tree

9 files changed

+203
-5
lines changed

9 files changed

+203
-5
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
The intended audience of this file is for py42 consumers -- as such, changes that don't affect
99
how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here.
1010

11+
## Unreleased
12+
13+
### Added
14+
15+
- `code42cli.extensions` module exposes `sdk_options` decorator and `script` group for writing custom extension scripts
16+
using the Code42 CLI.
17+
1118
## 1.3.1 - 2021-02-25
1219

1320
### Changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,3 +273,8 @@ eval (env _CODE42_COMPLETE=source_fish code42)
273273
```
274274

275275
Open a new shell to enable completion. Or run the eval command directly in your current shell to enable it temporarily.
276+
277+
278+
## Writing Extensions
279+
280+
The CLI exposes a few helpers for writing custom extension scripts powered by the CLI. Read the user-guide [here](https://clidocs.code42.com/en/feature-extension_scripts/userguides/extensions.html).

docs/guides.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
* [Ingest file events or alerts into a SIEM](userguides/siemexample.md)
66
* [Manage detection list users](userguides/detectionlists.md)
77
* [Manage legal hold users](userguides/legalhold.md)
8+
* [Write custom extension scripts using the Code42 CLI and py42](userguides/extensions.md)

docs/userguides/extensions.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Write custom extension scripts using the Code42 CLI and py42
2+
3+
While the Code42 CLI aims to provide an easy way to automate many common Code42 tasks, there will likely be times when
4+
you need to script something the CLI doesn't have out-of-the-box.
5+
6+
To accommodate for those scenarios, the Code42 CLI exposes a few helper objects in the `code42cli.extensions` module
7+
that make it easy to write custom scripts with `py42` that use features of the CLI (like profiles) to reduce the amount
8+
of boilerplate needed to be productive.
9+
10+
## Before you begin
11+
12+
The Code42 CLI is a python application written using the [click framework](https://click.palletsprojects.com/en/7.x/),
13+
and the exposed extension objects are custom `click` classes. A basic knowledge of how to define `click` commands,
14+
arguments, and options is required.
15+
16+
### The `sdk_options` decorator
17+
18+
The most important extension object is the `sdk_options` decorator. When you decorate a command you've defined in your
19+
script with `@sdk_options`, it will automatically add `--profile` and `--debug` options to your command. These work the
20+
same as in the main CLI commands.
21+
22+
Decorating a command with `@sdk_options` also causes the first argument to your command function to be the `state`
23+
object, which contains the initialized py42 sdk. There's no need to handle user credentials or login, the `sdk_options`
24+
does all that for you using the CLI profiles.
25+
26+
### The `script` group
27+
28+
The `script` object exposed in the extensions module is a `click.Group` subclass, which allows you to add multiple
29+
sub-commands and group functionality together. While not explicitly required when writing custom scripts, the `script`
30+
group has logic to help handle and log any uncaught exceptions to the `~/.code42cli/log/code42_errors.log` file.
31+
32+
If only a single command is added to the `script` group, the group will default to that command, so you don't need to
33+
explicitly provide the sub-command name.
34+
35+
An example command that just prints the username and ID that the sdk is authenticated with:
36+
37+
```python
38+
import click
39+
from code42cli.extensions import script, sdk_options
40+
41+
@click.command()
42+
@sdk_options
43+
def my_command(state):
44+
user = state.sdk.users.get_current()
45+
print(user["username"], user["userId"])
46+
47+
if __name__ == "__main__":
48+
script.add_command(my_command)
49+
script()
50+
```
51+
52+
## Ensuring your script runs in the Code42 CLI python environment
53+
54+
The above example works as a standalone script, if it were named `my_script.py` you could execute it by running:
55+
56+
```bash
57+
python3 my_script.py
58+
```
59+
60+
However, if the Code42 CLI is installed in a different python environment than your `python3` command, it might fail to
61+
import the extensions.
62+
63+
To workaround environment and path issues, the CLI has a `--python` option that prints out the path to the python
64+
executable the CLI uses, so you can execute your script with`$(code42 --python) script.py` on Mac/Linux or
65+
`&$(code42 --python) script.py` on Windows to ensure it always uses the correct python path for the extension script to
66+
work.
67+
68+
## Installing your extension script as a Code42 CLI plugin
69+
70+
The above example works as a standalone script, but it's also possible to install that same script as a plugin into the
71+
main CLI itself.
72+
73+
Assuming the above example code is in a file called `my_script.py`, just add a file `setup.py` in the same directory
74+
with the following:
75+
76+
```python
77+
from distutils.core import setup
78+
79+
setup(
80+
name="my_script",
81+
version="0.1",
82+
py_modules=["my_script"],
83+
install_requires=["code42cli"],
84+
entry_points="""
85+
[code42cli.plugins]
86+
my_command=my_script:my_command
87+
""",
88+
)
89+
```
90+
91+
The `entry_points` section tells the Code42 CLI where to look for the commands to add to its main group. If you have
92+
multiple commands defined in your script you can add one per line in the `entry_points` and they'll all get installed
93+
into the Code42 CLI.
94+
95+
Once your `setup.py` is ready, install it with pip while in the directory of `setup.py`:
96+
97+
```
98+
$(code42 --python) -m pip install .
99+
```
100+
101+
Then running `code42 -h` should show `my-command` as one of the available commands to run!

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
python_requires=">3, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4",
3333
install_requires=[
3434
"click>=7.1.1",
35+
"click_plugins>=1.1.1",
3536
"colorama>=0.4.3",
3637
"c42eventextractor==0.4.0",
3738
"keyring==18.0.1",

src/code42cli/click_ext/groups.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,21 @@ def __init__(self, name=None, commands=None, **attrs):
120120

121121
def list_commands(self, ctx):
122122
return self.commands
123+
124+
125+
class ExtensionGroup(ExceptionHandlingGroup):
126+
"""A helper click.Group for extension scripts. If only a single command is added to this group,
127+
that command will be the "default" and won't need to be explicitly passed as the first argument
128+
to the extension script.
129+
"""
130+
131+
def __init__(self, *args, **kwargs):
132+
super().__init__(*args, **kwargs)
133+
134+
def parse_args(self, ctx, args):
135+
if len(self.commands) == 1:
136+
cmd_name, cmd = next(iter(self.commands.items()))
137+
if not args or args[0] not in self.commands:
138+
self.commands = {"": cmd}
139+
args.insert(0, "")
140+
super().parse_args(ctx, args)

src/code42cli/extensions/__init__.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from code42cli.click_ext.groups import ExtensionGroup
2+
from code42cli.main import CONTEXT_SETTINGS
3+
from code42cli.options import debug_option
4+
from code42cli.options import pass_state
5+
from code42cli.options import profile_option
6+
7+
8+
def sdk_options(f):
9+
"""Decorator that adds two `click.option`s (--profile, --debug) to wrapped command, as well as
10+
passing the `code42cli.options.CLIState` object using the [click.make_pass_decorator](https://click.palletsprojects.com/en/7.x/api/#click.make_pass_decorator),
11+
which automatically instantiates the `py42` sdk using the Code42 profile provided from the `--profile`
12+
option. The `py42` sdk can be accessed from the `state.sdk` attribute.
13+
14+
Example:
15+
16+
@click.command()
17+
@sdk_options
18+
def get_current_user_command(state):
19+
my_user = state.sdk.users.get_current()
20+
print(my_user)
21+
"""
22+
f = profile_option()(f)
23+
f = debug_option()(f)
24+
f = pass_state(f)
25+
return f
26+
27+
28+
script = ExtensionGroup(context_settings=CONTEXT_SETTINGS)
29+
"""A `click.Group` subclass that enables the Code42 CLI's custom error handling/logging to be used
30+
in extension scripts. If only a single command is added to the `script` group it also uses that
31+
command as the default, so the command name doesn't need to be called explicitly.
32+
33+
Example:
34+
35+
@click.command()
36+
@click.argument("guid")
37+
@sdk_options
38+
def get_device_info(state, guid)
39+
device = state.sdk.devices.get_by_guid(guid)
40+
print(device)
41+
42+
if __name__ == "__main__":
43+
script.add_command(my_command)
44+
script()
45+
46+
The script can then be invoked directly without needing to call the `get-device-info` subcommand:
47+
48+
python script.py --profile my_profile <guid>
49+
"""

src/code42cli/main.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import sys
33

44
import click
5+
from click_plugins import with_plugins
6+
from pkg_resources import iter_entry_points
57
from py42.__version__ import __version__ as py42version
68
from py42.settings import set_user_agent_suffix
79

@@ -50,10 +52,24 @@ def exit_on_interrupt(signal, frame):
5052
}
5153

5254

53-
@click.group(cls=ExceptionHandlingGroup, context_settings=CONTEXT_SETTINGS, help=BANNER)
55+
@with_plugins(iter_entry_points("code42cli.plugins"))
56+
@click.group(
57+
cls=ExceptionHandlingGroup,
58+
context_settings=CONTEXT_SETTINGS,
59+
help=BANNER,
60+
invoke_without_command=True,
61+
no_args_is_help=True,
62+
)
63+
@click.option(
64+
"--python",
65+
is_flag=True,
66+
help="Print path to the python interpreter env that `code42` is installed in.",
67+
)
5468
@sdk_options(hidden=True)
55-
def cli(state):
56-
pass
69+
def cli(state, python):
70+
if python:
71+
click.echo(sys.executable)
72+
sys.exit(0)
5773

5874

5975
cli.add_command(alerts)

src/code42cli/options.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,14 @@ def set_assume_yes(self, param):
6868

6969
def set_profile(ctx, param, value):
7070
"""Sets the profile on the global state object when --profile <name> is passed to commands
71-
decorated with @global_options."""
71+
decorated with @sdk_options."""
7272
if value:
7373
ctx.ensure_object(CLIState).profile = get_profile(value)
7474

7575

7676
def set_debug(ctx, param, value):
7777
"""Sets debug to True on global state object when --debug/-d is passed to commands decorated
78-
with @global_options.
78+
with @sdk_options.
7979
"""
8080
if value:
8181
ctx.ensure_object(CLIState).debug = value

0 commit comments

Comments
 (0)