Create build_logic.py
Browse files- 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)}"
|