11import fnmatch
2+ import os
23import tarfile
34import zipfile
45from io import BytesIO
5- from typing import Annotated , List , Tuple
6+ from typing import Annotated , List , Optional , Set , Tuple
67
78import click
9+ from tabulate import tabulate
810
911import smart_tests .args4p .typer as typer
1012from smart_tests .utils .session import SessionId , get_session
1113
1214from ... import args4p
1315from ...app import Application
14- from ...utils .fail_fast_mode import warn_and_exit_if_fail_fast_mode
1516from ...utils .smart_tests_client import SmartTestsClient
16- from tabulate import tabulate
1717
1818
1919class AttachmentStatus :
2020 SUCCESS = "✓ Recorded successfully"
2121 FAILED = "⚠ Failed to record"
2222 SKIPPED_NON_TEXT = "⚠ Skipped: not a valid text file"
23+ SKIPPED_DUPLICATE = "⚠ Skipped: duplicate"
2324
2425
2526@args4p .command (help = "Record attachment information" )
@@ -39,6 +40,8 @@ def attachment(
3940):
4041 client = SmartTestsClient (app = app )
4142 summary_rows = []
43+ used_filenames : Set [str ] = set ()
44+
4245 try :
4346 # Note: Call get_session method to check test session exists
4447 _ = get_session (session , client )
@@ -60,9 +63,15 @@ def attachment(
6063 [zip_info .filename , AttachmentStatus .SKIPPED_NON_TEXT ])
6164 continue
6265
66+ file_name = get_unique_filename (zip_info .filename , used_filenames )
67+ if not file_name :
68+ summary_rows .append (
69+ [zip_info .filename , AttachmentStatus .SKIPPED_DUPLICATE ])
70+ continue
71+
6372 status = post_attachment (
64- client , session , file_content , zip_info . filename )
65- summary_rows .append ([zip_info . filename , status ])
73+ client , session , file_content , file_name )
74+ summary_rows .append ([file_name , status ])
6675
6776 # If tar file (tar, tar.gz, tar.bz2, tgz, etc.)
6877 elif tarfile .is_tarfile (a ):
@@ -85,9 +94,15 @@ def attachment(
8594 [tar_info .name , AttachmentStatus .SKIPPED_NON_TEXT ])
8695 continue
8796
97+ file_name = get_unique_filename (tar_info .name , used_filenames )
98+ if not file_name :
99+ summary_rows .append (
100+ [tar_info .name , AttachmentStatus .SKIPPED_DUPLICATE ])
101+ continue
102+
88103 status = post_attachment (
89- client , session , file_content , tar_info . name )
90- summary_rows .append ([tar_info . name , status ])
104+ client , session , file_content , file_name )
105+ summary_rows .append ([file_name , status ])
91106
92107 else :
93108 with open (a , mode = 'rb' ) as f :
@@ -98,15 +113,55 @@ def attachment(
98113 [a , AttachmentStatus .SKIPPED_NON_TEXT ])
99114 continue
100115
101- status = post_attachment (client , session , file_content , a )
102- summary_rows .append ([a , status ])
116+ file_name = get_unique_filename (a , used_filenames )
117+ if not file_name :
118+ summary_rows .append (
119+ [a , AttachmentStatus .SKIPPED_DUPLICATE ])
120+ continue
121+
122+ status = post_attachment (client , session , file_content , file_name )
123+ summary_rows .append ([file_name , status ])
124+
103125 except Exception as e :
104126 client .print_exception_and_recover (e )
105127
106128 display_summary_as_table (summary_rows )
107129
108130
109- def matches_include_patterns (filename : str , include_patterns : List [str ]) -> bool :
131+ def get_unique_filename (filepath : str , used_filenames : Set [str ]) -> Optional [str ]:
132+ """
133+ Get a unique filename by extracting the basename and prepending parent folders if needed.
134+ Strategy:
135+ 1. First occurrence: use basename (e.g., app.log)
136+ 2. Duplicate: prepend parent directories until unique
137+ """
138+ # Normalize path separators to forward slash (archives always use forward slash in both linux, and windows)
139+ normalized_path = filepath .replace (os .sep , '/' )
140+ normalized_path = normalize_filename (normalized_path )
141+
142+ basename = normalized_path .split ('/' )[- 1 ]
143+
144+ # If basename is not used, return it
145+ if basename not in used_filenames :
146+ used_filenames .add (basename )
147+ return basename
148+
149+ # Try prepending parents from nearest to farthest
150+ path_parts = normalized_path .split ('/' )
151+ parent_parts = [p for p in path_parts [:- 1 ] if p ]
152+
153+ prefixed_name = basename
154+ for parent in reversed (parent_parts ):
155+ prefixed_name = f"{ parent } /{ prefixed_name } "
156+
157+ if prefixed_name not in used_filenames :
158+ used_filenames .add (prefixed_name )
159+ return prefixed_name
160+
161+ return None
162+
163+
164+ def matches_include_patterns (filename : str , include_patterns : Tuple [str , ...]) -> bool :
110165 """
111166 Check if a file should be included based on the include patterns.
112167 If no patterns are specified, all files are included.
@@ -121,6 +176,13 @@ def matches_include_patterns(filename: str, include_patterns: List[str]) -> bool
121176 return False
122177
123178
179+ def normalize_filename (filename : str ) -> str :
180+ """
181+ Normalize filename by replacing whitespace with dashes.
182+ """
183+ return filename .replace (' ' , '-' )
184+
185+
124186def valid_utf8_file (file_content : bytes ) -> bool :
125187 # Check for null bytes (binary files)
126188 if b'\x00 ' in file_content :
0 commit comments