Skip to content

Commit a3bf62b

Browse files
committed
Log file names should be consistent across CLI, and web
1 parent dd99c97 commit a3bf62b

2 files changed

Lines changed: 93 additions & 41 deletions

File tree

launchable/commands/record/attachment.py

Lines changed: 44 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class AttachmentStatus:
1616
SUCCESS = "✓ Recorded successfully"
1717
FAILED = "⚠ Failed to record"
1818
SKIPPED_NON_TEXT = "⚠ Skipped: not a valid text file"
19+
SKIPPED_DUPLICATE = "⚠ Skipped: duplicate"
1920

2021

2122
@click.command()
@@ -66,8 +67,12 @@ def attachment(
6667
[zip_info.filename, AttachmentStatus.SKIPPED_NON_TEXT])
6768
continue
6869

69-
file_name = normalize_filename(zip_info.filename)
70-
file_name = get_unique_filename(file_name, used_filenames)
70+
file_name = get_unique_filename(zip_info.filename, used_filenames)
71+
if not file_name:
72+
summary_rows.append(
73+
[zip_info.filename, AttachmentStatus.SKIPPED_DUPLICATE])
74+
continue
75+
7176
status = post_attachment(
7277
client, session, file_content, file_name)
7378
summary_rows.append([file_name, status])
@@ -93,8 +98,12 @@ def attachment(
9398
[tar_info.name, AttachmentStatus.SKIPPED_NON_TEXT])
9499
continue
95100

96-
file_name = normalize_filename(tar_info.name)
97-
file_name = get_unique_filename(file_name, used_filenames)
101+
file_name = get_unique_filename(tar_info.name, used_filenames)
102+
if not file_name:
103+
summary_rows.append(
104+
[tar_info.name, AttachmentStatus.SKIPPED_DUPLICATE])
105+
continue
106+
98107
status = post_attachment(
99108
client, session, file_content, file_name)
100109
summary_rows.append([file_name, status])
@@ -108,8 +117,12 @@ def attachment(
108117
[a, AttachmentStatus.SKIPPED_NON_TEXT])
109118
continue
110119

111-
file_name = normalize_filename(a)
112-
file_name = get_unique_filename(file_name, used_filenames)
120+
file_name = get_unique_filename(a, used_filenames)
121+
if not file_name:
122+
summary_rows.append(
123+
[a, AttachmentStatus.SKIPPED_DUPLICATE])
124+
continue
125+
113126
status = post_attachment(client, session, file_content, file_name)
114127
summary_rows.append([file_name, status])
115128

@@ -119,40 +132,37 @@ def attachment(
119132
display_summary_as_table(summary_rows)
120133

121134

122-
def get_unique_filename(filepath: str, used_filenames: Set[str]) -> str:
135+
def get_unique_filename(filepath: str, used_filenames: Set[str]) -> Optional[str]:
123136
"""
124-
Get a unique filename by extracting the basename and prepending parent folder if needed.
137+
Get a unique filename by extracting the basename and prepending parent folders if needed.
125138
Strategy:
126139
1. First occurrence: use basename (e.g., app.log)
127-
2. Duplicate: prepend parent folder (e.g., nested-app.log)
128-
3. Still duplicate: append .1, .2, etc. (e.g., nested-app.1.log)
140+
2. Duplicate: prepend parent directories until unique
129141
"""
130-
filename = os.path.basename(filepath)
142+
# Normalize path separators to forward slash (archives always use forward slash in both linux, and windows)
143+
normalized_path = filepath.replace(os.sep, '/')
144+
normalized_path = normalize_filename(normalized_path)
145+
146+
basename = normalized_path.split('/')[-1]
131147

132148
# If basename is not used, return it
133-
if filename not in used_filenames:
134-
used_filenames.add(filename)
135-
return filename
136-
137-
# Try prepending the parent directory name
138-
parent_dir = os.path.basename(os.path.dirname(filepath))
139-
if parent_dir: # Has a parent directory
140-
name, ext = os.path.splitext(filename)
141-
filename = f"{parent_dir}-{name}{ext}"
142-
143-
if filename not in used_filenames:
144-
used_filenames.add(filename)
145-
return filename
146-
147-
# If still duplicate, append numbers
148-
name, ext = os.path.splitext(filename)
149-
counter = 1
150-
while True:
151-
filename = f"{name}.{counter}{ext}"
152-
if filename not in used_filenames:
153-
used_filenames.add(filename)
154-
return filename
155-
counter += 1
149+
if basename not in used_filenames:
150+
used_filenames.add(basename)
151+
return basename
152+
153+
# Try prepending parents from nearest to farthest
154+
path_parts = normalized_path.split('/')
155+
parent_parts = [p for p in path_parts[:-1] if p]
156+
157+
prefixed_name = basename
158+
for parent in reversed(parent_parts):
159+
prefixed_name = f"{parent}/{prefixed_name}"
160+
161+
if prefixed_name not in used_filenames:
162+
used_filenames.add(prefixed_name)
163+
return prefixed_name
164+
165+
return None
156166

157167

158168
def matches_include_patterns(filename: str, include_patterns: Tuple[str, ...]) -> bool:

tests/commands/record/test_attachment.py

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ def test_attachment_with_identical_file_names(self):
168168
with tempfile.TemporaryDirectory() as temp_dir:
169169
# Create temporary files
170170
text_file_1 = os.path.join(temp_dir, "app.log")
171-
text_file_2 = os.path.join(temp_dir, "nested", "app.log")
171+
text_file_2 = os.path.join(temp_dir, "nested_2", "app.log")
172172
zip_path = os.path.join(temp_dir, "logs.zip")
173173

174174
# Create directory structure
@@ -183,7 +183,8 @@ def test_attachment_with_identical_file_names(self):
183183
# Create zip file
184184
with zipfile.ZipFile(zip_path, 'w') as zf:
185185
zf.write(text_file_1, 'app.log')
186-
zf.write(text_file_2, 'nested/app.log')
186+
zf.write(text_file_2, 'nested_1/app.log')
187+
zf.write(text_file_2, 'nested_2/app.log')
187188

188189
responses.add(
189190
responses.POST,
@@ -196,15 +197,23 @@ def test_attachment_with_identical_file_names(self):
196197
responses.POST,
197198
"{}/intake/organizations/{}/workspaces/{}/builds/{}/test_sessions/{}/attachment".format(
198199
get_base_url(), self.organization, self.workspace, self.build_name, self.session_id),
199-
match=[responses.matchers.header_matcher({"Content-Disposition": 'attachment;filename="nested-app.log"'})],
200+
match=[responses.matchers.header_matcher({"Content-Disposition": 'attachment;filename="nested_1/app.log"'})],
201+
status=200)
202+
203+
responses.add(
204+
responses.POST,
205+
"{}/intake/organizations/{}/workspaces/{}/builds/{}/test_sessions/{}/attachment".format(
206+
get_base_url(), self.organization, self.workspace, self.build_name, self.session_id),
207+
match=[responses.matchers.header_matcher({"Content-Disposition": 'attachment;filename="nested_2/app.log"'})],
200208
status=200)
201209

202210
result = self.cli("record", "attachment", "--session", self.session, zip_path)
203211

204-
expect = """| File | Status |
205-
|----------------|-------------------------|
206-
| app.log | ✓ Recorded successfully |
207-
| nested-app.log | ✓ Recorded successfully |
212+
expect = """| File | Status |
213+
|------------------|-------------------------|
214+
| app.log | ✓ Recorded successfully |
215+
| nested_1/app.log | ✓ Recorded successfully |
216+
| nested_2/app.log | ✓ Recorded successfully |
208217
"""
209218
self.assertEqual(expect, result.output)
210219

@@ -236,3 +245,36 @@ def test_attachment_with_whitespace_in_filename(self):
236245

237246
self.assert_success(result)
238247
self.assertIn("✓ Recorded successfully", result.output)
248+
249+
@responses.activate
250+
@mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token})
251+
def test_attachment_duplicate_file_paths(self):
252+
with tempfile.TemporaryDirectory() as temp_dir:
253+
# Create temporary files
254+
text_file_1 = os.path.join(temp_dir, "app.log")
255+
zip_path = os.path.join(temp_dir, "logs.zip")
256+
257+
# Write test content
258+
with open(text_file_1, 'w') as f:
259+
f.write("[INFO] Test log entry")
260+
261+
# Create zip file
262+
with zipfile.ZipFile(zip_path, 'w') as zf:
263+
zf.write(text_file_1, 'app.log')
264+
265+
responses.add(
266+
responses.POST,
267+
"{}/intake/organizations/{}/workspaces/{}/builds/{}/test_sessions/{}/attachment".format(
268+
get_base_url(), self.organization, self.workspace, self.build_name, self.session_id),
269+
match=[responses.matchers.header_matcher({"Content-Disposition": 'attachment;filename="app.log"'})],
270+
status=200)
271+
272+
expect = """| File | Status |
273+
|---------|-------------------------|
274+
| app.log | ✓ Recorded successfully |
275+
| app.log | ⚠ Skipped: duplicate |
276+
"""
277+
278+
result = self.cli("record", "attachment", "--session", self.session, zip_path, zip_path)
279+
280+
self.assertIn(expect, result.output)

0 commit comments

Comments
 (0)