Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
20 changes: 15 additions & 5 deletions api_client/python/timesketch_api_client/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,7 @@ def _extract_chips(self, query_filter):

self.add_chip(chip)

def _execute_query(self, file_name="", count=False):
def _execute_query(self, file_name="", count=False, stream=False):
"""Execute a search request and store the results.

Args:
Expand All @@ -502,6 +502,9 @@ def _execute_query(self, file_name="", count=False):
set to True, the results will be stored in the
search object, and the number of events will be
returned.
stream (bool): Optional boolean that determines whether
we want to stream the results to a file. This is
useful for large exports.

Returns:
A dict with the search results or the total number of events
Expand All @@ -528,7 +531,7 @@ def _execute_query(self, file_name="", count=False):
}

response = self.api.session.post(
f"{self.api.api_root}/{self.resource_uri}", json=form_data
f"{self.api.api_root}/{self.resource_uri}", json=form_data, stream=stream
)
if not error.check_return_status(response, logger):
error.error_message(
Expand All @@ -537,7 +540,11 @@ def _execute_query(self, file_name="", count=False):

if file_name:
with open(file_name, "wb") as fw:
fw.write(response.content)
if stream:
for chunk in response.iter_content(chunk_size=8192):
fw.write(chunk)
else:
fw.write(response.content)
return None

response_json = error.get_response_json(response, logger)
Expand Down Expand Up @@ -1091,20 +1098,23 @@ def to_dict(self):

return self._raw_response

def to_file(self, file_name):
def to_file(self, file_name, stream=False):
"""Saves the content of the query to a file.

Args:
file_name (str): Full path to a file that will store the results
of the query to as a ZIP file. The ZIP file will contain a
METADATA file and a CSV with the results from the query.
stream (bool): Optional boolean that determines whether
we want to stream the results to a file. This is
useful for large exports.

Returns:
Boolean that determines if it was successful.
"""
old_scrolling = self.scrolling
self._scrolling = True
self._execute_query(file_name=file_name)
self._execute_query(file_name=file_name, stream=stream)
self._scrolling = old_scrolling
return True

Expand Down
28 changes: 20 additions & 8 deletions cli_client/python/timesketch_cli_client/commands/sketch.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,18 @@ def create_sketch(

@sketch_group.command("export", help="Export a sketch")
@click.option("--filename", required=True, help="Filename to export to.")
@click.option(
"--stream", is_flag=True, help="Stream the download to avoid memory issues."
)
@click.option(
"--use_sketch_export",
is_flag=True,
help="Use the sketch export functionality instead of search.",
)
@click.pass_context
def export_sketch(ctx: click.Context, filename: str) -> None:
def export_sketch(
ctx: click.Context, filename: str, stream: bool, use_sketch_export: bool
) -> None:
"""Export a sketch to a file.

Exports all events within the active sketch to a specified file.
Expand All @@ -159,6 +169,8 @@ def export_sketch(ctx: click.Context, filename: str) -> None:
Args:
ctx (click.Context): The Click context object, containing the sketch.
filename (str): The name of the file to export the sketch data to.
stream (bool): Whether to stream the download.
use_sketch_export (bool): Whether to use the sketch export functionality.

Raises:
click.exceptions.Exit: If a ValueError occurs during the export process.
Expand All @@ -174,17 +186,17 @@ def export_sketch(ctx: click.Context, filename: str) -> None:
# start counting the time the export took
start_time = time.time()
try:
search_obj = search.Search(sketch=sketch)

click.echo(f"Number of events in that sketch: {search_obj.expected_size}")
if use_sketch_export:
sketch.export(filename)
else:
search_obj = search.Search(sketch=sketch)
click.echo(f"Number of events in that sketch: {search_obj.expected_size}")
search_obj.to_file(filename, stream=stream)

search_obj.to_file(filename)
# Using the sketch.export function could be an alternative here
# TODO: https://github.com/google/timesketch/issues/2344
end_time = time.time()
click.echo(f"Export took {end_time - start_time} seconds")
click.echo("Finish")
except ValueError as e:
except Exception as e: # pylint: disable=broad-except
click.echo(f"Error: {e}")
ctx.exit(1)

Expand Down
15 changes: 14 additions & 1 deletion docs/guides/user/cli-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,20 @@ Running `sketch unarchive` will set the archive flag to the sketch.

### Export a sketch

Running `sketch export` will export the complete Sketch to a file.
Running `sketch export` will export all events from the sketch to a ZIP file.

```bash
timesketch sketch export --filename my_export.zip
```

Options:
- `--stream`: Stream the download. This is useful for large exports to avoid memory issues.
- `--use_sketch_export`: Use the full sketch export functionality (same as Web UI export) instead of just exporting events. This export includes metadata, stories, and views in addition to events.

Example:
```bash
timesketch sketch export --filename my_export.zip --stream
```

### Labels

Expand Down
59 changes: 58 additions & 1 deletion end_to_end_tests/cli_client_e2e_test.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
# /usr/local/google/home/jaegeral/dev/timesketch/end_to_end_tests/cli_client_e2e_test.py
# Copyright 2026 Google Inc. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""End to end tests for Timesketch CLI client commands."""

import os
from click.testing import CliRunner

from timesketch_cli_client.commands.sketch import sketch_group
Expand Down Expand Up @@ -97,6 +110,50 @@ def test_cli_sketch_describe(self):
"Sketch description not found or incorrect in 'sketch describe' output.",
)

def test_cli_sketch_export(self):
"""Tests 'timesketch sketch export'."""
active_sketch = self.sketch
self.import_timeline("evtx_part.csv") # ensure some data

cli_ctx_obj = E2ECliContextObject(
api_client=self.api,
sketch_instance=active_sketch,
output_format="text",
)

# Test default export
with self.runner.isolated_filesystem():
filename = "export.zip"
result = self.runner.invoke(
sketch_group, ["export", "--filename", filename], obj=cli_ctx_obj
)
self.assertions.assertEqual(result.exit_code, 0, f"Output: {result.output}")
self.assertions.assertTrue(os.path.exists(filename))

# Test streaming export
filename_stream = "export_stream.zip"
result_stream = self.runner.invoke(
sketch_group,
["export", "--filename", filename_stream, "--stream"],
obj=cli_ctx_obj,
)
self.assertions.assertEqual(
result_stream.exit_code, 0, f"Output: {result_stream.output}"
)
self.assertions.assertTrue(os.path.exists(filename_stream))

# Test use_sketch_export
filename_full = "export_full.zip"
result_full = self.runner.invoke(
sketch_group,
["export", "--filename", filename_full, "--use_sketch_export"],
obj=cli_ctx_obj,
)
self.assertions.assertEqual(
result_full.exit_code, 0, f"Output: {result_full.output}"
)
self.assertions.assertTrue(os.path.exists(filename_full))


# Register the new test class with the test manager
manager.EndToEndTestManager.register_test(CliClientE2ETest)
Loading