Skip to content

blext

blext.blender

Run the Blender command in various useful ways.

ATTRIBUTE DESCRIPTION
PATH_BLENDER_PYTHON_SCRIPTS

Python source code to be executed inside of Blender. This is shipped with blext.

detect_blender_version

detect_blender_version(blender_exe: Path) -> BLVersion

Detect the version of Blender by running blender --version.

Source code in blext/blender.py
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
def detect_blender_version(blender_exe: Path) -> extyp.BLVersion:
	"""Detect the version of Blender by running `blender --version`."""
	bl_process = run_blender(
		blender_exe,
		args=('--version',),
		capture=True,
	)
	if bl_process.stdout is None:
		msg = 'Blender process returned no output.'
		raise RuntimeError(msg)

	# Extract Version Output String
	blender_version_output = bl_process.stdout.read()
	blender_version_output = (
		blender_version_output
		if isinstance(blender_version_output, str)
		else blender_version_output.decode('utf-8')
	)

	# Extract Version Output String
	bl_release = extyp.BLReleaseDiscovered.from_blender_version_output(
		blender_version_output
	)
	return bl_release.bl_version

run_blender

run_blender(
	blender_exe: Path,
	startup_script: Path | None = None,
	factory_startup: bool = True,
	headless: bool = False,
	args: tuple[str, ...] = (),
	env: frozendict[str, str] = _EMPTY_FROZENDICT,
	capture: bool = True,
	block: bool = True,
	bufsize: int = 0,
) -> Popen[str] | Popen[bytes]

Run a Blender command.

Notes

Env Security: For security reasons, it may be desirable to use a minimal env. Depending on the threat model, passing os.environ may be sufficient, as this is generally not less secure than launching Blender normally.

Handling CTRL+C: When block=False, CTRL+C (aka. KeyboardInterrupt) will not automatically close the Blender subprocess.

Signal Handlers: This function does not register signal handlers.

When block=True, CTRL+C is handled with a try/except that catches KeyboardInterrupt.

The behavior of CTRL+C while this function is running is as follows:

  • When block=True, CTRL+C will also exit the underlying Blender process.
  • When block=False, CTRL+C must be manually handled.
PARAMETER DESCRIPTION
blender_exe

Path to a valid Blender executable.

TYPE: Path

startup_script

Path to a Python script to run as Blender starts.

TYPE: Path | None DEFAULT: None

factory_startup

Temporarily reset Blender to factory settings.

  • In particular, this disables other addons/extensions and/or non-standard user preferences.

TYPE: bool DEFAULT: True

headless

Run Blender without a user interface.

  • When False, it is suggested to pass env=os.environ, as it can be a little difficult to manually select environment variables responsible for window initialization, audio, and more, robustly enough.

TYPE: bool DEFAULT: False

args

Extra CLI arguments to pass to the Blender command invocation.

TYPE: tuple[str, ...] DEFAULT: ()

env

Environment variables to set.

TYPE: frozendict[str, str] DEFAULT: _EMPTY_FROZENDICT

capture

Whether to capture stderr and stdout to a string. When False, Blender's I/O will passthrough completely.

TYPE: bool DEFAULT: True

block

Wait for blender to exit before returning from this function.

TYPE: bool DEFAULT: True

bufsize

Passthrough to subprocess.Popen(..., bufsize=bufsize). If you don't know what this is, don't touch it!

TYPE: int DEFAULT: 0

Source code in blext/blender.py
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
def run_blender(  # noqa: PLR0913
	blender_exe: Path,
	startup_script: Path | None = None,
	factory_startup: bool = True,
	headless: bool = False,
	args: tuple[str, ...] = (),
	env: frozendict[str, str] = _EMPTY_FROZENDICT,
	capture: bool = True,
	block: bool = True,
	bufsize: int = 0,
) -> subprocess.Popen[str] | subprocess.Popen[bytes]:
	"""Run a Blender command.

	Notes:
		**Env Security**: For security reasons, it may be desirable to use a minimal `env`. Depending on the threat model, passing `os.environ` may be sufficient, as this is generally not less secure than launching Blender normally.

		**Handling `CTRL+C`**: When `block=False`, `CTRL+C` (aka. `KeyboardInterrupt`) will not automatically close the Blender subprocess.

		**Signal Handlers**: This function does not register signal handlers.

		When `block=True`, `CTRL+C` is handled with a `try/except` that catches `KeyboardInterrupt`.

		The behavior of `CTRL+C` while this function is running is as follows:

		- When `block=True`, `CTRL+C` will also exit the underlying Blender process.
		- When `block=False`, `CTRL+C` must be manually handled.


	Parameters:
		blender_exe: Path to a valid Blender executable.
		startup_script: Path to a Python script to run as Blender starts.
		factory_startup: Temporarily reset Blender to factory settings.

			- In particular, this disables other addons/extensions and/or non-standard user preferences.
		headless: Run Blender without a user interface.

			- When `False`, it is suggested to pass `env=os.environ`, as it can be a little difficult to manually select environment variables responsible for window initialization, audio, and more, robustly enough.
		args: Extra CLI arguments to pass to the Blender command invocation.
		env: Environment variables to set.
		capture: Whether to capture `stderr` and `stdout` to a string.
			When `False`, Blender's I/O will passthrough completely.
		block: Wait for `blender` to exit before returning from this function.
		bufsize: Passthrough to `subprocess.Popen(..., bufsize=bufsize)`.
			_If you don't know what this is, don't touch it!_
	"""
	blender_command = [
		str(blender_exe),
		*(['--python', str(startup_script)] if startup_script is not None else []),
		*(['--factory-startup'] if factory_startup else []),
		*(['--background'] if headless else []),
		*args,
	]

	if capture:
		bl_process = subprocess.Popen(
			blender_command,
			bufsize=bufsize,
			env=dict(env),
			stdout=subprocess.PIPE,
			stderr=subprocess.STDOUT,
			text=True,
		)
	else:
		bl_process = subprocess.Popen(
			blender_command,
			bufsize=bufsize,
			env=dict(env),
			stdin=sys.stdin,
			stdout=sys.stdout,
			stderr=sys.stdout,
		)

	if block:
		try:
			_ = bl_process.wait()
		except KeyboardInterrupt:
			bl_process.terminate()

	return bl_process

run_extension

run_extension(
	blender_exe: Path,
	*,
	path_zip: Path,
	path_blend: Path | None = None,
	headless: bool = False,
	factory_startup: bool = True,
) -> None

Run a Blender extension inside of Blender.

Notes

Data is passed to the startup script via env vars: - BLEXT_ZIP_PATH: Path to the extension zipfile to install and run.

PARAMETER DESCRIPTION
blender_exe

Path to a valid Blender executable.

TYPE: Path

path_zip

Extension zipfile to check.

TYPE: Path

path_blend

Optional .blend file to open, after the extension is installed.

TYPE: Path | None DEFAULT: None

headless

Whether to run Blender without a UI.

TYPE: bool DEFAULT: False

factory_startup

Temporarily reset Blender to factory settings.

TYPE: bool DEFAULT: True

RAISES DESCRIPTION
ValueError

If an invalid zipfile path was given, or the extension failed to validate.

Source code in blext/blender.py
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
def run_extension(
	blender_exe: Path,
	*,
	path_zip: Path,
	path_blend: Path | None = None,
	headless: bool = False,
	factory_startup: bool = True,
) -> None:
	"""Run a Blender extension inside of Blender.

	Notes:
		Data is passed to the startup script via env vars:
			- `BLEXT_ZIP_PATH`: Path to the extension zipfile to install and run.

	Parameters:
		blender_exe: Path to a valid Blender executable.
		path_zip: Extension zipfile to check.
		path_blend: Optional `.blend` file to open, after the extension is installed.
		headless: Whether to run Blender without a UI.
		factory_startup: Temporarily reset Blender to factory settings.

	Raises:
		ValueError: If an invalid zipfile path was given, or the extension failed to validate.
	"""
	_ = run_blender(
		blender_exe,
		startup_script=PATH_BL_INIT,
		factory_startup=factory_startup,
		headless=headless,
		args=(str(path_blend),) if path_blend is not None else (),
		env=frozendict(
			{
				'BLEXT_ZIP_PATH': str(path_zip),
			}
			| os.environ
		),
		capture=False,
	)

validate_extension

validate_extension(
	blender_exe: Path, *, path_zip: Path
) -> None

Run Blender's builtin validation procedure on a built extension zipfile.

PARAMETER DESCRIPTION
blender_exe

Path to a valid Blender executable.

TYPE: Path

path_zip

Path to the extension zipfile to check.

TYPE: Path

RAISES DESCRIPTION
ValueError

If an invalid zipfile path was given, or the extension failed to validate.

Source code in blext/blender.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
def validate_extension(blender_exe: Path, *, path_zip: Path) -> None:
	"""Run Blender's builtin validation procedure on a built extension zipfile.

	Parameters:
		blender_exe: Path to a valid Blender executable.
		path_zip: Path to the extension zipfile to check.

	Raises:
		ValueError: If an invalid zipfile path was given, or the extension failed to validate.
	"""
	bl_process = run_blender(
		blender_exe,
		headless=True,
		args=('--command', 'extension', 'validate', str(path_zip)),
		capture=True,
	)
	if bl_process.returncode != 0:
		if bl_process.stdout is not None:
			# Parse Output
			messages = bl_process.stdout.read()
			if isinstance(messages, bytes):
				messages = messages.decode('utf-8')

			# Report Failed Validation
			msgs = [
				'Blender failed to validate the packed extension with the following messages:',
				*[f'> {line}' for line in messages.split('\n')],
			]
			raise ValueError(*msgs)
		msgs = [
			'Blender failed to validate the packed extension.',
		]
		raise ValueError(*msgs)

blext.pack

Packing and pre-packing of Blender extension zipfiles from a specification and raw files.

existing_prepacked_files

existing_prepacked_files(
	all_files_to_prepack: frozendict[Path, Path]
	| dict[Path, Path],
	*,
	path_zip_prepack: Path,
) -> frozenset[Path]

Determine which files do not need to be pre-packed again, since they already exist in a pre-packed zipfile.

PARAMETER DESCRIPTION
all_files_to_prepack

Mapping from host files to files in the zip. All files specified here should be available in the final pre-packed zip.

TYPE: frozendict[Path, Path] | dict[Path, Path]

path_zip_prepack

Path to an existing pre-packed zipfile. If no file exists, then all files are assumed to need pre-packing.

TYPE: Path

RETURNS DESCRIPTION
frozenset[Path]

Set of files that need to be pre-packed.

See Also

blext.pack.prepack_extension: The output should be passed to this function, to perform the actual pre-packing.

Source code in blext/pack.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def existing_prepacked_files(
	all_files_to_prepack: frozendict[Path, Path] | dict[Path, Path],
	*,
	path_zip_prepack: Path,
) -> frozenset[Path]:
	"""Determine which files do not need to be pre-packed again, since they already exist in a pre-packed zipfile.

	Parameters:
		all_files_to_prepack: Mapping from host files to files in the zip.
			All files specified here should be available in the final pre-packed zip.
		path_zip_prepack: Path to an existing pre-packed zipfile.
			If no file exists, then all files are assumed to need pre-packing.

	Returns:
		Set of files that need to be pre-packed.

	See Also:
		`blext.pack.prepack_extension`: The output should be passed to this function, to perform the actual pre-packing.
	"""
	if path_zip_prepack.is_file():
		with zipfile.ZipFile(path_zip_prepack, 'r') as f_zip:
			existing_prepacked_files = set({Path(name) for name in f_zip.namelist()})

		# Re-Pack to Delete Files
		## - Deleting a single file from a .zip archive is not always a good idea.
		## - See https://github.com/python/cpython/pull/103033
		## - Instead, when a file should be deleted, we repack the entire `.zip`.
		_files_to_prepack_values = frozenset(all_files_to_prepack.values())
		if any(
			existing_zipfile_path not in _files_to_prepack_values
			for existing_zipfile_path in existing_prepacked_files
		):
			path_zip_prepack.unlink()
			existing_prepacked_files.clear()

		return frozenset(existing_prepacked_files)
	return frozenset()

pack_bl_extension

pack_bl_extension(
	blext_spec: BLExtSpec,
	*,
	bl_version: BLVersion,
	overwrite: bool = True,
	path_zip_prepack: Path,
	path_zip: Path,
	path_pysrc: Path,
	cb_update_status: Callable[
		[str], list[None] | None
	] = lambda *_: None,
) -> None

Pack all files needed by a Blender extension, into an installable .zip.

Configuration data is sourced from paths, which in turns sources much of its user-facing configuration from pyproject.toml.

PARAMETER DESCRIPTION
blext_spec

The extension specification to pack the zip file base on.

TYPE: BLExtSpec

bl_version

The Blender version to pack into the zipfile.

TYPE: BLVersion

overwrite

If packing to a zip file that already exists, replace it.

TYPE: bool DEFAULT: True

path_zip_prepack

Path to the prepacked zipfile.

TYPE: Path

path_zip

Path to the zipfile to pack.

TYPE: Path

path_pysrc

Path to the Python source code to pack as the extension package.

TYPE: Path

Source code in blext/pack.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
def pack_bl_extension(  # noqa: PLR0913
	blext_spec: BLExtSpec,
	*,
	bl_version: extyp.BLVersion,
	overwrite: bool = True,
	path_zip_prepack: Path,
	path_zip: Path,
	path_pysrc: Path,
	cb_update_status: typ.Callable[[str], list[None] | None] = lambda *_: None,  # pyright: ignore[reportUnknownLambdaType]
) -> None:
	"""Pack all files needed by a Blender extension, into an installable `.zip`.

	Configuration data is sourced from `paths`, which in turns sources much of its user-facing configuration from `pyproject.toml`.

	Parameters:
		blext_spec: The extension specification to pack the zip file base on.
		bl_version: The Blender version to pack into the zipfile.
		overwrite: If packing to a zip file that already exists, replace it.
		path_zip_prepack: Path to the prepacked zipfile.
		path_zip: Path to the zipfile to pack.
		path_pysrc: Path to the Python source code to pack as the extension package.
	"""
	bl_manifest_version = bl_version.max_manifest_version
	if not path_zip_prepack.is_file():
		msg = f'Cannot pack extension, since no pre-packed extension was found at {path_zip_prepack}.'
		raise RuntimeError(msg)

	# Overwrite Existing ZIP
	if path_zip.is_file():
		if not overwrite:
			msg = f'File already exists where extension ZIP is to be built: {path_zip}'
			raise ValueError(msg)
		path_zip.unlink()

	# Copy Pre-Packed ZIP
	_ = cb_update_status('Copying Pre-Packed Extension ZIP')
	_ = shutil.copyfile(path_zip_prepack, path_zip)

	with zipfile.ZipFile(path_zip, 'a', zipfile.ZIP_DEFLATED) as f_zip:
		####################
		# - INSTALL: Blender Manifest => /blender_manifest.toml
		####################
		manifest_filename = blext_spec.bl_manifest(
			bl_manifest_version, bl_version=bl_version
		).manifest_filename

		_ = cb_update_status(f'Writing `{manifest_filename}`')

		f_zip.writestr(
			manifest_filename,
			blext_spec.export_blender_manifest(
				bl_manifest_version, bl_version=bl_version, fmt='toml'
			),
		)

		####################
		# - INSTALL: Release Profile => /init_settings.toml
		####################
		if blext_spec.release_profile is not None:
			_ = cb_update_status('Writing Release Profile to `init_settings.toml`')
			f_zip.writestr(
				blext_spec.release_profile.init_settings_filename,
				blext_spec.release_profile.export_init_settings(fmt='toml'),
			)

		####################
		# - INSTALL: Addon Files => /*
		####################
		# Project: Write Extension Python Package
		if path_pysrc.is_dir():
			_ = cb_update_status('Writing Python Files')
			for file_to_zip in path_pysrc.rglob('*'):
				f_zip.write(
					file_to_zip,
					file_to_zip.relative_to(path_pysrc),
				)

		# Script: Write Script String as __init__.py
		elif path_pysrc.is_file():
			_ = cb_update_status('Writing Extension Script to __init__.py')
			with path_pysrc.open('r') as f_pysrc:
				pysrc = f_pysrc.read()

			f_zip.writestr(
				'__init__.py',
				pysrc,
			)
		else:
			msg = "Tried to pack an extension that is neither a project nor a script. This shouldn't happen."
			raise ValueError(msg)

prepack_extension

prepack_extension(
	files_to_prepack: frozendict[Path, Path]
	| dict[Path, Path],
	*,
	path_zip_prepack: Path,
	cb_pre_file_write: Callable[
		[Path, Path], Any
	] = lambda *_: None,
	cb_post_file_write: Callable[
		[Path, Path], Any
	] = lambda *_: None,
) -> None

Pre-pack zipfile containing large files, but not the extension code.

Notes

Writing extension source code to a zipfile is very fast. However, when working with ex. wheel dependencies, large files can quickly dominate the build time.

Therefore, blext first generates "pre-packed" zipfile extensions. This takes awhile, but is only done once (and/or when a big file changes). Then, blext copies the pre-packed zip and adds the extension source code.

Since changing source code doesn't re-pack large files, iteration speed is preserved.

PARAMETER DESCRIPTION
files_to_prepack

Mapping from host files to files in the zip. All files specified here will be packed.

TYPE: frozendict[Path, Path] | dict[Path, Path]

path_zip_prepack

The zip file to pre-pack.

TYPE: Path

cb_pre_file_write

Called before each file is written to the zip. Defaults is no-op.

TYPE: Callable[[Path, Path], Any] DEFAULT: lambda *_: None

cb_post_file_write

Called after each file is written to the zip. Defaults is no-op.

TYPE: Callable[[Path, Path], Any] DEFAULT: lambda *_: None

RAISES DESCRIPTION
ValueError

When not all wheels required by blext_spec are found in path_wheels.

See Also
  • blext.pack.existing_prepacked_files: Use to pre-filter files_to_prepack, in order to only pack files that aren't already present.
Source code in blext/pack.py
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
def prepack_extension(
	files_to_prepack: frozendict[Path, Path] | dict[Path, Path],
	*,
	path_zip_prepack: Path,
	cb_pre_file_write: typ.Callable[[Path, Path], typ.Any] = lambda *_: None,  # pyright: ignore[reportUnknownLambdaType]
	cb_post_file_write: typ.Callable[[Path, Path], typ.Any] = lambda *_: None,  # pyright: ignore[reportUnknownLambdaType]
) -> None:
	"""Pre-pack zipfile containing large files, but not the extension code.

	Notes:
		Writing extension source code to a zipfile is very fast.
		However, when working with ex. wheel dependencies, large files can quickly dominate the build time.

		Therefore, `blext` first generates "pre-packed" zipfile extensions.
		This takes awhile, but is only done once (and/or when a big file changes).
		Then, `blext` copies the pre-packed zip and adds the extension source code.

		Since **changing source code doesn't re-pack large files**, iteration speed is preserved.

	Parameters:
		files_to_prepack: Mapping from host files to files in the zip.
			All files specified here will be packed.
		path_zip_prepack: The zip file to pre-pack.
		cb_pre_file_write: Called before each file is written to the zip.
			Defaults is no-op.
		cb_post_file_write: Called after each file is written to the zip.
			Defaults is no-op.

	Raises:
		ValueError: When not all wheels required by `blext_spec` are found in `path_wheels`.

	See Also:
		- `blext.pack.existing_prepacked_files`: Use to pre-filter `files_to_prepack`,
		in order to only pack files that aren't already present.
	"""
	file_sizes = {path: path.stat().st_size for path in files_to_prepack}

	# Create Zipfile
	with zipfile.ZipFile(path_zip_prepack, 'a', zipfile.ZIP_DEFLATED) as f_zip:
		####################
		# - INSTALL: Files => /wheels/*.whl
		####################
		remaining_files_to_prepack = files_to_prepack.copy()

		for path, zipfile_path in sorted(
			remaining_files_to_prepack.items(), key=lambda el: file_sizes[el[0]]
		):
			cb_pre_file_write(path, zipfile_path)
			f_zip.write(path, zipfile_path)
			cb_post_file_write(path, zipfile_path)

blext.spec

Defines the Blender extension specification.

BLExtSpec pydantic-model

Bases: BaseModel

Specifies a Blender extension.

This model allows pyproject.toml to be the single source of truth for a Blender extension project. Thus, this model is designed to be parsed entirely from a pyproject.toml file, and in turn is capable of generating the Blender extension manifest file and more.

To the extent possible, appropriate standard pyproject.toml fields are scraped for information relevant to a Blender extension. | None = None This includes name, version, license, desired dependencies, homepage, and more. Naturally, many fields are quite specific to Blender extensions, such as Blender version constraints, permissions, and extension tags. For such options, the [tool.blext] section is introduced.

ATTRIBUTE DESCRIPTION
wheels_graph

All wheels that might be usable by this extension.

release_profile

Optional initialization settings and spec overrides. Overrides must be applied during construction.

TYPE: ReleaseProfile | None

id

Unique identifying name of the extension.

TYPE: str

name

Pretty, user-facing name of the extension.

TYPE: str

version

The version of the extension.

TYPE: SemanticVersion

tagline

Short description of the extension.

TYPE: str

maintainer

Primary maintainer of the extension (name and email).

TYPE: str | None

type

Type of extension. Currently, only add-on is supported.

TYPE: str | None

blender_version_min

The minimum version of Blender that this extension supports.

TYPE: str

blender_version_max

The maximum version of Blender that this extension supports.

TYPE: str | None

wheels

Relative paths to wheels distributed with this extension. These should be installed by Blender alongside the extension. See https://docs.blender.org/manual/en/dev/extensions/python_wheels.html for more information.

TYPE: frozendict[BLVersion, frozenset[PyDepWheel]]

permissions

Permissions required by the extension.

TYPE: FrozenDict[Literal['files', 'network', 'clipboard', 'camera', 'microphone'], str] | None

tags

Tags for categorizing the extension.

TYPE: frozenset[str] | None

license

License of the extension's source code.

TYPE: SPDXLicense

copyright

Copyright declaration of the extension.

TYPE: tuple[str, ...] | None

website

Homepage of the extension.

TYPE: HttpUrl | None

References

Fields:

  • bl_platforms (frozenset[BLPlatform])
  • deps (BLExtDeps)
  • release_profile (ReleaseProfile | None)
  • id (str)
  • name (str)
  • tagline (str)
  • version (SemanticVersion)
  • license (SPDXLicense)
  • blender_version_min (str)
  • blender_version_max (str | None)
  • permissions (FrozenDict[Literal['files', 'network', 'clipboard', 'camera', 'microphone'], str] | None)
  • copyright (tuple[str, ...] | None)
  • maintainer (str | None)
  • tags (frozenset[str] | None)
  • website (HttpUrl | None)

Validators:

bl_versions cached property

bl_versions: frozenset[BLVersion]

All Blender versions supported by this extension.

Notes

blext doesn't support official Blender versions released after a particular blext version was published.

This is because blext has no way of knowing critical information about such future releases, ex. the versions of vendored site-packages dependencies.

Derived by comparing self.blender_version_min and self.blender_version_max to hard-coded Blender versions that have already been released, whose properties are known.

bl_versions_by_granular cached property

bl_versions_by_granular: frozendict[BLVersion, BLVersion]

All Blender versions supported by this extension, indexed by the granular input version.

Notes

blext doesn't support official Blender versions released after a particular blext version was published.

This is because blext has no way of knowing critical information about such future releases, ex. the versions of vendored site-packages dependencies.

Derived by comparing self.blender_version_min and self.blender_version_max to hard-coded Blender versions that have already been released, whose properties are known.

bl_versions_by_wheel cached property

bl_versions_by_wheel: frozendict[
	PyDepWheel, frozenset[BLVersion]
]

Blender versions by wheel.

is_universal cached property

is_universal: bool

Whether this extension is works on all platforms of all supported Blender versions.

Notes

Pure-Python extensions that only use pure-Python dependencies are considered "universal".

Once any non-pure-Python wheels are introduced, this condition may become very difficult to uphold, depending on which wheels are available for supported platforms.

wheels cached property

wheels: frozendict[BLVersion, frozenset[PyDepWheel]]

Python wheels by (smooshed) Blender version and Blender platform.

bl_manifest

bl_manifest(
	bl_manifest_version: BLManifestVersion,
	*,
	bl_version: BLVersion,
) -> BLManifest

Export the Blender extension manifest.

Notes

Only fmt='toml' results in valid contents of blender_manifest.toml. This is also the default.

Other formats are included to enable easier interoperability with other systems - not with Blender.

PARAMETER DESCRIPTION
bl_manifest_version

The Blender manifest schema version to export to the appropriate filename.

TYPE: BLManifestVersion

bl_version

The Blender version to export a manifest schema for.

TYPE: BLVersion

RETURNS DESCRIPTION
BLManifest

String representing the Blender extension manifest.

RAISES DESCRIPTION
ValueError

When fmt is unknown.

Source code in blext/spec.py
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
def bl_manifest(
	self,
	bl_manifest_version: extyp.BLManifestVersion,
	*,
	bl_version: extyp.BLVersion,
) -> extyp.BLManifest:
	"""Export the Blender extension manifest.

	Notes:
		Only `fmt='toml'` results in valid contents of `blender_manifest.toml`.
		_This is also the default._

		Other formats are included to enable easier interoperability with other systems - **not** with Blender.

	Parameters:
		bl_manifest_version: The Blender manifest schema version to export to the appropriate filename.
		bl_version: The Blender version to export a manifest schema for.

	Returns:
		String representing the Blender extension manifest.

	Raises:
		ValueError: When `fmt` is unknown.
	"""
	if (
		bl_version not in self.bl_versions
		and bl_version in self.bl_versions_by_granular
	):
		bl_version = self.bl_versions_by_granular[bl_version]

	_empty_frozenset: frozenset[PyDepWheel] = frozenset()
	match bl_manifest_version:
		case extyp.BLManifestVersion.V1_0_0:
			return extyp.BLManifest_1_0_0(
				id=self.id,
				name=self.name,
				version=str(self.version),
				tagline=self.tagline,
				maintainer=self.maintainer,
				blender_version_min=bl_version.source.blender_version_min,
				blender_version_max=bl_version.source.blender_version_max,
				permissions=self.permissions,
				platforms=sorted(self.bl_platforms),  # pyright: ignore[reportArgumentType]
				tags=(tuple(sorted(self.tags)) if self.tags is not None else None),
				license=(self.license,),
				copyright=self.copyright,
				website=str(self.website) if self.website is not None else None,
				wheels=tuple(
					sorted(
						[
							f'./wheels/{wheel.filename}'
							for wheel in self.wheels[bl_version]
						]
					)
				)
				if len(self.wheels[bl_version]) > 0
				else None,
			)

cached_wheels

cached_wheels(
	*,
	path_wheels: Path,
	bl_versions: frozenset[BLVersion] | None = None,
) -> frozenset[PyDepWheel]

Wheels that have already been correctly downloaded.

Source code in blext/spec.py
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
def cached_wheels(
	self,
	*,
	path_wheels: Path,
	bl_versions: frozenset[extyp.BLVersion] | None = None,
) -> frozenset[PyDepWheel]:
	"""Wheels that have already been correctly downloaded."""
	bl_versions = self.bl_versions if bl_versions is None else bl_versions
	if all(bl_version in self.bl_versions for bl_version in bl_versions):
		return frozenset(
			{
				wheel
				for bl_version in sorted(bl_versions, key=lambda el: el.version)
				for wheel in self.wheels[bl_version]
				if wheel.is_download_valid(path_wheels / wheel.filename)
			}
		)

	msg = 'Requested cached wheels for `BLVersion`(s) not given in `self.bl_versions`.'
	raise ValueError(msg)

export_blender_manifest

export_blender_manifest(
	bl_manifest_version: BLManifestVersion,
	*,
	bl_version: BLVersion,
	fmt: Literal['json', 'toml'],
) -> str

Export the Blender extension manifest.

Notes

Only fmt='toml' results in valid contents of blender_manifest.toml. This is also the default.

Other formats are included to enable easier interoperability with other systems - not with Blender.

PARAMETER DESCRIPTION
fmt

String format to export Blender manifest to.

TYPE: Literal['json', 'toml']

RETURNS DESCRIPTION
str

String representing the Blender extension manifest.

RAISES DESCRIPTION
ValueError

When fmt is unknown.

Source code in blext/spec.py
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
def export_blender_manifest(
	self,
	bl_manifest_version: extyp.BLManifestVersion,
	*,
	bl_version: extyp.BLVersion,
	fmt: typ.Literal['json', 'toml'],
) -> str:
	"""Export the Blender extension manifest.

	Notes:
		Only `fmt='toml'` results in valid contents of `blender_manifest.toml`.
		_This is also the default._

		Other formats are included to enable easier interoperability with other systems - **not** with Blender.

	Parameters:
		fmt: String format to export Blender manifest to.

	Returns:
		String representing the Blender extension manifest.

	Raises:
		ValueError: When `fmt` is unknown.
	"""
	# Dump Manifest to Formatted String
	bl_manifest = self.bl_manifest(bl_manifest_version, bl_version=bl_version)
	if isinstance(bl_manifest, pyd.BaseModel):
		manifest_export: dict[str, typ.Any] = bl_manifest.model_dump(
			mode='json', exclude_none=True
		)
		if fmt == 'json':
			return json.dumps(manifest_export)
		if fmt == 'toml':
			return tomli_w.dumps(manifest_export)

	msg = '`bl_manifest` is not an instance of `pyd.BaseModel`. This should not happen.'
	raise RuntimeError(msg)

export_extension_filenames

export_extension_filenames(
	with_bl_version: bool = True,
	with_bl_platforms: bool = True,
) -> frozendict[BLVersion, str]

Default filename of the extension zipfile.

Source code in blext/spec.py
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
def export_extension_filenames(
	self,
	with_bl_version: bool = True,
	with_bl_platforms: bool = True,
) -> frozendict[extyp.BLVersion, str]:
	"""Default filename of the extension zipfile."""
	extension_filenames: dict[extyp.BLVersion, str] = {}
	for bl_version in sorted(self.bl_versions, key=lambda el: el.version):
		basename = f'{self.id}__{self.version}'

		if with_bl_version:
			basename += f'__{bl_version.version}'

		if with_bl_platforms:
			basename += '__' + '_'.join(sorted(self.bl_platforms))

		extension_filenames[bl_version] = f'{basename}.zip'
	return frozendict(extension_filenames)

from_proj_spec_dict classmethod

from_proj_spec_dict(
	proj_spec_dict: dict[str, Any],
	*,
	path_uv_exe: Path,
	path_proj_spec: Path,
	release_profile_id: StandardReleaseProfile | str | None,
) -> Self

Parse an extension spec from a dictionary.

Notes
  • The dictionary is presumed to be loaded directly from either a pyproject.toml file or inline script metadata. Therefore, please refer to the pyproject.toml documentation for more on the dictionary's structure.

  • This method aims to "show its work", in explaining exactly why parsing fails. To provide pleasant user feedback, print ValueError arguments as Markdown.

PARAMETER DESCRIPTION
proj_spec_dict

Dictionary representation of a pyproject.toml or inline script metadata.

TYPE: dict[str, Any]

path_proj_spec

Path to the file that defines the extension project.

TYPE: Path

release_profile_id

Identifier to deduce release profile settings with.

TYPE: StandardReleaseProfile | str | None

path_uv_exe

Optional overriden path to a uv executable. Generally sourced from blext.ui.GlobalConfig.path_uv_exe.

TYPE: Path

RAISES DESCRIPTION
ValueError

If the dictionary cannot be parsed to a complete BLExtSpec. Messages are valid Markdown.

Source code in blext/spec.py
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
@classmethod
def from_proj_spec_dict(  # noqa: C901, PLR0912, PLR0915
	cls,
	proj_spec_dict: dict[str, typ.Any],
	*,
	path_uv_exe: Path,
	path_proj_spec: Path,
	release_profile_id: extyp.StandardReleaseProfile | str | None,
) -> typ.Self:
	"""Parse an extension spec from a dictionary.

	Notes:
		- The dictionary is presumed to be loaded directly from either a `pyproject.toml` file or inline script metadata.
		Therefore, please refer to the `pyproject.toml` documentation for more on the dictionary's structure.

		- This method aims to "show its work", in explaining exactly why parsing fails.
		To provide pleasant user feedback, print `ValueError` arguments as Markdown.

	Parameters:
		proj_spec_dict: Dictionary representation of a `pyproject.toml` or inline script metadata.
		path_proj_spec: Path to the file that defines the extension project.
		release_profile_id: Identifier to deduce release profile settings with.
		path_uv_exe: Optional overriden path to a `uv` executable.
			_Generally sourced from `blext.ui.GlobalConfig.path_uv_exe`_.

	Raises:
		ValueError: If the dictionary cannot be parsed to a complete `BLExtSpec`.
			_Messages are valid Markdown_.

	"""
	is_proj_metadata = path_proj_spec.name == 'pyproject.toml'
	is_script_metadata = path_proj_spec.name.endswith('.py')

	####################
	# - Parsing: Stage 1
	####################
	###: Determine whether all required fields are accessible.

	# [project]
	if proj_spec_dict.get('project') is not None:
		project: dict[str, typ.Any] = proj_spec_dict['project']
	else:
		msgs = [
			f'**Invalid Extension Specification**: `{path_proj_spec}` exists, but has no `[project]` table.',
		]
		raise ValueError(*msgs)

	# [tool.blext]
	if (
		proj_spec_dict.get('tool') is not None
		and proj_spec_dict['tool'].get('blext') is not None  # pyright: ignore[reportAny]
		and isinstance(proj_spec_dict['tool']['blext'], dict)
	):
		blext_spec_dict: dict[str, typ.Any] = proj_spec_dict['tool']['blext']
	else:
		msgs = [
			'**Invalid Extension Specification**: No `[tool.blext]` table found.',
			f'> **Spec Path**: `{path_proj_spec}`',
			'>',
			'> **Suggestions**',
			'> - Is this project an extension?',
			'> - Add the `[tool.blext]` table. See the documentation for more.',
		]
		raise ValueError(*msgs)

	# [tool.blext.profiles]
	release_profile: None | extyp.ReleaseProfile = None
	if blext_spec_dict.get('profiles') is not None:
		project_release_profiles: dict[str, typ.Any] = blext_spec_dict['profiles']
		if release_profile_id in project_release_profiles:
			release_profile = extyp.ReleaseProfile(
				**project_release_profiles[release_profile_id]  # pyright: ignore[reportAny]
			)

	if release_profile is None and isinstance(
		release_profile_id, extyp.StandardReleaseProfile
	):
		release_profile = release_profile_id.release_profile

	elif release_profile_id is None:
		release_profile = None

	else:
		msgs = [
			'**Invalid Extension Specification**:',
			f'|    The selected "release profile" `{release_profile_id}` is not a standard release profile...',
			"|    ...and wasn't found in `[tool.blext.profiles]`.",
			'|',
			f'|    Please either define `{release_profile_id}` in `[tool.blext.profiles]`, or select a standard release profile.',
			'|',
			f'**Standard Release Profiles**: `{", ".join(extyp.StandardReleaseProfile)}`',
		]
		raise ValueError(*msgs)

	####################
	# - Parsing: Stage 2
	####################
	###: Parse values that require transformations.

	field_parse_errs: list[str] = []

	# project.requires-python
	project_requires_python: str | None = None
	if (is_proj_metadata and project.get('requires-python') is not None) or (
		is_script_metadata and proj_spec_dict.get('requires-python') is not None
	):
		if is_proj_metadata:
			requires_python_field = project['requires-python']  # pyright: ignore[reportAny]
		elif is_script_metadata:
			requires_python_field = proj_spec_dict['requires-python']  # pyright: ignore[reportAny]
		else:
			msg = 'BLExtSpec metadata is neither project nor script metadata. Something is very wrong.'
			raise RuntimeError(msg)

		if isinstance(requires_python_field, str):
			project_requires_python = requires_python_field.replace('~= ', '')
		else:
			field_parse_errs.append(
				f'- `project.requires-python` must be a string (current value: {project["requires-python"]})'
			)
	else:
		field_parse_errs.append('- `project.requires-python` is not defined.')

	# project.maintainers
	first_maintainer: dict[str, str] | None = None
	if project.get('maintainers') is not None:
		if isinstance(project['maintainers'], list):
			maintainers: list[typ.Any] = project['maintainers']
			if len(maintainers) > 0 and all(
				isinstance(maintainer, dict)
				and 'name' in maintainer
				and isinstance(maintainer['name'], str)
				and 'email' in maintainer
				and isinstance(maintainer['email'], str)
				for maintainer in maintainers  # pyright: ignore[reportAny]
			):
				first_maintainer = maintainers[0]
			else:
				field_parse_errs.append(
					f'- `project.maintainers` is malformed. It must be a **non-empty** list of dictionaries, where each dictionary has a "name: str" and an "email: str" field (current value: {maintainers}).'
				)
		else:
			field_parse_errs.append(
				f'- `project.maintainers` must be a list (current value: {project["maintainers"]})'
			)
	else:
		first_maintainer = {'name': 'Unknown', 'email': 'unknown@example.com'}

	# project.license
	extension_license: str | None = None
	if (
		project.get('license') is not None
		and isinstance(project['license'], dict)
		and project['license'].get('text') is not None  # pyright: ignore[reportUnknownMemberType]
		and isinstance(project['license']['text'], str)
	):
		extension_license = project['license']['text']
	else:
		field_parse_errs.append('- `project.license.text` is not defined.')
		field_parse_errs.append(
			'- Please note that all Blender addons MUST declare a GPL-compatible license: <https://docs.blender.org/manual/en/latest/advanced/extensions/licenses.html>'
		)

	## project.urls.homepage
	if (
		project.get('urls') is not None
		and isinstance(project['urls'], dict)
		and project['urls'].get('Homepage') is not None  # pyright: ignore[reportUnknownMemberType]
		and isinstance(project['urls']['Homepage'], str)
	):
		homepage = project['urls']['Homepage']
	else:
		homepage = None

	####################
	# - Parsing: Stage 3
	####################
	###: Parse field availability and provide for descriptive errors
	if project.get('name') is None:
		field_parse_errs += ['- `project.name` is not defined.']
	if blext_spec_dict.get('pretty_name') is None:
		field_parse_errs += ['- `tool.blext.pretty_name` is not defined.']
	if project.get('version') is None:
		field_parse_errs += ['- `project.version` is not defined.']
	if project.get('description') is None:
		field_parse_errs += ['- `project.description` is not defined.']
	if blext_spec_dict.get('blender_version_min') is None:
		field_parse_errs += ['- `tool.blext.blender_version_min` is not defined.']
	if blext_spec_dict.get('blender_version_max') is None:
		field_parse_errs += ['- `tool.blext.blender_version_max` is not defined.']
	if blext_spec_dict.get('copyright') is None:
		field_parse_errs += ['- `tool.blext.copyright` is not defined.']
		field_parse_errs += [
			'- Example: `copyright = ["<current_year> <proj_name> Contributors`'
		]

	if field_parse_errs:
		msgs = [
			f'In `{path_proj_spec}`:',
			*field_parse_errs,
		]
		raise ValueError(*msgs)

	####################
	# - Parsing: Stage 4
	####################
	###: Let pydantic take over the last stage of parsing.

	if project_requires_python is None or extension_license is None:
		msg = 'While parsing the project specification, some variables attained a theoretically impossible value. This is a serious bug, please report it!'
		raise RuntimeError(msg)

	# Compute Path to uv.lock
	if path_proj_spec.name == 'pyproject.toml':
		path_uv_lock = path_proj_spec.parent / 'uv.lock'
	elif path_proj_spec.name.endswith('.py'):
		path_uv_lock = path_proj_spec.parent / (path_proj_spec.name + '.lock')
	else:
		msg = f'Invalid project specification path: {path_proj_spec}. Please report this error as a bug.'
		raise RuntimeError(msg)

	_spec_params = {
		'bl_platforms': blext_spec_dict.get('supported_platforms'),
		'deps': BLExtDeps.from_uv_lock(
			uv.parse_uv_lock(
				path_uv_lock,
				path_uv_exe=path_uv_exe,
			),
			module_name=project['name'],  # pyright: ignore[reportAny]
			min_glibc_version=blext_spec_dict.get('min_glibc_version'),
			min_macos_version=blext_spec_dict.get('min_macos_version'),
			valid_python_tags=blext_spec_dict.get('supported_python_tags'),
			valid_abi_tags=blext_spec_dict.get('supported_abi_tags'),
		),
		'release_profile': release_profile,
		'id': project['name'],
		'name': blext_spec_dict['pretty_name'],
		'tagline': project['description'],
		'version': project['version'],
		'license': f'SPDX:{extension_license}',
		'blender_version_min': blext_spec_dict['blender_version_min'],
		'blender_version_max': blext_spec_dict['blender_version_max'],
		'permissions': blext_spec_dict.get('permissions'),
		'tags': blext_spec_dict.get('bl_tags'),
		'copyright': blext_spec_dict['copyright'],
		'maintainer': (
			f'{first_maintainer["name"]} <{first_maintainer["email"]}>'
			if first_maintainer is not None
			else None
		),
		'website': homepage,
	}

	# Inject Release Profile Overrides
	if release_profile is not None and release_profile.overrides:
		_spec_params.update(release_profile.overrides)
		return cls(**_spec_params)  # pyright: ignore[reportArgumentType]
	return cls(**_spec_params)  # pyright: ignore[reportArgumentType]

from_proj_spec_path classmethod

from_proj_spec_path(
	path_proj_spec: Path,
	*,
	path_uv_exe: Path,
	release_profile_id: StandardReleaseProfile | str | None,
) -> Self

Parse an extension specification from a compatible file.

PARAMETER DESCRIPTION
path_proj_spec

Path to either a pyproject.toml, or *.py script with inline metadata.

TYPE: Path

release_profile_id

Identifier for the release profile.

TYPE: StandardReleaseProfile | str | None

path_uv_exe

Optional overriden path to a uv executable. Generally sourced from blext.ui.GlobalConfig.path_uv_exe.

TYPE: Path

RAISES DESCRIPTION
ValueError

If the pyproject.toml file cannot be loaded, or it does not contain the required tables and/or fields.

Source code in blext/spec.py
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
@classmethod
def from_proj_spec_path(
	cls,
	path_proj_spec: Path,
	*,
	path_uv_exe: Path,
	release_profile_id: extyp.StandardReleaseProfile | str | None,
) -> typ.Self:
	"""Parse an extension specification from a compatible file.

	Args:
		path_proj_spec: Path to either a `pyproject.toml`, or `*.py` script with inline metadata.
		release_profile_id: Identifier for the release profile.
		path_uv_exe: Optional overriden path to a `uv` executable.
			_Generally sourced from `blext.ui.GlobalConfig.path_uv_exe`_.

	Raises:
		ValueError: If the `pyproject.toml` file cannot be loaded, or it does not contain the required tables and/or fields.
	"""
	if path_proj_spec.is_file():
		if path_proj_spec.name == 'pyproject.toml':
			with path_proj_spec.open('rb') as f:
				proj_spec_dict = tomllib.load(f)
		elif path_proj_spec.name.endswith('.py'):
			with path_proj_spec.open('r') as f:
				proj_spec_dict = parse_inline_script_metadata(
					py_source_code=f.read(),
				)

			if proj_spec_dict is None:
				msg = f'Could not find inline script metadata in "{path_proj_spec}" (looking for a `# /// script` block)`.'
				raise ValueError(msg)
		else:
			msg = f'Tried to load a Blender extension project specification from "{path_proj_spec}", but it is invalid: Only `pyproject.toml` and `*.py` scripts w/inline script metadata are supported.'
			raise ValueError(msg)
	else:
		msg = f'Tried to load a Blender extension project specification from "{path_proj_spec}", but no such file exists.'
		raise ValueError(msg)

	name_from_spec = proj_spec_dict.get('project', {}).get('name')  # pyright: ignore[reportAny]
	if name_from_spec is None:
		msgs = [
			'Extension has no `project.name` field.',
		]
		raise ValueError(*msgs)
	if not isinstance(name_from_spec, str):
		msgs = [
			'`project.name` is not a string.',
		]
		raise TypeError(*msgs)

	if (
		path_proj_spec.name == 'pyproject.toml'
		and not (path_proj_spec.parent / name_from_spec).is_dir()
	):
		msgs = [
			'Project extension package name did not match `project.name`.',
			'> **Remedies**:',
			f'> 1. Rename extension package to `{name_from_spec}/`',
			'> 1. Set `project.name` to the name of the extension package.',
		]
		raise ValueError(*msgs)

	if path_proj_spec.name.endswith(
		'.py'
	) and name_from_spec != path_proj_spec.name.removesuffix('.py'):
		msgs = [
			'Script extension name did not match `project.name`.',
			'> **Remedies**:',
			f'> 1. Rename `{path_proj_spec.name}` to `{name_from_spec}.py`',
			f'> 2. Set `project.name = {path_proj_spec.name.removesuffix(".py")}`.',
		]
		raise ValueError(*msgs)

	# Parse Extension Specification
	return cls.from_proj_spec_dict(
		proj_spec_dict,
		path_uv_exe=path_uv_exe,
		path_proj_spec=path_proj_spec,
		release_profile_id=release_profile_id,
	)

missing_wheels

missing_wheels(
	*,
	path_wheels: Path,
	bl_versions: frozenset[BLVersion] | None = None,
) -> frozenset[PyDepWheel]

Wheels that need to be downloaded, since they are not available / valid.

Source code in blext/spec.py
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
def missing_wheels(
	self,
	*,
	path_wheels: Path,
	bl_versions: frozenset[extyp.BLVersion] | None = None,
) -> frozenset[PyDepWheel]:
	"""Wheels that need to be downloaded, since they are not available / valid."""
	bl_versions = self.bl_versions if bl_versions is None else bl_versions

	if all(bl_version in self.bl_versions for bl_version in bl_versions):
		return frozenset(
			{
				wheel
				for bl_version in sorted(bl_versions, key=lambda el: el.version)
				for wheel in self.wheels[bl_version]
				if not wheel.is_download_valid(path_wheels / wheel.filename)
			}
		)

	msg = 'Requested missing wheels for `BLVersion`(s) not given in `self.bl_versions`.'
	raise ValueError(msg)

replace_bl_platforms

replace_bl_platforms(
	bl_platforms: frozenset[BLPlatform],
) -> Self

Create a copy of this extension spec, with altered platform support.

Notes

By default, an extension specification defines a wide range of supported platforms.

Sometimes, it's important to consider the same extension defined only for a subset of platforms (for example, to build the extension only for Windows). This amounts to a "new extension", which can be generated using this method.

PARAMETER DESCRIPTION
bl_platforms

The Blender platforms to support exclusively.

  • frozenset[BLPlatform]: Directly write to self.bl_platforms.
  • set[BLPlatform]: Directly write to self.bl_platforms.
  • BLPlatform: Place in a single-element set.

TYPE: frozenset[BLPlatform]

RETURNS DESCRIPTION
Self

A copy of self, with the following modifications: - self.wheels_graph.valid_bl_platforms: Modified according to parameters.

Self

In practice, self.bl_platforms will also reflect the change.l

Source code in blext/spec.py
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
def replace_bl_platforms(
	self, bl_platforms: frozenset[extyp.BLPlatform]
) -> typ.Self:
	"""Create a copy of this extension spec, with altered platform support.

	Notes:
		By default, an extension specification defines a wide range of supported platforms.

		Sometimes, it's important to consider the same extension defined only for a subset of platforms (for example, to build the extension only for Windows).
		This amounts to a "new extension", which can be generated using this method.

	Parameters:
		bl_platforms: The Blender platforms to support exclusively.

			- `frozenset[BLPlatform]`: Directly write to `self.bl_platforms`.
			- `set[BLPlatform]`: Directly write to `self.bl_platforms`.
			- `BLPlatform`: Place in a single-element set.

	Returns:
		A copy of `self`, with the following modifications:
			- `self.wheels_graph.valid_bl_platforms`: Modified according to parameters.

		In practice, `self.bl_platforms` will also reflect the change.l

	"""
	if bl_platforms.issubset(self.bl_platforms):
		return self.model_copy(
			update={
				'bl_platforms': bl_platforms,
			},
			deep=True,
		)

	msg = "Can't set BLPlatforms that aren't already supported by an extension."
	raise ValueError(msg)

set_default_bl_platforms_to_universal pydantic-validator

set_default_bl_platforms_to_universal(data: Any) -> Any

Set the default BLPlatforms to the largest common subset of platforms supported by given Blender versions.

Source code in blext/spec.py
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
@pyd.model_validator(mode='before')
@classmethod
def set_default_bl_platforms_to_universal(cls, data: typ.Any) -> typ.Any:  # pyright: ignore[reportAny]
	"""Set the default BLPlatforms to the largest common subset of platforms supported by given Blender versions."""
	if (isinstance(data, dict) and 'bl_platforms' not in data) or data[
		'bl_platforms'
	] is None:
		if (
			'blender_version_min' in data
			and isinstance(data['blender_version_min'], str)
			and (
				'blender_version_max' not in data
				or (
					'blender_version_max' in data
					and isinstance(data['blender_version_max'], str)
				)
			)
		):
			released_bl_versions = (
				extyp.BLReleaseOfficial.from_official_version_range(
					data['blender_version_min'],  # pyright: ignore[reportUnknownArgumentType]
					data.get('blender_version_max'),  # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType]
				)
			)
			valid_bl_platforms = functools.reduce(
				lambda a, b: a | b,
				[
					released_bl_version.bl_version.valid_bl_platforms
					for released_bl_version in released_bl_versions
				],
			)
			data['bl_platforms'] = valid_bl_platforms
		else:
			msg = 'blender_version_min must be given to deduce bl_platforms'
			raise ValueError(msg)

	return data  # pyright: ignore[reportUnknownVariableType]

validate_tags_against_bl_versions pydantic-validator

validate_tags_against_bl_versions() -> Self

Validate that all extension tags can actually be parsed by all supported Blender versions.

Source code in blext/spec.py
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
@pyd.model_validator(mode='after')
def validate_tags_against_bl_versions(self) -> typ.Self:
	"""Validate that all extension tags can actually be parsed by all supported Blender versions."""
	if self.tags is not None:
		valid_tags = functools.reduce(
			lambda a, b: a & b,
			[bl_version.valid_extension_tags for bl_version in self.bl_versions],
		)

		if self.tags.issubset(valid_tags):
			return self
		msgs = [
			'The following extension tags are not valid in all supported Blender versions:',
			*[f'- `{tag}`' for tag in sorted(self.tags - valid_tags)],
		]
		raise ValueError(*msgs)
	return self

wheel_paths_to_prepack

wheel_paths_to_prepack(
	*, path_wheels: Path
) -> frozendict[BLVersion, frozendict[Path, Path]]

Wheel file paths that should be pre-packed.

Source code in blext/spec.py
491
492
493
494
495
496
497
498
499
500
501
502
503
504
def wheel_paths_to_prepack(
	self, *, path_wheels: Path
) -> frozendict[extyp.BLVersion, frozendict[Path, Path]]:
	"""Wheel file paths that should be pre-packed."""
	wheel_paths_to_prepack = {
		bl_version: {
			path_wheels / wheel.filename: Path('wheels') / wheel.filename
			for bl_platform in sorted(self.bl_platforms)
			if bl_platform in bl_version.valid_bl_platforms
			for wheel in self.wheels[bl_version]
		}
		for bl_version in sorted(self.bl_versions, key=lambda el: el.version)
	}
	return deepfreeze(wheel_paths_to_prepack)  # pyright: ignore[reportAny]