broadfield-dev commited on
Commit
b80b022
·
verified ·
1 Parent(s): 2b87e9c

Create build_logic.py

Browse files
Files changed (1) hide show
  1. build_logic.py +593 -0
build_logic.py ADDED
@@ -0,0 +1,593 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import tempfile
4
+ import shutil
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ from huggingface_hub import (
9
+ create_repo,
10
+ upload_folder,
11
+ list_repo_files,
12
+ whoami,
13
+ hf_hub_download,
14
+ delete_file as hf_delete_file,
15
+ HfApi
16
+ )
17
+ from huggingface_hub.hf_api import CommitOperationDelete
18
+ # Import the general HTTP error from huggingface_hub.utils
19
+ from huggingface_hub.utils import HfHubHTTPError # For catching specific HF HTTP errors
20
+
21
+ # Setup basic logging
22
+ logging.basicConfig(
23
+ level=logging.INFO,
24
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
25
+ )
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # --- Helper Function to Get API Token ---
29
+ def _get_api_token(ui_token_from_textbox=None):
30
+ env_token = os.getenv('HF_TOKEN')
31
+ if env_token: return env_token, None
32
+ if ui_token_from_textbox: return ui_token_from_textbox, None
33
+ return None, "Error: Hugging Face API token not provided in UI or HF_TOKEN env var."
34
+
35
+ # --- Helper Function to Determine Repo ID ---
36
+ def _determine_repo_id(ui_api_token_from_textbox, space_name_ui, owner_ui):
37
+ if not space_name_ui: return None, "Error: Space Name cannot be empty."
38
+ if "/" in space_name_ui: return None, "Error: Space Name should not contain '/'. Use Owner field for the owner part."
39
+
40
+ final_owner = owner_ui
41
+ error_message = None
42
+
43
+ if not final_owner:
44
+ resolved_api_token, token_err = _get_api_token(ui_api_token_from_textbox)
45
+ if token_err: return None, token_err
46
+ if not resolved_api_token: return None, "Error: API token required for auto owner determination if Owner field is empty."
47
+ try:
48
+ user_info = whoami(token=resolved_api_token)
49
+ if user_info and 'name' in user_info:
50
+ final_owner = user_info['name']
51
+ else:
52
+ error_message = "Error: Could not retrieve username from token. Check token permissions or specify Owner."
53
+ except Exception as e:
54
+ error_message = f"Error retrieving username from token: {str(e)}. Specify Owner or check token."
55
+ if error_message: return None, error_message
56
+
57
+ if not final_owner: return None, "Error: Owner could not be determined. Please specify it in the Owner field."
58
+ return f"{final_owner}/{space_name_ui}", None
59
+
60
+
61
+ # --- Corrected Markdown Parsing ---
62
+ def parse_markdown(markdown_input):
63
+ space_info = {"repo_name_md": "", "owner_md": "", "files": []}
64
+ current_file_path = None
65
+ current_file_content_lines = []
66
+ in_file_definition = False
67
+ in_code_block = False
68
+
69
+ lines = markdown_input.strip().split("\n")
70
+
71
+ # Clean up potential leading '#' added by Gradio's Markdown sometimes
72
+ cleaned_lines = []
73
+ for line_content_orig in lines:
74
+ if line_content_orig.strip().startswith("# "):
75
+ # Only strip leading # if it looks like a Markdown heading related to our format
76
+ if line_content_orig.strip().startswith("# ### File:") or \
77
+ line_content_orig.strip().startswith("# ## File Structure") or \
78
+ line_content_orig.strip().startswith("# # Space:"):
79
+ cleaned_lines.append(line_content_orig.strip()[2:])
80
+ else:
81
+ cleaned_lines.append(line_content_orig)
82
+ else:
83
+ cleaned_lines.append(line_content_orig)
84
+
85
+ lines = cleaned_lines
86
+
87
+
88
+ for line_content_orig in lines:
89
+ line_content_stripped = line_content_orig.strip()
90
+
91
+ if line_content_stripped.startswith("### File:"):
92
+ # Before processing a new file, save the content of the previous one
93
+ if current_file_path is not None and in_file_definition: # Check if we were inside a file definition
94
+ space_info["files"].append({"path": current_file_path, "content": "\n".join(current_file_content_lines).strip()})
95
+
96
+ current_file_path = line_content_stripped.replace("### File:", "").strip()
97
+ # Clean up potential trailing descriptions like "(main application)"
98
+ current_file_path = re.split(r'\s*\(', current_file_path, 1)[0].strip()
99
+ # Clean up potential backticks around the filename
100
+ current_file_path = current_file_path.strip('`')
101
+
102
+
103
+ current_file_content_lines = []
104
+ in_file_definition = True
105
+ in_code_block = False # Reset code block flag for the new file
106
+ continue
107
+
108
+ # If we are not currently inside a file definition block (i.e., before the first "### File:")
109
+ if not in_file_definition:
110
+ if line_content_stripped.startswith("# Space:"):
111
+ full_space_name_md = line_content_stripped.replace("# Space:", "").strip()
112
+ if "/" in full_space_name_md:
113
+ parts = full_space_name_md.split("/", 1)
114
+ if len(parts) == 2:
115
+ space_info["owner_md"], space_info["repo_name_md"] = parts[0].strip(), parts[1].strip()
116
+ else:
117
+ space_info["repo_name_md"] = full_space_name_md # Handle case like "user/repo/"
118
+ else:
119
+ space_info["repo_name_md"] = full_space_name_md
120
+ # Ignore other lines outside a file block for now (like "## File Structure" preamble)
121
+ continue
122
+
123
+ # If we are inside a file definition block
124
+ if in_file_definition:
125
+ if line_content_stripped.startswith("```"):
126
+ # Toggle code block status
127
+ in_code_block = not in_code_block
128
+ # If exiting a code block, the next lines are not part of the code
129
+ if not in_code_block:
130
+ # We consume the ``` line itself, don't add it to content
131
+ pass
132
+ else:
133
+ # If entering a code block, we consume the ```lang line itself
134
+ pass
135
+ continue # Do not add the ``` line to content
136
+
137
+ # If inside a code block, add the line as-is (original content, including leading/trailing whitespace)
138
+ if in_code_block:
139
+ current_file_content_lines.append(line_content_orig)
140
+ # If not inside a code block, check for binary file marker
141
+ elif line_content_stripped.startswith("[Binary file") or line_content_stripped.startswith("[Error loading content:") or line_content_stripped.startswith("[Binary or Skipped file]"):
142
+ # Handle binary file markers or error messages as content if not in code block
143
+ current_file_content_lines.append(line_content_orig)
144
+ # Any other lines outside code blocks within a file definition are ignored (e.g., descriptions, blank lines)
145
+ # This assumes all code/content *must* be within ``` blocks or be a specific marker line.
146
+
147
+
148
+ # After the loop, save the content of the last file
149
+ if current_file_path is not None and in_file_definition:
150
+ space_info["files"].append({"path": current_file_path, "content": "\n".join(current_file_content_lines).strip()})
151
+
152
+ # Ensure all file paths are valid and clean up empty files if necessary (based on content parsing)
153
+ # The parsing logic above should handle stripping content, but this is a final check
154
+ space_info["files"] = [f for f in space_info["files"] if f.get("path")] # Ensure path exists
155
+ # Optional: Filter out files where content became empty after strip() if that's desired behavior.
156
+ # Currently, it keeps files with empty content, which is fine for creating empty files.
157
+
158
+ # Clean up owner/repo names from potential whitespace
159
+ space_info["owner_md"] = space_info["owner_md"].strip()
160
+ space_info["repo_name_md"] = space_info["repo_name_md"].strip()
161
+
162
+
163
+ return space_info
164
+
165
+
166
+ # --- Function to Get Space SDK and Files ---
167
+ def get_space_repository_info(ui_api_token_from_textbox, space_name_ui, owner_ui):
168
+ repo_id_for_error_logging = f"{owner_ui}/{space_name_ui}" if owner_ui else space_name_ui
169
+ sdk = None
170
+ files = []
171
+ error = None
172
+ repo_id = None # Define repo_id here to ensure it's available for error logging after _determine_repo_id
173
+
174
+ try:
175
+ resolved_api_token, token_err = _get_api_token(ui_api_token_from_textbox)
176
+ if token_err: return None, None, token_err
177
+
178
+ repo_id, err_repo_id = _determine_repo_id(ui_api_token_from_textbox, space_name_ui, owner_ui)
179
+ if err_repo_id: return None, None, err_repo_id
180
+ repo_id_for_error_logging = repo_id # Update logging name
181
+
182
+ api = HfApi(token=resolved_api_token)
183
+ # Use repo_info endpoint as it's more robust and gives SDK
184
+ repo_info_obj = api.repo_info(repo_id=repo_id, repo_type="space", timeout=10) # Added timeout
185
+ sdk = repo_info_obj.sdk
186
+ files = [sibling.rfilename for sibling in repo_info_obj.siblings if sibling.rfilename]
187
+
188
+ if not files and repo_info_obj.siblings:
189
+ logger.warning(f"Repo {repo_id} has siblings but no rfilenames extracted.")
190
+
191
+ except HfHubHTTPError as e_http: # Catch specific HF HTTP errors first
192
+ logger.error(f"HTTP error getting repo info for {repo_id_for_error_logging or 'unknown repo'}: {e_http}")
193
+ error_message = str(e_http)
194
+ status_code = e_http.response.status_code if e_http.response is not None else None
195
+
196
+ if status_code == 404:
197
+ error = f"Space '{repo_id_for_error_logging or 'unknown repo'}' not found (404)."
198
+ elif status_code in (401,403):
199
+ error = f"Access denied for '{repo_id_for_error_logging or 'unknown repo'}' ({status_code}). Check token permissions."
200
+ else:
201
+ error = f"HTTP Error {status_code or 'unknown'} for '{repo_id_for_error_logging or 'unknown repo'}': {error_message}"
202
+
203
+ except Exception as e: # Catch other general exceptions
204
+ # If repo_info failed, try listing files as a fallback
205
+ logger.warning(f"Could not get full repo_info for {repo_id_for_error_logging or 'unknown repo'}, attempting list_repo_files fallback: {e}")
206
+ error = f"Error retrieving Space info for `{repo_id_for_error_logging or 'unknown repo'}`: {str(e)}. Attempting file list fallback." # Set a warning message
207
+
208
+ try:
209
+ # Re-determine repo_id and get token for fallback
210
+ resolved_api_token_fb, token_err_fb = _get_api_token(ui_api_token_from_textbox)
211
+ if token_err_fb: return None, None, f"{error}\nAPI Token Error during fallback: {token_err_fb}" # Propagate token error
212
+ repo_id_fb, err_repo_id_fb = _determine_repo_id(ui_api_token_from_textbox, space_name_ui, owner_ui)
213
+ if err_repo_id_fb: return None, None, f"{error}\nRepo ID Error during fallback: {err_repo_id_fb}" # Propagate repo ID error
214
+
215
+ # Attempt to list files
216
+ files = list_repo_files(repo_id=repo_id_fb, token=resolved_api_token_fb, repo_type="space", timeout=10) # Added timeout
217
+ # If fallback is successful, update error message to a warning about repo_info
218
+ error = f"Warning: Could not fetch full Space info (SDK etc.) for `{repo_id_for_error_logging or 'unknown repo'}`: {str(e)}. File list loaded via fallback."
219
+
220
+ except HfHubHTTPError as e2_http:
221
+ logger.error(f"HTTP error during fallback list_repo_files for {repo_id_for_error_logging or 'unknown repo'}: {e2_http}")
222
+ error_message_fb = str(e2_http)
223
+ status_code_fb = e2_http.response.status_code if e2_http.response is not None else None
224
+ if status_code_fb == 404:
225
+ error = f"Space '{repo_id_for_error_logging or 'unknown repo'}' not found during fallback (404)."
226
+ else:
227
+ error = f"HTTP Error {status_code_fb or 'unknown'} for '{repo_id_for_error_logging or 'unknown repo'}' during fallback: {error_message_fb}"
228
+ files = [] # Ensure files list is empty on fallback error
229
+
230
+ except Exception as e2:
231
+ logger.exception(f"Error listing files for {repo_id_for_error_logging or 'unknown repo'} during fallback: {e2}")
232
+ error = f"{error}\nError listing files during fallback for `{repo_id_for_error_logging or 'unknown repo'}`: {str(e2)}"
233
+ files = [] # Ensure files list is empty on fallback error
234
+
235
+
236
+ # Final check: if files are still empty and there's no specific error, provide a generic "no files" message
237
+ if not files and not error:
238
+ error = f"No files found in Space `{repo_id_for_error_logging or 'unknown repo'}` (or an issue fetching them)."
239
+
240
+ return sdk, files, error
241
+
242
+
243
+ # --- Function to list files ---
244
+ def list_space_files_for_browsing(ui_api_token_from_textbox, space_name_ui, owner_ui):
245
+ _sdk, files, err = get_space_repository_info(ui_api_token_from_textbox, space_name_ui, owner_ui)
246
+ return files, err
247
+
248
+
249
+ # --- Function to Fetch File Content from Hub ---
250
+ def get_space_file_content(ui_api_token_from_textbox, space_name_ui, owner_ui, file_path_in_repo):
251
+ repo_id_for_error_logging = f"{owner_ui}/{space_name_ui}" if owner_ui else space_name_ui
252
+ repo_id = None
253
+ try:
254
+ resolved_api_token, token_err = _get_api_token(ui_api_token_from_textbox)
255
+ if token_err: return None, token_err
256
+ repo_id, err_repo_id = _determine_repo_id(ui_api_token_from_textbox, space_name_ui, owner_ui)
257
+ if err_repo_id: return None, err_repo_id
258
+ repo_id_for_error_logging = repo_id
259
+ if not file_path_in_repo: return None, "Error: File path cannot be empty."
260
+ # Ensure file_path_in_repo uses forward slashes
261
+ file_path_in_repo = file_path_in_repo.replace("\\", "/")
262
+
263
+ # Use hf_hub_download first, which caches locally
264
+ downloaded_file_path = hf_hub_download(
265
+ repo_id=repo_id,
266
+ filename=file_path_in_repo,
267
+ repo_type="space",
268
+ token=resolved_api_token,
269
+ local_dir_use_symlinks=False, # Avoid symlinks issues
270
+ cache_dir=None # Use default cache dir
271
+ )
272
+ content = Path(downloaded_file_path).read_text(encoding="utf-8")
273
+ return content, None
274
+ except FileNotFoundError:
275
+ return None, f"Error: File '{file_path_in_repo}' not found locally after download attempt."
276
+ except UnicodeDecodeError:
277
+ # If read_text fails, it's likely binary or non-utf8 text
278
+ return None, f"Error: File '{file_path_in_repo}' is not valid UTF-8 text. Cannot display."
279
+ except HfHubHTTPError as e_http:
280
+ logger.error(f"HTTP error fetching file {file_path_in_repo} from {repo_id_for_error_logging or 'unknown repo'}: {e_http}")
281
+ error_message = str(e_http)
282
+ status_code = e_http.response.status_code if e_http.response is not None else None
283
+ if status_code == 404:
284
+ return None, f"Error: File '{file_path_in_repo}' not found in Space '{repo_id_for_error_logging or 'unknown repo'}' (404)."
285
+ if status_code in (401, 403):
286
+ return None, f"Error: Access denied or authentication required for '{repo_id_for_error_logging or 'unknown repo'}' ({status_code}). Check token permissions."
287
+ return None, f"HTTP Error {status_code or 'unknown'} fetching file '{file_path_in_repo}': {error_message}"
288
+ except Exception as e:
289
+ logger.exception(f"Error fetching file content for {file_path_in_repo} from {repo_id_for_error_logging or 'unknown repo'}:")
290
+ return None, f"Error fetching file content: {str(e)}"
291
+
292
+ # --- Create/Update Space ---
293
+ def create_space(ui_api_token_from_textbox, space_name_ui, owner_ui, sdk_ui, markdown_input, private):
294
+ repo_id_for_error_logging = f"{owner_ui}/{space_name_ui}" if owner_ui else space_name_ui
295
+ repo_id = None
296
+ try:
297
+ resolved_api_token, token_err = _get_api_token(ui_api_token_from_textbox)
298
+ if token_err: return token_err
299
+ repo_id, err_repo_id = _determine_repo_id(ui_api_token_from_textbox, space_name_ui, owner_ui)
300
+ if err_repo_id: return err_repo_id
301
+ repo_id_for_error_logging = repo_id # Update logging name
302
+
303
+ space_info = parse_markdown(markdown_input)
304
+
305
+ with tempfile.TemporaryDirectory() as temp_dir:
306
+ repo_staging_path = Path(temp_dir) / "repo_staging_content"
307
+ repo_staging_path.mkdir(exist_ok=True)
308
+
309
+ # Always write .gitattributes to ensure LF line endings
310
+ gitattributes_path = repo_staging_path / ".gitattributes"
311
+ with open(gitattributes_path, "w") as f:
312
+ f.write("* text=auto eol=lf\n")
313
+
314
+ # If there are no files parsed from markdown *other than* the structure block,
315
+ # ensure the .gitattributes file is still staged.
316
+ if not [f for f in space_info["files"] if not f.get("is_structure_block")]:
317
+ logger.info(f"Markdown contained no standard files. Staging only .gitattributes for {repo_id}.")
318
+
319
+
320
+ for file_info in space_info["files"]:
321
+ if not file_info.get("path") or file_info.get("is_structure_block"):
322
+ # Skip entries without a path or the structure block representation
323
+ if not file_info.get("path"): logger.warning(f"Skipping file_info with no path: {file_info}")
324
+ continue
325
+
326
+ # Skip files that were marked as binary/error during loading
327
+ content_to_write = file_info.get("content", "")
328
+ if content_to_write.startswith("[Binary file") or content_to_write.startswith("[Error loading content:") or content_to_write.startswith("[Binary or Skipped file]"):
329
+ logger.info(f"Skipping binary/error placeholder file from build: {file_info['path']}")
330
+ continue
331
+
332
+
333
+ file_path_abs = repo_staging_path / file_info["path"]
334
+ file_path_abs.parent.mkdir(parents=True, exist_ok=True) # Create parent directories
335
+ try:
336
+ # Ensure content is treated as text and written with utf-8 encoding
337
+ with open(file_path_abs, "w", encoding="utf-8") as f:
338
+ f.write(content_to_write)
339
+ except Exception as file_write_error:
340
+ logger.error(f"Error writing file {file_info['path']} during staging: {file_write_error}")
341
+ return f"Error staging file {file_info['path']}: {file_write_error}"
342
+
343
+
344
+ # Create or ensure repo exists
345
+ create_repo(repo_id=repo_id, token=resolved_api_token, repo_type="space", space_sdk=sdk_ui, private=private, exist_ok=True)
346
+
347
+ api = HfApi(token=resolved_api_token)
348
+
349
+ # Determine files to delete (files on Hub not in markdown)
350
+ try:
351
+ current_hub_files_info = api.list_repo_files(repo_id=repo_id, repo_type="space", recursive=True)
352
+ current_hub_files = set(current_hub_files_info)
353
+ # Get filenames from the markdown that were actually staged (not skipped binaries/structure)
354
+ markdown_staged_filenames = set(str(Path(temp_dir) / "repo_staging_content" / f.get("path")).relative_to(repo_staging_path) for f in space_info["files"] if f.get("path") and not f.get("is_structure_block") and not (f.get("content", "").startswith("[Binary file") or f.get("content", "").startswith("[Error loading content:") or f.get("content", "").startswith("[Binary or Skipped file]")))
355
+ markdown_staged_filenames.add(".gitattributes") # Always keep .gitattributes if we staged it
356
+
357
+ files_to_delete_on_hub = list(current_hub_files - markdown_staged_filenames)
358
+
359
+ # Exclude .git/ files and potentially README.md if we didn't explicitly include it in markdown
360
+ files_to_delete_on_hub = [f for f in files_to_delete_on_hub if not (f.startswith('.git') or (f == "README.md" and "README.md" not in markdown_staged_filenames))]
361
+
362
+
363
+ if files_to_delete_on_hub:
364
+ logger.info(f"Deleting {len(files_to_delete_on_hub)} files from {repo_id} not in new markdown structure: {files_to_delete_on_hub}")
365
+ delete_operations = [CommitOperationDelete(path_in_repo=f) for f in files_to_delete_on_hub]
366
+ if delete_operations:
367
+ # Check if there are also files to upload in this commit
368
+ if list(repo_staging_path.iterdir()): # Check if staging dir has anything to upload
369
+ # Combine delete and upload if possible (advanced scenario, requires specific hf_api methods)
370
+ # For simplicity here, do deletes in a separate commit before upload_folder
371
+ try:
372
+ api.create_commit(
373
+ repo_id=repo_id,
374
+ repo_type="space",
375
+ operations=delete_operations,
376
+ commit_message=f"AI Space Builder: Removed {len(files_to_delete_on_hub)} files not in updated structure."
377
+ )
378
+ logger.info("Successfully committed deletions.")
379
+ except Exception as e_delete_commit:
380
+ logger.error(f"Error committing deletions in {repo_id}: {e_delete_commit}. Proceeding with upload.")
381
+ # If delete commit fails, maybe upload_folder can handle concurrent ops?
382
+ # Or perhaps the files will be overwritten anyway if present in staging?
383
+ # It's safest to report the delete error but attempt upload.
384
+ else:
385
+ # If only deletions are happening (staging is empty except maybe .gitattributes)
386
+ try:
387
+ api.create_commit(
388
+ repo_id=repo_id,
389
+ repo_type="space",
390
+ operations=delete_operations,
391
+ commit_message=f"AI Space Builder: Removed {len(files_to_delete_on_hub)} files."
392
+ )
393
+ logger.info("Successfully committed deletions (only deletions).")
394
+ # If only deleting, we are done.
395
+ return f"Successfully updated Space: [{repo_id}](https://huggingface.co/spaces/{repo_id}) (Files deleted)."
396
+ except Exception as e_only_delete_commit:
397
+ logger.error(f"Error committing deletions (only deletions) in {repo_id}: {e_only_delete_commit}.")
398
+ return f"Error during Space update (deletions only): {str(e_only_delete_commit)}"
399
+
400
+
401
+ except Exception as e_delete_old_prep:
402
+ logger.error(f"Error during preparation for deletion of old files in {repo_id}: {e_delete_old_prep}. Proceeding with upload.")
403
+ # Don't return here, allow the upload to happen.
404
+
405
+
406
+ # Upload the staged files (including .gitattributes and any new/updated files)
407
+ logger.info(f"Uploading staged files from {str(repo_staging_path)} to {repo_id}")
408
+ # Use upload_folder which handles creating/updating files based on the staging directory content
409
+ upload_folder(
410
+ repo_id=repo_id,
411
+ folder_path=str(repo_staging_path),
412
+ path_in_repo=".", # Upload to the root of the repository
413
+ token=resolved_api_token,
414
+ repo_type="space",
415
+ commit_message=f"AI Space Builder: Space content update for {repo_id}"
416
+ )
417
+
418
+ return f"Successfully created/updated Space: [{repo_id}](https://huggingface.co/spaces/{repo_id})"
419
+
420
+ except HfHubHTTPError as e_http:
421
+ logger.error(f"HTTP error during create_space for {repo_id_for_error_logging or 'unknown repo'}: {e_http}")
422
+ error_message = str(e_http)
423
+ status_code = e_http.response.status_code if e_http.response is not None else None
424
+ if status_code == 409: # Conflict, often means repo exists but maybe wrong type/owner?
425
+ return f"Error creating/updating Space '{repo_id_for_error_logging or 'unknown repo'}: Conflict (Space might exist with different owner/settings)."
426
+ if status_code in (401, 403):
427
+ return f"Error creating/updating Space '{repo_id_for_error_logging or 'unknown repo'}': Access denied or authentication required ({status_code}). Check token permissions."
428
+ return f"HTTP Error {status_code or 'unknown'} during Space creation/update: {error_message}"
429
+ except Exception as e:
430
+ logger.exception(f"Error in create_space for {repo_id_for_error_logging or 'unknown repo'}:")
431
+ return f"Error during Space creation/update: {str(e)}"
432
+
433
+ # --- Update Single File ---
434
+ def update_space_file(ui_api_token_from_textbox, space_name_ui, owner_ui, file_path_in_repo, file_content, commit_message_ui):
435
+ repo_id_for_error_logging = f"{owner_ui}/{space_name_ui}" if owner_ui else space_name_ui
436
+ repo_id = None
437
+ try:
438
+ resolved_api_token, token_err = _get_api_token(ui_api_token_from_textbox)
439
+ if token_err: return token_err
440
+ repo_id, err_repo_id = _determine_repo_id(ui_api_token_from_textbox, space_name_ui, owner_ui)
441
+ if err_repo_id: return err_repo_id
442
+ repo_id_for_error_logging = repo_id # Update logging name
443
+
444
+ if not file_path_in_repo: return "Error: File Path to update cannot be empty."
445
+ file_path_in_repo = file_path_in_repo.lstrip('/').replace(os.sep, '/') # Clean path for Hub
446
+ commit_msg = commit_message_ui or f"Update {file_path_in_repo} via AI Space Editor"
447
+
448
+ api = HfApi(token=resolved_api_token)
449
+
450
+ # Use a temporary file to upload content safely
451
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as tmp_file_obj:
452
+ tmp_file_obj.write(file_content)
453
+ tmp_file_path = tmp_file_obj.name
454
+
455
+ try:
456
+ # Upload the temporary file to the specified path in the repo
457
+ api.upload_file(
458
+ path_or_fileobj=tmp_file_path,
459
+ path_in_repo=file_path_in_repo,
460
+ repo_id=repo_id,
461
+ repo_type="space",
462
+ commit_message=commit_msg
463
+ )
464
+ return f"Successfully updated `{file_path_in_repo}` in Space [{repo_id}](https://huggingface.co/spaces/{repo_id})"
465
+ finally:
466
+ # Ensure the temporary file is removed
467
+ if os.path.exists(tmp_file_path):
468
+ os.remove(tmp_file_path)
469
+
470
+ except FileNotFoundError:
471
+ return f"Error: Local temporary file not found during upload for '{file_path_in_repo}'."
472
+ except UnicodeDecodeError:
473
+ # If read_text fails, it's likely binary or non-utf8 text
474
+ return f"Error: File '{file_path_in_repo}' is not valid UTF-8 text. Cannot display or edit."
475
+ except HfHubHTTPError as e_http:
476
+ logger.error(f"HTTP error in update_space_file for {repo_id_for_error_logging or 'unknown repo'}, file {file_path_in_repo}: {e_http}")
477
+ error_message = str(e_http)
478
+ status_code = e_http.response.status_code if e_http.response is not None else None
479
+ if status_code == 404:
480
+ return f"Error: Space '{repo_id_for_error_logging or 'unknown repo'}' or file '{file_path_in_repo}' not found (404)."
481
+ if status_code in (401, 403):
482
+ return f"Error: Access denied or authentication required for '{repo_id_for_error_logging or 'unknown repo'}' ({status_code}). Check token permissions."
483
+ return f"HTTP Error {status_code or 'unknown'} updating file '{file_path_in_repo}': {error_message}"
484
+ except Exception as e:
485
+ logger.exception(f"Error in update_space_file for {repo_id_for_error_logging or 'unknown repo'}, file {file_path_in_repo}:")
486
+ return f"Error updating file for `{repo_id_for_error_logging or 'unknown repo'}`: {str(e)}"
487
+
488
+
489
+ # --- Delete Single File ---
490
+ def delete_space_file(ui_api_token_from_textbox, space_name_ui, owner_ui, file_path_in_repo, commit_message_ui=None):
491
+ repo_id_for_error_logging = f"{owner_ui}/{space_name_ui}" if owner_ui else space_name_ui
492
+ repo_id = None
493
+ try:
494
+ resolved_api_token, token_err = _get_api_token(ui_api_token_from_textbox)
495
+ if token_err: return f"API Token Error: {token_err}"
496
+ repo_id, err_repo_id = _determine_repo_id(ui_api_token_from_textbox, space_name_ui, owner_ui)
497
+ if err_repo_id: return f"Repo ID Error: {err_repo_id}"
498
+ repo_id_for_error_logging = repo_id # Update logging name
499
+
500
+ if not file_path_in_repo: return "Error: File path cannot be empty for deletion."
501
+ file_path_in_repo = file_path_in_repo.lstrip('/').replace(os.sep, '/') # Clean path for Hub
502
+
503
+ # Prevent deleting essential files like .gitattributes or README.md unless explicitly handled?
504
+ # For now, allow deleting anything selected in the dropdown.
505
+
506
+ effective_commit_message = commit_message_ui or f"Deleted file: {file_path_in_repo} via AI Space Editor"
507
+
508
+ # Use hf_delete_file directly
509
+ hf_delete_file(
510
+ path_in_repo=file_path_in_repo,
511
+ repo_id=repo_id,
512
+ repo_type="space",
513
+ token=resolved_api_token,
514
+ commit_message=effective_commit_message
515
+ )
516
+ return f"Successfully deleted file: {file_path_in_repo}"
517
+
518
+ except HfHubHTTPError as e_http: # Catch specific HF HTTP errors
519
+ logger.error(f"HTTP error deleting file {file_path_in_repo} from {repo_id_for_error_logging or 'unknown repo'}: {e_http}")
520
+ error_message = str(e_http)
521
+ status_code = e_http.response.status_code if e_http.response is not None else None
522
+
523
+ if status_code == 404:
524
+ return f"Error: File '{file_path_in_repo}' not found in Space '{repo_id_for_error_logging or 'unknown repo'}' for deletion (404)."
525
+ if status_code in (401, 403):
526
+ return f"Error: Access denied or authentication required for '{repo_id_for_error_logging or 'unknown repo'}' ({status_code}). Check token permissions."
527
+ return f"HTTP Error {status_code or 'unknown'} deleting file '{file_path_in_repo}': {error_message}"
528
+ except Exception as e:
529
+ logger.exception(f"Error deleting file {file_path_in_repo} from {repo_id_for_error_logging or 'unknown repo'}:")
530
+ return f"Error deleting file '{file_path_in_repo}': {str(e)}"
531
+
532
+ # --- Get Space Runtime Status ---
533
+ def get_space_runtime_status(ui_api_token_from_textbox, space_name_ui, owner_ui):
534
+ repo_id_for_error_logging = f"{owner_ui}/{space_name_ui}" if owner_ui else space_name_ui
535
+ repo_id = None
536
+ try:
537
+ resolved_api_token, token_err = _get_api_token(ui_api_token_from_textbox)
538
+ if token_err: return None, f"API Token Error: {token_err}"
539
+ repo_id, err_repo_id = _determine_repo_id(ui_api_token_from_textbox, space_name_ui, owner_ui)
540
+ if err_repo_id: return None, f"Repo ID Error: {err_repo_id}"
541
+ repo_id_for_error_logging = repo_id # Update logging name
542
+
543
+ api = HfApi(token=resolved_api_token)
544
+ logger.info(f"Fetching runtime status for Space: {repo_id}")
545
+
546
+ # Use get_space_runtime which provides details like stage, hardware, etc.
547
+ runtime_info = api.get_space_runtime(repo_id=repo_id)
548
+
549
+ # Structure the details for display
550
+ status_details = {
551
+ "stage": runtime_info.stage,
552
+ "hardware": runtime_info.hardware,
553
+ "requested_hardware": runtime_info.requested_hardware if hasattr(runtime_info, 'requested_hardware') else None, # requested_hardware might not always be present
554
+ "error_message": None,
555
+ "full_log_link": f"https://huggingface.co/spaces/{repo_id}/logs",
556
+ "raw_data": runtime_info.raw # Include raw data for detailed inspection if needed
557
+ }
558
+
559
+ # Check for specific error states or messages
560
+ if runtime_info.stage == "ERRORED":
561
+ error_content = None
562
+ # Look for error details in various places within the raw data or the error attribute
563
+ if hasattr(runtime_info, 'error') and runtime_info.error: error_content = str(runtime_info.error)
564
+ elif 'message' in runtime_info.raw and isinstance(runtime_info.raw['message'], str) and ('error' in runtime_info.raw['message'].lower() or runtime_info.raw['message'].strip().endswith('!')): # Basic check for message indicative of error
565
+ error_content = runtime_info.raw['message']
566
+ elif 'error' in runtime_info.raw: error_content = str(runtime_info.raw['error'])
567
+
568
+ # Check build/run specific error messages in raw data
569
+ if 'build' in runtime_info.raw and isinstance(runtime_info.raw['build'], dict) and runtime_info.raw['build'].get('status') == 'error':
570
+ error_content = f"Build Error: {runtime_info.raw['build'].get('message', error_content or 'Unknown build error')}"
571
+ elif 'run' in runtime_info.raw and isinstance(runtime_info.raw['run'], dict) and runtime_info.raw['run'].get('status') == 'error':
572
+ error_content = f"Runtime Error: {runtime_info.raw['run'].get('message', error_content or 'Unknown runtime error')}"
573
+
574
+ status_details["error_message"] = error_content if error_content else "Space is in an errored state. Check logs for details."
575
+
576
+ logger.info(f"Runtime status for {repo_id}: {status_details['stage']}")
577
+ return status_details, None
578
+
579
+ except HfHubHTTPError as e_http: # Catch specific HF HTTP errors
580
+ logger.error(f"HTTP error fetching runtime status for {repo_id_for_error_logging or 'unknown repo'}: {e_http}")
581
+ error_message = str(e_http)
582
+ status_code = e_http.response.status_code if e_http.response is not None else None
583
+
584
+ if status_code == 404:
585
+ # A 404 could mean the space doesn't exist or doesn't have an active runtime state recorded
586
+ return None, f"Error: Space '{repo_id_for_error_logging or 'unknown repo'}' not found or has no active runtime status (404)."
587
+ if status_code in (401, 403):
588
+ return None, f"Error: Access denied or authentication required for '{repo_id_for_error_logging or 'unknown repo'}' ({status_code}). Check token permissions."
589
+ return None, f"HTTP Error {status_code or 'unknown'} fetching runtime status for '{repo_id_for_error_logging or 'unknown repo'}': {error_message}"
590
+
591
+ except Exception as e:
592
+ logger.exception(f"Error fetching runtime status for {repo_id_for_error_logging or 'unknown repo'}:")
593
+ return None, f"Error fetching runtime status: {str(e)}"