Skip to content

Commit 14aac5d

Browse files
authored
Chore/allow optional cols for bulk cmds (#352)
* switching flat file bulk commands to use csv_args * test for flat file compatability * update file_reader * strip commented header line from files * update user guide * remove flat file methods * typo
1 parent d16ded8 commit 14aac5d

File tree

12 files changed

+225
-80
lines changed

12 files changed

+225
-80
lines changed

docs/guides.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@
1111
* [Configure Trusted Activities](userguides/trustedactivities.md)
1212
* [Configure Alert Rules](userguides/alertrules.md)
1313
* [Add and Manage Cases](userguides/cases.md)
14+
* [Use Bulk Commands](userguides/bulkcommands.md)

docs/userguides/bulkcommands.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Using Bulk Commands
2+
3+
Bulk functionality is available for many Code42 CLI methods, more details on which commands have bulk capabilities can be found in the [Commands Documentation](../commands.md).
4+
5+
All bulk methods take a CSV file as input.
6+
7+
The `generate-template` command can be used to create a CSV file with the necessary headers for a particular command.
8+
9+
For instance, the following command will create a file named `devices_bulk_deactivate.csv` with a single column header row of `guid`.
10+
```bash
11+
code42 devices bulk generate-template deactivate
12+
```
13+
14+
The CSV file can contain more columns than are necessary for the command, however then the header row is **required**.
15+
16+
If the CSV file contains the *exact* number of columns that are necessary for the command then the header row is **optional**, but columns are expected to be in the same order as the template.
17+
18+
To run a bulk method, simply pass the CSV file path to the desired command. For example, you would use to following command to deactivate multiple devices within your organization at once:
19+
20+
21+
```bash
22+
code42 devices bulk deactivate devices_bulk_deactivate.csv
23+
```

src/code42cli/bulk.py

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,9 @@ def __iter__(self):
1717
return iter([self.ADD, self.REMOVE])
1818

1919

20-
def write_template_file(path, columns=None, flat_item=None):
20+
def write_template_file(path, columns):
2121
with open(path, "w", encoding="utf8") as new_file:
22-
if columns:
23-
new_file.write(",".join(columns))
24-
else:
25-
new_file.write(
26-
f"# This template takes a single {flat_item or 'item'} to be processed on each row."
27-
)
22+
new_file.write(",".join(columns))
2823

2924

3025
def generate_template_cmd_factory(group_name, commands_dict, help_message=None):
@@ -58,10 +53,7 @@ def generate_template(cmd, path):
5853
if not path:
5954
filename = f"{group_name}_bulk_{cmd.replace('-', '_')}.csv"
6055
path = os.path.join(os.getcwd(), filename)
61-
if isinstance(columns, str):
62-
write_template_file(path, columns=None, flat_item=columns)
63-
else:
64-
write_template_file(path, columns=columns)
56+
write_template_file(path, columns)
6557

6658
return generate_template
6759

@@ -148,10 +140,7 @@ def run(self):
148140
return self._stats._results
149141

150142
def _process_row(self, row):
151-
if isinstance(row, dict):
152-
self._process_csv_row(row)
153-
elif row:
154-
self._process_flat_file_row(row.strip())
143+
self._process_csv_row(row)
155144

156145
def _process_csv_row(self, row):
157146
# Removes problems from including extra columns. Error messages from out of order args
@@ -163,12 +152,6 @@ def _process_csv_row(self, row):
163152
lambda *args, **kwargs: self._handle_row(*args, **kwargs), **row_values
164153
)
165154

166-
def _process_flat_file_row(self, row):
167-
if row:
168-
self.__worker.do_async(
169-
lambda *args, **kwargs: self._handle_row(*args, **kwargs), row
170-
)
171-
172155
def _handle_row(self, *args, **kwargs):
173156
return self._row_handler(*args, **kwargs)
174157

src/code42cli/cmds/departing_employee.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
from code42cli.cmds.shared import get_user_id
1616
from code42cli.errors import Code42CLIError
1717
from code42cli.file_readers import read_csv_arg
18-
from code42cli.file_readers import read_flat_file_arg
1918
from code42cli.options import format_option
2019
from code42cli.options import sdk_options
2120

@@ -86,16 +85,21 @@ def bulk(state):
8685

8786
DEPARTING_EMPLOYEE_CSV_HEADERS = ["username", "cloud_alias", "departure_date", "notes"]
8887

88+
REMOVE_EMPLOYEE_HEADERS = ["username"]
89+
8990
departing_employee_generate_template = generate_template_cmd_factory(
9091
group_name="departing_employee",
91-
commands_dict={"add": DEPARTING_EMPLOYEE_CSV_HEADERS, "remove": "username"},
92+
commands_dict={
93+
"add": DEPARTING_EMPLOYEE_CSV_HEADERS,
94+
"remove": REMOVE_EMPLOYEE_HEADERS,
95+
},
9296
)
9397
bulk.add_command(departing_employee_generate_template)
9498

9599

96100
@bulk.command(
97101
name="add",
98-
help="Bulk add users to the Departing Employees detection list using a CSV file with "
102+
help="Bulk add users to the departing employees detection list using a CSV file with "
99103
f"format: {','.join(DEPARTING_EMPLOYEE_CSV_HEADERS)}.",
100104
)
101105
@read_csv_arg(headers=DEPARTING_EMPLOYEE_CSV_HEADERS)
@@ -125,20 +129,19 @@ def handle_row(username, cloud_alias, departure_date, notes):
125129

126130
@bulk.command(
127131
name="remove",
128-
help="Bulk remove users from the Departing Employees detection list using a line-separated "
129-
"file of usernames.",
132+
help=f"Bulk remove users from the departing employees detection list using a CSV file with format {','.join(REMOVE_EMPLOYEE_HEADERS)}.",
130133
)
131-
@read_flat_file_arg
134+
@read_csv_arg(headers=REMOVE_EMPLOYEE_HEADERS)
132135
@sdk_options()
133-
def bulk_remove(state, file_rows):
136+
def bulk_remove(state, csv_rows):
134137
sdk = state.sdk
135138

136139
def handle_row(username):
137140
_remove_departing_employee(sdk, username)
138141

139142
run_bulk_process(
140143
handle_row,
141-
file_rows,
144+
csv_rows,
142145
progress_label="Removing users from the Departing Employees detection list:",
143146
)
144147

src/code42cli/cmds/devices.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -566,7 +566,6 @@ def bulk(state):
566566
_bulk_device_activation_headers = ["guid"]
567567
_bulk_device_rename_headers = ["guid", "name"]
568568

569-
570569
devices_generate_template = generate_template_cmd_factory(
571570
group_name="devices",
572571
commands_dict={

src/code42cli/cmds/high_risk_employee.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
from code42cli.cmds.detectionlists.options import username_arg
1919
from code42cli.cmds.shared import get_user_id
2020
from code42cli.file_readers import read_csv_arg
21-
from code42cli.file_readers import read_flat_file_arg
2221
from code42cli.options import format_option
2322
from code42cli.options import sdk_options
2423

@@ -110,12 +109,13 @@ def bulk(state):
110109

111110
HIGH_RISK_EMPLOYEE_CSV_HEADERS = ["username", "cloud_alias", "risk_tag", "notes"]
112111
RISK_TAG_CSV_HEADERS = ["username", "tag"]
112+
REMOVE_EMPLOYEE_HEADERS = ["username"]
113113

114114
high_risk_employee_generate_template = generate_template_cmd_factory(
115115
group_name="high_risk_employee",
116116
commands_dict={
117117
"add": HIGH_RISK_EMPLOYEE_CSV_HEADERS,
118-
"remove": "username",
118+
"remove": REMOVE_EMPLOYEE_HEADERS,
119119
"add-risk-tags": RISK_TAG_CSV_HEADERS,
120120
"remove-risk-tags": RISK_TAG_CSV_HEADERS,
121121
},
@@ -145,20 +145,19 @@ def handle_row(username, cloud_alias, risk_tag, notes):
145145

146146
@bulk.command(
147147
name="remove",
148-
help="Bulk remove users from the high risk employees detection list using a line-separated file "
149-
"of usernames.",
148+
help=f"Bulk remove users from the high risk employees detection list using a CSV file with format {','.join(REMOVE_EMPLOYEE_HEADERS)}.",
150149
)
151-
@read_flat_file_arg
150+
@read_csv_arg(headers=REMOVE_EMPLOYEE_HEADERS)
152151
@sdk_options()
153-
def bulk_remove(state, file_rows):
152+
def bulk_remove(state, csv_rows):
154153
sdk = state.sdk
155154

156155
def handle_row(username):
157156
_remove_high_risk_employee(sdk, username)
158157

159158
run_bulk_process(
160159
handle_row,
161-
file_rows,
160+
csv_rows,
162161
progress_label="Removing users from high risk employee detection list:",
163162
)
164163

src/code42cli/file_readers.py

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ def read_csv(file, headers):
2828
else error is raised.
2929
"""
3030
lines = file.readlines()
31+
32+
# check if header is commented for flat-file backwards compatability
33+
if lines[0].startswith("#"):
34+
# strip comment line
35+
lines.pop(0)
36+
3137
first_line = lines[0].strip().split(",")
3238

3339
# handle when first row has all of our expected headers
@@ -52,21 +58,3 @@ def read_csv(file, headers):
5258
else:
5359
missing = [field for field in headers if field not in first_line]
5460
raise Code42CLIError(f"Missing required columns in csv: {missing}")
55-
56-
57-
def read_flat_file(file):
58-
"""Helper to read rows of a flat file, automatically removing header comment row if
59-
it exists, and strips whitespace from each row automatically."""
60-
first_row = next(file)
61-
if first_row.startswith("#"):
62-
return [row.strip() for row in file]
63-
else:
64-
return [first_row.strip(), *[row.strip() for row in file]]
65-
66-
67-
read_flat_file_arg = click.argument(
68-
"file_rows",
69-
type=AutoDecodedFile("r"),
70-
metavar="FILE",
71-
callback=lambda ctx, param, arg: read_flat_file(arg),
72-
)

tests/cmds/test_departing_employee.py

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -277,13 +277,76 @@ def test_remove_bulk_users_uses_expected_arguments(runner, mocker, cli_state_wit
277277
bulk_processor = mocker.patch("code42cli.cmds.departing_employee.run_bulk_process")
278278
with runner.isolated_filesystem():
279279
with open("test_remove.csv", "w") as csv:
280-
csv.writelines(["# username\n", "test_user1\n", "test_user2\n"])
280+
csv.writelines(["username\n", "test_user1\n", "test_user2\n"])
281+
runner.invoke(
282+
cli,
283+
["departing-employee", "bulk", "remove", "test_remove.csv"],
284+
obj=cli_state_with_user,
285+
)
286+
assert bulk_processor.call_args[0][1] == [
287+
{"username": "test_user1"},
288+
{"username": "test_user2"},
289+
]
290+
291+
292+
def test_remove_bulk_users_uses_expected_arguments_when_no_header(
293+
runner, mocker, cli_state_with_user
294+
):
295+
bulk_processor = mocker.patch("code42cli.cmds.departing_employee.run_bulk_process")
296+
with runner.isolated_filesystem():
297+
with open("test_remove.csv", "w") as csv:
298+
csv.writelines(["test_user1\n", "test_user2\n"])
299+
runner.invoke(
300+
cli,
301+
["departing-employee", "bulk", "remove", "test_remove.csv"],
302+
obj=cli_state_with_user,
303+
)
304+
assert bulk_processor.call_args[0][1] == [
305+
{"username": "test_user1"},
306+
{"username": "test_user2"},
307+
]
308+
309+
310+
def test_remove_bulk_users_uses_expected_arguments_when_extra_columns(
311+
runner, mocker, cli_state_with_user
312+
):
313+
bulk_processor = mocker.patch("code42cli.cmds.departing_employee.run_bulk_process")
314+
with runner.isolated_filesystem():
315+
with open("test_remove.csv", "w") as csv:
316+
csv.writelines(
317+
[
318+
"username,test_column\n",
319+
"test_user1,test_value1\n",
320+
"test_user2,test_value2\n",
321+
]
322+
)
281323
runner.invoke(
282324
cli,
283325
["departing-employee", "bulk", "remove", "test_remove.csv"],
284326
obj=cli_state_with_user,
285327
)
286-
assert bulk_processor.call_args[0][1] == ["test_user1", "test_user2"]
328+
assert bulk_processor.call_args[0][1] == [
329+
{"username": "test_user1"},
330+
{"username": "test_user2"},
331+
]
332+
333+
334+
def test_remove_bulk_users_uses_expected_arguments_when_flat_file(
335+
runner, mocker, cli_state_with_user
336+
):
337+
bulk_processor = mocker.patch("code42cli.cmds.departing_employee.run_bulk_process")
338+
with runner.isolated_filesystem():
339+
with open("test_remove.txt", "w") as csv:
340+
csv.writelines(["# username\n", "test_user1\n", "test_user2\n"])
341+
runner.invoke(
342+
cli,
343+
["departing-employee", "bulk", "remove", "test_remove.txt"],
344+
obj=cli_state_with_user,
345+
)
346+
assert bulk_processor.call_args[0][1] == [
347+
{"username": "test_user1"},
348+
{"username": "test_user2"},
349+
]
287350

288351

289352
def test_add_departing_employee_when_invalid_date_validation_raises_error(
@@ -350,7 +413,7 @@ def test_remove_departing_employee_when_user_not_on_list_prints_expected_error(
350413
(f"{DEPARTING_EMPLOYEE_COMMAND} add", "Missing argument 'USERNAME'."),
351414
(f"{DEPARTING_EMPLOYEE_COMMAND} remove", "Missing argument 'USERNAME'.",),
352415
(f"{DEPARTING_EMPLOYEE_COMMAND} bulk add", "Missing argument 'CSV_FILE'.",),
353-
(f"{DEPARTING_EMPLOYEE_COMMAND} bulk remove", "Missing argument 'FILE'.",),
416+
(f"{DEPARTING_EMPLOYEE_COMMAND} bulk remove", "Missing argument 'CSV_FILE'.",),
354417
],
355418
)
356419
def test_departing_employee_command_when_missing_required_parameters_returns_error(

tests/cmds/test_devices.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -942,6 +942,28 @@ def test_bulk_deactivate_uses_expected_arguments(runner, mocker, cli_state):
942942
]
943943

944944

945+
def test_bulk_deactivate_uses_expected_arguments_when_no_header(
946+
runner, mocker, cli_state
947+
):
948+
bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process")
949+
with runner.isolated_filesystem():
950+
with open("test_bulk_deactivate.csv", "w") as csv:
951+
csv.writelines(["test_guid1\n"])
952+
runner.invoke(
953+
cli,
954+
["devices", "bulk", "deactivate", "test_bulk_deactivate.csv"],
955+
obj=cli_state,
956+
)
957+
assert bulk_processor.call_args[0][1] == [
958+
{
959+
"guid": "test_guid1",
960+
"deactivated": "False",
961+
"change_device_name": False,
962+
"purge_date": None,
963+
}
964+
]
965+
966+
945967
def test_bulk_deactivate_ignores_blank_lines(runner, mocker, cli_state):
946968
bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process")
947969
with runner.isolated_filesystem():
@@ -1001,6 +1023,23 @@ def test_bulk_reactivate_uses_expected_arguments(runner, mocker, cli_state):
10011023
assert bulk_processor.call_args[0][1] == [{"guid": "test", "reactivated": "False"}]
10021024

10031025

1026+
def test_bulk_reactivate_uses_expected_arguments_when_no_header(
1027+
runner, mocker, cli_state
1028+
):
1029+
bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process")
1030+
with runner.isolated_filesystem():
1031+
with open("test_bulk_reactivate.csv", "w") as csv:
1032+
csv.writelines(["test_guid1\n"])
1033+
runner.invoke(
1034+
cli,
1035+
["devices", "bulk", "reactivate", "test_bulk_reactivate.csv"],
1036+
obj=cli_state,
1037+
)
1038+
assert bulk_processor.call_args[0][1] == [
1039+
{"guid": "test_guid1", "reactivated": "False"},
1040+
]
1041+
1042+
10041043
def test_bulk_reactivate_ignores_blank_lines(runner, mocker, cli_state):
10051044
bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process")
10061045
with runner.isolated_filesystem():

0 commit comments

Comments
 (0)