lua-annotations 0.1.0__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.
@@ -0,0 +1,8 @@
1
+ from lua_annotations.api.annotations import ExtensionRegistry
2
+ from . import lifecycle, index, networking
3
+
4
+
5
+ def load(ctx: ExtensionRegistry):
6
+ ctx.register_extension(index.IndexExtension())
7
+ ctx.register_extension(lifecycle.LifecycleExtension(), deps=['ManifestExtension'], hook_order='before')
8
+ ctx.register_extension(networking.NetworkingExtension())
@@ -0,0 +1,65 @@
1
+ from typing import Any
2
+
3
+ from lua_annotations.api.annotations import AnnotationBuildCtx, AnnotationDef, ExtensionRegistry, Extension
4
+ from lua_annotations.build_process import PostProcessCtx
5
+ from lua_annotations.parser_schemas import Annotation, LuaMethod
6
+
7
+ REMOTE_INSTANCE_MAP = {
8
+ 'function': 'RemoteFunction',
9
+ 'event': 'RemoteEvent',
10
+ 'unreliable': 'UnreliableRemoteEvent',
11
+ }
12
+
13
+
14
+ class NetworkingExtension(Extension):
15
+ def __init__(self):
16
+ self.remotes: dict[Any, Any] = {}
17
+
18
+
19
+ def remote_on_build(self, ctx: AnnotationBuildCtx):
20
+ anot: Annotation = ctx.annotation
21
+ adornee = anot.adornee
22
+ assert isinstance(adornee, LuaMethod)
23
+
24
+ class_name = REMOTE_INSTANCE_MAP[ctx.annotation.args_val[0]]
25
+ module_name = adornee.module.returned_name
26
+
27
+ anot.export_data["remote_name"] = adornee.name
28
+ anot.export_data["remote_parent"] = module_name
29
+
30
+ self.remotes.setdefault(module_name, {"ClassName": "Folder", "Children": {}})
31
+ self.remotes[module_name]["Children"][adornee.name] = {"ClassName": class_name}
32
+
33
+
34
+ def on_post_process(self, ctx: PostProcessCtx):
35
+ #Convert dict to valid .model.json format
36
+ root_children = []
37
+
38
+ for module_name, module_data in self.remotes.items():
39
+ module_children_map = module_data.get("Children", {})
40
+ module_children = []
41
+
42
+ for remote_name, remote_data in module_children_map.items():
43
+ module_children.append({
44
+ "Name": remote_name,
45
+ "ClassName": remote_data["ClassName"],
46
+ })
47
+
48
+ root_children.append({
49
+ "Name": module_name,
50
+ "ClassName": "Folder",
51
+ "Children": module_children,
52
+ })
53
+
54
+ model = {
55
+ "ClassName": "Folder",
56
+ "Children": root_children,
57
+ }
58
+
59
+ ctx.dump_json("shared", "Remotes.model.json", model)
60
+
61
+
62
+ def load(self, ctx: ExtensionRegistry):
63
+ ctx.register_anot(
64
+ AnnotationDef('remote', scope='method', retention='init', args=[str], on_build=self.remote_on_build)
65
+ )
@@ -0,0 +1,197 @@
1
+ import importlib
2
+ from pathlib import Path
3
+ import shutil
4
+ import sys
5
+ import time
6
+ from datetime import datetime
7
+
8
+ from .api.annotations import ENVIRONMENTS, ExtensionRegistry
9
+ from .build_process import BuildCtxList, BuildProcessCtx, Config, Environment, Extension, PostProcessCtx, Workspace, get_template
10
+ from .exceptions import BuildError, ConfigError
11
+ from .extensions import default as default_ext
12
+
13
+ WATCH_FILENAMES = ('*.lua', '*.luau')
14
+
15
+ def create_config(workdir: Path, config_file: Path):
16
+ if not config_file.exists():
17
+ default_config = get_template('annotations.config.json')
18
+ config_file.write_text(default_config)
19
+ print('Created a default config file')
20
+ else:
21
+ print('Config file already exists. Skipping')
22
+
23
+
24
+ def iter_rel_paths(path_map: dict[str, str], workdir: Path, env: Environment):
25
+ for path, lua_expr in path_map.items():
26
+ if '@' in path:
27
+ p, lua_expr = process_tags(path, lua_expr, env, workdir)
28
+ else:
29
+ p = workdir / Path(path)
30
+
31
+ if not p.is_dir():
32
+ print(f'WARNING: directory {p.as_posix()} does not exist')
33
+ continue
34
+
35
+ yield p, lua_expr
36
+
37
+
38
+ def import_extension_from_path(workdir: Path, entry: str):
39
+ workdir = workdir.resolve()
40
+ p = (workdir / entry).resolve()
41
+
42
+ if str(workdir) not in sys.path:
43
+ sys.path.insert(0, str(workdir))
44
+ importlib.invalidate_caches()
45
+
46
+ mod_name = '.'.join(p.relative_to(workdir).with_suffix('').parts)
47
+ return importlib.import_module(mod_name)
48
+
49
+
50
+ def import_extension(ext: Extension, workdir: Path):
51
+ entry_type, entry = ext
52
+
53
+ if entry_type == 'library':
54
+ return importlib.import_module("lua_annotations.extensions.game_framework.main")
55
+ return import_extension_from_path(workdir, entry)
56
+
57
+
58
+ def process_tags(raw: str, raw_expr: str, env: Environment, workdir: Path):
59
+ name, data = raw.split('@')
60
+
61
+ if name == 'wally':
62
+ package_dir_name = 'Packages' if env == 'shared' else 'ServerPackages'
63
+ packages = workdir / package_dir_name / '_Index'
64
+ ext_dir = next(packages.glob(f'*_{data}@*'), None)
65
+ if not ext_dir:
66
+ raise ConfigError(
67
+ f'wally package {data} not found under {packages.as_posix()}'
68
+ )
69
+
70
+ return ext_dir / data, f'require({raw_expr}.{data})'
71
+
72
+ raise ConfigError(f'invalid path tag: {raw}')
73
+
74
+
75
+ def build(workdir: Path, config: Config):
76
+ init_time = datetime.now()
77
+
78
+ for raw_workspace in config.workspaces:
79
+ #process workspace
80
+ workspace: Workspace = {}
81
+ for env in ENVIRONMENTS:
82
+ path_map = raw_workspace.get(env)
83
+ if not path_map:
84
+ raise ConfigError(f'path for the `{env}` environment is not defined in the config.')
85
+
86
+ rel_paths = dict(iter_rel_paths(path_map, workdir, env))
87
+ workspace[env] = rel_paths
88
+
89
+
90
+ #load extensions
91
+ reg = ExtensionRegistry()
92
+ default_ext.load(reg)
93
+
94
+ for ext in config.extensions:
95
+ #py_entry
96
+ module = import_extension(ext, workdir)
97
+ load_fn = getattr(module, 'load')
98
+
99
+ if not callable(load_fn):
100
+ raise BuildError(f'module {ext[1]} does not have a `load()` function')
101
+ load_fn(reg)
102
+
103
+
104
+ reg = reg.sort_extensions()
105
+ print(f'loaded {len(reg.anot_registry)} annotations')
106
+
107
+
108
+ #env processing
109
+ build_contexts: BuildCtxList = {}
110
+
111
+ for env in ENVIRONMENTS:
112
+ path_map = workspace.get(env)
113
+ if not path_map:
114
+ raise ConfigError(f'path for the `{env}` environment is not defined in the config.')
115
+
116
+ #process output root
117
+ rel_paths = workspace[env]
118
+ root_path = next(iter(rel_paths.keys()))
119
+ output_root = root_path / Path(config.out_dir_name)
120
+
121
+ shutil.rmtree(output_root, True)
122
+ output_root.mkdir(parents=True, exist_ok=True)
123
+
124
+ #create and use a ctx
125
+ ctx = BuildProcessCtx(reg, root_path, workspace, rel_paths, output_root, env)
126
+ for path in rel_paths:
127
+ ctx.process_dir(path)
128
+
129
+ build_contexts[env] = ctx
130
+
131
+
132
+ # run post-build hooks
133
+ if build_contexts:
134
+ ctx = PostProcessCtx(reg, workdir, workspace, build_contexts)
135
+ for hook in reg.post_build_hooks:
136
+ hook(ctx)
137
+
138
+ #logging
139
+ delta = datetime.now() - init_time
140
+ print(f'Built in {delta.total_seconds()}s')
141
+
142
+
143
+ #builds a fingerprint of all the last modified times of files
144
+ def _watch_fingerprint(workdir: Path, config_file: Path, config: Config):
145
+ output_dir_name = config.out_dir_name
146
+
147
+ #track config file
148
+ tracked: dict[str, int] = {str(config_file): config_file.stat().st_mtime_ns}
149
+
150
+ #track workspaces
151
+ for workspace in config.workspaces:
152
+ for env in ENVIRONMENTS:
153
+ path_map = workspace.get(env)
154
+ if not path_map:
155
+ continue
156
+
157
+ for rel_path in path_map:
158
+ env_workdir = workdir / rel_path
159
+ if not env_workdir.exists() or not env_workdir.is_dir():
160
+ continue
161
+
162
+ for pattern in WATCH_FILENAMES:
163
+ for file in env_workdir.rglob(pattern):
164
+ if output_dir_name in file.parts:
165
+ continue
166
+ tracked[str(file)] = file.stat().st_mtime_ns
167
+
168
+ return tuple(sorted(tracked.items()))
169
+
170
+
171
+ def watch(workdir: Path, config_file: Path, poll_interval: float = 1.0):
172
+ print('Running initial build...')
173
+ config = read_config(config_file)
174
+ build(workdir, config)
175
+
176
+ last_fingerprint = _watch_fingerprint(workdir, config_file, config)
177
+ print(f'Watching for changes in {workdir} (interval: {poll_interval}s). Press Ctrl+C to stop.')
178
+
179
+ #poll fingerprints every `poll_interval` seconds
180
+ while True:
181
+ time.sleep(poll_interval)
182
+
183
+ config = read_config(config_file)
184
+ fingerprint = _watch_fingerprint(workdir, config_file, config)
185
+
186
+ if fingerprint != last_fingerprint:
187
+ print('Change detected, rebuilding...')
188
+ build(workdir, config)
189
+ last_fingerprint = fingerprint
190
+
191
+
192
+ def read_config(config_file: Path):
193
+ import json
194
+
195
+ if not config_file.exists():
196
+ raise ConfigError('Config file not found. Run the program in init mode to create one!')
197
+ return Config(json.loads(config_file.read_text()))
@@ -0,0 +1,59 @@
1
+ import argparse
2
+ from pathlib import Path
3
+ import sys
4
+ from typing import Literal
5
+
6
+ from .exceptions import LuaAnnotationsError
7
+ from .init_project import build, create_config, read_config, watch
8
+
9
+
10
+ def main():
11
+ parser = argparse.ArgumentParser(prog='lua-anot')
12
+ parser.add_argument('mode', help='mode of the program', choices=['build', 'init', 'watch'])
13
+ parser.add_argument(
14
+ 'workdir',
15
+ nargs='?',
16
+ default='',
17
+ help='working directory for the program; this should be your rojo project root',
18
+ )
19
+ parser.add_argument('-c', '--config', default='annotations.config.json', help='filename of the config file to use')
20
+ parser.add_argument(
21
+ '--watch-interval',
22
+ type=float,
23
+ default=1.0,
24
+ help='polling interval in seconds when mode is watch',
25
+ )
26
+ args = parser.parse_args()
27
+
28
+ mode: Literal['build', 'init', 'watch'] = args.mode
29
+
30
+ workdir: Path = Path.cwd() / args.workdir
31
+ if not workdir.exists() or not workdir.is_dir():
32
+ raise LuaAnnotationsError(f'Invalid workdir: {workdir}. Provide a valid project directory.')
33
+
34
+ config_file: Path = workdir / args.config
35
+
36
+ #init mode
37
+ if mode == 'init':
38
+ create_config(workdir, config_file)
39
+ return
40
+
41
+ #main mode
42
+ if mode == 'build':
43
+ build(workdir, read_config(config_file))
44
+ return
45
+
46
+ #watch mode
47
+ watch(workdir, config_file, poll_interval=args.watch_interval)
48
+
49
+ return 0
50
+
51
+
52
+ if __name__ == '__main__':
53
+ try:
54
+ main()
55
+ except KeyboardInterrupt:
56
+ print('\nStopped watching.')
57
+ except LuaAnnotationsError as exc:
58
+ print(f'Error: {exc}')
59
+ sys.exit(1)
@@ -0,0 +1,277 @@
1
+ from dataclasses import dataclass, field
2
+ from pathlib import Path
3
+ from typing import TYPE_CHECKING, Any, TypeVar
4
+
5
+ from .api.annotations import AnnotationBuildCtx, AnnotationDef, ExtensionRegistry, SortedRegistry
6
+ from .parser_schemas import *
7
+
8
+ if TYPE_CHECKING:
9
+ from build_process import BuildProcessCtx
10
+
11
+ #helper functions
12
+ K = TypeVar('K')
13
+ V = TypeVar('V')
14
+ def reverse_dict(d: dict[K, V]) -> dict[V, K]:
15
+ return {v: k for k, v in d.items()}
16
+
17
+ def set_adornee(anots: list[Annotation], adornee: Adornee):
18
+ for anot in anots:
19
+ anot.adornee = adornee
20
+
21
+ def remove_whitespace(t: list[Any]):
22
+ return [p.strip() for p in t]
23
+
24
+ def map_param_list(params: list[str]):
25
+ out: dict[str, str] = {}
26
+ for param in params:
27
+ parts = remove_whitespace(param.split(':'))
28
+ if len(parts) > 1:
29
+ out[parts[0]] = parts[1]
30
+ else:
31
+ out[parts[0]] = 'any'
32
+
33
+ return out
34
+
35
+ #parsing
36
+ @dataclass
37
+ class FileParser():
38
+ reg: SortedRegistry
39
+ file: Path
40
+ build_ctx: 'BuildProcessCtx'
41
+ annotations: list[Annotation] = field(default_factory=list)
42
+ cur_annotations: list[Annotation] = field(default_factory=list)
43
+ modules: dict[str, LuaModule] = field(default_factory=dict)
44
+ types: dict[str, LuaType] = field(default_factory=dict)
45
+ cur_line = 0
46
+
47
+ def __post_init__(self):
48
+ self.file_name = self.file.name.split('.')[0]
49
+
50
+ #assertion functions
51
+ def _check_anot_scopes(self, line: str, anots: list[AnnotationDef]):
52
+ scope = anots[0].scope
53
+ for anot in anots:
54
+ if not anot.scope == scope:
55
+ self.error(line, f'all annotations must have scope: `{scope}`')
56
+
57
+ def _check_anot_relations(self, line: str, anots: list[AnnotationDef]):
58
+ for anot in anots:
59
+ for inc in anot.mutual_exclude:
60
+ if inc in anots:
61
+ self.error(line, f'annotation {anot.name} excludes {inc.name}, but it is present in this code block')
62
+
63
+ for inc in anot.mutual_include:
64
+ if not inc in anots:
65
+ self.error(line, f'annotation {anot.name} requires {inc.name}, but it is not present in this code block')
66
+
67
+
68
+ #parsing helpers
69
+ def _parse_anot_args(self, adef: AnnotationDef, args: list[str]):
70
+ kwargs_val: dict[str, Any] = {}
71
+ args_val: list[Any] = []
72
+
73
+ for i, arg in enumerate(args):
74
+ if '=' in arg:
75
+ name, val = [part.strip() for part in arg.split('=', 1)]
76
+ proc = adef.kwargs[name]
77
+ kwargs_val[name] = proc(val)
78
+ else:
79
+ proc = adef.args[i]
80
+ args_val.append(proc(arg))
81
+
82
+ return args_val, kwargs_val
83
+
84
+ def _parse_annotation(self, text: str, ctx: ExtensionRegistry):
85
+ parts = remove_whitespace(ANNOTATION_ARG_RE.split(text.removeprefix(ANNOTATION_PREFIX)))
86
+ name = parts[0]
87
+
88
+ adef = ctx.anot_registry.get(name)
89
+ if adef:
90
+ args, kwargs = self._parse_anot_args(adef, parts[1:])
91
+ return Annotation(adef, name, args, kwargs)
92
+ else:
93
+ self.error(text, 'Annotation does not exist')
94
+
95
+ def _get_dict_data(self, text: str):
96
+ matches = DICT_REGEX.findall(text)
97
+ if not len(matches) > 0:
98
+ self.error(text, 'line is not a dict')
99
+
100
+ keys: list[str] = [m[0] for m in matches]
101
+ values: list[str] = [m[1] for m in matches]
102
+
103
+ if len(keys) == len(values):
104
+ out: dict[str, str] = {}
105
+ for i, key in enumerate(keys):
106
+ out[key] = values[i].strip().removesuffix('}').strip()
107
+
108
+ return out
109
+
110
+ def _get_returned(self, text: str, default_name: str):
111
+ match = RETURN_REGEX.search(text)
112
+ if not match:
113
+ return
114
+
115
+ single: str = match.group(2)
116
+
117
+ if single:
118
+ return ReturnDefinition(default_name, 'single', single_module=single)
119
+ else:
120
+ tablestr: str = match.group(1)
121
+ if not tablestr:
122
+ self.error(text, 'module export is incorrectly defined')
123
+
124
+ dict_data = self._get_dict_data(tablestr)
125
+ if dict_data:
126
+ return ReturnDefinition(default_name, 'dict', dict_val=reverse_dict(dict_data))
127
+ else:
128
+ self.error(text, 'module export is not a table')
129
+
130
+ def _get_returned_value(self, text: str, returned: ReturnDefinition):
131
+ match = VARIABLE_REGEX.search(text)
132
+ if not match:
133
+ self.error(text, 'code block is not a variable declaration')
134
+
135
+ name: str = match.group(1)
136
+ returned_name, is_submodule = returned.get_returned_name(name)
137
+
138
+ if not (name and returned_name):
139
+ self.error(text, 'invalid returned value definition or it is not exported.')
140
+
141
+ return ReturnedValue(self.file, name, returned_name, is_submodule)
142
+
143
+
144
+ def _get_function(self, text: str, modules: dict[str, LuaModule]):
145
+ match = FUNCTION_REGEX.search(text)
146
+ if not match:
147
+ self.error(text, 'function is incorrectly defined')
148
+
149
+ module_name: str = match.group(1)
150
+ fun_name: str = match.group(2)
151
+ raw_params: str = match.group(3)
152
+ return_type: str = match.group(4) or 'any'
153
+
154
+ if not (module_name or fun_name):
155
+ self.error(text, 'method is incorrectly defined')
156
+
157
+ if not raw_params.strip() == '':
158
+ params = remove_whitespace(raw_params.split(','))
159
+ param_dict = map_param_list(params)
160
+ else:
161
+ param_dict = {}
162
+
163
+ if not module_name in modules:
164
+ self.error(module_name, 'cannot use method annotations for an unindexed module.')
165
+ return LuaMethod(fun_name, modules[module_name], param_dict, return_type)
166
+
167
+
168
+ #main functions
169
+ def error(self, text: str, message: str):
170
+ raise LuaParserError(message, text, self.cur_line, self.file_name)
171
+
172
+ def parse(self, text: str):
173
+ returned = self._get_returned(text, self.file_name)
174
+ if not returned:
175
+ print(f'Skipping file {self.file_name}; doesn\'t return a value')
176
+ return
177
+ lines = [l.rstrip() for l in text.splitlines()]
178
+
179
+ for i, line in enumerate(lines):
180
+ self.cur_line += 1
181
+ #skip empty lines
182
+ if line == '':
183
+ continue
184
+
185
+ #comments
186
+ elif line.startswith('--'):
187
+ #annotation
188
+ if line.startswith(ANNOTATION_PREFIX):
189
+ anot = self._parse_annotation(line, self.reg)
190
+ if anot:
191
+ self.cur_annotations.append(anot)
192
+ else:
193
+ self.error(line, 'Not an annotation')
194
+
195
+ else:
196
+ #if there are annotations in this block of code, then find adornee
197
+ if len(self.cur_annotations) > 0:
198
+ adefs = [anot.adef for anot in self.cur_annotations]
199
+
200
+ self._check_anot_relations(line, adefs)
201
+ self._check_anot_scopes(line, adefs)
202
+
203
+ scope = adefs[0].scope
204
+
205
+ #strip comments
206
+ line = line.split('--')[0]
207
+
208
+ #methods
209
+ if scope == 'method':
210
+ method = self._get_function(line, self.modules)
211
+ set_adornee(self.cur_annotations, method)
212
+
213
+ #module
214
+ elif scope == 'module':
215
+ match = MODULE_REGEX.search(line)
216
+ if not match:
217
+ self.error(line, 'code block is not a module')
218
+
219
+ name: str = match.group(1)
220
+ returned_name, is_submodule = returned.get_returned_name(name)
221
+
222
+ if not (name and returned_name):
223
+ self.error(line, 'invalid module definition or it is not returned.')
224
+
225
+ module = LuaModule(self.file, name, returned_name, is_submodule)
226
+ set_adornee(self.cur_annotations, module)
227
+ self.modules[module.name] = module
228
+
229
+
230
+ #returned value
231
+ elif scope == 'returned_value':
232
+ returned_value = self._get_returned_value(line, returned)
233
+ set_adornee(self.cur_annotations, returned_value)
234
+
235
+
236
+ #type
237
+ elif scope == 'type':
238
+ #get entire code block
239
+ block = ''
240
+ for line2 in lines[i:]:
241
+ block += line2 + '\n'
242
+ if '}' in line2:
243
+ break
244
+
245
+ #use type regex
246
+ match = TYPE_REGEX.search(block)
247
+ if not match:
248
+ self.error(line, 'code block is not a type definition')
249
+
250
+ exported = bool(match.group(1))
251
+ name: str = match.group(2)
252
+ contents: str = match.group(3)
253
+
254
+ if not (name and contents):
255
+ self.error(line, 'type definition is missing name or contents')
256
+
257
+ if contents.startswith('{'):
258
+ data = self._get_dict_data(contents)
259
+ else:
260
+ data = contents
261
+
262
+ if not data:
263
+ self.error(line, 'type definition is missing type data')
264
+
265
+ lua_type = LuaType(name, data, exported)
266
+
267
+ set_adornee(self.cur_annotations, lua_type)
268
+ self.types[name] = lua_type
269
+
270
+ #now run anot on_build
271
+ for anot in self.cur_annotations:
272
+ for adef in [anot.adef] + anot.adef.extends:
273
+ if adef.on_build:
274
+ adef.on_build(AnnotationBuildCtx(anot, self, self.build_ctx))
275
+
276
+ self.annotations += self.cur_annotations
277
+ self.cur_annotations = []