nimporter-plus 0.0.2__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,21 @@
1
+ import sys
2
+ import os
3
+ from icecream import ic, colorama
4
+
5
+ ic.configureOutput(
6
+ includeContext=True,
7
+ prefix=f'{colorama.Fore.CYAN}ic|{colorama.Fore.RESET} ',
8
+
9
+ # https://github.com/gruns/icecream/issues/35#issuecomment-908730426
10
+ outputFunction=lambda *args: print(*args)
11
+ )
12
+
13
+ ic.enabled = 'NIMPORTER_INSTRUMENT' in os.environ
14
+
15
+ from nimporter_plus.lib import (
16
+ WINDOWS, MACOS, LINUX, EXT_DIR, PLATFORM_TABLE, ARCH_TABLE, compiler_args, get_host_info
17
+ )
18
+
19
+ import nimporter_plus.nimporter # Register importers
20
+
21
+ from nimporter_plus.nexporter import get_nim_extensions, build_nim_extensions
nimporter_plus/cli.py ADDED
@@ -0,0 +1,206 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ import time
6
+ import shutil
7
+ import pathlib
8
+ import argparse
9
+ import subprocess
10
+ from typing import Any
11
+ from pathlib import Path
12
+ from cookiecutter.main import cookiecutter
13
+ from nimporter_plus.lib import *
14
+ from nimporter_plus.nimporter import *
15
+
16
+ # TODO(pbz): Need to move this to a doc/tutorial
17
+ SETUPPY_TEMPLATE: str = f'''
18
+ # Setup.py tutorial:
19
+ # https://github.com/navdeep-G/setup.py
20
+ # Edit `packages=` to fit your requirements
21
+
22
+ import setuptools, pathlib, sysconfig
23
+ from setuptools.command.build_ext import build_ext
24
+ import nimporter_plus
25
+
26
+
27
+ class NoSuffixBuilder(build_ext):
28
+ """
29
+ Optional.
30
+
31
+ Removes the target platform, architecture, and Python version from the
32
+ final artifact.
33
+
34
+ Example:
35
+ The artifact: `module.linux-x86_64.cpython.3.8.5.so`
36
+ Becomes: `module.so`
37
+ """
38
+ def get_ext_filename(self, ext_name: str) -> str:
39
+ filename = super().get_ext_filename(ext_name)
40
+ ext_suffix = sysconfig.get_config_var('EXT_SUFFIX')
41
+ return filename.replace(ext_suffix, '') + pathlib.Path(filename).suffix
42
+
43
+
44
+ setuptools.setup(
45
+ name="{ pathlib.Path().absolute().name }",
46
+ packages=[..], # Please read the above tutorial
47
+ ext_modules=nimporter_plus.build_nim_extensions()
48
+ cmdclass={{"build_ext": NoSuffixBuilder}},
49
+ )
50
+ '''
51
+
52
+
53
+ def nimporter_list() -> None:
54
+ for extension in find_extensions(Path()):
55
+ print(extension)
56
+ return
57
+
58
+
59
+ def nimporter_clean(path: Path) -> None:
60
+ for item in path.iterdir():
61
+ item_full_path = item.resolve().absolute()
62
+
63
+ if item.is_dir():
64
+ DELETE_THESE_DIRS = {EXT_DIR, '__pycache__', '.pytest_cache'}
65
+
66
+ if item.stem in DELETE_THESE_DIRS:
67
+ print('Deleting', item_full_path)
68
+ shutil.rmtree(item)
69
+
70
+ elif item.name.endswith('.egg-info'):
71
+ print('Deleting', item_full_path)
72
+ shutil.rmtree(item)
73
+
74
+ elif item.stem == 'dist' and (item.parent / 'setup.py').exists():
75
+ print('Deleting', item_full_path)
76
+ shutil.rmtree(item)
77
+
78
+ elif item.stem == 'build' and (item.parent / 'setup.py').exists():
79
+ print('Deleting', item_full_path)
80
+ shutil.rmtree(item)
81
+
82
+ else:
83
+ nimporter_clean(item)
84
+ return
85
+
86
+
87
+ def nimporter_init(extension_type: str, extension_name: str) -> None:
88
+ print(f'Initializing new extension {extension_type} "{extension_name}"')
89
+
90
+ if extension_type == 'mod':
91
+ Path(f'{extension_name}.nim').write_text(
92
+ 'import nimpy\n\nproc add(a: int, b: int): int {.exportpy.} =\n'
93
+ ' return a + b\n'
94
+ )
95
+
96
+ elif extension_type == 'lib':
97
+ cookiecutter(
98
+ template='https://github.com/Pebaz/template-nimporter-ext-lib',
99
+ extra_context=dict(ext_name=extension_name),
100
+ no_input=True
101
+ )
102
+
103
+ else:
104
+ raise ValueError(
105
+ f'Extension is not one of [`mod`, `lib`], got: {extension_type}'
106
+ )
107
+ return
108
+
109
+
110
+ def nimporter_compile() -> None:
111
+ def current_time_ms() -> float:
112
+ return round(time.time() * 1000)
113
+
114
+ overall_start = current_time_ms()
115
+
116
+ nimporter_clean(Path())
117
+
118
+ for ext in find_extensions(Path()):
119
+ print(
120
+ f'Building Extension {"Lib" if ext.is_dir() else "Mod"}: '
121
+ f'{ext.name}'
122
+ )
123
+
124
+ start = current_time_ms()
125
+ module_path = ext if ext.is_file() else ext / f'{ext.name}.nim'
126
+ compile_extension_to_lib(ExtLib(module_path, Path(), ext.is_dir()))
127
+ print(' Completed in', current_time_ms() - start, 'ms')
128
+
129
+ print(
130
+ 'Completed all in',
131
+ (current_time_ms() - overall_start) / 1000.0,
132
+ 'seconds'
133
+ )
134
+ return
135
+
136
+
137
+ def build_parser() -> argparse.ArgumentParser:
138
+ parser = argparse.ArgumentParser(description='Nimporter CLI')
139
+ subs = parser.add_subparsers(dest='cmd', required=True)
140
+
141
+ # List command
142
+ subs.add_parser('list', help='List Nim extensions starting in current dir')
143
+
144
+ # Clean command
145
+ subs.add_parser(
146
+ 'clean',
147
+ help=(
148
+ 'Run in project root to recursively remove __pycache__, '
149
+ '.egg-info, build, and dist folders used by Nimporter'
150
+ )
151
+ )
152
+
153
+ # Init command
154
+ init = subs.add_parser(
155
+ 'init',
156
+ help='Initializes the folder structure of a new extension'
157
+ )
158
+ init.add_argument(
159
+ 'extension_type',
160
+ type=str,
161
+ help=(
162
+ 'Either `mod` or `lib`. Extension modules are single files, '
163
+ 'extension libraries are are fully configurable mini Nim projects'
164
+ )
165
+ )
166
+ init.add_argument(
167
+ 'extension_name',
168
+ type=str,
169
+ help='The importable name of the extension module or library'
170
+ )
171
+
172
+ # Compile command
173
+ subs.add_parser(
174
+ 'compile',
175
+ help='Precompile all extensions exactly as if they were imported'
176
+ )
177
+
178
+ return parser
179
+
180
+
181
+ def main(cli_args: list[str] | None = None) -> int:
182
+ args = build_parser().parse_args(cli_args or sys.argv[1:])
183
+
184
+ if args.cmd == 'list':
185
+ nimporter_list()
186
+
187
+ elif args.cmd == 'clean':
188
+ # cwd = pathlib.Path()
189
+ # print('Cleaning Directory:', cwd.resolve())
190
+ # clean(cwd)
191
+ nimporter_clean(Path().resolve().absolute())
192
+
193
+ # elif args.cmd == 'bundle': # nimporter_bundle has nit been defined yet.
194
+ # nimporter_bundle(args.exp)
195
+
196
+ elif args.cmd == 'compile':
197
+ nimporter_compile()
198
+
199
+ elif args.cmd == 'init':
200
+ nimporter_init(args.extension_type, args.extension_name)
201
+
202
+ return 0
203
+
204
+
205
+ if __name__ == '__main__':
206
+ sys.exit(main(sys.argv[1:]))
nimporter_plus/lib.py ADDED
@@ -0,0 +1,345 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ import shlex
6
+ import shutil
7
+ import hashlib
8
+ import tempfile
9
+ import platform
10
+ import sysconfig
11
+ import subprocess
12
+ import cpuinfo
13
+ from typing import Any, Text
14
+ from collections.abc import Iterator
15
+ from pathlib import Path
16
+ from contextlib import contextmanager
17
+ from icecream import ic
18
+
19
+ PathParts = tuple[str, str, str] | tuple[str] | tuple[str, str]
20
+
21
+ PYTHON: str = 'python' if sys.platform == 'win32' else 'python3'
22
+ PIP: str = 'pip' if shutil.which('pip') else 'pip3'
23
+ PYTHON_LIB_EXT: str = sysconfig.get_config_var('EXT_SUFFIX')
24
+ WINDOWS: str = 'windows'
25
+ MACOS: str = 'darwin'
26
+ LINUX: str = 'linux'
27
+ EXT_DIR: str = 'nim-extensions'
28
+
29
+ PLATFORM_TABLE: dict[str, str] = { # Keys are known to Python and values are Nim-understood
30
+ 'windows': 'Windows',
31
+ 'darwin': 'MacOSX',
32
+ 'linux': 'Linux',
33
+ }
34
+
35
+ ARCH_TABLE: dict[str, str] = { # Keys are known to Python and values are Nim-understood
36
+ 'x86_32': 'i386',
37
+ 'x86_64': 'amd64',
38
+ 'arm_8': 'arm64',
39
+ 'aarch64': 'arm64',
40
+ 'arm64': 'arm64',
41
+ }
42
+
43
+ compiler_args: list[str] = [
44
+ ]
45
+
46
+ ALWAYS_ARGS: list[str] = [
47
+ 'nimble', # Installs dependencies :)
48
+ 'c',
49
+ '--accept', # Allow installing dependencies
50
+ '--skipUserCfg',
51
+ '--app:lib',
52
+ '--backend:c',
53
+ '--threads:on',
54
+ '--warning[ProveInit]:off', # https://github.com/Pebaz/nimporter/issues/41
55
+ ]
56
+
57
+
58
+ def find_extensions(path: Path) -> list[Path]:
59
+ nim_exts = []
60
+
61
+ for item in path.iterdir():
62
+ if item.is_dir() and list(item.glob('*.nimble')):
63
+ # Treat directory as one single Extension
64
+ (nimble_file,) = item.glob('*.nimble')
65
+
66
+ # NOTE(pbz): Folder must contain .nimble file of exact same name
67
+ nim_file = nimble_file.parent / (nimble_file.stem + '.nim')
68
+
69
+ # NOTE(pbz): Folder must contain Nim file of exact same name.
70
+ if nim_file.exists():
71
+ nim_exts.append(item)
72
+
73
+ elif item.is_dir():
74
+ # Treat item as directory
75
+ nim_exts.extend(find_extensions(item))
76
+
77
+ elif item.suffix == '.nim':
78
+ # Treat item as a Nim Extension.
79
+ nim_exts.append(item)
80
+
81
+ return nim_exts
82
+
83
+
84
+ def get_import_path(path: Path, root: Path) -> str:
85
+ """
86
+ Coerce proper import path using root path
87
+
88
+ Args:
89
+ library(bool): hint as to how to treat the `module_path` parameter.
90
+ module_name(str): name of nim module.
91
+ module_path(Path): path to nim module.
92
+ root(Path): path to project root.
93
+
94
+ Returns:
95
+ str: Returns the path of the nim module.
96
+ """
97
+
98
+ def get_import_prefix(module_path: Path, root: Path) -> PathParts:
99
+ root_path = root.resolve()
100
+ full_path = module_path.resolve()
101
+
102
+ assert full_path >= root_path, 'Extension path is not within root dir.'
103
+
104
+ return ic(full_path.parts[len(root_path.parts):])
105
+
106
+ library = path.is_dir()
107
+ module_name = path.stem
108
+ module_path = path if path.is_file() else path / path.stem
109
+ import_prefix = get_import_prefix(module_path.parent, root)
110
+ module_part = tuple() if library else (module_name,)
111
+ import_path = '.'.join(import_prefix + module_part)
112
+
113
+ return ic(import_path)
114
+
115
+
116
+ @contextmanager
117
+ def convert_to_lib_if_needed(path: Path) -> Iterator[Path]:
118
+ if path.is_file():
119
+ package_root = None
120
+ for parent in path.parents:
121
+ nimbles = list(parent.glob('*.nimble'))
122
+ matching = [n for n in nimbles if n.stem in (path.stem, parent.name)]
123
+ if matching:
124
+ package_root = parent
125
+ break
126
+
127
+ if package_root:
128
+ yield package_root
129
+ else:
130
+ with tempfile.TemporaryDirectory() as compilation_dir:
131
+ shutil.copy(path, compilation_dir)
132
+ fake_nimble = Path(compilation_dir) / path.with_suffix('.nimble').name
133
+ fake_nimble.write_text('requires "nimpy"\n')
134
+ yield Path(compilation_dir)
135
+ else:
136
+ yield path
137
+
138
+
139
+ def run_process(
140
+ process_args: list[str],
141
+ show_output: bool = False,
142
+ ) -> tuple[int, bytes | Text, bytes | Text]:
143
+ """
144
+ Invokes the compiler (or any executable) and returns the output.
145
+
146
+ While this can (and has been) used to call executables other than Nim
147
+ and Nimble, it should be noted that the warnings and hints are artifacts
148
+ of being mainly targeted as a Nim compiler invoker.
149
+
150
+ Args:
151
+ process_args(list): the arg being the executable and the rest are args.
152
+
153
+ Returns:
154
+ A tuple containing any errors, warnings, or hints from the
155
+ compilation process.
156
+ """
157
+ try:
158
+ process = subprocess.run(
159
+ process_args,
160
+ stdout=None if show_output else subprocess.PIPE,
161
+ stderr=None if show_output else subprocess.PIPE,
162
+ )
163
+ except FileNotFoundError:
164
+ executable = process_args[0] if process_args else "Command"
165
+ raise FileNotFoundError(
166
+ f"Cannot find '{executable}'. Please ensure it is installed and in your system PATH."
167
+ )
168
+
169
+ code, out, err = process.returncode, process.stdout, process.stderr
170
+ out = out.decode(errors='ignore') if out else '' # type: ignore[assignment]
171
+ err = err.decode(errors='ignore') if err else '' # type: ignore[assignment]
172
+
173
+ return code, out, err
174
+
175
+
176
+ @contextmanager
177
+ def cd(path: Path) -> Iterator[Path]:
178
+ "Convenience function to step in and out of a directory temporarily."
179
+ cwd = os.getcwd()
180
+ os.chdir(path)
181
+ try:
182
+ yield path
183
+ finally:
184
+ os.chdir(cwd)
185
+
186
+
187
+ def get_c_compiler_used_to_build_python() -> str:
188
+ "This func is included just to be a bit clearer as to its significance."
189
+ return 'vcc' if 'MSC' in sys.version else 'gcc'
190
+
191
+
192
+ def get_host_info() -> tuple[str, str, str]:
193
+ """
194
+ Returns the host platform, architecture, and C compiler used to build the
195
+ running Python process.
196
+ """
197
+ # Calling get_cpu_info() is expensive
198
+ if not getattr(get_host_info, 'host_arch', None):
199
+ setattr(get_host_info, 'host_arch', cpuinfo.get_cpu_info()['arch'].lower())
200
+ # get_host_info.host_arch = cpuinfo.get_cpu_info()['arch'].lower()
201
+
202
+ return ic((
203
+ platform.system().lower(),
204
+ get_host_info.host_arch, # type: ignore[attr-defined]
205
+ get_c_compiler_used_to_build_python()
206
+ ))
207
+
208
+
209
+ def ensure_nimpy() -> None:
210
+ """
211
+ Makes sure that the Nimpy Nim library is installed.
212
+
213
+ Verifies that the [Nimpy Library](https://github.com/yglukhov/nimpy) is
214
+ installed and installs it otherwise.
215
+
216
+ NOTE: Nimporter would not be possible without Nimpy. Thank you
217
+ Yuriy Glukhov for making this project possible!
218
+ """
219
+ ic()
220
+
221
+ show_output = 'NIMPORTER_INSTRUMENT' in os.environ
222
+ code, *_ = run_process(shlex.split('nimble path nimpy'), show_output)
223
+
224
+ if code != 0:
225
+ ic()
226
+ nimble_args = shlex.split('nimble install nimpy --accept')
227
+ code, _, stderr = run_process(nimble_args, show_output)
228
+
229
+ if code:
230
+ raise CompilationFailedException(stderr)
231
+ return
232
+
233
+
234
+ class NimporterException(Exception):
235
+ "Base exception for Nimporter's exception hierarchy."
236
+ pass
237
+
238
+
239
+ class CompilationFailedException(NimporterException):
240
+ def __init__(self, stderr: bytes | str) -> None:
241
+ if isinstance(stderr, bytes):
242
+ stderr_str = stderr.decode(errors='ignore')
243
+ else:
244
+ stderr_str = str(stderr)
245
+ super().__init__(
246
+ f'\n{"="*60}\nNim Compilation Failed:\n{stderr_str.strip()}\n{"="*60}'
247
+ )
248
+ return
249
+
250
+
251
+ class ImportFailedException(NimporterException):
252
+ "Custom exception for when compilation succeeds but importing fails."
253
+ pass
254
+
255
+
256
+ def hash_extension(module_path: Path) -> bytes:
257
+ """
258
+ Convenience function to hash an extension module or extension library recursively,
259
+ including any associated project configuration files (.nimble, config.nims, .cfg).
260
+ """
261
+ digest = hashlib.sha256()
262
+ root_folder = module_path.parent if module_path.is_file() else module_path
263
+
264
+ def walk_folder(path: Path) -> Iterator[Path]:
265
+ for item in sorted(path.iterdir()):
266
+ if item.is_dir():
267
+ if item.name in ('__pycache__', 'nimcache', '.git', '.venv', 'build', 'dist', '.pytest_cache', 'node_modules'):
268
+ continue
269
+ yield from walk_folder(item)
270
+ elif item.suffix in ('.nim', '.nimble', '.nims', '.cfg', '.json', '.c', '.h'):
271
+ yield item
272
+
273
+ for item in walk_folder(root_folder):
274
+ digest.update(str(item.relative_to(root_folder)).encode())
275
+ with item.open('rb') as f:
276
+ while buf := f.read(65536):
277
+ digest.update(buf)
278
+
279
+ # Also check parent directories up to project root for .nimble or config.nims
280
+ for parent in root_folder.parents:
281
+ if parent.name in ('dl', 'Users', 'home', ''):
282
+ break
283
+ configs = sorted([
284
+ c for c in parent.iterdir()
285
+ if c.is_file() and c.suffix in ('.nimble', '.nims', '.cfg')
286
+ ])
287
+ for cfg in configs:
288
+ digest.update(str(cfg).encode())
289
+ with cfg.open('rb') as f:
290
+ while buf := f.read(65536):
291
+ digest.update(buf)
292
+
293
+ ic(digest.hexdigest())
294
+ return digest.digest()
295
+
296
+
297
+ class ExtLib:
298
+ """
299
+ All extensions are assumed to be libraries only. Modules are convert to
300
+ libraries as needed.
301
+ """
302
+ def __init__(self, path: Path, root: Path, library_hint: bool) -> None:
303
+ """
304
+ Args:
305
+ path(str): the relative path to the Nim file (for both lib & mod).
306
+ """
307
+ self.library = all((
308
+ any(path.parent.glob(f'{path.stem}.nim')),
309
+ any(path.parent.glob(f'{path.stem}.nimble')),
310
+ any(path.parent.glob(f'{path.stem}.nim.cfg'))
311
+ ))
312
+
313
+ assert library_hint and self.library if library_hint else True, (
314
+ f'ExtLib must define: {path.stem}.nim, {path.stem}.nimble, and '
315
+ f'{path.stem}.nim.cfg'
316
+ )
317
+
318
+ self.symbol = path.stem
319
+ self.module_path = path
320
+
321
+ if self.library:
322
+ self.relative_path = path.parent
323
+ self.full_path = path.parent.resolve().absolute()
324
+
325
+ else:
326
+ self.relative_path = path
327
+ self.full_path = path.resolve().absolute()
328
+
329
+ self.pycache = self.full_path.parent / '__pycache__'
330
+
331
+ self.import_namespace = get_import_path(self.relative_path, root)
332
+ self.hash_filename = self.pycache / f'{self.symbol}.hash'
333
+ self.build_artifact = (
334
+ self.pycache / f'{self.symbol}{PYTHON_LIB_EXT}'
335
+ )
336
+ return
337
+
338
+ def __str__(self) -> str:
339
+ return f'<ExtLib {self.import_namespace}>'
340
+
341
+ def __repr__(self) -> str:
342
+ return str(self)
343
+
344
+ def __format__(self, *args, **kwargs) -> str: # type: ignore[no-untyped-def]
345
+ return str(self)