Skip to content

Commit 9754e60

Browse files
committed
Tests and fixes
1 parent 1d7e4c1 commit 9754e60

File tree

3 files changed

+289
-6
lines changed

3 files changed

+289
-6
lines changed

minio/api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2729,7 +2729,7 @@ def compose_object(
27292729
elif src.offset is not None:
27302730
size -= src.offset
27312731
offset = src.offset or 0
2732-
headers = cast(HTTPHeaderDict, src.headers)
2732+
headers = HTTPHeaderDict(src.headers)
27332733
headers.extend(ssec_headers)
27342734
if size <= MAX_PART_SIZE:
27352735
part_number += 1

minio/commonconfig.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -466,22 +466,22 @@ def build_headers(self, object_size: int, etag: str):
466466
@property
467467
def object_size(self) -> Optional[int]:
468468
"""Get object size."""
469-
if self.object_size is None:
469+
if self._object_size is None:
470470
raise MinioException(
471471
"build_headers() must be called prior to "
472472
"this method invocation",
473473
)
474-
return self.object_size
474+
return self._object_size
475475

476476
@property
477477
def headers(self) -> dict[str, str]:
478478
"""Get headers."""
479-
if self.headers is None:
479+
if self._headers is None:
480480
raise MinioException(
481481
"build_headers() must be called prior to "
482482
"this method invocation",
483483
)
484-
return self.headers.copy()
484+
return self._headers.copy()
485485

486486
@classmethod
487487
def of(cls: Type[F], src: ObjectConditionalReadArgs) -> F:

tests/functional/tests.py

Lines changed: 284 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343

4444
from minio import Minio
4545
from minio.checksum import Algorithm
46-
from minio.commonconfig import ENABLED, REPLACE, CopySource, SnowballObject
46+
from minio.commonconfig import ENABLED, REPLACE, ComposeSource, CopySource, SnowballObject
4747
from minio.datatypes import PostPolicy
4848
from minio.deleteobjects import DeleteObject
4949
from minio.error import S3Error
@@ -2001,6 +2001,284 @@ def test_presigned_post_policy(log_entry):
20012001
_client.remove_bucket(bucket_name=bucket_name)
20022002

20032003

2004+
def test_backward_compatibility_positional_args(log_entry):
2005+
"""
2006+
Critical test: Verify all main APIs accept positional arguments.
2007+
This was BROKEN in 6daf366 and must work in our fix.
2008+
"""
2009+
log_entry["name"] = "test_backward_compatibility_positional_args"
2010+
bucket_name = _gen_bucket_name()
2011+
2012+
log_entry["args"] = {
2013+
"bucket_name": bucket_name,
2014+
}
2015+
2016+
try:
2017+
_client.make_bucket(bucket_name)
2018+
2019+
# Test 1: put_object with positional args (BROKEN in master)
2020+
data = io.BytesIO(b"test data for positional args")
2021+
_client.put_object(bucket_name, "test1", data, 29) # All positional
2022+
2023+
# Test 2: fput_object with positional args
2024+
_client.fput_object(bucket_name, "test2", _test_file) # Positional
2025+
2026+
# Test 3: get_object with positional args
2027+
_client.get_object(bucket_name, "test1") # Positional
2028+
2029+
# Test 4: stat_object with positional args
2030+
_client.stat_object(bucket_name, "test1") # Positional
2031+
2032+
# Test 5: copy_object with positional args
2033+
_client.copy_object(bucket_name, "test3", CopySource(bucket_name, "test1"))
2034+
2035+
# Test 6: list_objects with positional args
2036+
list(_client.list_objects(bucket_name)) # Positional
2037+
2038+
# Test 7: remove_object with positional args
2039+
_client.remove_object(bucket_name, "test1") # Positional
2040+
2041+
finally:
2042+
# Cleanup
2043+
for obj in _client.list_objects(bucket_name):
2044+
_client.remove_object(bucket_name, obj.object_name)
2045+
_client.remove_bucket(bucket_name)
2046+
2047+
2048+
def test_metadata_parameter_backward_compatibility(log_entry):
2049+
"""
2050+
Test that old 'metadata' parameter still works for backward compatibility.
2051+
6daf366 split metadata -> headers/user_metadata, but we keep both.
2052+
"""
2053+
log_entry["name"] = "test_metadata_parameter_backward_compatibility"
2054+
bucket_name = _gen_bucket_name()
2055+
2056+
log_entry["args"] = {
2057+
"bucket_name": bucket_name,
2058+
}
2059+
2060+
try:
2061+
_client.make_bucket(bucket_name)
2062+
2063+
# OLD API: Using 'metadata' parameter (must still work!)
2064+
old_metadata = HTTPHeaderDict({"X-Amz-Meta-Old-Key": "old-value"})
2065+
_client.fput_object(
2066+
bucket_name,
2067+
"test-old-metadata",
2068+
_test_file,
2069+
metadata=old_metadata # OLD parameter
2070+
)
2071+
2072+
# Verify it was stored (user metadata keys have x-amz-meta- prefix)
2073+
stat = _client.stat_object(bucket_name, "test-old-metadata")
2074+
# Find the key (case-insensitive, may have x-amz-meta- prefix)
2075+
found_key = None
2076+
for key in stat.metadata.keys():
2077+
if key.lower().endswith("old-key"):
2078+
found_key = key
2079+
break
2080+
if not found_key:
2081+
raise Exception(f"Old metadata parameter didn't work. Keys: {list(stat.metadata.keys())}")
2082+
if stat.metadata[found_key] != "old-value":
2083+
raise Exception(f"Old metadata value mismatch: {stat.metadata[found_key]}")
2084+
2085+
# NEW API: Using 'user_metadata' parameter (should also work)
2086+
new_metadata = HTTPHeaderDict({"X-Amz-Meta-New-Key": "new-value"})
2087+
_client.fput_object(
2088+
bucket_name,
2089+
"test-new-metadata",
2090+
_test_file,
2091+
user_metadata=new_metadata # NEW parameter
2092+
)
2093+
2094+
stat2 = _client.stat_object(bucket_name, "test-new-metadata")
2095+
# Find the key (case-insensitive, may have x-amz-meta- prefix)
2096+
found_key2 = None
2097+
for key in stat2.metadata.keys():
2098+
if key.lower().endswith("new-key"):
2099+
found_key2 = key
2100+
break
2101+
if not found_key2:
2102+
raise Exception(f"New user_metadata parameter didn't work. Keys: {list(stat2.metadata.keys())}")
2103+
if stat2.metadata[found_key2] != "new-value":
2104+
raise Exception(f"New metadata value mismatch: {stat2.metadata[found_key2]}")
2105+
2106+
finally:
2107+
for obj in _client.list_objects(bucket_name):
2108+
_client.remove_object(bucket_name, obj.object_name)
2109+
_client.remove_bucket(bucket_name)
2110+
2111+
2112+
def test_real_world_usage_patterns(log_entry):
2113+
"""
2114+
Test common usage patterns from real codebases.
2115+
These are patterns that would BREAK in master but MUST work in our fix.
2116+
"""
2117+
log_entry["name"] = "test_real_world_usage_patterns"
2118+
bucket_name = _gen_bucket_name()
2119+
2120+
log_entry["args"] = {
2121+
"bucket_name": bucket_name,
2122+
}
2123+
2124+
try:
2125+
# Pattern 1: Minimal arguments (very common)
2126+
_client.make_bucket(bucket_name)
2127+
2128+
# Pattern 2: Quick upload without keywords (BROKEN in master)
2129+
with open(_test_file, 'rb') as file_data:
2130+
_client.put_object(
2131+
bucket_name,
2132+
"test",
2133+
file_data,
2134+
os.path.getsize(_test_file)
2135+
)
2136+
2137+
# Pattern 3: Copy with minimal args (BROKEN in master)
2138+
_client.copy_object(bucket_name, "test-copy", CopySource(bucket_name, "test"))
2139+
2140+
# Pattern 4: List with prefix positionally (BROKEN in master)
2141+
list(_client.list_objects(bucket_name, "test"))
2142+
2143+
# Pattern 5: Mixed positional and keyword (should work)
2144+
_client.fput_object(bucket_name, "test2", _test_file, content_type="text/plain")
2145+
2146+
# Pattern 6: Stat with version (if supported)
2147+
_client.stat_object(bucket_name, "test")
2148+
2149+
# Pattern 7: Remove positionally (BROKEN in master)
2150+
_client.remove_object(bucket_name, "test")
2151+
2152+
finally:
2153+
for obj in _client.list_objects(bucket_name):
2154+
_client.remove_object(bucket_name, obj.object_name)
2155+
_client.remove_bucket(bucket_name)
2156+
2157+
2158+
def test_edge_cases_still_work(log_entry):
2159+
"""
2160+
Test edge cases that might break with signature changes.
2161+
"""
2162+
log_entry["name"] = "test_edge_cases_still_work"
2163+
bucket_name = _gen_bucket_name()
2164+
2165+
log_entry["args"] = {
2166+
"bucket_name": bucket_name,
2167+
}
2168+
2169+
try:
2170+
_client.make_bucket(bucket_name)
2171+
2172+
# Edge 1: Empty metadata (both old and new way)
2173+
_client.fput_object(bucket_name, "test1", _test_file, metadata=HTTPHeaderDict())
2174+
_client.fput_object(bucket_name, "test2", _test_file, user_metadata=HTTPHeaderDict())
2175+
2176+
# Edge 2: None metadata (explicit None)
2177+
_client.fput_object(bucket_name, "test3", _test_file, metadata=None)
2178+
2179+
# Edge 3: Large metadata
2180+
large_meta = HTTPHeaderDict({f"X-Amz-Meta-Key{i}": f"value{i}" for i in range(20)})
2181+
_client.fput_object(bucket_name, "test4", _test_file, metadata=large_meta)
2182+
2183+
# Edge 4: Special characters in metadata
2184+
special_meta = HTTPHeaderDict({"X-Amz-Meta-Special-Key": "value with spaces & symbols!"})
2185+
_client.fput_object(bucket_name, "test5", _test_file, metadata=special_meta)
2186+
2187+
# Edge 5: Zero-byte file
2188+
zero_file = tempfile.NamedTemporaryFile(delete=False)
2189+
zero_file.close()
2190+
try:
2191+
_client.fput_object(bucket_name, "test6", zero_file.name)
2192+
finally:
2193+
os.unlink(zero_file.name)
2194+
2195+
finally:
2196+
for obj in _client.list_objects(bucket_name):
2197+
_client.remove_object(bucket_name, obj.object_name)
2198+
_client.remove_bucket(bucket_name)
2199+
2200+
2201+
def test_no_regression_from_6daf366(log_entry):
2202+
"""
2203+
Specific regression tests for issues introduced in 6daf366.
2204+
"""
2205+
log_entry["name"] = "test_no_regression_from_6daf366"
2206+
bucket_name = _gen_bucket_name()
2207+
2208+
log_entry["args"] = {
2209+
"bucket_name": bucket_name,
2210+
}
2211+
2212+
# Create 6MB files for compose_object (minimum 5MB per source required)
2213+
part1_file = None
2214+
part2_file = None
2215+
try:
2216+
_client.make_bucket(bucket_name)
2217+
2218+
# Create temporary 6MB files for compose_object
2219+
part1_file = tempfile.NamedTemporaryFile(delete=False)
2220+
part1_file.write(b"a" * (6 * 1024 * 1024)) # 6MB
2221+
part1_file.close()
2222+
2223+
part2_file = tempfile.NamedTemporaryFile(delete=False)
2224+
part2_file.write(b"b" * (6 * 1024 * 1024)) # 6MB
2225+
part2_file.close()
2226+
2227+
# Regression 1: compose_object must accept metadata parameter
2228+
_client.fput_object(bucket_name, "part1", part1_file.name)
2229+
_client.fput_object(bucket_name, "part2", part2_file.name)
2230+
2231+
sources = [
2232+
ComposeSource(bucket_name, "part1"),
2233+
ComposeSource(bucket_name, "part2"),
2234+
]
2235+
2236+
# This FAILED in 6daf366 if using old 'metadata' parameter
2237+
metadata = HTTPHeaderDict({"X-Amz-Meta-Test": "value"})
2238+
_client.compose_object(
2239+
bucket_name,
2240+
"composed",
2241+
sources,
2242+
metadata=metadata # OLD parameter must work
2243+
)
2244+
2245+
stat = _client.stat_object(bucket_name, "composed")
2246+
# Find the key (case-insensitive, may have x-amz-meta- prefix)
2247+
found_test_key = None
2248+
for key in stat.metadata.keys():
2249+
if key.lower().endswith("test"):
2250+
found_test_key = key
2251+
break
2252+
if not found_test_key:
2253+
raise Exception(f"compose_object metadata parameter didn't work. Keys: {list(stat.metadata.keys())}")
2254+
if stat.metadata[found_test_key] != "value":
2255+
raise Exception(f"compose_object metadata value mismatch: {stat.metadata[found_test_key]}")
2256+
2257+
# Regression 2: copy_object with positional source
2258+
_client.copy_object(
2259+
bucket_name,
2260+
"copied",
2261+
CopySource(bucket_name, "part1") # Positional source
2262+
)
2263+
2264+
finally:
2265+
# Clean up temporary files
2266+
if part1_file:
2267+
try:
2268+
os.unlink(part1_file.name)
2269+
except:
2270+
pass
2271+
if part2_file:
2272+
try:
2273+
os.unlink(part2_file.name)
2274+
except:
2275+
pass
2276+
# Clean up bucket
2277+
for obj in _client.list_objects(bucket_name):
2278+
_client.remove_object(bucket_name, obj.object_name)
2279+
_client.remove_bucket(bucket_name)
2280+
2281+
20042282
def test_thread_safe(log_entry):
20052283
"""Test thread safety."""
20062284
bucket_name = _gen_bucket_name()
@@ -2522,6 +2800,11 @@ def main():
25222800
test_presigned_put_object_default_expiry: None,
25232801
test_presigned_put_object_expiry: None,
25242802
test_presigned_post_policy: None,
2803+
test_backward_compatibility_positional_args: None,
2804+
test_metadata_parameter_backward_compatibility: None,
2805+
test_real_world_usage_patterns: None,
2806+
test_edge_cases_still_work: None,
2807+
test_no_regression_from_6daf366: None,
25252808
test_thread_safe: None,
25262809
test_set_get_bucket_versioning: None,
25272810
test_get_bucket_policy: None,

0 commit comments

Comments
 (0)