@@ -46,59 +46,61 @@ def default_html_report_file_path() -> str:
46
46
class FixtureDownloader :
47
47
"""Handles downloading and extracting fixture archives."""
48
48
49
- def __init__ (self , url : str , base_directory : Path ): # noqa: D107
49
+ def __init__ (self , url : str , destination_folder : Path ): # noqa: D107
50
50
self .url = url
51
- self .base_directory = base_directory
51
+ self .destination_folder = destination_folder
52
52
self .parsed_url = urlparse (url )
53
53
self .archive_name = self .strip_archive_extension (Path (self .parsed_url .path ).name )
54
54
55
- @property
56
- def extract_to (self ) -> Path :
57
- """Path to the directory where the archive will be extracted."""
58
- if is_release_url (self .url ):
59
- version = Path (self .parsed_url .path ).parts [- 2 ]
60
- self .org_repo = self .extract_github_repo ()
61
- return self .base_directory / self .org_repo / version / self .archive_name
62
- return self .base_directory / "other" / self .archive_name
63
-
64
55
def download_and_extract (self ) -> Tuple [bool , Path ]:
65
56
"""Download the URL and extract it locally if it hasn't already been downloaded."""
66
- if self .extract_to .exists ():
57
+ if self .destination_folder .exists ():
67
58
return True , self .detect_extracted_directory ()
68
59
69
60
return False , self .fetch_and_extract ()
70
61
71
- def extract_github_repo (self ) -> str :
72
- """Extract <username>/<repo> from GitHub URLs, otherwise return 'other'."""
73
- parts = self .parsed_url .path .strip ("/" ).split ("/" )
74
- return (
75
- f"{ parts [0 ]} /{ parts [1 ]} "
76
- if self .parsed_url .netloc == "github.com" and len (parts ) >= 2
77
- else "other"
78
- )
79
-
80
62
@staticmethod
81
63
def strip_archive_extension (filename : str ) -> str :
82
64
"""Remove .tar.gz or .tgz extensions from filename."""
83
65
return filename .removesuffix (".tar.gz" ).removesuffix (".tgz" )
84
66
67
+ @staticmethod
68
+ def get_cache_path (url : str , cache_folder : Path ) -> Path :
69
+ """Get the appropriate cache path for a given URL."""
70
+ parsed_url = urlparse (url )
71
+ archive_name = FixtureDownloader .strip_archive_extension (Path (parsed_url .path ).name )
72
+
73
+ if is_release_url (url ):
74
+ version = Path (parsed_url .path ).parts [- 2 ]
75
+ parts = parsed_url .path .strip ("/" ).split ("/" )
76
+ org_repo = (
77
+ f"{ parts [0 ]} /{ parts [1 ]} "
78
+ if parsed_url .netloc == "github.com" and len (parts ) >= 2
79
+ else "other"
80
+ )
81
+ return cache_folder / org_repo / version / archive_name
82
+ return cache_folder / "other" / archive_name
83
+
85
84
def fetch_and_extract (self ) -> Path :
86
85
"""Download and extract an archive from the given URL."""
87
- self .extract_to .mkdir (parents = True , exist_ok = False )
86
+ self .destination_folder .mkdir (parents = True , exist_ok = True )
88
87
response = requests .get (self .url )
89
88
response .raise_for_status ()
90
89
91
90
with tarfile .open (fileobj = BytesIO (response .content ), mode = "r:gz" ) as tar :
92
- tar .extractall (path = self .extract_to , filter = "data" )
91
+ tar .extractall (path = self .destination_folder , filter = "data" )
93
92
94
93
return self .detect_extracted_directory ()
95
94
96
95
def detect_extracted_directory (self ) -> Path :
97
96
"""
98
- Detect a single top-level dir within the extracted archive, otherwise return extract_to.
97
+ Detect a single top-level dir within the extracted archive, otherwise return
98
+ destination_folder.
99
99
""" # noqa: D200
100
- extracted_dirs = [d for d in self .extract_to .iterdir () if d .is_dir () and d .name != ".meta" ]
101
- return extracted_dirs [0 ] if len (extracted_dirs ) == 1 else self .extract_to
100
+ extracted_dirs = [
101
+ d for d in self .destination_folder .iterdir () if d .is_dir () and d .name != ".meta"
102
+ ]
103
+ return extracted_dirs [0 ] if len (extracted_dirs ) == 1 else self .destination_folder
102
104
103
105
104
106
@dataclass
@@ -112,31 +114,45 @@ class FixturesSource:
112
114
is_local : bool = True
113
115
is_stdin : bool = False
114
116
was_cached : bool = False
117
+ extract_to_local_path : bool = False
115
118
116
119
@classmethod
117
120
def from_input (
118
- cls , input_source : str , cache_folder : Optional [Path ] = None
121
+ cls ,
122
+ input_source : str ,
123
+ cache_folder : Optional [Path ] = None ,
124
+ extract_to : Optional [Path ] = None ,
119
125
) -> "FixturesSource" :
120
126
"""Determine the fixture source type and return an instance."""
121
127
if cache_folder is None :
122
128
cache_folder = CACHED_DOWNLOADS_DIRECTORY
123
129
if input_source == "stdin" :
124
130
return cls (input_option = input_source , path = Path (), is_local = False , is_stdin = True )
125
131
if is_release_url (input_source ):
126
- return cls .from_release_url (input_source , cache_folder )
132
+ return cls .from_release_url (input_source , cache_folder , extract_to )
127
133
if is_url (input_source ):
128
- return cls .from_url (input_source , cache_folder )
134
+ return cls .from_url (input_source , cache_folder , extract_to )
129
135
if ReleaseTag .is_release_string (input_source ):
130
- return cls .from_release_spec (input_source , cache_folder )
136
+ return cls .from_release_spec (input_source , cache_folder , extract_to )
131
137
return cls .validate_local_path (Path (input_source ))
132
138
133
139
@classmethod
134
- def from_release_url (cls , url : str , cache_folder : Optional [Path ] = None ) -> "FixturesSource" :
140
+ def from_release_url (
141
+ cls , url : str , cache_folder : Optional [Path ] = None , extract_to : Optional [Path ] = None
142
+ ) -> "FixturesSource" :
135
143
"""Create a fixture source from a supported github repo release URL."""
136
144
if cache_folder is None :
137
145
cache_folder = CACHED_DOWNLOADS_DIRECTORY
138
- downloader = FixtureDownloader (url , cache_folder )
139
- was_cached , path = downloader .download_and_extract ()
146
+
147
+ destination_folder = extract_to or FixtureDownloader .get_cache_path (url , cache_folder )
148
+ downloader = FixtureDownloader (url , destination_folder )
149
+
150
+ # Skip cache check for extract_to (always download fresh)
151
+ if extract_to is not None :
152
+ was_cached = False
153
+ path = downloader .fetch_and_extract ()
154
+ else :
155
+ was_cached , path = downloader .download_and_extract ()
140
156
141
157
return cls (
142
158
input_option = url ,
@@ -145,40 +161,65 @@ def from_release_url(cls, url: str, cache_folder: Optional[Path] = None) -> "Fix
145
161
release_page = "" ,
146
162
is_local = False ,
147
163
was_cached = was_cached ,
164
+ extract_to_local_path = extract_to is not None ,
148
165
)
149
166
150
167
@classmethod
151
- def from_url (cls , url : str , cache_folder : Optional [Path ] = None ) -> "FixturesSource" :
168
+ def from_url (
169
+ cls , url : str , cache_folder : Optional [Path ] = None , extract_to : Optional [Path ] = None
170
+ ) -> "FixturesSource" :
152
171
"""Create a fixture source from a direct URL."""
153
172
if cache_folder is None :
154
173
cache_folder = CACHED_DOWNLOADS_DIRECTORY
155
- downloader = FixtureDownloader (url , cache_folder )
156
- was_cached , path = downloader .download_and_extract ()
174
+
175
+ destination_folder = extract_to or FixtureDownloader .get_cache_path (url , cache_folder )
176
+ downloader = FixtureDownloader (url , destination_folder )
177
+
178
+ # Skip cache check for extract_to (always download fresh)
179
+ if extract_to is not None :
180
+ was_cached = False
181
+ path = downloader .fetch_and_extract ()
182
+ else :
183
+ was_cached , path = downloader .download_and_extract ()
184
+
157
185
return cls (
158
186
input_option = url ,
159
187
path = path ,
160
188
url = url ,
161
189
release_page = "" ,
162
190
is_local = False ,
163
191
was_cached = was_cached ,
192
+ extract_to_local_path = extract_to is not None ,
164
193
)
165
194
166
195
@classmethod
167
- def from_release_spec (cls , spec : str , cache_folder : Optional [Path ] = None ) -> "FixturesSource" :
196
+ def from_release_spec (
197
+ cls , spec : str , cache_folder : Optional [Path ] = None , extract_to : Optional [Path ] = None
198
+ ) -> "FixturesSource" :
168
199
"""Create a fixture source from a release spec (e.g., develop@latest)."""
169
200
if cache_folder is None :
170
201
cache_folder = CACHED_DOWNLOADS_DIRECTORY
171
202
url = get_release_url (spec )
172
203
release_page = get_release_page_url (url )
173
- downloader = FixtureDownloader (url , cache_folder )
174
- was_cached , path = downloader .download_and_extract ()
204
+
205
+ destination_folder = extract_to or FixtureDownloader .get_cache_path (url , cache_folder )
206
+ downloader = FixtureDownloader (url , destination_folder )
207
+
208
+ # Skip cache check for extract_to (always download fresh)
209
+ if extract_to is not None :
210
+ was_cached = False
211
+ path = downloader .fetch_and_extract ()
212
+ else :
213
+ was_cached , path = downloader .download_and_extract ()
214
+
175
215
return cls (
176
216
input_option = spec ,
177
217
path = path ,
178
218
url = url ,
179
219
release_page = release_page ,
180
220
is_local = False ,
181
221
was_cached = was_cached ,
222
+ extract_to_local_path = extract_to is not None ,
182
223
)
183
224
184
225
@staticmethod
@@ -268,6 +309,17 @@ def pytest_addoption(parser): # noqa: D103
268
309
f"Defaults to the following directory: '{ CACHED_DOWNLOADS_DIRECTORY } '."
269
310
),
270
311
)
312
+ consume_group .addoption (
313
+ "--extract-to" ,
314
+ action = "store" ,
315
+ dest = "extract_to_folder" ,
316
+ default = None ,
317
+ help = (
318
+ "Extract downloaded fixtures to the specified directory. Only valid with 'cache' "
319
+ "command. When used, fixtures are extracted directly to this path instead of the "
320
+ "user's execution-spec-tests cache directory."
321
+ ),
322
+ )
271
323
if "cache" in sys .argv :
272
324
return
273
325
consume_group .addoption (
@@ -308,6 +360,10 @@ def pytest_configure(config): # noqa: D103
308
360
called before the pytest-html plugin's pytest_configure to ensure that
309
361
it uses the modified `htmlpath` option.
310
362
"""
363
+ # Validate --extract-to usage
364
+ if config .option .extract_to_folder is not None and "cache" not in sys .argv :
365
+ pytest .exit ("The --extract-to flag is only valid with the 'cache' command." )
366
+
311
367
if config .option .fixtures_source is None :
312
368
# NOTE: Setting the default value here is necessary for correct stdin/piping behavior.
313
369
config .fixtures_source = FixturesSource (
@@ -318,7 +374,11 @@ def pytest_configure(config): # noqa: D103
318
374
# be evaluated twice which breaks the result of `was_cached`; the work-around is to call it
319
375
# manually here.
320
376
config .fixtures_source = FixturesSource .from_input (
321
- config .option .fixtures_source , Path (config .option .fixture_cache_folder )
377
+ config .option .fixtures_source ,
378
+ Path (config .option .fixture_cache_folder ),
379
+ Path (config .option .extract_to_folder )
380
+ if config .option .extract_to_folder is not None
381
+ else None ,
322
382
)
323
383
config .fixture_source_flags = ["--input" , config .fixtures_source .input_option ]
324
384
@@ -327,7 +387,9 @@ def pytest_configure(config): # noqa: D103
327
387
328
388
if "cache" in sys .argv :
329
389
reason = ""
330
- if config .fixtures_source .was_cached :
390
+ if config .fixtures_source .extract_to_local_path :
391
+ reason += "Fixtures downloaded and extracted to specified directory."
392
+ elif config .fixtures_source .was_cached :
331
393
reason += "Fixtures already cached."
332
394
elif not config .fixtures_source .is_local :
333
395
reason += "Fixtures downloaded and cached."
0 commit comments