xllify 0.8.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xllify/XLLIFY_DIST_VERSION +2 -0
- xllify/__init__.py +101 -0
- xllify/__main__.py +343 -0
- xllify/diagnostics.py +78 -0
- xllify/funcinfo.py +375 -0
- xllify/install.py +251 -0
- xllify/py.typed +1 -0
- xllify/rpc_server.py +639 -0
- xllify/rtd_client.py +576 -0
- xllify-0.8.9.dist-info/METADATA +407 -0
- xllify-0.8.9.dist-info/RECORD +15 -0
- xllify-0.8.9.dist-info/WHEEL +5 -0
- xllify-0.8.9.dist-info/entry_points.txt +5 -0
- xllify-0.8.9.dist-info/licenses/LICENSE +21 -0
- xllify-0.8.9.dist-info/top_level.txt +1 -0
xllify/funcinfo.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Python Function Info Extractor for xllify
|
|
4
|
+
|
|
5
|
+
Parses Python files to extract metadata from @xllify.fn() decorated functions
|
|
6
|
+
and outputs structured JSON similar to the Luau funcinfo tool.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
xllify-funcinfo <script.py>
|
|
10
|
+
python -m xllify.funcinfo <script.py>
|
|
11
|
+
|
|
12
|
+
Output:
|
|
13
|
+
JSON object containing:
|
|
14
|
+
- script_name: Name of the script
|
|
15
|
+
- functions: Array of function metadata
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import ast
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
import os
|
|
22
|
+
from typing import List, Dict, Any, Optional
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ParameterInfo:
|
|
27
|
+
"""Information about a function parameter"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
name: str,
|
|
32
|
+
type_hint: str = "any",
|
|
33
|
+
optional: bool = False,
|
|
34
|
+
default: Any = None,
|
|
35
|
+
description: str = "",
|
|
36
|
+
):
|
|
37
|
+
self.name = name
|
|
38
|
+
self.type_hint = type_hint
|
|
39
|
+
self.optional = optional
|
|
40
|
+
self.default = default
|
|
41
|
+
self.description = description
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict:
|
|
44
|
+
result = {"name": self.name, "type": self.type_hint, "optional": self.optional}
|
|
45
|
+
if self.default is not None:
|
|
46
|
+
result["default"] = self.default
|
|
47
|
+
if self.description:
|
|
48
|
+
result["description"] = self.description
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FunctionMetadata:
|
|
53
|
+
"""Metadata for an Excel function"""
|
|
54
|
+
|
|
55
|
+
def __init__(self):
|
|
56
|
+
self.config_name: str = ""
|
|
57
|
+
self.description: str = ""
|
|
58
|
+
self.category: str = ""
|
|
59
|
+
self.execution_type: str = "external" # Python functions always use external execution
|
|
60
|
+
self.parameters: List[ParameterInfo] = []
|
|
61
|
+
self.has_vararg: bool = False
|
|
62
|
+
self.has_kwargs: bool = False
|
|
63
|
+
self.return_type: str = "any"
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> dict:
|
|
66
|
+
result = {
|
|
67
|
+
"config_name": self.config_name,
|
|
68
|
+
"description": self.description,
|
|
69
|
+
"category": self.category,
|
|
70
|
+
"execution_type": self.execution_type,
|
|
71
|
+
"parameters": [p.to_dict() for p in self.parameters],
|
|
72
|
+
"has_vararg": self.has_vararg,
|
|
73
|
+
}
|
|
74
|
+
if self.has_kwargs:
|
|
75
|
+
result["has_kwargs"] = self.has_kwargs
|
|
76
|
+
if self.return_type != "any":
|
|
77
|
+
result["return_type"] = self.return_type
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class FunctionExtractor(ast.NodeVisitor):
|
|
82
|
+
"""AST visitor to extract xllify.fn decorated functions"""
|
|
83
|
+
|
|
84
|
+
def __init__(self):
|
|
85
|
+
self.functions: List[FunctionMetadata] = []
|
|
86
|
+
self._current_decorators = []
|
|
87
|
+
self.spawn_count: Optional[int] = None
|
|
88
|
+
|
|
89
|
+
def visit_Expr(self, node: ast.Expr):
|
|
90
|
+
"""Visit expression statements to find configure_spawn_count() calls"""
|
|
91
|
+
if isinstance(node.value, ast.Call):
|
|
92
|
+
self._check_spawn_count_call(node.value)
|
|
93
|
+
self.generic_visit(node)
|
|
94
|
+
|
|
95
|
+
def visit_FunctionDef(self, node: ast.FunctionDef):
|
|
96
|
+
"""Visit function definitions and extract metadata if decorated with @xllify.fn"""
|
|
97
|
+
# Check if this function has an @xllify.fn decorator
|
|
98
|
+
xllify_decorator = self._find_xllify_decorator(node.decorator_list)
|
|
99
|
+
|
|
100
|
+
if xllify_decorator:
|
|
101
|
+
metadata = self._extract_metadata(node, xllify_decorator)
|
|
102
|
+
if metadata:
|
|
103
|
+
self.functions.append(metadata)
|
|
104
|
+
|
|
105
|
+
self.generic_visit(node)
|
|
106
|
+
|
|
107
|
+
def _check_spawn_count_call(self, call: ast.Call):
|
|
108
|
+
"""Check if this is a configure_spawn_count() call and extract the value"""
|
|
109
|
+
# Check for xllify.configure_spawn_count(...)
|
|
110
|
+
if isinstance(call.func, ast.Attribute):
|
|
111
|
+
if (
|
|
112
|
+
call.func.attr == "configure_spawn_count"
|
|
113
|
+
and isinstance(call.func.value, ast.Name)
|
|
114
|
+
and call.func.value.id == "xllify"
|
|
115
|
+
):
|
|
116
|
+
self._extract_spawn_count(call)
|
|
117
|
+
# Check for configure_spawn_count(...) where it was imported from xllify
|
|
118
|
+
elif isinstance(call.func, ast.Name):
|
|
119
|
+
if call.func.id == "configure_spawn_count":
|
|
120
|
+
self._extract_spawn_count(call)
|
|
121
|
+
|
|
122
|
+
def _extract_spawn_count(self, call: ast.Call):
|
|
123
|
+
"""Extract the spawn_count value from the function call"""
|
|
124
|
+
if call.args and isinstance(call.args[0], ast.Constant):
|
|
125
|
+
if isinstance(call.args[0].value, int):
|
|
126
|
+
self.spawn_count = call.args[0].value
|
|
127
|
+
|
|
128
|
+
def _find_xllify_decorator(self, decorators: List[ast.expr]) -> Optional[ast.Call]:
|
|
129
|
+
"""Find the @xllify.fn() or @fn() decorator call if present"""
|
|
130
|
+
for decorator in decorators:
|
|
131
|
+
if isinstance(decorator, ast.Call):
|
|
132
|
+
# Check if it's xllify.fn(...)
|
|
133
|
+
if isinstance(decorator.func, ast.Attribute):
|
|
134
|
+
if (
|
|
135
|
+
decorator.func.attr == "fn"
|
|
136
|
+
and isinstance(decorator.func.value, ast.Name)
|
|
137
|
+
and decorator.func.value.id == "xllify"
|
|
138
|
+
):
|
|
139
|
+
return decorator
|
|
140
|
+
# Check if it's fn(...) where fn was imported from xllify
|
|
141
|
+
elif isinstance(decorator.func, ast.Name):
|
|
142
|
+
if decorator.func.id == "fn":
|
|
143
|
+
return decorator
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
def _extract_metadata(
|
|
147
|
+
self, func_node: ast.FunctionDef, decorator: ast.Call
|
|
148
|
+
) -> Optional[FunctionMetadata]:
|
|
149
|
+
"""Extract all metadata from a decorated function"""
|
|
150
|
+
metadata = FunctionMetadata()
|
|
151
|
+
|
|
152
|
+
# Extract config from decorator arguments (this may set return_type)
|
|
153
|
+
decorator_params = self._extract_decorator_config(decorator, metadata)
|
|
154
|
+
decorator_return_type = metadata.return_type
|
|
155
|
+
|
|
156
|
+
# Extract function signature (this may override return_type from annotation)
|
|
157
|
+
self._extract_function_signature(func_node, metadata)
|
|
158
|
+
if decorator_return_type and decorator_return_type != "any":
|
|
159
|
+
metadata.return_type = decorator_return_type
|
|
160
|
+
|
|
161
|
+
if decorator_params:
|
|
162
|
+
self._merge_parameter_descriptions(metadata, decorator_params)
|
|
163
|
+
|
|
164
|
+
# Extract docstring as description only if not already set
|
|
165
|
+
if not metadata.description:
|
|
166
|
+
docstring = ast.get_docstring(func_node)
|
|
167
|
+
if docstring:
|
|
168
|
+
metadata.description = docstring.strip()
|
|
169
|
+
|
|
170
|
+
return metadata
|
|
171
|
+
|
|
172
|
+
def _extract_decorator_config(
|
|
173
|
+
self, decorator: ast.Call, metadata: FunctionMetadata
|
|
174
|
+
) -> Optional[Dict[str, Dict]]:
|
|
175
|
+
"""Extract configuration from @xllify.fn() decorator arguments
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Dictionary mapping parameter names to their metadata (description, type) if parameters kwarg exists
|
|
179
|
+
"""
|
|
180
|
+
decorator_params = None
|
|
181
|
+
|
|
182
|
+
# First positional argument is the function name
|
|
183
|
+
if decorator.args:
|
|
184
|
+
name_arg = decorator.args[0]
|
|
185
|
+
if isinstance(name_arg, ast.Constant):
|
|
186
|
+
metadata.config_name = name_arg.value
|
|
187
|
+
|
|
188
|
+
# Check for keyword arguments: description, category, parameters, return_type
|
|
189
|
+
# Note: execution_type is always "external" for Python functions
|
|
190
|
+
for keyword in decorator.keywords:
|
|
191
|
+
if keyword.arg == "description" and isinstance(keyword.value, ast.Constant):
|
|
192
|
+
metadata.description = keyword.value.value
|
|
193
|
+
elif keyword.arg == "category" and isinstance(keyword.value, ast.Constant):
|
|
194
|
+
metadata.category = keyword.value.value
|
|
195
|
+
elif keyword.arg == "return_type" and isinstance(keyword.value, ast.Constant):
|
|
196
|
+
metadata.return_type = keyword.value.value
|
|
197
|
+
elif keyword.arg == "parameters" and isinstance(keyword.value, ast.List):
|
|
198
|
+
decorator_params = self._parse_parameters_list(keyword.value)
|
|
199
|
+
|
|
200
|
+
return decorator_params
|
|
201
|
+
|
|
202
|
+
def _parse_parameters_list(self, params_list: ast.List) -> Dict[str, Dict]:
|
|
203
|
+
"""Parse the parameters list from decorator into a dict keyed by parameter name
|
|
204
|
+
|
|
205
|
+
Expects Parameter(name="...", type="...", description="...") objects
|
|
206
|
+
"""
|
|
207
|
+
result = {}
|
|
208
|
+
|
|
209
|
+
for elem in params_list.elts:
|
|
210
|
+
if isinstance(elem, ast.Call):
|
|
211
|
+
if isinstance(elem.func, ast.Name) and elem.func.id == "Parameter":
|
|
212
|
+
param_info = self._parse_parameter_call(elem)
|
|
213
|
+
if param_info and "name" in param_info:
|
|
214
|
+
result[param_info["name"]] = param_info
|
|
215
|
+
|
|
216
|
+
return result
|
|
217
|
+
|
|
218
|
+
def _parse_parameter_call(self, call: ast.Call) -> Dict[str, str]:
|
|
219
|
+
"""Parse a Parameter(...) call into a dict"""
|
|
220
|
+
param_info = {}
|
|
221
|
+
|
|
222
|
+
# Handle positional argument (name)
|
|
223
|
+
if call.args and isinstance(call.args[0], ast.Constant):
|
|
224
|
+
param_info["name"] = call.args[0].value
|
|
225
|
+
|
|
226
|
+
# Handle keyword arguments
|
|
227
|
+
for keyword in call.keywords:
|
|
228
|
+
if isinstance(keyword.value, ast.Constant):
|
|
229
|
+
param_info[keyword.arg] = keyword.value.value
|
|
230
|
+
|
|
231
|
+
return param_info
|
|
232
|
+
|
|
233
|
+
def _merge_parameter_descriptions(
|
|
234
|
+
self, metadata: FunctionMetadata, decorator_params: Dict[str, Dict]
|
|
235
|
+
):
|
|
236
|
+
"""Merge parameter descriptions from decorator into extracted parameter info"""
|
|
237
|
+
for param in metadata.parameters:
|
|
238
|
+
if param.name in decorator_params:
|
|
239
|
+
decorator_info = decorator_params[param.name]
|
|
240
|
+
if "description" in decorator_info:
|
|
241
|
+
param.description = decorator_info["description"]
|
|
242
|
+
if "type" in decorator_info and param.type_hint == "any":
|
|
243
|
+
param.type_hint = decorator_info["type"]
|
|
244
|
+
|
|
245
|
+
def _extract_function_signature(self, func_node: ast.FunctionDef, metadata: FunctionMetadata):
|
|
246
|
+
"""Extract parameter information from function signature"""
|
|
247
|
+
args = func_node.args
|
|
248
|
+
|
|
249
|
+
# Process regular arguments
|
|
250
|
+
num_defaults = len(args.defaults)
|
|
251
|
+
num_args = len(args.args)
|
|
252
|
+
|
|
253
|
+
for i, arg in enumerate(args.args):
|
|
254
|
+
if arg.arg == "self": # Skip self parameter
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
param = ParameterInfo(name=arg.arg)
|
|
258
|
+
|
|
259
|
+
# Extract type annotation
|
|
260
|
+
if arg.annotation:
|
|
261
|
+
param.type_hint = self._get_type_name(arg.annotation)
|
|
262
|
+
|
|
263
|
+
# Check if parameter has a default value (making it optional)
|
|
264
|
+
default_index = i - (num_args - num_defaults)
|
|
265
|
+
if default_index >= 0:
|
|
266
|
+
param.optional = True
|
|
267
|
+
default_value = args.defaults[default_index]
|
|
268
|
+
param.default = self._get_default_value(default_value)
|
|
269
|
+
|
|
270
|
+
metadata.parameters.append(param)
|
|
271
|
+
|
|
272
|
+
if args.vararg:
|
|
273
|
+
metadata.has_vararg = True
|
|
274
|
+
|
|
275
|
+
if args.kwarg:
|
|
276
|
+
metadata.has_kwargs = True
|
|
277
|
+
|
|
278
|
+
# Extract return type annotation
|
|
279
|
+
if func_node.returns:
|
|
280
|
+
metadata.return_type = self._get_type_name(func_node.returns)
|
|
281
|
+
|
|
282
|
+
def _get_type_name(self, annotation: ast.expr) -> str:
|
|
283
|
+
"""Extract type name from type annotation"""
|
|
284
|
+
if isinstance(annotation, ast.Name):
|
|
285
|
+
return annotation.id
|
|
286
|
+
elif isinstance(annotation, ast.Constant):
|
|
287
|
+
return str(annotation.value)
|
|
288
|
+
elif isinstance(annotation, ast.Subscript):
|
|
289
|
+
# Handle types like List[str], Dict[str, int], Optional[str]
|
|
290
|
+
if isinstance(annotation.value, ast.Name):
|
|
291
|
+
base_type = annotation.value.id
|
|
292
|
+
if isinstance(annotation.slice, ast.Name):
|
|
293
|
+
inner_type = annotation.slice.id
|
|
294
|
+
return f"{base_type}[{inner_type}]"
|
|
295
|
+
elif isinstance(annotation.slice, ast.Tuple):
|
|
296
|
+
inner_types = [self._get_type_name(elt) for elt in annotation.slice.elts]
|
|
297
|
+
return f"{base_type}[{', '.join(inner_types)}]"
|
|
298
|
+
return base_type
|
|
299
|
+
elif isinstance(annotation, ast.Attribute):
|
|
300
|
+
# Handle types like typing.Optional
|
|
301
|
+
return annotation.attr
|
|
302
|
+
|
|
303
|
+
return "any"
|
|
304
|
+
|
|
305
|
+
def _get_default_value(self, node: ast.expr) -> Any:
|
|
306
|
+
"""Extract default value from AST node"""
|
|
307
|
+
if isinstance(node, ast.Constant):
|
|
308
|
+
return node.value
|
|
309
|
+
elif isinstance(node, ast.Name):
|
|
310
|
+
if node.id == "None":
|
|
311
|
+
return None
|
|
312
|
+
elif node.id == "True":
|
|
313
|
+
return True
|
|
314
|
+
elif node.id == "False":
|
|
315
|
+
return False
|
|
316
|
+
elif isinstance(node, ast.List):
|
|
317
|
+
return []
|
|
318
|
+
elif isinstance(node, ast.Dict):
|
|
319
|
+
return {}
|
|
320
|
+
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def extract_functions(source_code: str) -> tuple[List[FunctionMetadata], Optional[int]]:
|
|
325
|
+
"""Parse Python source code and extract xllify function metadata
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
Tuple of (functions list, spawn_count if configured)
|
|
329
|
+
"""
|
|
330
|
+
try:
|
|
331
|
+
tree = ast.parse(source_code)
|
|
332
|
+
extractor = FunctionExtractor()
|
|
333
|
+
extractor.visit(tree)
|
|
334
|
+
return extractor.functions, extractor.spawn_count
|
|
335
|
+
except SyntaxError as e:
|
|
336
|
+
print(f"Syntax error in Python file: {e}", file=sys.stderr)
|
|
337
|
+
return [], None
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def output_json(script_name: str, functions: List[FunctionMetadata], spawn_count: Optional[int] = None) -> str:
|
|
341
|
+
"""Generate JSON output from extracted function metadata"""
|
|
342
|
+
output = {"script_name": script_name, "functions": [func.to_dict() for func in functions]}
|
|
343
|
+
if spawn_count is not None:
|
|
344
|
+
output["spawn_count"] = spawn_count
|
|
345
|
+
return json.dumps(output, indent=2)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def main():
|
|
349
|
+
if len(sys.argv) < 2:
|
|
350
|
+
print(f"Usage: {sys.argv[0]} <script.py>", file=sys.stderr)
|
|
351
|
+
sys.exit(1)
|
|
352
|
+
|
|
353
|
+
filename = sys.argv[1]
|
|
354
|
+
|
|
355
|
+
# Extract script name from filename
|
|
356
|
+
script_name = Path(filename).stem
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
with open(filename, "r", encoding="utf-8") as f:
|
|
360
|
+
source_code = f.read()
|
|
361
|
+
except FileNotFoundError:
|
|
362
|
+
print(f"Error: Could not open file {filename}", file=sys.stderr)
|
|
363
|
+
sys.exit(1)
|
|
364
|
+
except Exception as e:
|
|
365
|
+
print(f"Error reading file: {e}", file=sys.stderr)
|
|
366
|
+
sys.exit(1)
|
|
367
|
+
|
|
368
|
+
functions, spawn_count = extract_functions(source_code)
|
|
369
|
+
print(output_json(script_name, functions, spawn_count))
|
|
370
|
+
# yay
|
|
371
|
+
return 0
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
if __name__ == "__main__":
|
|
375
|
+
sys.exit(main())
|
xllify/install.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""
|
|
2
|
+
xllify-install - Download and install the xllify CLI binary
|
|
3
|
+
|
|
4
|
+
This module provides functionality to download and install the xllify CLI
|
|
5
|
+
binary for Windows and macOS.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import platform
|
|
11
|
+
import zipfile
|
|
12
|
+
import shutil
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_xllify_version() -> str:
|
|
20
|
+
"""
|
|
21
|
+
Read the xllify CLI version from the XLLIFY_DIST_VERSION file.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
The version string (e.g., "0.7.6")
|
|
25
|
+
"""
|
|
26
|
+
# Look for XLLIFY_DIST_VERSION file in the package directory
|
|
27
|
+
version_file = Path(__file__).parent / "XLLIFY_DIST_VERSION"
|
|
28
|
+
|
|
29
|
+
if not version_file.exists():
|
|
30
|
+
raise FileNotFoundError(
|
|
31
|
+
f"XLLIFY_DIST_VERSION file not found at {version_file}. "
|
|
32
|
+
"Please ensure the file exists in the package."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
return version_file.read_text().strip()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_download_urls(version: Optional[str] = None) -> dict:
|
|
39
|
+
"""
|
|
40
|
+
Get the download URLs for the xllify binaries based on platform.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
version: Specific version to download (defaults to version from XLLIFY_DIST_VERSION)
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Dictionary with 'dist' and 'cli' URLs for the current platform
|
|
47
|
+
"""
|
|
48
|
+
if version is None:
|
|
49
|
+
version = get_xllify_version()
|
|
50
|
+
|
|
51
|
+
system = platform.system()
|
|
52
|
+
base_url = "https://storage.googleapis.com/xllify-action-assets"
|
|
53
|
+
|
|
54
|
+
if system == "Windows":
|
|
55
|
+
return {
|
|
56
|
+
"dist": f"{base_url}/xllify-dist-v{version}-windows.zip",
|
|
57
|
+
"cli": f"{base_url}/xllify-cli-v{version}-windows.zip",
|
|
58
|
+
}
|
|
59
|
+
elif system == "Darwin": # macOS
|
|
60
|
+
return {"cli": f"{base_url}/xllify-cli-v{version}-macos.zip"}
|
|
61
|
+
else:
|
|
62
|
+
raise OSError(f"Unsupported platform: {system}. Only Windows and macOS are supported.")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_bin_directory() -> Path:
|
|
66
|
+
"""
|
|
67
|
+
Get the bin directory in the current virtual environment.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Path to the bin/Scripts directory where executables should be installed.
|
|
71
|
+
"""
|
|
72
|
+
# Check if we're in a virtual environment
|
|
73
|
+
venv_path = os.environ.get("VIRTUAL_ENV")
|
|
74
|
+
|
|
75
|
+
if venv_path:
|
|
76
|
+
base_path = Path(venv_path)
|
|
77
|
+
else:
|
|
78
|
+
base_path = Path(sys.prefix)
|
|
79
|
+
|
|
80
|
+
if platform.system() == "Windows":
|
|
81
|
+
bin_dir = base_path / "Scripts"
|
|
82
|
+
else:
|
|
83
|
+
bin_dir = base_path / "bin"
|
|
84
|
+
|
|
85
|
+
return bin_dir
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def download_file(url: str, dest_path: Path, progress: bool = True) -> None:
|
|
89
|
+
print(f"Downloading from {url}...")
|
|
90
|
+
|
|
91
|
+
response = requests.get(url, stream=True)
|
|
92
|
+
response.raise_for_status() # Raise exception for HTTP errors
|
|
93
|
+
|
|
94
|
+
total_size = int(response.headers.get("content-length", 0))
|
|
95
|
+
|
|
96
|
+
with open(dest_path, "wb") as f:
|
|
97
|
+
if total_size and progress:
|
|
98
|
+
downloaded = 0
|
|
99
|
+
chunk_size = 8192
|
|
100
|
+
|
|
101
|
+
for chunk in response.iter_content(chunk_size=chunk_size):
|
|
102
|
+
if chunk: # filter out keep-alive new chunks
|
|
103
|
+
f.write(chunk)
|
|
104
|
+
downloaded += len(chunk)
|
|
105
|
+
|
|
106
|
+
# Show progress
|
|
107
|
+
percent = (downloaded / total_size) * 100
|
|
108
|
+
print(f"\rProgress: {percent:.1f}% ({downloaded}/{total_size} bytes)", end="")
|
|
109
|
+
|
|
110
|
+
print() # New line after progress
|
|
111
|
+
else:
|
|
112
|
+
# No progress bar, just download
|
|
113
|
+
for chunk in response.iter_content(chunk_size=8192):
|
|
114
|
+
if chunk:
|
|
115
|
+
f.write(chunk)
|
|
116
|
+
|
|
117
|
+
print(f"Downloaded to {dest_path}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def extract_zip(zip_path: Path, extract_dir: Path) -> None:
|
|
121
|
+
print(f"Extracting {zip_path.name}...")
|
|
122
|
+
|
|
123
|
+
with zipfile.ZipFile(zip_path, "r") as zip_ref:
|
|
124
|
+
zip_ref.extractall(extract_dir)
|
|
125
|
+
|
|
126
|
+
print(f"Extracted to {extract_dir}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def install_binaries_from_zip(zip_url: str, zip_name: str, bin_dir: Path, temp_dir: Path) -> None:
|
|
130
|
+
# Download the zip file
|
|
131
|
+
zip_path = temp_dir / zip_name
|
|
132
|
+
download_file(zip_url, zip_path)
|
|
133
|
+
|
|
134
|
+
# Extract the zip file to a subdirectory to avoid conflicts
|
|
135
|
+
extract_subdir = temp_dir / zip_name.replace(".zip", "")
|
|
136
|
+
extract_subdir.mkdir(exist_ok=True)
|
|
137
|
+
extract_zip(zip_path, extract_subdir)
|
|
138
|
+
|
|
139
|
+
# Find and move executable files to bin directory
|
|
140
|
+
# On Windows, look for .exe files; on macOS/Unix, look for files without extension or with execute permission
|
|
141
|
+
if platform.system() == "Windows":
|
|
142
|
+
executables = list(extract_subdir.glob("*.exe"))
|
|
143
|
+
else:
|
|
144
|
+
# On macOS/Unix, find executable files
|
|
145
|
+
executables = [f for f in extract_subdir.iterdir() if f.is_file() and os.access(f, os.X_OK)]
|
|
146
|
+
|
|
147
|
+
if not executables:
|
|
148
|
+
print(f"Warning: No executable files found in {zip_name}")
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
for exe_file in executables:
|
|
152
|
+
dest_path = bin_dir / exe_file.name
|
|
153
|
+
|
|
154
|
+
# Remove existing file if it exists
|
|
155
|
+
if dest_path.exists():
|
|
156
|
+
print(f"Removing existing {dest_path.name}...")
|
|
157
|
+
dest_path.unlink()
|
|
158
|
+
|
|
159
|
+
# Move the executable to bin directory
|
|
160
|
+
shutil.move(str(exe_file), str(dest_path))
|
|
161
|
+
|
|
162
|
+
# Ensure it's executable on Unix-like systems
|
|
163
|
+
if platform.system() != "Windows":
|
|
164
|
+
dest_path.chmod(0o755)
|
|
165
|
+
|
|
166
|
+
print(f"Installed {exe_file.name} to {dest_path}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def install_binary(version: Optional[str] = None, custom_urls: Optional[dict] = None) -> None:
|
|
170
|
+
if version is None:
|
|
171
|
+
version = get_xllify_version()
|
|
172
|
+
|
|
173
|
+
if custom_urls is None:
|
|
174
|
+
download_urls = get_download_urls(version)
|
|
175
|
+
else:
|
|
176
|
+
download_urls = custom_urls
|
|
177
|
+
|
|
178
|
+
# Get the bin directory
|
|
179
|
+
bin_dir = get_bin_directory()
|
|
180
|
+
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
|
|
182
|
+
system = platform.system()
|
|
183
|
+
print(f"Installing xllify v{version} for {system} to {bin_dir}")
|
|
184
|
+
|
|
185
|
+
# Create a temporary directory for download
|
|
186
|
+
temp_dir = bin_dir / ".xllify-install-temp"
|
|
187
|
+
temp_dir.mkdir(exist_ok=True)
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
# Install dist binary (Windows only)
|
|
191
|
+
if "dist" in download_urls:
|
|
192
|
+
print("\nInstalling xllify-dist...")
|
|
193
|
+
install_binaries_from_zip(download_urls["dist"], "xllify-dist.zip", bin_dir, temp_dir)
|
|
194
|
+
|
|
195
|
+
# Install CLI binary (all platforms)
|
|
196
|
+
if "cli" in download_urls:
|
|
197
|
+
print("\nInstalling xllify-cli...")
|
|
198
|
+
install_binaries_from_zip(download_urls["cli"], "xllify-cli.zip", bin_dir, temp_dir)
|
|
199
|
+
|
|
200
|
+
print("\nInstallation complete!")
|
|
201
|
+
print(f"\nThe xllify binaries are now available in your PATH at:")
|
|
202
|
+
print(f" {bin_dir}")
|
|
203
|
+
|
|
204
|
+
# Check if bin directory is in PATH
|
|
205
|
+
path_dirs = os.environ.get("PATH", "").split(os.pathsep)
|
|
206
|
+
if str(bin_dir) not in path_dirs:
|
|
207
|
+
print("\nWarning: The bin directory may not be in your PATH.")
|
|
208
|
+
print("If you're using a virtual environment, make sure it's activated.")
|
|
209
|
+
|
|
210
|
+
finally:
|
|
211
|
+
# Clean up temporary directory
|
|
212
|
+
if temp_dir.exists():
|
|
213
|
+
shutil.rmtree(temp_dir)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def main() -> None:
|
|
217
|
+
"""Main entry point for the xllify-install command."""
|
|
218
|
+
import argparse
|
|
219
|
+
|
|
220
|
+
parser = argparse.ArgumentParser(
|
|
221
|
+
description="Download and install the xllify binaries for your platform"
|
|
222
|
+
)
|
|
223
|
+
parser.add_argument(
|
|
224
|
+
"--version",
|
|
225
|
+
help="Specific version to install (default: read from XLLIFY_DIST_VERSION)",
|
|
226
|
+
default=None,
|
|
227
|
+
)
|
|
228
|
+
parser.add_argument(
|
|
229
|
+
"--dist-url", help="Custom download URL for xllify-dist (Windows only)", default=None
|
|
230
|
+
)
|
|
231
|
+
parser.add_argument("--cli-url", help="Custom download URL for xllify-cli", default=None)
|
|
232
|
+
|
|
233
|
+
args = parser.parse_args()
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
custom_urls = None
|
|
237
|
+
if args.dist_url or args.cli_url:
|
|
238
|
+
custom_urls = {}
|
|
239
|
+
if args.dist_url:
|
|
240
|
+
custom_urls["dist"] = args.dist_url
|
|
241
|
+
if args.cli_url:
|
|
242
|
+
custom_urls["cli"] = args.cli_url
|
|
243
|
+
|
|
244
|
+
install_binary(version=args.version, custom_urls=custom_urls)
|
|
245
|
+
except Exception as e:
|
|
246
|
+
print(f"\nError during installation: {e}", file=sys.stderr)
|
|
247
|
+
sys.exit(1)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
if __name__ == "__main__":
|
|
251
|
+
main()
|
xllify/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker file for PEP 561 - indicates this package supports type hints
|