Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
d97f620
initial work for extension scripts
timabrmsn Feb 3, 2021
43d6670
handle cmd construction on Windows
timabrmsn Feb 3, 2021
d1c93cf
use next(iter(dict)) instead of list(dict.keys)[0]
timabrmsn Feb 3, 2021
35e3374
require --python to be first arg to trigger interpreter
timabrmsn Feb 4, 2021
8a6f9ea
adjust arg swap given we know it's arg[0]
timabrmsn Feb 4, 2021
a77a5fb
remove cmd name if there's only one
timabrmsn Feb 4, 2021
718942e
rename --python option to --script, document option and add docstring…
timabrmsn Feb 4, 2021
c4c3976
alias sdk_options in extensions.py
timabrmsn Feb 4, 2021
b668c4d
style
timabrmsn Feb 4, 2021
1dde0f2
allow single commands to execute when no args/options required
timabrmsn Feb 4, 2021
3a2e5e5
style
timabrmsn Feb 4, 2021
1fd7b95
just print executable path instead of exec-ing directly
timabrmsn Feb 5, 2021
119ae7a
remove unused imports
timabrmsn Feb 5, 2021
71671fb
test installable plugin
timabrmsn Feb 8, 2021
97f258f
delete test plugin folder
timabrmsn Feb 16, 2021
1e8e708
add required lib, style
timabrmsn Feb 16, 2021
14e06f8
style
timabrmsn Feb 16, 2021
23633f3
exit if --python is passed to not allow subcommands
timabrmsn Feb 16, 2021
908d1f0
make extensions a module
timabrmsn Feb 17, 2021
1b7dfaa
add unused import ignore to setup.cfg, document import to prevent fut…
timabrmsn Feb 17, 2021
9e57452
Merge branch 'master' into feature/extension_scripts
timabrmsn Feb 24, 2021
abfa3db
Add docs and redefine sdk_options to be regular decorator instead of …
timabrmsn Mar 1, 2021
8651fb7
Merge branch 'master' into feature/extension_scripts
timabrmsn Mar 1, 2021
66c1e4d
style
timabrmsn Mar 1, 2021
3d29f2f
update changelog
timabrmsn Mar 1, 2021
1c42b6d
Add note on extensions to README
timabrmsn Mar 1, 2021
b6fae55
style
timabrmsn Mar 1, 2021
4e5047e
Merge branch 'master' into feature/extension_scripts
timabrmsn Mar 1, 2021
0fb2fb3
code42cli > Code42 CLI
timabrmsn Mar 1, 2021
1b74cff
remove ignore since we're using all imports now
timabrmsn Mar 1, 2021
ae7eb6c
comma
timabrmsn Mar 1, 2021
c538691
make readable w linebreaks
timabrmsn Mar 1, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
The intended audience of this file is for py42 consumers -- as such, changes that don't affect
how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here.

## Unreleased

### Added

- `code42cli.extensions` module exposes `sdk_options` decorator and `script` group for writing custom extension scripts
using the Code42 CLI.

## 1.3.1 - 2021-02-25

### Changed
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,3 +273,8 @@ eval (env _CODE42_COMPLETE=source_fish code42)
```

Open a new shell to enable completion. Or run the eval command directly in your current shell to enable it temporarily.


## Writing Extensions

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).
1 change: 1 addition & 0 deletions docs/guides.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
* [Ingest file events or alerts into a SIEM](userguides/siemexample.md)
* [Manage detection list users](userguides/detectionlists.md)
* [Manage legal hold users](userguides/legalhold.md)
* [Write custom extension scripts using the Code42 CLI and py42](userguides/extensions.md)
101 changes: 101 additions & 0 deletions docs/userguides/extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Write custom extension scripts using the Code42 CLI and py42

While the Code42 CLI aims to provide an easy way to automate many common Code42 tasks, there will likely be times when
you need to script something the CLI doesn't have out-of-the-box.

To accommodate for those scenarios, the Code42 CLI exposes a few helper objects in the `code42cli.extensions` module
that make it easy to write custom scripts with `py42` that use features of the CLI (like profiles) to reduce the amount
of boilerplate needed to be productive.

## Before you begin

The Code42 CLI is a python application written using the [click framework](https://click.palletsprojects.com/en/7.x/),
and the exposed extension objects are custom `click` classes. A basic knowledge of how to define `click` commands,
arguments, and options is required.

### The `sdk_options` decorator

The most important extension object is the `sdk_options` decorator. When you decorate a command you've defined in your
script with `@sdk_options`, it will automatically add `--profile` and `--debug` options to your command. These work the
same as in the main CLI commands.

Decorating a command with `@sdk_options` also causes the first argument to your command function to be the `state`
object, which contains the initialized py42 sdk. There's no need to handle user credentials or login, the `sdk_options`
does all that for you using the CLI profiles.

### The `script` group

The `script` object exposed in the extensions module is a `click.Group` subclass, which allows you to add multiple
sub-commands and group functionality together. While not explicitly required when writing custom scripts, the `script`
group has logic to help handle and log any uncaught exceptions to the `~/.code42cli/log/code42_errors.log` file.

If only a single command is added to the `script` group, the group will default to that command, so you don't need to
explicitly provide the sub-command name.

An example command that just prints the username and ID that the sdk is authenticated with:

```python
import click
from code42cli.extensions import script, sdk_options

@click.command()
@sdk_options
def my_command(state):
user = state.sdk.users.get_current()
print(user["username"], user["userId"])

if __name__ == "__main__":
script.add_command(my_command)
script()
```

## Ensuring your script runs in the Code42 CLI python environment

The above example works as a standalone script, if it were named `my_script.py` you could execute it by running:

```bash
python3 my_script.py
```

However, if the Code42 CLI is installed in a different python environment than your `python3` command, it might fail to
import the extensions.

To workaround environment and path issues, the CLI has a `--python` option that prints out the path to the python
executable the CLI uses, so you can execute your script with`$(code42 --python) script.py` on Mac/Linux or
`&$(code42 --python) script.py` on Windows to ensure it always uses the correct python path for the extension script to
work.

## Installing your extension script as a Code42 CLI plugin

The above example works as a standalone script, but it's also possible to install that same script as a plugin into the
main CLI itself.

Assuming the above example code is in a file called `my_script.py`, just add a file `setup.py` in the same directory
with the following:

```python
from distutils.core import setup

setup(
name="my_script",
version="0.1",
py_modules=["my_script"],
install_requires=["code42cli"],
entry_points="""
[code42cli.plugins]
my_command=my_script:my_command
""",
)
```

The `entry_points` section tells the Code42 CLI where to look for the commands to add to its main group. If you have
multiple commands defined in your script you can add one per line in the `entry_points` and they'll all get installed
into the Code42 CLI.

Once your `setup.py` is ready, install it with pip while in the directory of `setup.py`:

```
$(code42 --python) -m pip install .
```

Then running `code42 -h` should show `my-command` as one of the available commands to run!
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
python_requires=">3, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4",
install_requires=[
"click>=7.1.1",
"click_plugins>=1.1.1",
"colorama>=0.4.3",
"c42eventextractor==0.4.0",
"keyring==18.0.1",
Expand Down
18 changes: 18 additions & 0 deletions src/code42cli/click_ext/groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,21 @@ def __init__(self, name=None, commands=None, **attrs):

def list_commands(self, ctx):
return self.commands


class ExtensionGroup(ExceptionHandlingGroup):
"""A helper click.Group for extension scripts. If only a single command is added to this group,
that command will be the "default" and won't need to be explicitly passed as the first argument
to the extension script.
"""

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

def parse_args(self, ctx, args):
if len(self.commands) == 1:
cmd_name, cmd = next(iter(self.commands.items()))
if not args or args[0] not in self.commands:
self.commands = {"": cmd}
args.insert(0, "")
super().parse_args(ctx, args)
49 changes: 49 additions & 0 deletions src/code42cli/extensions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from code42cli.click_ext.groups import ExtensionGroup
from code42cli.main import CONTEXT_SETTINGS
from code42cli.options import debug_option
from code42cli.options import pass_state
from code42cli.options import profile_option


def sdk_options(f):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you just want to be really explicit, but this could just be

from code42cli.options import sdk_options as sdk_opts

def sdk_options(f):
    """Decorator that adds two `click.option`s (--profile, --debug) to wrapped command, as well as
    passing the `code42cli.options.CLIState` object using the [click.make_pass_decorator](https://click.palletsprojects.com/en/7.x/api/#click.make_pass_decorator),
    which automatically instantiates the `py42` sdk using the Code42 profile provided from the `--profile`
    option. The `py42` sdk can be accessed from the `state.sdk` attribute.

    Example:

        @click.command()
        @sdk_options
        def get_current_user_command(state):
            my_user = state.sdk.users.get_current()
            print(my_user)
    """
    f = sdk_opts()(f)
    return f

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or even sdk_options = sdk_opts

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mainly wanted to have the object we expose be it's own thing in case we ever want to change our internal sdk_options we're not having to work around the extension backwards compatibility.

"""Decorator that adds two `click.option`s (--profile, --debug) to wrapped command, as well as
passing the `code42cli.options.CLIState` object using the [click.make_pass_decorator](https://click.palletsprojects.com/en/7.x/api/#click.make_pass_decorator),
which automatically instantiates the `py42` sdk using the Code42 profile provided from the `--profile`
option. The `py42` sdk can be accessed from the `state.sdk` attribute.

Example:

@click.command()
@sdk_options
def get_current_user_command(state):
my_user = state.sdk.users.get_current()
print(my_user)
"""
f = profile_option()(f)
f = debug_option()(f)
f = pass_state(f)
return f


script = ExtensionGroup(context_settings=CONTEXT_SETTINGS)
"""A `click.Group` subclass that enables the Code42 CLI's custom error handling/logging to be used
in extension scripts. If only a single command is added to the `script` group it also uses that
command as the default, so the command name doesn't need to be called explicitly.

Example:

@click.command()
@click.argument("guid")
@sdk_options
def get_device_info(state, guid)
device = state.sdk.devices.get_by_guid(guid)
print(device)

if __name__ == "__main__":
script.add_command(my_command)
script()

The script can then be invoked directly without needing to call the `get-device-info` subcommand:

python script.py --profile my_profile <guid>
"""
22 changes: 19 additions & 3 deletions src/code42cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import sys

import click
from click_plugins import with_plugins
from pkg_resources import iter_entry_points
from py42.__version__ import __version__ as py42version
from py42.settings import set_user_agent_suffix

Expand Down Expand Up @@ -50,10 +52,24 @@ def exit_on_interrupt(signal, frame):
}


@click.group(cls=ExceptionHandlingGroup, context_settings=CONTEXT_SETTINGS, help=BANNER)
@with_plugins(iter_entry_points("code42cli.plugins"))
@click.group(
cls=ExceptionHandlingGroup,
context_settings=CONTEXT_SETTINGS,
help=BANNER,
invoke_without_command=True,
no_args_is_help=True,
)
@click.option(
"--python",
is_flag=True,
help="Print path to the python interpreter env that `code42` is installed in.",
)
@sdk_options(hidden=True)
def cli(state):
pass
def cli(state, python):
if python:
click.echo(sys.executable)
sys.exit(0)


cli.add_command(alerts)
Expand Down
4 changes: 2 additions & 2 deletions src/code42cli/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,14 @@ def set_assume_yes(self, param):

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


def set_debug(ctx, param, value):
"""Sets debug to True on global state object when --debug/-d is passed to commands decorated
with @global_options.
with @sdk_options.
"""
if value:
ctx.ensure_object(CLIState).debug = value
Expand Down