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/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