micropython-microbit-fs 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.
Binary file
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ micropython-microbit-fs: Inject and extract files from MicroPython Intel Hex files.
4
+
5
+ This library provides a simple API for working with MicroPython filesystem
6
+ embedded in Intel Hex files for the BBC micro:bit.
7
+
8
+ Main functions:
9
+ - add_files: Add files to a MicroPython hex file
10
+ - get_files: Read files from a MicroPython hex file
11
+ - get_device_info: Get device memory information from a hex file
12
+ - get_bundled_hex: Get a bundled MicroPython hex file
13
+ - list_bundled_versions: List available bundled hex versions
14
+ """
15
+
16
+ from micropython_microbit_fs.api import (
17
+ add_files,
18
+ get_device_info,
19
+ get_files,
20
+ )
21
+ from micropython_microbit_fs.device_info import DeviceInfo, DeviceVersion
22
+ from micropython_microbit_fs.exceptions import (
23
+ FilesystemError,
24
+ HexNotFoundError,
25
+ InvalidFileError,
26
+ InvalidHexError,
27
+ NotMicroPythonError,
28
+ StorageFullError,
29
+ )
30
+ from micropython_microbit_fs.file import File
31
+ from micropython_microbit_fs.hexes import (
32
+ MicroPythonHex,
33
+ get_bundled_hex,
34
+ list_bundled_versions,
35
+ )
36
+
37
+ __version__ = "0.1.0"
38
+
39
+ __all__ = [
40
+ # Main API functions
41
+ "add_files",
42
+ "get_files",
43
+ "get_device_info",
44
+ # Bundled hex functions
45
+ "MicroPythonHex",
46
+ "get_bundled_hex",
47
+ "list_bundled_versions",
48
+ # Data classes
49
+ "File",
50
+ "DeviceInfo",
51
+ "DeviceVersion",
52
+ # Exceptions
53
+ "FilesystemError",
54
+ "InvalidHexError",
55
+ "NotMicroPythonError",
56
+ "InvalidFileError",
57
+ "StorageFullError",
58
+ "HexNotFoundError",
59
+ ]
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Main public API for micropython-microbit-fs.
4
+
5
+ This module provides the main functions for working with micro:bit MicroPython
6
+ filesystems in Intel Hex files.
7
+ """
8
+
9
+ from micropython_microbit_fs.device_info import DeviceInfo, get_device_info_ih
10
+ from micropython_microbit_fs.exceptions import InvalidFileError, InvalidHexError
11
+ from micropython_microbit_fs.file import File
12
+ from micropython_microbit_fs.filesystem import (
13
+ add_files_to_hex,
14
+ read_files_from_hex,
15
+ )
16
+ from micropython_microbit_fs.hex_utils import hex_to_string, load_hex
17
+
18
+
19
+ def add_files(
20
+ hex_data: str,
21
+ files: list[File],
22
+ ) -> str:
23
+ """
24
+ Add files to a micro:bit MicroPython Intel Hex file.
25
+
26
+ Takes a micro:bit MicroPython hex file and a list of files to add,
27
+ returning a new hex file with the files encoded in the filesystem region.
28
+
29
+ :param hex_data: Intel Hex file content as a string.
30
+ :param files: List of File objects to inject into the filesystem.
31
+ :returns: New Intel Hex file content with the files injected.
32
+
33
+ :raises InvalidHexError: If the hex data is invalid.
34
+ :raises NotMicroPythonError: If the hex does not contain MicroPython.
35
+ :raises InvalidFileError: If a file has invalid name or content.
36
+ :raises StorageFullError: If the files don't fit in the filesystem.
37
+
38
+ Example::
39
+
40
+ >>> import micropython_microbit_fs as micropython
41
+ >>> files = [micropython.File.from_text("main.py", "print('Hello!')")]
42
+ >>> new_hex = micropython.add_files(micropython_hex, files)
43
+ """
44
+ try:
45
+ ih = load_hex(hex_data)
46
+ except Exception as e:
47
+ raise InvalidHexError(f"Failed to parse Intel Hex data: {e}") from e
48
+ device_info = get_device_info_ih(ih)
49
+
50
+ files_dict = {}
51
+ for file in files:
52
+ if file.name in files_dict:
53
+ raise InvalidFileError(f"Duplicate file name: {file.name}")
54
+ files_dict[file.name] = file.content
55
+ add_files_to_hex(ih, device_info, files_dict)
56
+
57
+ return hex_to_string(ih)
58
+
59
+
60
+ def get_files(hex_data: str) -> list[File]:
61
+ """
62
+ Get files from a micro:bit MicroPython Intel Hex file.
63
+
64
+ Reads a micro:bit MicroPython hex file and returns all files found in the
65
+ filesystem region.
66
+
67
+ :param hex_data: Intel Hex file content as a string.
68
+ :returns: List of File objects found in the filesystem.
69
+
70
+ :raises InvalidHexError: If the hex data is invalid.
71
+ :raises NotMicroPythonError: If the hex does not contain MicroPython.
72
+ :raises FilesystemError: If the filesystem structure is corrupted.
73
+
74
+ Example::
75
+
76
+ >>> import micropython_microbit_fs as micropython
77
+ >>> files = micropython.get_files(hex_with_files)
78
+ >>> for f in files:
79
+ ... print(f"{f.name}: {f.size} bytes")
80
+ """
81
+ try:
82
+ ih = load_hex(hex_data)
83
+ except Exception as e:
84
+ raise InvalidHexError(f"Failed to parse Intel Hex data: {e}") from e
85
+ device_info = get_device_info_ih(ih)
86
+ files_dict = read_files_from_hex(ih, device_info)
87
+ return [File(name=name, content=content) for name, content in files_dict.items()]
88
+
89
+
90
+ def get_device_info(hex_data: str) -> DeviceInfo:
91
+ """
92
+ Get device memory information from a MicroPython Intel Hex file.
93
+
94
+ Extracts information about the flash memory layout, including
95
+ filesystem boundaries and MicroPython version.
96
+
97
+ :param hex_data: Intel Hex file content as a string.
98
+ :returns: DeviceInfo containing memory layout information.
99
+
100
+ :raises InvalidHexError: If the hex data is invalid.
101
+ :raises NotMicroPythonError: If the hex does not contain MicroPython.
102
+
103
+ Example::
104
+
105
+ >>> import micropython_microbit_fs as micropython
106
+ >>> info = micropython.get_device_info(micropython_hex)
107
+ >>> print(f"FS Size: {info.fs_size} bytes")
108
+ >>> print(f"MicroPython: {info.micropython_version}")
109
+ """
110
+ try:
111
+ ih = load_hex(hex_data)
112
+ except Exception as e:
113
+ raise InvalidHexError(f"Failed to parse Intel Hex data: {e}") from e
114
+ return get_device_info_ih(ih)
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Command-line interface for micropython-microbit-fs.
4
+
5
+ This module provides CLI commands for working with micro:bit MicroPython
6
+ filesystems in Intel Hex files.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Annotated, Optional
13
+
14
+ from cyclopts import App, Parameter
15
+
16
+ import micropython_microbit_fs as upyfs
17
+ from micropython_microbit_fs.exceptions import HexNotFoundError
18
+ from micropython_microbit_fs.hexes import (
19
+ get_bundled_hex,
20
+ list_bundled_versions,
21
+ )
22
+
23
+ app = App(
24
+ name="microbit-fs",
25
+ help="Inject and extract files from MicroPython Hex files for micro:bit.",
26
+ version=upyfs.__version__,
27
+ )
28
+
29
+
30
+ @app.command
31
+ def info(hex_file: Path) -> None:
32
+ """Display device and filesystem information from a MicroPython hex file.
33
+
34
+ :param hex_file: Path to the Intel Hex file.
35
+ """
36
+ hex_data = hex_file.read_text()
37
+ device_info = upyfs.get_device_info(hex_data)
38
+
39
+ print(f"Device: micro:bit {device_info.device_version.value}")
40
+ print(f"MicroPython version: {device_info.micropython_version}")
41
+ print(f"Flash page size: {device_info.flash_page_size} bytes")
42
+ print(f"Filesystem size: {device_info.fs_size} bytes")
43
+ print(f"Filesystem start: 0x{device_info.fs_start_address:08X}")
44
+ print(f"Filesystem end: 0x{device_info.fs_end_address:08X}")
45
+
46
+
47
+ @app.command(name="list")
48
+ def list_files(hex_file: Path) -> None:
49
+ """List files stored in a MicroPython hex file.
50
+
51
+ :param hex_file: Path to the Intel Hex file.
52
+ """
53
+ hex_data = hex_file.read_text()
54
+ files = upyfs.get_files(hex_data)
55
+
56
+ if not files:
57
+ print("No files found in filesystem.")
58
+ return
59
+
60
+ total_size = sum(file.size for file in files)
61
+ name_col_width = 40
62
+ size_col_width = 12
63
+
64
+ print(f"\n{'File':<{name_col_width}} {'Size':>{size_col_width}}")
65
+ print(f"{'─' * name_col_width} {'─' * size_col_width}")
66
+ for file in files:
67
+ size_str = f"{file.size} bytes"
68
+ print(f"{file.name:<{name_col_width}} {size_str:>{size_col_width}}")
69
+
70
+ print(f"{'─' * name_col_width} {'─' * size_col_width}")
71
+ total_str = f"{total_size} bytes"
72
+ print(
73
+ f"{'Total (' + str(len(files)) + ' files)':<{name_col_width}} {total_str:>{size_col_width}}"
74
+ )
75
+
76
+
77
+ @app.command
78
+ def get(
79
+ hex_file: Path,
80
+ output_dir: Path = Path("."),
81
+ filename: Optional[str] = None,
82
+ force: bool = False,
83
+ ) -> None:
84
+ """Extract files from a MicroPython hex file.
85
+
86
+ :param hex_file: Path to the Intel Hex file.
87
+ :param output_dir: Directory to extract files to (default: current directory).
88
+ :param filename: Extract only this specific file (default: extract all).
89
+ :param force: Overwrite existing files without prompting (default: False).
90
+ """
91
+ hex_data = hex_file.read_text()
92
+ files = upyfs.get_files(hex_data)
93
+
94
+ if not files:
95
+ print("No files found in filesystem.")
96
+ return
97
+
98
+ # Filter files if a specific filename was requested
99
+ files_to_extract = files
100
+ if filename is not None:
101
+ files_to_extract = [f for f in files if f.name == filename]
102
+ if not files_to_extract:
103
+ raise SystemExit(f"Error: File not found in hex: {filename}")
104
+
105
+ # Check for existing files before extracting (unless --force is used)
106
+ if not force:
107
+ existing_files = []
108
+ for file in files_to_extract:
109
+ output_path = output_dir / file.name
110
+ if output_path.exists():
111
+ existing_files.append(file.name)
112
+ if existing_files:
113
+ raise SystemExit(
114
+ f"Error: Files already exist: {', '.join(existing_files)}\n"
115
+ "Use --force to overwrite."
116
+ )
117
+
118
+ output_dir.mkdir(parents=True, exist_ok=True)
119
+
120
+ for file in files_to_extract:
121
+ output_path = output_dir / file.name
122
+ output_path.write_bytes(file.content)
123
+ print(f"Extracted: {file.name} ({file.size} bytes)")
124
+
125
+
126
+ @app.command
127
+ def add(
128
+ files: list[Path],
129
+ hex_file: Annotated[
130
+ Optional[Path],
131
+ Parameter(
132
+ name="--hex-file",
133
+ help="Path to input Intel Hex file. Required if --v1 or --v2 is not used.",
134
+ ),
135
+ ] = None,
136
+ output: Optional[Path] = None,
137
+ v1: Annotated[
138
+ Optional[str],
139
+ Parameter(
140
+ help=(
141
+ "Use bundled micro:bit V1 MicroPython hex. "
142
+ "Specify a version (e.g., --v1=1.1) or 'latest' for the newest."
143
+ ),
144
+ ),
145
+ ] = None,
146
+ v2: Annotated[
147
+ Optional[str],
148
+ Parameter(
149
+ help=(
150
+ "Use bundled micro:bit V2 MicroPython hex. "
151
+ "Specify a version (e.g., --v2=1.2) or 'latest' for the newest."
152
+ ),
153
+ ),
154
+ ] = None,
155
+ ) -> None:
156
+ """Inject files into a MicroPython hex file.
157
+
158
+ You must provide either --hex-file OR --v1/--v2 to specify the base hex file.
159
+
160
+ **Examples:**
161
+
162
+ - microbit-fs add main.py --v1=latest
163
+ - microbit-fs add main.py --v2=1.2 --output my_program.hex
164
+ - microbit-fs add main.py --hex-file micropython.hex
165
+ - microbit-fs add main.py helper.py --hex-file micropython.hex --output output.hex
166
+
167
+ :param files: One or more files to add to the filesystem.
168
+ :param hex_file: Path to the input Intel Hex file.
169
+ :param output: Output hex file path (default: <input_name>_output.hex).
170
+ :param v1: Use bundled micro:bit V1 hex with specified version or 'latest'.
171
+ :param v2: Use bundled micro:bit V2 hex with specified version or 'latest'.
172
+ """
173
+ has_hex_file = hex_file is not None
174
+ has_v1 = v1 is not None
175
+ has_v2 = v2 is not None
176
+ if sum([has_hex_file, has_v1, has_v2]) == 0:
177
+ raise SystemExit(
178
+ "Error: You must provide a hex file or use --v1/--v2 for a bundled hex."
179
+ )
180
+ if sum([has_hex_file, has_v1, has_v2]) > 1:
181
+ raise SystemExit(
182
+ "Error: Cannot combine hex_file with --v1 or --v2, or use both --v1 and --v2."
183
+ )
184
+
185
+ hex_content: str
186
+ try:
187
+ # "latest" means use None to get the newest version
188
+ if has_v1:
189
+ version = None if v1.lower() == "latest" else v1 # type: ignore
190
+ bundled_hex = get_bundled_hex(1, version)
191
+ hex_content = bundled_hex.content
192
+ resolved_version = version or bundled_hex.version
193
+ hex_path = bundled_hex.file_path
194
+ print(f"Using bundled micro:bit V1 MicroPython v{resolved_version}")
195
+ elif has_v2:
196
+ version = None if v2.lower() == "latest" else v2 # type: ignore
197
+ bundled_hex = get_bundled_hex(2, version)
198
+ hex_content = bundled_hex.content
199
+ resolved_version = version or bundled_hex.version
200
+ hex_path = bundled_hex.file_path
201
+ print(f"Using bundled micro:bit V2 MicroPython v{resolved_version}")
202
+ elif has_hex_file:
203
+ hex_content = hex_file.read_text() # type: ignore
204
+ hex_path = hex_file # type: ignore
205
+ except HexNotFoundError as e:
206
+ raise SystemExit(f"Error: {e}") from None
207
+
208
+ file_objects = []
209
+ for file_path in files:
210
+ upy_file = upyfs.File(name=file_path.name, content=file_path.read_bytes())
211
+ file_objects.append(upy_file)
212
+ print(f"Adding: {file_path.name} ({upy_file.size_fs} bytes)")
213
+
214
+ new_hex = upyfs.add_files(hex_content, file_objects)
215
+
216
+ # Generate default output in cwd, filename: <name>_output.hex
217
+ output_path = output if output else Path(f"{hex_path.stem}_output.hex")
218
+ output_path.write_text(new_hex)
219
+ print(f"Written to: {output_path.absolute().relative_to(Path.cwd())}")
220
+
221
+
222
+ @app.command
223
+ def versions() -> None:
224
+ """List available bundled MicroPython hex versions.
225
+
226
+ Shows all MicroPython versions bundled with this tool for both
227
+ micro:bit V1 and V2.
228
+ """
229
+ versions_by_device = list_bundled_versions()
230
+
231
+ print("Bundled MicroPython hex files:")
232
+ for device_version in sorted(versions_by_device.keys()):
233
+ print(f"\nmicro:bit V{device_version}:")
234
+ for version in versions_by_device[device_version]:
235
+ print(f" - {version}")
236
+ if len(versions_by_device[device_version]) == 0:
237
+ print(" (none available)")
238
+
239
+
240
+ def main() -> None:
241
+ """Entry point for the CLI."""
242
+ app()
243
+
244
+
245
+ if __name__ == "__main__":
246
+ main()
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env python3
2
+ """Device memory information data structures."""
3
+
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+ from typing import Any
7
+
8
+
9
+ class DeviceVersion(Enum):
10
+ """micro:bit hardware version."""
11
+
12
+ V1 = "V1" # micro:bit V1 (nRF51822)
13
+ V2 = "V2" # micro:bit V2 (nRF52833)
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class DeviceSpec:
18
+ """Hardware specification for a micro:bit version."""
19
+
20
+ device_version: DeviceVersion
21
+ uicr_magic: int
22
+ page_size: int
23
+ flash_start_address: int
24
+ flash_size: int
25
+ fs_end_address: int
26
+
27
+
28
+ DEVICE_SPECS = {
29
+ DeviceVersion.V1: DeviceSpec(
30
+ device_version=DeviceVersion.V1,
31
+ uicr_magic=0x17EEB07C,
32
+ page_size=1024,
33
+ flash_start_address=0,
34
+ flash_size=256 * 1024,
35
+ fs_end_address=256 * 1024,
36
+ ),
37
+ DeviceVersion.V2: DeviceSpec(
38
+ device_version=DeviceVersion.V2,
39
+ uicr_magic=0x47EEB07C,
40
+ page_size=4096,
41
+ flash_start_address=0,
42
+ flash_size=512 * 1024,
43
+ fs_end_address=0x73000,
44
+ ),
45
+ }
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class DeviceInfo:
50
+ """
51
+ Device information extracted from a MicroPython hex file.
52
+
53
+ This contains information about the flash memory layout including
54
+ where the MicroPython runtime and filesystem are located.
55
+ """
56
+
57
+ flash_page_size: int
58
+ """Size of a flash page in bytes (V1: 1024, V2: 4096)."""
59
+
60
+ flash_size: int
61
+ """Total flash size in bytes (V1: 256KB, V2: 512KB)."""
62
+
63
+ flash_start_address: int
64
+ """Start address of flash memory (always 0)."""
65
+
66
+ flash_end_address: int
67
+ """End address of flash memory."""
68
+
69
+ runtime_start_address: int
70
+ """Start address of the MicroPython runtime."""
71
+
72
+ runtime_end_address: int
73
+ """End address of the MicroPython runtime."""
74
+
75
+ fs_start_address: int
76
+ """Start address of the filesystem region."""
77
+
78
+ fs_end_address: int
79
+ """End address of the filesystem region."""
80
+
81
+ micropython_version: str
82
+ """MicroPython version string."""
83
+
84
+ device_version: DeviceVersion
85
+ """micro:bit hardware version (V1 or V2)."""
86
+
87
+ @property
88
+ def fs_size(self) -> int:
89
+ """Total filesystem size in bytes."""
90
+ # One page is used as scratch, so exclude it from the size
91
+ return self.fs_end_address - self.fs_start_address - self.flash_page_size
92
+
93
+
94
+ def get_device_info_ih(ih: Any) -> DeviceInfo:
95
+ """
96
+ Internal function to get device info from an already-loaded IntelHex.
97
+
98
+ :param ih: IntelHex object.
99
+ :returns: DeviceInfo containing memory layout information.
100
+
101
+ :raises NotMicroPythonError: If the hex does not contain MicroPython.
102
+ """
103
+ # Delayed imports to avoid circular dependency
104
+ from micropython_microbit_fs.exceptions import NotMicroPythonError
105
+ from micropython_microbit_fs.flash_regions import get_device_info_from_flash_regions
106
+ from micropython_microbit_fs.uicr import get_device_info_from_uicr
107
+
108
+ # First try Flash Regions Table detection, as it's more likely to be a V2
109
+ device_info = get_device_info_from_flash_regions(ih)
110
+ if device_info is not None:
111
+ return device_info
112
+
113
+ # Try UICR detection next (works for V1 and pre-release V2)
114
+ device_info = get_device_info_from_uicr(ih)
115
+ if device_info is not None:
116
+ return device_info
117
+
118
+ raise NotMicroPythonError(
119
+ "Could not detect MicroPython in hex file. "
120
+ "The hex file may not contain MicroPython or may be corrupted."
121
+ )
@@ -0,0 +1,37 @@
1
+ """Custom exceptions for the micropython-microbit-fs library."""
2
+
3
+
4
+ class FilesystemError(Exception):
5
+ """Base exception for filesystem-related errors."""
6
+
7
+ pass
8
+
9
+
10
+ class InvalidHexError(FilesystemError):
11
+ """Raised when the Intel Hex data is invalid or malformed."""
12
+
13
+ pass
14
+
15
+
16
+ class NotMicroPythonError(FilesystemError):
17
+ """Raised when the hex file does not contain MicroPython."""
18
+
19
+ pass
20
+
21
+
22
+ class InvalidFileError(FilesystemError):
23
+ """Raised when a file has invalid name or content."""
24
+
25
+ pass
26
+
27
+
28
+ class StorageFullError(FilesystemError):
29
+ """Raised when there is not enough space in the filesystem."""
30
+
31
+ pass
32
+
33
+
34
+ class HexNotFoundError(Exception):
35
+ """Raised when a requested hex file is not found."""
36
+
37
+ pass
@@ -0,0 +1,62 @@
1
+ """File representation for the MicroPython filesystem."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from micropython_microbit_fs.exceptions import InvalidFileError
6
+ from micropython_microbit_fs.filesystem import calculate_file_size
7
+
8
+
9
+ @dataclass
10
+ class File:
11
+ """
12
+ Represents a file to be stored in the MicroPython filesystem.
13
+
14
+ Attributes:
15
+ name: The filename (max 120 characters, no path separators).
16
+ content: The file content as bytes.
17
+ """
18
+
19
+ name: str
20
+ content: bytes
21
+
22
+ def __post_init__(self) -> None:
23
+ """Validate file name and content."""
24
+ if not self.name:
25
+ raise InvalidFileError("Filename cannot be empty")
26
+ if len(self.name) > 120:
27
+ raise InvalidFileError(
28
+ f"Filename too long: {len(self.name)} > 120 characters"
29
+ )
30
+ if not self.content:
31
+ raise InvalidFileError("File content cannot be empty")
32
+
33
+ @classmethod
34
+ def from_text(cls, name: str, text: str, encoding: str = "utf-8") -> "File":
35
+ """
36
+ Create a File from text content.
37
+
38
+ :param name: The filename.
39
+ :param text: The text content.
40
+ :param encoding: Text encoding (default: utf-8).
41
+ :returns: A new File instance.
42
+ """
43
+ return cls(name=name, content=text.encode(encoding))
44
+
45
+ def get_text(self, encoding: str = "utf-8") -> str:
46
+ """
47
+ Get the file content as text.
48
+
49
+ :param encoding: Text encoding (default: utf-8).
50
+ :returns: The file content as a string.
51
+ """
52
+ return self.content.decode(encoding)
53
+
54
+ @property
55
+ def size(self) -> int:
56
+ """Return the size of the file content in bytes."""
57
+ return len(self.content)
58
+
59
+ @property
60
+ def size_fs(self) -> int:
61
+ """Return the total size the file consumes in the filesystem storage."""
62
+ return calculate_file_size(self.name, self.content)