Skip to content

Commit 5d02283

Browse files
Merge pull request #152 from code42/INTEG-2427/file-download
add file download to sdk
2 parents 89a469e + 2ecd0f4 commit 5d02283

File tree

11 files changed

+290
-41
lines changed

11 files changed

+290
-41
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@
88
The intended audience of this file is for `incydr` SDK and CLI consumers -- as such, changes that don't affect
99
how a consumer would use the library or CLI tool (e.g. adding unit tests, updating documentation, etc) are not captured
1010
here.
11+
## Unreleased
12+
13+
### Added
14+
15+
- The `files` client to the SDK with two methods:
16+
- `sdk.files.v1.download_file_by_sha256` to download a file and save it in the file system.
17+
- `sdk.files.v1.stream_file_by_sha256` to stream a file, allowing more control over how it is downloaded.
18+
- Added the `files download` command to the CLI to download a file by SHA256 hash.
1119

1220
## 2.3.1 - 2025-05-13
1321

docs/cli/cmds/files.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Files Commands
2+
3+
::: mkdocs-click
4+
:module: _incydr_cli.cmds.files
5+
:command: files
6+
:list_subcommands:

docs/sdk/clients/files.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Files
2+
3+
::: _incydr_sdk.files.client.FilesV1
4+
:docstring:
5+
:members:

mkdocs.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ nav:
5252
- Departments: 'sdk/clients/departments.md'
5353
- Directory Groups: 'sdk/clients/directory_groups.md'
5454
- File Events: 'sdk/clients/file_events.md'
55+
- Files: 'sdk/clients/files.md'
5556
- File Event Querying: 'sdk/clients/file_event_queries.md'
5657
- Sessions: 'sdk/clients/sessions.md'
5758
- Trusted Activites: 'sdk/clients/trusted_activities.md'
@@ -78,6 +79,7 @@ nav:
7879
- Departments: 'cli/cmds/departments.md'
7980
- Directory Groups: 'cli/cmds/directory_groups.md'
8081
- File Events: 'cli/cmds/file_events.md'
82+
- Files: 'cli/cmds/files.md'
8183
- Sessions: 'cli/cmds/sessions.md'
8284
- Trusted Activites: 'cli/cmds/trusted_activities.md'
8385
- Users: 'cli/cmds/users.md'

src/_incydr_cli/cmds/files.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import os
2+
from pathlib import Path
3+
4+
import click
5+
6+
from _incydr_cli import logging_options
7+
from _incydr_cli.core import IncydrCommand
8+
from _incydr_cli.core import IncydrGroup
9+
from _incydr_sdk.core.client import Client
10+
11+
path_option = click.option(
12+
"--path",
13+
help='The file path where to save the file. The path must include the file name (e.g. "/path/to/my_file.txt"). Defaults to a file named "downloaded_file" in the current directory.',
14+
default=str(Path(os.getcwd()) / "downloaded_file"),
15+
)
16+
17+
18+
@click.group(cls=IncydrGroup)
19+
@logging_options
20+
def files():
21+
"""Download files by SHA256 hash."""
22+
23+
24+
@files.command(cls=IncydrCommand)
25+
@click.argument("SHA256")
26+
@path_option
27+
@logging_options
28+
def download(sha256: str, path: str):
29+
"""
30+
Download the file matching the given SHA256 hash to the target path.
31+
"""
32+
client = Client()
33+
client.files.v1.download_file_by_sha256(sha256, path)

src/_incydr_cli/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from _incydr_cli.cmds.devices import devices
1919
from _incydr_cli.cmds.directory_groups import directory_groups
2020
from _incydr_cli.cmds.file_events import file_events
21+
from _incydr_cli.cmds.files import files as files_client
2122
from _incydr_cli.cmds.risk_profiles import risk_profiles
2223
from _incydr_cli.cmds.sessions import sessions
2324
from _incydr_cli.cmds.trusted_activities import trusted_activities
@@ -81,6 +82,7 @@ def incydr(version, python, script_dir):
8182
incydr.add_command(devices)
8283
incydr.add_command(directory_groups)
8384
incydr.add_command(file_events)
85+
incydr.add_command(files_client)
8486
incydr.add_command(cases)
8587
incydr.add_command(risk_profiles)
8688
incydr.add_command(sessions)

src/_incydr_sdk/core/client.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from _incydr_sdk.directory_groups.client import DirectoryGroupsClient
2323
from _incydr_sdk.exceptions import AuthMissingError
2424
from _incydr_sdk.file_events.client import FileEventsClient
25+
from _incydr_sdk.files.client import FilesClient
2526
from _incydr_sdk.risk_profiles.client import RiskProfiles
2627
from _incydr_sdk.sessions.client import SessionsClient
2728
from _incydr_sdk.trusted_activities.client import TrustedActivitiesClient
@@ -106,6 +107,7 @@ def response_hook(response, *args, **kwargs):
106107
self._devices = DevicesClient(self)
107108
self._directory_groups = DirectoryGroupsClient(self)
108109
self._file_events = FileEventsClient(self)
110+
self._files = FilesClient(self)
109111
self._sessions = SessionsClient(self)
110112
self._trusted_activities = TrustedActivitiesClient(self)
111113
self._users = UsersClient(self)
@@ -283,6 +285,16 @@ def file_events(self):
283285
"""
284286
return self._file_events
285287

288+
@property
289+
def files(self):
290+
"""
291+
Property returning a [`FilesClient`](../files) for interacting with `/v1/files` API endpoints.
292+
Usage:
293+
294+
>>> client.files.v1.get_file_by_sha256("sha256 hash", "/path/to/file.extension")
295+
"""
296+
return self._files
297+
286298
@property
287299
def sessions(self):
288300
"""

src/_incydr_sdk/core/settings.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,12 @@ def _log_response_debug(self, response):
225225
dumped = indent(dumped, prefix="\t")
226226
self.logger.debug(dumped)
227227
except Exception as err:
228-
self.logger.debug(f"Error dumping request/response info: {err}")
229-
self.logger.debug(response)
228+
if isinstance(response.content, bytes):
229+
self.logger.debug("Unable to log binary response data.")
230+
self.logger.debug(response)
231+
else:
232+
self.logger.debug(f"Error dumping request/response info: {err}")
233+
self.logger.debug(response)
230234

231235
def _log_error(self, err, invocation_str=None):
232236
message = str(err) if err else None

src/_incydr_sdk/files/client.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from pathlib import Path
2+
3+
4+
class FilesClient:
5+
def __init__(self, parent):
6+
self._parent = parent
7+
self._v1 = None
8+
9+
@property
10+
def v1(self):
11+
if self._v1 is None:
12+
self._v1 = FilesV1(self._parent)
13+
return self._v1
14+
15+
16+
class FilesV1:
17+
"""Client for `/v1/files` endpoints.
18+
19+
Usage example:
20+
21+
>>> import incydr
22+
>>>
23+
>>> client = incydr.Client(**kwargs)
24+
>>> client.files.v1.download_file_by_sha256("example_hash", "./testfile.test")
25+
"""
26+
27+
def __init__(self, parent):
28+
self._parent = parent
29+
30+
def download_file_by_sha256(self, sha256: str, target_path: Path) -> Path:
31+
"""Download a file that matches the given SHA256 hash.
32+
33+
**Parameters:**
34+
35+
* **sh256**: `str` (required) The SHA256 hash matching the file you wish to download.
36+
* **target_path**: `Path | str` a string or `pathlib.Path` object that represents the target file path and
37+
name to which the file will be saved to.
38+
39+
**Returns**: A `pathlib.Path` object representing the location of the downloaded file.
40+
"""
41+
target = Path(
42+
target_path
43+
) # ensure that target is a path even if we're given a string
44+
response = self._parent.session.get(f"/v1/files/get-file-by-sha256/{sha256}")
45+
target.write_bytes(response.content)
46+
return target
47+
48+
def stream_file_by_sha256(self, sha256: str):
49+
"""Stream a file that matches the given SHA256 hash.
50+
51+
**Example usage:**
52+
```
53+
>>> with sdk.files.v1.stream_file_by_sha256("example_hash") as response:
54+
>>> with open("./testfile.zip", "wb") as file:
55+
>>> for chunk in response.iter_content(chunk_size=128):
56+
>>> file.write(chunk)
57+
```
58+
59+
**Parameters:**
60+
61+
* **sh256**: `str` (required) The SHA256 hash matching the file you wish to download.
62+
63+
**Returns**: A `requests.Response` object with a stream of the requested file.
64+
"""
65+
return self._parent.session.get(
66+
f"/v1/files/get-file-by-sha256/{sha256}", stream=True
67+
)

tests/test_agents.py

Lines changed: 63 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,7 @@ def test_cli_bulk_deactivate_JSON_file_input(httpserver_auth: HTTPServer, runner
439439
def test_cli_bulk_activate_retries_with_agent_ids_not_found_removed(
440440
httpserver_auth: HTTPServer, runner
441441
):
442-
input_lines = "\n".join(("agent_id", "1234", "5678", "2345", "9876"))
442+
input_lines = ("agent_id", "1234", "5678", "2345", "9876")
443443

444444
httpserver_auth.expect_request(
445445
uri="/v1/agents/activate",
@@ -453,21 +453,25 @@ def test_cli_bulk_activate_retries_with_agent_ids_not_found_removed(
453453
httpserver_auth.expect_request(
454454
uri="/v1/agents/activate", method="POST", json={"agentIds": ["2345", "1234"]}
455455
).respond_with_data(status=204)
456-
457-
result = runner.invoke(
458-
incydr, ["agents", "bulk-activate", "--format", "csv", "-"], input=input_lines
459-
)
460-
assert (
461-
"404 Error processing batch of 4 agent activations, agent_ids not found: ['5678', '9876']"
462-
in result.output
463-
)
464-
assert "Activating agents..." in result.output
456+
with runner.isolated_filesystem():
457+
with open("tmpfile", "w") as tmpfile:
458+
for line in input_lines:
459+
tmpfile.write(line)
460+
tmpfile.write("\n")
461+
result = runner.invoke(
462+
incydr, ["agents", "bulk-activate", "--format", "csv", "tmpfile"]
463+
)
464+
assert (
465+
"404 Error processing batch of 4 agent activations, agent_ids not found: ['5678', '9876']"
466+
in result.output
467+
)
468+
assert "Activating agents..." in result.output
465469

466470

467471
def test_cli_bulk_activate_retries_ids_individually_when_unknown_error_occurs(
468472
httpserver_auth: HTTPServer, runner
469473
):
470-
input_lines = "\n".join(("agent_id", "1234", "5678", "2345", "9876"))
474+
input_lines = ("agent_id", "1234", "5678", "2345", "9876")
471475

472476
httpserver_auth.expect_request(
473477
uri="/v1/agents/activate",
@@ -487,21 +491,28 @@ def test_cli_bulk_activate_retries_ids_individually_when_unknown_error_occurs(
487491
uri="/v1/agents/activate", method="POST", json={"agentIds": ["9876"]}
488492
).respond_with_data(status=204)
489493

490-
result = runner.invoke(
491-
incydr, ["agents", "bulk-activate", "--format", "csv", "-"], input=input_lines
492-
)
493-
assert "Unknown error processing batch of 4 agent activations" in result.output
494-
assert "Trying agent activation for this batch individually" in result.output
495-
assert "Activating agents..." in result.output
496-
assert (
497-
"Failed to process activation for 5678: Unknown Server Error" in result.output
498-
)
494+
with runner.isolated_filesystem():
495+
with open("tmpfile", "w") as tmpfile:
496+
for line in input_lines:
497+
tmpfile.write(line)
498+
tmpfile.write("\n")
499+
500+
result = runner.invoke(
501+
incydr, ["agents", "bulk-activate", "--format", "csv", "tmpfile"]
502+
)
503+
assert "Unknown error processing batch of 4 agent activations" in result.output
504+
assert "Trying agent activation for this batch individually" in result.output
505+
assert "Activating agents..." in result.output
506+
assert (
507+
"Failed to process activation for 5678: Unknown Server Error"
508+
in result.output
509+
)
499510

500511

501512
def test_cli_bulk_deactivate_retries_with_agent_ids_not_found_removed(
502513
httpserver_auth: HTTPServer, runner
503514
):
504-
input_lines = "\n".join(("agent_id", "1234", "5678", "2345", "9876"))
515+
input_lines = ("agent_id", "1234", "5678", "2345", "9876")
505516

506517
httpserver_auth.expect_request(
507518
uri="/v1/agents/deactivate",
@@ -515,21 +526,26 @@ def test_cli_bulk_deactivate_retries_with_agent_ids_not_found_removed(
515526
httpserver_auth.expect_request(
516527
uri="/v1/agents/deactivate", method="POST", json={"agentIds": ["2345", "1234"]}
517528
).respond_with_data(status=204)
529+
with runner.isolated_filesystem():
530+
with open("tmpfile", "w") as tmpfile:
531+
for line in input_lines:
532+
tmpfile.write(line)
533+
tmpfile.write("\n")
518534

519-
result = runner.invoke(
520-
incydr, ["agents", "bulk-deactivate", "--format", "csv", "-"], input=input_lines
521-
)
522-
assert (
523-
"404 Error processing batch of 4 agent deactivations, agent_ids not found: ['5678', '9876']"
524-
in result.output
525-
)
526-
assert "Deactivating agents..." in result.output
535+
result = runner.invoke(
536+
incydr, ["agents", "bulk-deactivate", "--format", "csv", "tmpfile"]
537+
)
538+
assert (
539+
"404 Error processing batch of 4 agent deactivations, agent_ids not found: ['5678', '9876']"
540+
in result.output
541+
)
542+
assert "Deactivating agents..." in result.output
527543

528544

529545
def test_cli_bulk_deactivate_retries_ids_individually_when_unknown_error_occurs(
530546
httpserver_auth: HTTPServer, runner
531547
):
532-
input_lines = "\n".join(("agent_id", "1234", "5678", "2345", "9876"))
548+
input_lines = ("agent_id", "1234", "5678", "2345", "9876")
533549

534550
httpserver_auth.expect_request(
535551
uri="/v1/agents/deactivate",
@@ -548,13 +564,21 @@ def test_cli_bulk_deactivate_retries_ids_individually_when_unknown_error_occurs(
548564
httpserver_auth.expect_request(
549565
uri="/v1/agents/deactivate", method="POST", json={"agentIds": ["9876"]}
550566
).respond_with_data(status=204)
567+
with runner.isolated_filesystem():
568+
with open("tmpfile", "w") as tmpfile:
569+
for line in input_lines:
570+
tmpfile.write(line)
571+
tmpfile.write("\n")
551572

552-
result = runner.invoke(
553-
incydr, ["agents", "bulk-deactivate", "--format", "csv", "-"], input=input_lines
554-
)
555-
assert "Unknown error processing batch of 4 agent deactivations" in result.output
556-
assert "Trying agent deactivation for this batch individually" in result.output
557-
assert "Deactivating agents..." in result.output
558-
assert (
559-
"Failed to process deactivation for 5678: Unknown Server Error" in result.output
560-
)
573+
result = runner.invoke(
574+
incydr, ["agents", "bulk-deactivate", "--format", "csv", "tmpfile"]
575+
)
576+
assert (
577+
"Unknown error processing batch of 4 agent deactivations" in result.output
578+
)
579+
assert "Trying agent deactivation for this batch individually" in result.output
580+
assert "Deactivating agents..." in result.output
581+
assert (
582+
"Failed to process deactivation for 5678: Unknown Server Error"
583+
in result.output
584+
)

0 commit comments

Comments
 (0)