broadfield-dev commited on
Commit
db7e1e2
·
verified ·
1 Parent(s): ffa2171

Update build_logic.py

Browse files
Files changed (1) hide show
  1. build_logic.py +453 -217
build_logic.py CHANGED
@@ -14,7 +14,7 @@ from huggingface_hub import (
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
 
@@ -28,8 +28,13 @@ logger = logging.getLogger(__name__)
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 ---
@@ -41,6 +46,7 @@ def _determine_repo_id(ui_api_token_from_textbox, space_name_ui, 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."
@@ -48,23 +54,32 @@ def _determine_repo_id(ui_api_token_from_textbox, space_name_ui, owner_ui):
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
 
@@ -85,27 +100,39 @@ def parse_markdown(markdown_input):
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()
@@ -117,49 +144,73 @@ def parse_markdown(markdown_input):
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
 
@@ -171,22 +222,29 @@ def get_space_repository_info(ui_api_token_from_textbox, space_name_ui, owner_ui
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}")
@@ -208,14 +266,15 @@ def get_space_repository_info(ui_api_token_from_textbox, space_name_ui, owner_ui
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}")
@@ -234,15 +293,16 @@ def get_space_repository_info(ui_api_token_from_textbox, space_name_ui, owner_ui
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
 
@@ -250,12 +310,14 @@ def list_space_files_for_browsing(ui_api_token_from_textbox, space_name_ui, owne
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("\\", "/")
@@ -267,14 +329,18 @@ def get_space_file_content(ui_api_token_from_textbox, space_name_ui, owner_ui, f
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}")
@@ -289,151 +355,314 @@ def get_space_file_content(ui_api_token_from_textbox, space_name_ui, owner_ui, f
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
@@ -443,35 +672,40 @@ def update_space_file(ui_api_token_from_textbox, space_name_ui, owner_ui, file_p
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)
@@ -486,53 +720,11 @@ def update_space_file(ui_api_token_from_textbox, space_name_ui, owner_ui, file_p
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}"
@@ -541,19 +733,22 @@ def get_space_runtime_status(ui_api_token_from_textbox, space_name_ui, owner_ui)
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
@@ -561,19 +756,17 @@ def get_space_runtime_status(ui_api_token_from_textbox, space_name_ui, owner_ui)
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
@@ -590,4 +783,47 @@ def get_space_runtime_status(ui_api_token_from_textbox, space_name_ui, owner_ui)
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)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  delete_file as hf_delete_file,
15
  HfApi
16
  )
17
+ from huggingface_hub.hf_api import CommitOperationDelete, CommitOperationAdd, CommitOperation
18
  # Import the general HTTP error from huggingface_hub.utils
19
  from huggingface_hub.utils import HfHubHTTPError # For catching specific HF HTTP errors
20
 
 
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:
32
+ logger.info("Using HF_TOKEN from environment variable.")
33
+ return env_token, None
34
+ if ui_token_from_textbox:
35
+ logger.info("Using HF_TOKEN from UI textbox.")
36
+ return ui_token_from_textbox.strip(), None
37
+ logger.warning("Hugging Face API token not provided in UI or HF_TOKEN env var.")
38
  return None, "Error: Hugging Face API token not provided in UI or HF_TOKEN env var."
39
 
40
  # --- Helper Function to Determine Repo ID ---
 
46
  error_message = None
47
 
48
  if not final_owner:
49
+ logger.info("Owner not specified, attempting to auto-detect from token.")
50
  resolved_api_token, token_err = _get_api_token(ui_api_token_from_textbox)
51
  if token_err: return None, token_err
52
  if not resolved_api_token: return None, "Error: API token required for auto owner determination if Owner field is empty."
 
54
  user_info = whoami(token=resolved_api_token)
55
  if user_info and 'name' in user_info:
56
  final_owner = user_info['name']
57
+ logger.info(f"Auto-detected owner: {final_owner}")
58
  else:
59
  error_message = "Error: Could not retrieve username from token. Check token permissions or specify Owner."
60
+ logger.error(error_message)
61
  except Exception as e:
62
  error_message = f"Error retrieving username from token: {str(e)}. Specify Owner or check token."
63
+ logger.exception("Error retrieving username from token:")
64
  if error_message: return None, error_message
65
 
66
  if not final_owner: return None, "Error: Owner could not be determined. Please specify it in the Owner field."
67
+ repo_id = f"{final_owner}/{space_name_ui}"
68
+ logger.info(f"Determined repo_id: {repo_id}")
69
+ return repo_id, None
70
 
71
 
72
  # --- Corrected Markdown Parsing ---
73
+ # This function remains mostly the same as its purpose is just to parse the *AI's output format*
74
+ # into a structured format, not necessarily to represent the *current state* of a Space.
75
+ # The app.py logic will use this output and combine it with the current Space state.
76
  def parse_markdown(markdown_input):
77
  space_info = {"repo_name_md": "", "owner_md": "", "files": []}
78
  current_file_path = None
79
  current_file_content_lines = []
80
  in_file_definition = False
81
  in_code_block = False
82
+ file_parsing_errors = [] # To collect potential parsing issues
83
 
84
  lines = markdown_input.strip().split("\n")
85
 
 
100
  lines = cleaned_lines
101
 
102
 
103
+ for i, line_content_orig in enumerate(lines):
104
  line_content_stripped = line_content_orig.strip()
105
+ line_num = i + 1
106
 
107
+ # Check for file header
108
+ file_match = re.match(r"### File:\s*(?P<filename_line>[^\n]+)", line_content_stripped)
109
+ if file_match:
110
  # Before processing a new file, save the content of the previous one
111
  if current_file_path is not None and in_file_definition: # Check if we were inside a file definition
112
+ # Remove leading/trailing blank lines from content lines
113
+ content_to_save = "\n".join(current_file_content_lines).strip()
114
+ space_info["files"].append({"path": current_file_path, "content": content_to_save})
115
 
116
+ filename_line = file_match.group("filename_line").strip()
117
+ current_file_path = filename_line
118
  # Clean up potential trailing descriptions like "(main application)"
119
  current_file_path = re.split(r'\s*\(', current_file_path, 1)[0].strip()
120
  # Clean up potential backticks around the filename
121
+ current_file_path = current_file_path.strip('`\'"').strip() # Add more chars to strip
122
 
123
+ if not current_file_path:
124
+ file_parsing_errors.append(f"Line {line_num}: Found '### File:' but filename is empty or invalid.")
125
+ current_file_path = None # Invalidate current file path if parsing failed
126
+ in_file_definition = False # Exit file definition mode until a valid one is found
127
+ continue # Move to next line
128
 
129
  current_file_content_lines = []
130
  in_file_definition = True
131
  in_code_block = False # Reset code block flag for the new file
132
+ logger.debug(f"Parsed file header: {current_file_path}")
133
+ continue # Move to next line
134
 
135
+ # If not a file header, check for other top-level structures *before* file definitions start
136
  if not in_file_definition:
137
  if line_content_stripped.startswith("# Space:"):
138
  full_space_name_md = line_content_stripped.replace("# Space:", "").strip()
 
144
  space_info["repo_name_md"] = full_space_name_md # Handle case like "user/repo/"
145
  else:
146
  space_info["repo_name_md"] = full_space_name_md
147
+ logger.debug(f"Parsed space header: {space_info['owner_md']}/{space_info['repo_name_md']}")
148
+ continue
149
+ # Ignore ## File Structure headers and their code blocks, as they are not file content
150
+ if line_content_stripped.startswith("## File Structure"):
151
+ # Need to consume the following code block if it exists
152
+ structure_block_start = i + 1
153
+ while structure_block_start < len(lines) and not lines[structure_block_start].strip().startswith("```"):
154
+ structure_block_start += 1
155
+ if structure_block_start < len(lines) and lines[structure_block_start].strip().startswith("```"):
156
+ # Found opening ```, look for closing ```
157
+ structure_block_end = structure_block_start + 1
158
+ while structure_block_end < len(lines) and not lines[structure_block_end].strip().startswith("```"):
159
+ structure_block_end += 1
160
+ if structure_block_end < len(lines) and lines[structure_block_end].strip().startswith("```"):
161
+ # Found closing ```, skip all these lines
162
+ logger.debug(f"Skipping File Structure block from line {i+1} to {structure_block_end+1}")
163
+ i = structure_block_end # Adjust loop counter (outer loop will increment)
164
+ continue
165
+ # Ignore other lines outside a file block definition
166
  continue
167
 
168
+ # If we are inside a file definition block (in_file_definition is True)
169
  if in_file_definition:
170
+ # Check for code block start/end
171
  if line_content_stripped.startswith("```"):
172
  # Toggle code block status
173
  in_code_block = not in_code_block
174
+ # We consume the ``` line(s), do not add to content
175
+ logger.debug(f"Toggled code block to {in_code_block} at line {line_num}")
 
 
 
 
 
176
  continue # Do not add the ``` line to content
177
 
178
  # If inside a code block, add the line as-is (original content, including leading/trailing whitespace)
179
  if in_code_block:
180
  current_file_content_lines.append(line_content_orig)
181
+ # If not inside a code block, check for binary file marker or error messages
182
  elif line_content_stripped.startswith("[Binary file") or line_content_stripped.startswith("[Error loading content:") or line_content_stripped.startswith("[Binary or Skipped file]"):
183
  # Handle binary file markers or error messages as content if not in code block
184
  current_file_content_lines.append(line_content_orig)
185
+ logger.debug(f"Parsed binary/error marker for {current_file_path} at line {line_num}")
186
  # Any other lines outside code blocks within a file definition are ignored (e.g., descriptions, blank lines)
187
  # This assumes all code/content *must* be within ``` blocks or be a specific marker line.
188
+ else:
189
+ # Optionally log ignored lines within a file block if debugging parsing
190
+ # logger.debug(f"Ignoring line {line_num} within file {current_file_path}: '{line_content_orig}'")
191
+ pass
192
 
193
 
194
+ # After the loop, save the content of the last file if we were inside a file definition
195
  if current_file_path is not None and in_file_definition:
196
+ content_to_save = "\n".join(current_file_content_lines).strip()
197
+ space_info["files"].append({"path": current_file_path, "content": content_to_save})
198
+
199
 
200
  # Ensure all file paths are valid and clean up empty files if necessary (based on content parsing)
201
  # The parsing logic above should handle stripping content, but this is a final check
202
  space_info["files"] = [f for f in space_info["files"] if f.get("path")] # Ensure path exists
 
 
203
 
204
  # Clean up owner/repo names from potential whitespace
205
  space_info["owner_md"] = space_info["owner_md"].strip()
206
  space_info["repo_name_md"] = space_info["repo_name_md"].strip()
207
 
208
+ if file_parsing_errors:
209
+ logger.warning(f"Markdown parsing encountered errors: {file_parsing_errors}")
210
+ # You might want to return the errors or include them in the space_info dict
211
+ # For now, we just log them.
212
 
213
+ logger.info(f"Parsed markdown. Found {len(space_info['files'])} files.")
214
  return space_info
215
 
216
 
 
222
  error = None
223
  repo_id = None # Define repo_id here to ensure it's available for error logging after _determine_repo_id
224
 
225
+ logger.info(f"Attempting to get repo info for {repo_id_for_error_logging}")
226
+
227
  try:
228
  resolved_api_token, token_err = _get_api_token(ui_api_token_from_textbox)
229
+ if token_err: return None, [], token_err
230
 
231
  repo_id, err_repo_id = _determine_repo_id(ui_api_token_from_textbox, space_name_ui, owner_ui)
232
+ if err_repo_id: return None, [], err_repo_id
233
  repo_id_for_error_logging = repo_id # Update logging name
234
 
235
  api = HfApi(token=resolved_api_token)
236
  # Use repo_info endpoint as it's more robust and gives SDK
237
+ repo_info_obj = api.repo_info(repo_id=repo_id, repo_type="space", timeout=20) # Added timeout, increased slightly
238
  sdk = repo_info_obj.sdk
239
  files = [sibling.rfilename for sibling in repo_info_obj.siblings if sibling.rfilename]
240
 
241
  if not files and repo_info_obj.siblings:
242
+ logger.warning(f"Repo {repo_id} has siblings but no rfilenames extracted. Total siblings: {len(repo_info_obj.siblings)}")
243
+ # Sometimes empty repos exist, or listing might fail partially.
244
+ # Continue, files list is just empty.
245
+
246
+ logger.info(f"Successfully got repo info for {repo_id}. SDK: {sdk}, Files found: {len(files)}")
247
+
248
 
249
  except HfHubHTTPError as e_http: # Catch specific HF HTTP errors first
250
  logger.error(f"HTTP error getting repo info for {repo_id_for_error_logging or 'unknown repo'}: {e_http}")
 
266
  try:
267
  # Re-determine repo_id and get token for fallback
268
  resolved_api_token_fb, token_err_fb = _get_api_token(ui_api_token_from_textbox)
269
+ if token_err_fb: return None, [], f"{error}\nAPI Token Error during fallback: {token_err_fb}" # Propagate token error
270
  repo_id_fb, err_repo_id_fb = _determine_repo_id(ui_api_token_from_textbox, space_name_ui, owner_ui)
271
+ if err_repo_id_fb: return None, [], f"{error}\nRepo ID Error during fallback: {err_repo_id_fb}" # Propagate repo ID error
272
 
273
  # Attempt to list files
274
+ files = list_repo_files(repo_id=repo_id_fb, token=resolved_api_token_fb, repo_type="space", timeout=20) # Added timeout
275
  # If fallback is successful, update error message to a warning about repo_info
276
  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."
277
+ logger.info(f"Fallback list_repo_files successful for {repo_id_fb}. Files found: {len(files)}")
278
 
279
  except HfHubHTTPError as e2_http:
280
  logger.error(f"HTTP error during fallback list_repo_files for {repo_id_for_error_logging or 'unknown repo'}: {e2_http}")
 
293
 
294
 
295
  # Final check: if files are still empty and there's no specific error, provide a generic "no files" message
296
+ # or if a specific 404 error occurred.
297
+ if not files and not error and (repo_id_for_error_logging is not None):
298
+ error = f"No files found in Space `{repo_id_for_error_logging or 'unknown repo'}`."
299
 
300
  return sdk, files, error
301
 
302
 
303
  # --- Function to list files ---
304
  def list_space_files_for_browsing(ui_api_token_from_textbox, space_name_ui, owner_ui):
305
+ files, err = get_space_repository_info(ui_api_token_from_textbox, space_name_ui, owner_ui)[1:]
306
  return files, err
307
 
308
 
 
310
  def get_space_file_content(ui_api_token_from_textbox, space_name_ui, owner_ui, file_path_in_repo):
311
  repo_id_for_error_logging = f"{owner_ui}/{space_name_ui}" if owner_ui else space_name_ui
312
  repo_id = None
313
+ logger.info(f"Attempting to get content for file '{file_path_in_repo}' from {repo_id_for_error_logging}")
314
  try:
315
  resolved_api_token, token_err = _get_api_token(ui_api_token_from_textbox)
316
  if token_err: return None, token_err
317
  repo_id, err_repo_id = _determine_repo_id(ui_api_token_from_textbox, space_name_ui, owner_ui)
318
  if err_repo_id: return None, err_repo_id
319
+ repo_id_for_error_logging = repo_id # Update logging name
320
+
321
  if not file_path_in_repo: return None, "Error: File path cannot be empty."
322
  # Ensure file_path_in_repo uses forward slashes
323
  file_path_in_repo = file_path_in_repo.replace("\\", "/")
 
329
  repo_type="space",
330
  token=resolved_api_token,
331
  local_dir_use_symlinks=False, # Avoid symlinks issues
332
+ cache_dir=None, # Use default cache dir
333
+ timeout=20 # Added timeout
334
  )
335
  content = Path(downloaded_file_path).read_text(encoding="utf-8")
336
+ logger.info(f"Successfully downloaded and read content for '{file_path_in_repo}'.")
337
  return content, None
338
  except FileNotFoundError:
339
+ logger.error(f"FileNotFoundError for '{file_path_in_repo}' in {repo_id_for_error_logging or 'unknown'}")
340
+ return None, f"Error: File '{file_path_in_repo}' not found in Space '{repo_id_for_error_logging or 'unknown repo'}' (404)." # Often gets translated from 404 by hf_hub_download
341
  except UnicodeDecodeError:
342
  # If read_text fails, it's likely binary or non-utf8 text
343
+ logger.warning(f"UnicodeDecodeError for '{file_path_in_repo}'. Likely binary.")
344
  return None, f"Error: File '{file_path_in_repo}' is not valid UTF-8 text. Cannot display."
345
  except HfHubHTTPError as e_http:
346
  logger.error(f"HTTP error fetching file {file_path_in_repo} from {repo_id_for_error_logging or 'unknown repo'}: {e_http}")
 
355
  logger.exception(f"Error fetching file content for {file_path_in_repo} from {repo_id_for_error_logging or 'unknown repo'}:")
356
  return None, f"Error fetching file content: {str(e)}"
357
 
358
+ # --- Create/Update Space from Staged Changes ---
359
+ # This function is modified to take a list of operations (changeset) instead of markdown
360
+ # It's designed to be called by handle_confirm_changes
361
+ def apply_staged_changes(ui_api_token_from_textbox, owner_ui, space_name_ui, changeset):
362
  repo_id_for_error_logging = f"{owner_ui}/{space_name_ui}" if owner_ui else space_name_ui
363
  repo_id = None
364
+ status_messages = []
365
+ operations = [] # List of CommitOperation objects
366
+
367
+ logger.info(f"Attempting to apply {len(changeset)} staged changes to {repo_id_for_error_logging}")
368
+
369
  try:
370
  resolved_api_token, token_err = _get_api_token(ui_api_token_from_textbox)
371
+ if token_err: return [token_err]
372
+
373
+ repo_id, err_repo_id = _determine_repo_id(ui_api_token_from_textbox, owner_ui, space_name_ui)
374
+ if err_repo_id: return [err_repo_id]
375
+ repo_id_for_error_logging = repo_id
376
 
377
+ api = HfApi(token=resolved_api_token)
378
+
379
+ # First, handle Space Creation if it's part of the changeset
380
+ create_space_op = next((c for c in changeset if c['type'] == 'CREATE_SPACE'), None)
381
+ if create_space_op:
382
+ # The repo_id here should match the one determined above, derived from owner_ui/space_name_ui
383
+ # We assume the UI fields owner_ui and space_name_ui are set to the *target* space for creation
384
+ # This is a slight divergence from the AI's CREATE_SPACE command which specifies repo_id
385
+ # We'll prioritize the UI fields as the user's explicit target.
386
+ # The AI command should ideally set the UI fields first.
387
+ # TODO: Refine AI CREATE_SPACE to update UI fields first? Or make build_logic_create_space use the AI's repo_id?
388
+ # Let's adjust `create_space` to take the repo_id directly from the AI command, but validate/use UI token/owner if needed.
389
+ # But the *current* structure assumes owner_ui/space_name_ui are the target.
390
+ # Let's stick to the current structure for now: CREATE_SPACE action is noted, but the target repo is from UI fields.
391
+ # The `create_space` call below *will* create the repo specified by UI fields.
392
+ # The file operations will then be applied to this new repo.
393
+
394
+ logger.info(f"Detected CREATE_SPACE action for {create_space_op['repo_id']}. Proceeding with creation of {repo_id_for_error_logging} based on UI fields.")
395
+ # Call the existing create_space logic, but perhaps without markdown?
396
+ # The subsequent file operations will populate it.
397
+ # We need a way to create an *empty* repo first. HfApi().create_repo handles this.
398
+ try:
399
+ api.create_repo(repo_id=repo_id, repo_type="space", space_sdk=create_space_op.get('sdk', 'gradio'), private=create_space_op.get('private', False), exist_ok=True)
400
+ status_messages.append(f"CREATE_SPACE: Successfully created or ensured space [{repo_id}](https://huggingface.co/spaces/{repo_id}) exists with SDK '{create_space_op.get('sdk', 'gradio')}' and private={create_space_op.get('private', False)}.")
401
+ logger.info(f"Successfully created or ensured space {repo_id} exists.")
402
+ except Exception as e:
403
+ status_messages.append(f"CREATE_SPACE Error: {e}")
404
+ logger.error(f"Error creating space {repo_id}: {e}")
405
+ # If space creation fails, subsequent operations will also fail.
406
+ # Should we stop here? Let's add a check.
407
+ # We could try to proceed with file operations if `exist_ok=True` succeeded partially.
408
+ # For simplicity, let's just report the error and continue, hoping upload_folder is resilient.
409
+ # A better approach might stop if create_repo fails definitively (e.g., 401/403/409).
410
+ pass # Continue attempting other operations
411
+
412
+ # Prepare commit operations for file changes (Add/Update/Delete)
413
+ temp_dir = None
414
+ operations = [] # Reset operations list for the file commit
415
+ paths_to_upload = {} # Map of local temp path -> path in repo for Add/Update
416
 
417
+ try:
418
+ temp_dir = tempfile.TemporaryDirectory()
419
+ repo_staging_path = Path(temp_dir.name) / "repo_staging_content"
420
  repo_staging_path.mkdir(exist_ok=True)
421
 
422
+ # Always stage .gitattributes to ensure consistent line endings
423
+ gitattributes_path_local = repo_staging_path / ".gitattributes"
424
+ with open(gitattributes_path_local, "w", encoding="utf-8") as f:
425
  f.write("* text=auto eol=lf\n")
426
+ paths_to_upload[str(gitattributes_path_local)] = ".gitattributes"
427
+
428
+
429
+ for change in changeset:
430
+ if change['type'] == 'UPDATE_FILE' or change['type'] == 'CREATE_FILE':
431
+ file_path_in_repo = change['path'].lstrip('/').replace(os.sep, '/')
432
+ if not file_path_in_repo:
433
+ status_messages.append(f"Skipping {change['type']} operation: empty path.")
434
+ continue
435
+
436
+ content_to_write = change.get('content', '')
437
+ # Skip files that were marked as binary/error during loading
438
+ if content_to_write.startswith("[Binary file") or content_to_write.startswith("[Error loading content:") or content_to_write.startswith("[Binary or Skipped file]"):
439
+ status_messages.append(f"Skipping {change['type']} for '{file_path_in_repo}': Content is a binary/error placeholder.")
440
+ logger.warning(f"Skipping {change['type']} operation for '{file_path_in_repo}': Content is binary/error placeholder.")
441
+ continue
442
+
443
+ file_path_local = repo_staging_path / file_path_in_repo
444
+ file_path_local.parent.mkdir(parents=True, exist_ok=True) # Create parent directories
445
+
446
+ try:
447
+ with open(file_path_local, "w", encoding="utf-8") as f:
448
+ f.write(content_to_write)
449
+ paths_to_upload[str(file_path_local)] = file_path_in_repo
450
+ logger.info(f"Staged file for {change['type']}: {file_path_in_repo}")
451
+ except Exception as file_write_error:
452
+ status_messages.append(f"Error staging file {file_path_in_repo} for {change['type']}: {file_write_error}")
453
+ logger.error(f"Error writing file {file_path_in_repo} during staging for {change['type']}: {file_write_error}")
454
+
455
+
456
+ elif change['type'] == 'DELETE_FILE':
457
+ file_path_in_repo = change['path'].lstrip('/').replace(os.sep, '/')
458
+ if not file_path_in_repo:
459
+ status_messages.append(f"Skipping DELETE_FILE operation: empty path.")
460
+ continue
461
+ operations.append(CommitOperationDelete(path_in_repo=file_path_in_repo))
462
+ logger.info(f"Added DELETE_FILE operation for: {file_path_in_repo}")
463
+
464
+ # SET_PRIVACY and DELETE_SPACE are handled separately below after the commit
465
+
466
+ # Add Add/Update operations from staged files
467
+ for local_path, repo_path in paths_to_upload.items():
468
+ # upload_folder/upload_file is simpler than CommitOperationAdd for content
469
+ # Let's use upload_folder approach if there are files to upload
470
+ pass # Just stage paths, upload_folder handles the ops
471
+
472
+
473
+ # Perform the combined file commit (uploads and deletes)
474
+ if paths_to_upload or operations: # Check if there's anything to commit
475
+ logger.info(f"Committing file changes to {repo_id_for_error_logging}. Uploads: {len(paths_to_upload)}, Deletes: {len(operations)}")
476
+
477
+ # upload_folder automatically handles Add/Update operations based on the local folder state
478
+ # Deletes need to be handled via CommitOperations or a separate delete call.
479
+ # The simplest approach might be:
480
+ # 1. Perform deletes using create_commit with CommitOperationDelete.
481
+ # 2. Perform adds/updates using upload_folder.
482
+ # This requires two commits if there are both types of operations.
483
+
484
+ delete_operations = [op for op in operations if isinstance(op, CommitOperationDelete)]
485
+ if delete_operations:
486
+ try:
487
+ commit_message_delete = f"AI Space Builder: Deleted {len(delete_operations)} files."
488
+ logger.info(f"Performing delete commit for {repo_id_for_error_logging}: {commit_message_delete}")
489
+ api.create_commit(
490
+ repo_id=repo_id,
491
+ repo_type="space",
492
+ operations=delete_operations,
493
+ commit_message=commit_message_delete
494
+ )
495
+ status_messages.append(f"File Deletions: Successfully committed {len(delete_operations)} deletions.")
496
+ logger.info("Delete commit successful.")
497
+ except HfHubHTTPError as e_http:
498
+ status_messages.append(f"File Deletion Error ({e_http.response.status_code if e_http.response else 'N/A'}): {e_http.response.text if e_http.response else str(e_http)}. Check logs.")
499
+ logger.error(f"HTTP error during delete commit for {repo_id}: {e_http}")
500
+ except Exception as e_delete_commit:
501
+ status_messages.append(f"File Deletion Error: {str(e_delete_commit)}. Check logs.")
502
+ logger.exception(f"Error during delete commit for {repo_id}:")
503
+
504
+
505
+ if paths_to_upload: # If there are files to upload/update (including .gitattributes)
506
+ try:
507
+ commit_message_upload = f"AI Space Builder: Updated Space content for {repo_id}"
508
+ logger.info(f"Uploading staged files from {str(repo_staging_path)} to {repo_id}...")
509
+ upload_folder(
510
+ repo_id=repo_id,
511
+ folder_path=str(repo_staging_path),
512
+ path_in_repo=".", # Upload to the root of the repository
513
+ token=resolved_api_token,
514
+ repo_type="space",
515
+ commit_message=commit_message_upload,
516
+ allow_patterns=["*"], # Ensure all staged files are considered
517
+ # Use force_patterns to ensure specific files are uploaded even if git thinks they are binary
518
+ # force_patterns=[f.get("path").replace("\\", "/") for f in space_info["files"] if f.get("path")] # This requires knowing the original markdown files
519
+ # Let's rely on .gitattributes text=auto instead for now.
520
+ )
521
+ status_messages.append(f"File Uploads/Updates: Successfully uploaded/updated {len(paths_to_upload)} files.")
522
+ logger.info("Upload/Update commit successful.")
523
+ except HfHubHTTPError as e_http:
524
+ status_messages.append(f"File Upload/Update Error ({e_http.response.status_code if e_http.response else 'N/A'}): {e_http.response.text if e_http.response else str(e_http)}. Check logs.")
525
+ logger.error(f"HTTP error during upload_folder for {repo_id}: {e_http}")
526
+ except Exception as e_upload:
527
+ status_messages.append(f"File Upload/Update Error: {str(e_upload)}. Check logs.")
528
+ logger.exception(f"Error during upload_folder for {repo_id}:")
529
 
530
+ else:
531
+ status_messages.append("No file changes (create/update/delete) to commit.")
532
+ logger.info("No file changes to commit.")
 
533
 
534
 
535
+ finally:
536
+ # Clean up temporary directory
537
+ if temp_dir:
538
+ try:
539
+ temp_dir.cleanup()
540
+ logger.info("Cleaned up temporary staging directory.")
541
+ except Exception as e:
542
+ logger.error(f"Error cleaning up temp dir: {e}")
543
+
544
+
545
+ # Handle Space Privacy and Delete actions *after* the file commit (if any)
546
+ for change in changeset:
547
+ if change['type'] == 'SET_PRIVACY':
548
+ try:
549
+ target_repo_id = change.get('repo_id', repo_id) # Use specified repo_id or current
550
+ if not target_repo_id:
551
+ status_messages.append("SET_PRIVACY Error: Target repo_id not specified.")
552
+ continue
553
+ api.update_repo_visibility(repo_id=target_repo_id, private=change['private'], repo_type='space')
554
+ status_messages.append(f"SET_PRIVACY: Successfully set `{target_repo_id}` to `private={change['private']}`.")
555
+ logger.info(f"Successfully set privacy for {target_repo_id} to {change['private']}.")
556
+ except HfHubHTTPError as e_http:
557
+ status_messages.append(f"SET_PRIVACY Error ({e_http.response.status_code if e_http.response else 'N/A'}): {e_http.response.text if e_http.response else str(e_http)}. Check token/permissions.")
558
+ logger.error(f"HTTP error setting privacy for {target_repo_id}: {e_http}")
559
+ except Exception as e:
560
+ status_messages.append(f"SET_PRIVACY Error: {str(e)}. Check logs.")
561
+ logger.exception(f"Error setting privacy for {target_repo_id}:")
562
+
563
+ elif change['type'] == 'DELETE_SPACE':
564
+ # This is destructive and typically requires the exact owner/space_name
565
+ delete_owner = change.get('owner') or owner_ui # Use specified owner or current UI owner
566
+ delete_space = change.get('space_name') or space_name_ui # Use specified space_name or current UI space name
567
+ delete_repo_id = f"{delete_owner}/{delete_space}" if delete_owner and delete_space else repo_id
568
+
569
+ if not delete_repo_id:
570
+ status_messages.append("DELETE_SPACE Error: Target repo_id not specified.")
571
+ continue
572
+
573
+ # Add an extra safeguard: Only delete the *currently loaded* space unless AI specifies otherwise AND it matches the current UI fields?
574
+ # Or strictly use the repo_id from the action? Let's strictly use the action's specified repo_id,
575
+ # falling back to UI fields only if the action didn't provide them.
576
+ # The action format is `DELETE_SPACE` (implies current UI space) or maybe `DELETE_SPACE owner/repo`?
577
+ # The prompt defined `DELETE_SPACE` only, implying the current space. Let's enforce that.
578
+ # The change object should have owner/space_name populated by generate_and_stage_changes based on current UI.
579
+ if delete_repo_id != repo_id:
580
+ status_messages.append(f"DELETE_SPACE Error: AI requested deletion of '{delete_repo_id}', but this action is only permitted for the currently loaded space '{repo_id}'. Action blocked.")
581
+ logger.warning(f"Blocked DELETE_SPACE action: requested '{delete_repo_id}', current '{repo_id}'.")
582
+ continue # Block deletion if not the currently loaded space
583
+
584
+ logger.warning(f"Attempting DESTRUCTIVE DELETE_SPACE action for {delete_repo_id}")
585
+ try:
586
+ api.delete_repo(repo_id=delete_repo_id, repo_type='space')
587
+ status_messages.append(f"DELETE_SPACE: Successfully deleted space `{delete_repo_id}`.")
588
+ logger.info(f"Successfully deleted space {delete_repo_id}.")
589
+ except HfHubHTTPError as e_http:
590
+ status_messages.append(f"DELETE_SPACE Error ({e_http.response.status_code if e_http.response else 'N/A'}): {e_http.response.text if e_http.response else str(e_http)}. Check token/permissions.")
591
+ logger.error(f"HTTP error deleting space {delete_repo_id}: {e_http}")
592
+ except Exception as e:
593
+ status_messages.append(f"DELETE_SPACE Error: {str(e)}. Check logs.")
594
+ logger.exception(f"Error deleting space {delete_repo_id}:")
595
 
 
 
 
 
 
596
 
597
+ except HfHubHTTPError as e_http:
598
+ logger.error(f"Top-level HTTP error during apply_staged_changes for {repo_id_for_error_logging or 'unknown repo'}: {e_http}")
599
+ status_messages.append(f"API HTTP Error ({e_http.response.status_code if e_http.response else 'N/A'}): {e_http.response.text if e_http.response else str(e_http)}")
600
+ except Exception as e:
601
+ logger.exception(f"Top-level error during apply_staged_changes for {repo_id_for_error_logging or 'unknown repo'}:")
602
+ status_messages.append(f"An unexpected error occurred: {str(e)}")
603
 
604
+ # Format the final status message
605
+ final_status = " | ".join(status_messages) if status_messages else "No operations were applied."
606
+ logger.info(f"Finished applying staged changes. Final status: {final_status}")
607
+ return final_status
 
 
 
 
 
608
 
609
 
610
+ # --- Delete Single File (Manual UI Trigger) ---
611
+ # This function remains for direct UI file deletion, distinct from the AI-driven workflow
612
+ def delete_space_file(ui_api_token_from_textbox, space_name_ui, owner_ui, file_path_in_repo, commit_message_ui=None):
613
+ repo_id_for_error_logging = f"{owner_ui}/{space_name_ui}" if owner_ui else space_name_ui
614
+ repo_id = None
615
+ logger.info(f"Attempting manual file deletion for '{file_path_in_repo}' from {repo_id_for_error_logging}")
616
+ try:
617
+ resolved_api_token, token_err = _get_api_token(ui_api_token_from_textbox)
618
+ if token_err: return f"API Token Error: {token_err}"
619
+ repo_id, err_repo_id = _determine_repo_id(ui_api_token_from_textbox, space_name_ui, owner_ui)
620
+ if err_repo_id: return f"Repo ID Error: {err_repo_id}"
621
+ repo_id_for_error_logging = repo_id # Update logging name
622
 
623
+ if not file_path_in_repo: return "Error: File path cannot be empty for deletion."
624
+ file_path_in_repo = file_path_in_repo.lstrip('/').replace(os.sep, '/') # Clean path for Hub
625
 
626
+ # Prevent deleting essential files like .gitattributes or README.md unless explicitly handled?
627
+ # For now, allow deleting anything selected in the dropdown.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
628
 
629
+ effective_commit_message = commit_message_ui or f"Deleted file: {file_path_in_repo} via AI Space Editor UI"
630
 
631
+ # Use hf_delete_file directly
632
+ hf_delete_file(
633
+ path_in_repo=file_path_in_repo,
634
+ repo_id=repo_id,
635
+ repo_type="space",
636
+ token=resolved_api_token,
637
+ commit_message=effective_commit_message,
638
+ timeout=20 # Added timeout
639
+ )
640
+ logger.info(f"Successfully deleted file: {file_path_in_repo}")
641
+ return f"Successfully deleted file: `{file_path_in_repo}`"
642
+
643
+ except FileNotFoundError:
644
+ logger.error(f"FileNotFoundError during manual delete for '{file_path_in_repo}' in {repo_id_for_error_logging or 'unknown'}")
645
+ return f"Error: File '{file_path_in_repo}' not found in Space '{repo_id_for_error_logging or 'unknown repo'}' (404)." # hf_delete_file translates 404
646
+ except HfHubHTTPError as e_http: # Catch specific HF HTTP errors
647
+ logger.error(f"HTTP error deleting file {file_path_in_repo} from {repo_id_for_error_logging or 'unknown repo'}: {e_http}")
648
  error_message = str(e_http)
649
  status_code = e_http.response.status_code if e_http.response is not None else None
650
+
651
+ if status_code == 404:
652
+ return f"Error: File '{file_path_in_repo}' not found in Space '{repo_id_for_error_logging or 'unknown repo'}' for deletion (404)."
653
  if status_code in (401, 403):
654
+ return f"Error: Access denied or authentication required for '{repo_id_for_error_logging or 'unknown repo'}' ({status_code}). Check token permissions."
655
+ return f"HTTP Error {status_code or 'unknown'} deleting file '{file_path_in_repo}': {error_message}"
656
  except Exception as e:
657
+ logger.exception(f"Error deleting file {file_path_in_repo} from {repo_id_for_error_logging or 'unknown repo'}:")
658
+ return f"Error deleting file '{file_path_in_repo}': {str(e)}"
659
 
660
+ # --- Update Single File (Manual UI Trigger) ---
661
+ # This function remains for direct UI file editing, distinct from the AI-driven workflow
662
  def update_space_file(ui_api_token_from_textbox, space_name_ui, owner_ui, file_path_in_repo, file_content, commit_message_ui):
663
  repo_id_for_error_logging = f"{owner_ui}/{space_name_ui}" if owner_ui else space_name_ui
664
  repo_id = None
665
+ logger.info(f"Attempting manual file update for '{file_path_in_repo}' in {repo_id_for_error_logging}")
666
  try:
667
  resolved_api_token, token_err = _get_api_token(ui_api_token_from_textbox)
668
  if token_err: return token_err
 
672
 
673
  if not file_path_in_repo: return "Error: File Path to update cannot be empty."
674
  file_path_in_repo = file_path_in_repo.lstrip('/').replace(os.sep, '/') # Clean path for Hub
675
+ commit_msg = commit_message_ui or f"Update {file_path_in_repo} via AI Space Editor UI"
676
 
677
  api = HfApi(token=resolved_api_token)
678
 
679
  # Use a temporary file to upload content safely
680
+ tmp_file_path = None
 
 
 
681
  try:
682
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as tmp_file_obj:
683
+ tmp_file_obj.write(file_content)
684
+ tmp_file_path = tmp_file_obj.name
685
+
686
  # Upload the temporary file to the specified path in the repo
687
  api.upload_file(
688
  path_or_fileobj=tmp_file_path,
689
  path_in_repo=file_path_in_repo,
690
  repo_id=repo_id,
691
  repo_type="space",
692
+ commit_message=commit_msg,
693
+ timeout=20 # Added timeout
694
  )
695
+ logger.info(f"Successfully updated file: {file_path_in_repo}")
696
+ return f"Successfully updated `{file_path_in_repo}`"
697
  finally:
698
  # Ensure the temporary file is removed
699
+ if tmp_file_path and os.path.exists(tmp_file_path):
700
  os.remove(tmp_file_path)
701
 
702
  except FileNotFoundError:
703
+ logger.error(f"FileNotFoundError during manual update for '{file_path_in_repo}' in {repo_id_for_error_logging or 'unknown'}")
704
  return f"Error: Local temporary file not found during upload for '{file_path_in_repo}'."
705
  except UnicodeDecodeError:
706
  # If read_text fails, it's likely binary or non-utf8 text
707
+ logger.warning(f"UnicodeDecodeError for '{file_path_in_repo}' during manual update.")
708
+ return f"Error: Content for '{file_path_in_repo}' is not valid UTF-8 text. Cannot edit this way."
709
  except HfHubHTTPError as e_http:
710
  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}")
711
  error_message = str(e_http)
 
720
  return f"Error updating file for `{repo_id_for_error_logging or 'unknown repo'}`: {str(e)}"
721
 
722
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
723
  # --- Get Space Runtime Status ---
724
  def get_space_runtime_status(ui_api_token_from_textbox, space_name_ui, owner_ui):
725
  repo_id_for_error_logging = f"{owner_ui}/{space_name_ui}" if owner_ui else space_name_ui
726
  repo_id = None
727
+ logger.info(f"Fetching runtime status for Space: {repo_id_for_error_logging}")
728
  try:
729
  resolved_api_token, token_err = _get_api_token(ui_api_token_from_textbox)
730
  if token_err: return None, f"API Token Error: {token_err}"
 
733
  repo_id_for_error_logging = repo_id # Update logging name
734
 
735
  api = HfApi(token=resolved_api_token)
 
736
 
737
  # Use get_space_runtime which provides details like stage, hardware, etc.
738
+ # Added timeout for robustness
739
+ runtime_info = api.get_space_runtime(repo_id=repo_id, timeout=20)
740
+ logger.info(f"Received runtime info for {repo_id}. Stage: {runtime_info.stage}")
741
+
742
 
743
  # Structure the details for display
744
  status_details = {
745
  "stage": runtime_info.stage,
746
  "hardware": runtime_info.hardware,
747
  "requested_hardware": runtime_info.requested_hardware if hasattr(runtime_info, 'requested_hardware') else None, # requested_hardware might not always be present
748
+ "error_message": None, # Populate this if stage is ERRORED
749
+ "status": runtime_info.status if hasattr(runtime_info, 'status') else None, # Additional status field
750
+ "full_log_link": f"https://huggingface.co/spaces/{repo_id}/logs" if repo_id else "#"
751
+ # We can add more fields from runtime_info.raw if useful
752
  }
753
 
754
  # Check for specific error states or messages
 
756
  error_content = None
757
  # Look for error details in various places within the raw data or the error attribute
758
  if hasattr(runtime_info, 'error') and runtime_info.error: error_content = str(runtime_info.error)
 
 
 
 
759
  # Check build/run specific error messages in raw data
760
  if 'build' in runtime_info.raw and isinstance(runtime_info.raw['build'], dict) and runtime_info.raw['build'].get('status') == 'error':
761
  error_content = f"Build Error: {runtime_info.raw['build'].get('message', error_content or 'Unknown build error')}"
762
  elif 'run' in runtime_info.raw and isinstance(runtime_info.raw['run'], dict) and runtime_info.raw['run'].get('status') == 'error':
763
  error_content = f"Runtime Error: {runtime_info.raw['run'].get('message', error_content or 'Unknown runtime error')}"
764
+ 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
765
+ error_content = runtime_info.raw['message'] # Prioritize messages from raw data if they look like errors
766
 
767
  status_details["error_message"] = error_content if error_content else "Space is in an errored state. Check logs for details."
768
 
769
+ logger.info(f"Runtime status details for {repo_id}: {status_details}")
770
  return status_details, None
771
 
772
  except HfHubHTTPError as e_http: # Catch specific HF HTTP errors
 
783
 
784
  except Exception as e:
785
  logger.exception(f"Error fetching runtime status for {repo_id_for_error_logging or 'unknown repo'}:")
786
+ return None, f"Error fetching runtime status: {str(e)}"
787
+
788
+ # --- Function to set space privacy ---
789
+ def build_logic_set_space_privacy(hf_api_key, repo_id, private: bool):
790
+ """Sets the privacy of a Hugging Face Space."""
791
+ logger.info(f"Attempting to set privacy for '{repo_id}' to {private}.")
792
+ try:
793
+ token, err = _get_api_token(hf_api_key)
794
+ if err or not token:
795
+ logger.error(f"Token error setting privacy: {err or 'Token not found'}")
796
+ return f"Error getting token: {err or 'Token not found.'}"
797
+ api = HfApi(token=token)
798
+ api.update_repo_visibility(repo_id=repo_id, private=private, repo_type='space')
799
+ logger.info(f"Successfully set privacy for {repo_id} to {private}.")
800
+ return f"Successfully set privacy for `{repo_id}` to `{private}`."
801
+ except HfHubHTTPError as e_http:
802
+ logger.error(f"HTTP error setting privacy for {repo_id}: {e_http}")
803
+ status_code = e_http.response.status_code if e_http.response else 'N/A'
804
+ return f"HTTP Error ({status_code}) setting privacy for `{repo_id}`: {e_http.response.text if e_http.response else str(e_http)}"
805
+ except Exception as e:
806
+ logger.exception(f"Error setting privacy for {repo_id}:")
807
+ return f"Error setting privacy for `{repo_id}`: {e}"
808
+
809
+ # --- Function to delete an entire space ---
810
+ def build_logic_delete_space(hf_api_key, owner, space_name):
811
+ """Deletes an entire Hugging Face Space."""
812
+ repo_id = f"{owner}/{space_name}"
813
+ logger.warning(f"Attempting DESTRUCTIVE DELETE_SPACE action for '{repo_id}'.")
814
+ try:
815
+ token, err = _get_api_token(hf_api_key)
816
+ if err or not token:
817
+ logger.error(f"Token error deleting space: {err or 'Token not found'}")
818
+ return f"Error getting token: {err or 'Token not found.'}"
819
+ api = HfApi(token=token)
820
+ api.delete_repo(repo_id=repo_id, repo_type='space')
821
+ logger.warning(f"Successfully deleted space {repo_id}.")
822
+ return f"Successfully deleted space `{repo_id}`."
823
+ except HfHubHTTPError as e_http:
824
+ logger.error(f"HTTP error deleting space {repo_id}: {e_http}")
825
+ status_code = e_http.response.status_code if e_http.response else 'N/A'
826
+ return f"HTTP Error ({status_code}) deleting space `{repo_id}`: {e_http.response.text if e_http.response else str(e_http)}"
827
+ except Exception as e:
828
+ logger.exception(f"Error deleting space {repo_id}:")
829
+ return f"Error deleting space `{repo_id}`: {e}"