Skip to content

Commit f5d1e92

Browse files
authored
Merge pull request #1224 from cloudbees-oss/AIENG-330
Update attachment command to support zip files
2 parents c270702 + 1b649e4 commit f5d1e92

2 files changed

Lines changed: 163 additions & 6 deletions

File tree

launchable/commands/record/attachment.py

Lines changed: 96 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
1+
import tarfile
2+
import zipfile
3+
from io import BytesIO
14
from typing import Optional
25

36
import click
7+
from tabulate import tabulate
48

59
from ...utils.launchable_client import LaunchableClient
610
from ..helper import require_session
711

812

13+
class AttachmentStatus:
14+
SUCCESS = "✓ Recorded successfully"
15+
FAILED = "⚠ Failed to record"
16+
SKIPPED_NON_TEXT = "⚠ Skipped: not a valid text file"
17+
18+
919
@click.command()
1020
@click.option(
1121
'--session',
@@ -21,15 +31,95 @@ def attachment(
2131
session: Optional[str] = None
2232
):
2333
client = LaunchableClient(app=context.obj)
34+
summary_rows = []
2435
try:
2536
session = require_session(session)
37+
assert session is not None
2638

2739
for a in attachments:
28-
click.echo("Sending {}".format(a))
29-
with open(a, mode='rb') as f:
30-
res = client.request(
31-
"post", "{}/attachment".format(session), compress=True, payload=f,
32-
additional_headers={"Content-Disposition": "attachment;filename=\"{}\"".format(a)})
33-
res.raise_for_status()
40+
# If zip file
41+
if zipfile.is_zipfile(a):
42+
with zipfile.ZipFile(a, 'r') as zip_file:
43+
for zip_info in zip_file.infolist():
44+
if zip_info.is_dir():
45+
continue
46+
47+
file_content = zip_file.read(zip_info.filename)
48+
49+
if not valid_utf8_file(file_content):
50+
summary_rows.append(
51+
[zip_info.filename, AttachmentStatus.SKIPPED_NON_TEXT])
52+
continue
53+
54+
status = post_attachment(
55+
client, session, file_content, zip_info.filename)
56+
summary_rows.append([zip_info.filename, status])
57+
58+
# If tar file (tar, tar.gz, tar.bz2, tgz, etc.)
59+
elif tarfile.is_tarfile(a):
60+
with tarfile.open(a, 'r:*') as tar_file:
61+
for tar_info in tar_file:
62+
if tar_info.isdir():
63+
continue
64+
65+
file_obj = tar_file.extractfile(tar_info)
66+
if file_obj is None:
67+
continue
68+
69+
file_content = file_obj.read()
70+
71+
if not valid_utf8_file(file_content):
72+
summary_rows.append(
73+
[tar_info.name, AttachmentStatus.SKIPPED_NON_TEXT])
74+
continue
75+
76+
status = post_attachment(
77+
client, session, file_content, tar_info.name)
78+
summary_rows.append([tar_info.name, status])
79+
80+
else:
81+
with open(a, mode='rb') as f:
82+
file_content = f.read()
83+
84+
if not valid_utf8_file(file_content):
85+
summary_rows.append(
86+
[a, AttachmentStatus.SKIPPED_NON_TEXT])
87+
continue
88+
89+
status = post_attachment(client, session, file_content, a)
90+
summary_rows.append([a, status])
91+
3492
except Exception as e:
3593
client.print_exception_and_recover(e)
94+
95+
display_summary_as_table(summary_rows)
96+
97+
98+
def valid_utf8_file(file_content: bytes) -> bool:
99+
# Check for null bytes (binary files)
100+
if b'\x00' in file_content:
101+
return False
102+
103+
try:
104+
file_content.decode('utf-8')
105+
return True
106+
except UnicodeDecodeError:
107+
return False
108+
109+
110+
def post_attachment(client: LaunchableClient, session: str, file_content: bytes, filename: str) -> str:
111+
try:
112+
res = client.request(
113+
"post", "{}/attachment".format(session), compress=True, payload=BytesIO(file_content),
114+
additional_headers={"Content-Disposition": "attachment;filename=\"{}\"".format(filename)})
115+
res.raise_for_status()
116+
return AttachmentStatus.SUCCESS
117+
except Exception as e:
118+
click.echo("Failed to upload {}: {}".format(
119+
filename, str(e)), err=True)
120+
return AttachmentStatus.FAILED
121+
122+
123+
def display_summary_as_table(rows):
124+
headers = ["File", "Status"]
125+
click.echo(tabulate(rows, headers, tablefmt="github"))

tests/commands/record/test_attachment.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import gzip
22
import os
3+
import tarfile
34
import tempfile
5+
import zipfile
46
from unittest import mock
57

68
import responses # type: ignore
@@ -44,3 +46,68 @@ def verify_body(request):
4446
self.assertEqual(TEST_CONTENT, body)
4547

4648
os.unlink(attachment.name)
49+
50+
@responses.activate
51+
@mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token})
52+
def test_attachment_zip_file(self):
53+
with tempfile.TemporaryDirectory() as temp_dir:
54+
# Create temporary files
55+
text_file_1 = os.path.join(temp_dir, "app.log")
56+
text_file_2 = os.path.join(temp_dir, "nested", "debug.log")
57+
binary_file = os.path.join(temp_dir, "binary.dat")
58+
zip_path = os.path.join(temp_dir, "logs.zip")
59+
tar_path = os.path.join(temp_dir, "logs.tar.gz")
60+
61+
# Create directory structure
62+
os.makedirs(os.path.dirname(text_file_2))
63+
64+
# Write test content
65+
with open(text_file_1, 'w') as f:
66+
f.write("[INFO] Test log entry")
67+
with open(text_file_2, 'w') as f:
68+
f.write("[DEBUG] Nested log entry")
69+
with open(binary_file, 'wb') as f:
70+
f.write(b'\x00\x01\x02\x03')
71+
72+
# Create zip file
73+
with zipfile.ZipFile(zip_path, 'w') as zf:
74+
zf.write(text_file_1, 'app.log')
75+
zf.write(text_file_2, 'nested/debug.log')
76+
zf.write(binary_file, 'binary.dat')
77+
78+
# Create tar.gz file
79+
with tarfile.open(tar_path, 'w:gz') as tf:
80+
tf.add(text_file_1, 'app.log')
81+
tf.add(text_file_2, 'nested/debug.log')
82+
tf.add(binary_file, 'binary.dat')
83+
84+
responses.add(
85+
responses.POST,
86+
"{}/intake/organizations/{}/workspaces/{}/builds/{}/test_sessions/{}/attachment".format(
87+
get_base_url(), self.organization, self.workspace, self.build_name, self.session_id),
88+
match=[responses.matchers.header_matcher({"Content-Disposition": 'attachment;filename="app.log"'})],
89+
json={"error": "Log file of the same name already exists"},
90+
status=400)
91+
92+
responses.add(
93+
responses.POST,
94+
"{}/intake/organizations/{}/workspaces/{}/builds/{}/test_sessions/{}/attachment".format(
95+
get_base_url(), self.organization, self.workspace, self.build_name, self.session_id),
96+
match=[responses.matchers.header_matcher({"Content-Disposition": 'attachment;filename="nested/debug.log"'})],
97+
status=200)
98+
99+
expect = """
100+
| File | Status |
101+
|------------------|----------------------------------|
102+
| app.log | ⚠ Failed to record |
103+
| nested/debug.log | ✓ Recorded successfully |
104+
| binary.dat | ⚠ Skipped: not a valid text file |
105+
"""
106+
107+
result = self.cli("record", "attachment", "--session", self.session, zip_path)
108+
109+
self.assertIn(expect, result.output)
110+
111+
result = self.cli("record", "attachment", "--session", self.session, tar_path)
112+
113+
self.assertIn(expect, result.output)

0 commit comments

Comments
 (0)