folder2uf2 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.
folder2uf2/__init__.py ADDED
@@ -0,0 +1,734 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-FileCopyrightText: 2026 Mikey Sklar
3
+ # SPDX-License-Identifier: MIT
4
+ #
5
+ # folder2uf2: pack a folder into a FAT filesystem image wrapped in a UF2,
6
+ # targeting the CIRCUITPY drive region of an RP2040/RP2350 CircuitPython
7
+ # board. Lets a product ship code + libraries + assets as one drag-and-drop
8
+ # UF2 alongside (or after) the CircuitPython firmware UF2.
9
+ #
10
+ # The FAT geometry replicates what CircuitPython itself creates: oofatfs
11
+ # f_mkfs(FM_FAT, au=0) called on a virtual device whose sector 0 is a
12
+ # synthesized MBR with partition 1 at LBA 1 (supervisor/shared/flash.c).
13
+ # The raw flash region therefore holds a bare FAT volume (no MBR), whose
14
+ # BPB records 1 hidden sector. See README.md for the source derivation.
15
+ #
16
+ # Idea credit: jepler's archived mkfatimg (C/oofatfs) explored the same
17
+ # folder -> FAT image direction; this is an independent stdlib-only
18
+ # implementation.
19
+
20
+ import argparse
21
+ import os
22
+ import random
23
+ import struct
24
+ import sys
25
+ import time
26
+
27
+ SECTOR = 512
28
+ UF2_MAGIC0 = 0x0A324655
29
+ UF2_MAGIC1 = 0x9E5D5157
30
+ UF2_MAGIC_END = 0x0AB16F30
31
+ UF2_FLAG_FAMILY_ID = 0x00002000
32
+ UF2_PAYLOAD = 256
33
+
34
+ FAMILY_RP2040 = 0xE48BFF56
35
+ FAMILY_RP2350_ARM_S = 0xE48BFF59
36
+ FAMILY_ABSOLUTE = 0xE48BFF57 # rp2xxx absolute (data anywhere in flash)
37
+
38
+ XIP_BASE = 0x10000000
39
+
40
+ # drive_start = CIRCUITPY_FIRMWARE_SIZE + CIRCUITPY_INTERNAL_NVM_SIZE (4K).
41
+ # Default firmware size is 1020K (ports/raspberrypi/mpconfigport.h); boards
42
+ # with CYW43 wifi override it to 1536K in their mpconfigboard.mk.
43
+ # flash_total from EXTERNAL_FLASH_DEVICES part number.
44
+ BOARDS = {
45
+ "raspberry_pi_pico": (FAMILY_RP2040, 0x100000, 2 * 1024 * 1024),
46
+ "raspberry_pi_pico_w": (FAMILY_RP2040, 0x181000, 2 * 1024 * 1024),
47
+ "raspberry_pi_pico2": (FAMILY_RP2350_ARM_S, 0x100000, 4 * 1024 * 1024),
48
+ "raspberry_pi_pico2_w": (FAMILY_RP2350_ARM_S, 0x181000, 4 * 1024 * 1024),
49
+ "adafruit_feather_rp2040": (FAMILY_RP2040, 0x100000, 8 * 1024 * 1024),
50
+ "adafruit_qtpy_rp2040": (FAMILY_RP2040, 0x100000, 8 * 1024 * 1024),
51
+ "adafruit_metro_rp2350": (FAMILY_RP2350_ARM_S, 0x100000, 16 * 1024 * 1024),
52
+ "adafruit_fruit_jam": (FAMILY_RP2350_ARM_S, 0x100000, 16 * 1024 * 1024),
53
+ }
54
+
55
+ # Espressif boards keep the CIRCUITPY drive in a data/fat partition. tinyuf2
56
+ # on ESP32 only writes ota_0, so these produce a raw image for esptool rather
57
+ # than a UF2. Values are (offset, size) from
58
+ # ports/espressif/esp-idf-config/partitions-*.csv, sized to the fat partition
59
+ # ALONE, which is always safe.
60
+ #
61
+ # Real boards vary, so prefer --partition-table with a dump read off the chip:
62
+ # esptool --before no-reset --after no-reset read-flash 0x8000 0xc00 pt.bin
63
+ # A Metro ESP32-S3 16MB has ffat 11968K plus an ota_1 to extend into; a Metro
64
+ # ESP32-S2 4MB has ffat 960K and no ota_1 at all.
65
+ #
66
+ # With CIRCUITPY_STORAGE_EXTEND the filesystem may span the fat partition PLUS
67
+ # the spare OTA partition, since supervisor_flash_get_block_count() returns
68
+ # (_partition[0]->size + _partition[1]->size). The port decides at boot with
69
+ # storage_extended = (_partition[0]->size < fatfs_bytes());
70
+ # so sizing to the fat partition alone turns extension off and gives a smaller
71
+ # drive, while over-sizing writes a filesystem that runs past its partition.
72
+ # Use --storage-extend WITH --partition-table to size it correctly.
73
+ ESP_PARTITIONS = {
74
+ "esp32_4mb": (0x310000, 960 * 1024),
75
+ "esp32_8mb": (0x450000, 3776 * 1024),
76
+ "esp32_16mb": (0x450000, 11968 * 1024),
77
+ }
78
+
79
+
80
+ def parse_partition_table(path):
81
+ """Parse an esp-idf partition table dump. Returns (fat_offset, fat_size,
82
+ next_ota_size). next_ota_size is 0 when the table has no spare OTA slot."""
83
+ d = open(path, "rb").read()
84
+ fat = None
85
+ otas = []
86
+ for i in range(0, len(d) - 32, 32):
87
+ e = d[i:i + 32]
88
+ if e[:2] != b"\xaa\x50":
89
+ break
90
+ _, ptype, subtype, off, size, raw, _ = struct.unpack("<HBBII16sI", e)
91
+ name = raw.rstrip(b"\x00").decode("ascii", "replace")
92
+ if ptype == 1 and subtype == 0x81: # data, fat
93
+ fat = (off, size, name)
94
+ elif ptype == 0 and 0x10 <= subtype <= 0x1f: # app, ota_N
95
+ otas.append((subtype, off, size, name))
96
+ if fat is None:
97
+ raise ValueError("%s has no data/fat partition" % path)
98
+ # CircuitPython extends into the *next* OTA slot, which only exists when
99
+ # the table defines more than one.
100
+ next_ota = sorted(otas)[1][2] if len(otas) > 1 else 0
101
+ return fat[0], fat[1], next_ota
102
+
103
+ MAX_FAT12 = 0xFF5
104
+ MAX_FAT16 = 0xFFF5
105
+
106
+
107
+ class Geometry:
108
+ """FAT12/16 geometry exactly as oofatfs f_mkfs computes it for
109
+ CircuitPython's flash blockdev (sz_blk=1, ss=512, au=0, 1 FAT)."""
110
+
111
+ def __init__(self, volume_sectors):
112
+ sz_vol = volume_sectors
113
+ if sz_vol < 22:
114
+ raise ValueError("volume too small")
115
+ cst = [1, 4, 16, 64, 256, 512]
116
+ au = 0
117
+ while True:
118
+ pau = au
119
+ if pau == 0:
120
+ # C: for (i=0, pau=1; cst[i] && cst[i] <= n; i++, pau <<= 1);
121
+ n = sz_vol // 0x1000
122
+ pau = 1
123
+ for c in cst:
124
+ if c <= n:
125
+ pau <<= 1
126
+ else:
127
+ break
128
+ n_clst = sz_vol // pau
129
+ if n_clst > MAX_FAT12:
130
+ fmt = 16
131
+ nbytes = n_clst * 2 + 4
132
+ else:
133
+ fmt = 12
134
+ nbytes = (n_clst * 3 + 1) // 2 + 3
135
+ sz_fat = (nbytes + SECTOR - 1) // SECTOR
136
+ sz_rsv = 1
137
+ n_rootdir = 128 if sz_vol <= 256 else 512
138
+ sz_dir = n_rootdir * 32 // SECTOR
139
+ b_data = sz_rsv + sz_fat + sz_dir # relative to volume start
140
+ # sz_blk == 1 so no erase-block alignment adjustment
141
+ if sz_vol < b_data + pau * 16:
142
+ raise ValueError("volume too small")
143
+ n_clst = (sz_vol - sz_rsv - sz_fat - sz_dir) // pau
144
+ if fmt == 16:
145
+ if n_clst > MAX_FAT16:
146
+ if au == 0 and pau * 2 <= 64:
147
+ au = pau * 2
148
+ continue
149
+ raise ValueError("too many clusters for FAT16")
150
+ if n_clst <= MAX_FAT12:
151
+ au = pau * 2
152
+ if au <= 128:
153
+ continue
154
+ raise ValueError("cluster count in FAT12/16 gap")
155
+ if fmt == 12 and n_clst > MAX_FAT12:
156
+ raise ValueError("too many clusters for FAT12")
157
+ break
158
+ self.fat_type = fmt
159
+ self.sectors_per_cluster = pau
160
+ self.reserved = sz_rsv
161
+ self.fat_sectors = sz_fat
162
+ self.root_entries = n_rootdir
163
+ self.rootdir_sectors = sz_dir
164
+ self.total_sectors = sz_vol
165
+ self.cluster_count = n_clst
166
+ self.data_start = b_data # sector index within volume
167
+ self.cluster_bytes = pau * SECTOR
168
+
169
+
170
+ INVALID_SFN = set('"*+,./:;<=>?[\\]| ')
171
+
172
+
173
+ def _sfn_char(c):
174
+ c = c.upper()
175
+ if ord(c) < 0x20 or c in INVALID_SFN:
176
+ return "_"
177
+ return c
178
+
179
+
180
+ def make_sfn(name, used):
181
+ """Generate an 8.3 short name (11 bytes, space padded). Returns
182
+ (sfn_bytes, needs_lfn)."""
183
+ if "." in name and not name.startswith("."):
184
+ base, _, ext = name.rpartition(".")
185
+ else:
186
+ base, ext = name, ""
187
+ lossy = name.startswith(".")
188
+ base_f = "".join(_sfn_char(c) for c in base if c not in ". ")
189
+ ext_f = "".join(_sfn_char(c) for c in ext if c not in ". ")
190
+ if base_f != base or ext_f != ext or len(base_f) > 8 or len(ext_f) > 3 \
191
+ or not base_f:
192
+ lossy = True
193
+ fits = (not lossy and base == base_f and ext == ext_f
194
+ and len(base_f) <= 8 and len(ext_f) <= 3)
195
+ sfn = None
196
+ if fits:
197
+ cand = (base_f[:8].ljust(8) + ext_f[:3].ljust(3)).encode("ascii")
198
+ if cand not in used:
199
+ sfn = cand
200
+ if sfn is None:
201
+ base_f = (base_f or "_")[:8]
202
+ for tail in range(1, 1000000):
203
+ t = "~%d" % tail
204
+ cand_base = base_f[: 8 - len(t)] + t
205
+ cand = (cand_base.ljust(8) + ext_f[:3].ljust(3)).encode("ascii")
206
+ if cand not in used:
207
+ sfn = cand
208
+ break
209
+ lossy = True
210
+ used.add(sfn)
211
+ return sfn, lossy or not fits
212
+
213
+
214
+ def lfn_checksum(sfn):
215
+ s = 0
216
+ for b in sfn:
217
+ s = (((s & 1) << 7) + (s >> 1) + b) & 0xFF
218
+ return s
219
+
220
+
221
+ def lfn_entries(name, sfn):
222
+ """VFAT long-name entries, in on-disk order (last logical first)."""
223
+ u = name.encode("utf-16-le")
224
+ chars = list(struct.unpack("<%dH" % (len(u) // 2), u))
225
+ chars.append(0)
226
+ while len(chars) % 13:
227
+ chars.append(0xFFFF)
228
+ n = len(chars) // 13
229
+ ck = lfn_checksum(sfn)
230
+ out = []
231
+ for i in range(n - 1, -1, -1):
232
+ seq = i + 1
233
+ if i == n - 1:
234
+ seq |= 0x40
235
+ c = chars[i * 13:(i + 1) * 13]
236
+ ent = struct.pack(
237
+ "<B10sBBB12sH4s",
238
+ seq,
239
+ struct.pack("<5H", *c[0:5]),
240
+ 0x0F, 0, ck,
241
+ struct.pack("<6H", *c[5:11]),
242
+ 0,
243
+ struct.pack("<2H", *c[11:13]),
244
+ )
245
+ out.append(ent)
246
+ return out
247
+
248
+
249
+ def fat_datetime(ts):
250
+ t = time.localtime(ts)
251
+ year = max(1980, min(2107, t.tm_year))
252
+ d = ((year - 1980) << 9) | (t.tm_mon << 5) | t.tm_mday
253
+ tm = (t.tm_hour << 11) | (t.tm_min << 5) | (t.tm_sec // 2)
254
+ return d, tm
255
+
256
+
257
+ def dirent(sfn, attr, cluster, size, ts):
258
+ d, tm = fat_datetime(ts)
259
+ return struct.pack(
260
+ "<11sBBBHHHHHHHI",
261
+ sfn, attr, 0, 0, tm, d, d, 0, tm, d, cluster & 0xFFFF, size)
262
+
263
+
264
+ ATTR_DIR = 0x10
265
+ ATTR_VOLUME = 0x08
266
+
267
+
268
+ class Node:
269
+ def __init__(self, name, path=None, is_dir=False, data=None, ts=None):
270
+ self.name = name
271
+ self.path = path
272
+ self.is_dir = is_dir
273
+ self.data = data # bytes for synthesized files
274
+ self.ts = ts
275
+ self.children = [] # for dirs
276
+ self.cluster = 0
277
+ self.size = 0
278
+
279
+
280
+ EXTRA_FILES = [
281
+ # (path-in-image, data) -- what CircuitPython's own formatter creates
282
+ # to keep macOS/Linux from writing trash/index files (filesystem.c).
283
+ (".fseventsd/no_log", b""),
284
+ (".metadata_never_index", b""),
285
+ (".Trashes", b""),
286
+ (".Trash-1000", b""),
287
+ ]
288
+
289
+
290
+ def build_tree(src, extra_files=True, build_ts=None):
291
+ if build_ts is None:
292
+ build_ts = time.time()
293
+ root = Node("", is_dir=True, ts=build_ts)
294
+
295
+ def get_dir(node, name, ts):
296
+ for c in node.children:
297
+ if c.name == name and c.is_dir:
298
+ return c
299
+ d = Node(name, is_dir=True, ts=ts)
300
+ node.children.append(d)
301
+ return d
302
+
303
+ if extra_files:
304
+ for relpath, data in EXTRA_FILES:
305
+ parts = relpath.split("/")
306
+ cur = root
307
+ for p in parts[:-1]:
308
+ cur = get_dir(cur, p, build_ts)
309
+ cur.children.append(Node(parts[-1], data=data, ts=build_ts))
310
+
311
+ def walk(dirpath, node):
312
+ for entry in sorted(os.listdir(dirpath)):
313
+ if entry in (".DS_Store",):
314
+ continue
315
+ full = os.path.join(dirpath, entry)
316
+ st = os.stat(full)
317
+ if os.path.isdir(full):
318
+ sub = get_dir(node, entry, st.st_mtime)
319
+ sub.ts = st.st_mtime
320
+ walk(full, sub)
321
+ else:
322
+ # user file replaces a synthesized one of the same name
323
+ node.children = [c for c in node.children
324
+ if not (c.name == entry and not c.is_dir)]
325
+ node.children.append(
326
+ Node(entry, path=full, ts=st.st_mtime,
327
+ data=None))
328
+ walk(src, root)
329
+ return root
330
+
331
+
332
+ class FatBuilder:
333
+ def __init__(self, geo, label="CIRCUITPY", volume_id=None, build_ts=None):
334
+ self.geo = geo
335
+ self.label = label
336
+ self.volume_id = volume_id if volume_id is not None \
337
+ else random.getrandbits(32)
338
+ self.build_ts = build_ts if build_ts is not None else time.time()
339
+ self.image = bytearray(geo.total_sectors * SECTOR)
340
+ self.fat = [0] * (geo.cluster_count + 2)
341
+ self.next_cluster = 2
342
+
343
+ def alloc_chain(self, nbytes):
344
+ """Allocate a contiguous cluster chain; returns first cluster."""
345
+ n = max(1, (nbytes + self.geo.cluster_bytes - 1)
346
+ // self.geo.cluster_bytes)
347
+ if self.next_cluster + n - 1 > self.geo.cluster_count + 1:
348
+ raise ValueError(
349
+ "content does not fit: need %d more clusters" % n)
350
+ first = self.next_cluster
351
+ for i in range(n):
352
+ c = first + i
353
+ self.fat[c] = c + 1 if i < n - 1 else \
354
+ (0xFFF if self.geo.fat_type == 12 else 0xFFFF)
355
+ self.next_cluster += n
356
+ return first
357
+
358
+ def cluster_offset(self, cluster):
359
+ return (self.geo.data_start + (cluster - 2)
360
+ * self.geo.sectors_per_cluster) * SECTOR
361
+
362
+ def write_cluster_data(self, cluster, data):
363
+ off = self.cluster_offset(cluster)
364
+ self.image[off:off + len(data)] = data
365
+
366
+ def entries_for(self, node, used_sfns):
367
+ sfn, needs_lfn = make_sfn(node.name, used_sfns)
368
+ ents = lfn_entries(node.name, sfn) if needs_lfn else []
369
+ attr = ATTR_DIR if node.is_dir else 0
370
+ ents.append(dirent(sfn, attr, node.cluster, node.size, node.ts))
371
+ return ents
372
+
373
+ def layout(self, root):
374
+ # Pass 1: read file data, then allocate clusters depth-first the
375
+ # way FatFs would create them sequentially.
376
+ def prep(node):
377
+ for c in node.children:
378
+ if c.is_dir:
379
+ prep(c)
380
+ else:
381
+ if c.data is None:
382
+ with open(c.path, "rb") as f:
383
+ c.data = f.read()
384
+ c.size = len(c.data)
385
+
386
+ def alloc(node, is_root):
387
+ if not is_root:
388
+ # entry count: . and .. plus children entries (with LFNs)
389
+ count = 2
390
+ used = set()
391
+ for c in node.children:
392
+ _, needs = make_sfn(c.name, used)
393
+ count += 1 + (len(lfn_entries(c.name, b"x" * 11))
394
+ if needs else 0)
395
+ node.size = 0
396
+ node.cluster = self.alloc_chain(count * 32)
397
+ for c in node.children:
398
+ if c.is_dir:
399
+ alloc(c, False)
400
+ for c in node.children:
401
+ if not c.is_dir:
402
+ c.cluster = self.alloc_chain(c.size) if c.size else 0
403
+ if c.size:
404
+ self.write_cluster_data(c.cluster, c.data)
405
+ prep(root)
406
+ alloc(root, True)
407
+
408
+ # Pass 2: emit directory entries.
409
+ def emit(node, parent_cluster, is_root):
410
+ used = set()
411
+ out = b""
412
+ if is_root:
413
+ d, tm = fat_datetime(self.build_ts)
414
+ out += struct.pack(
415
+ "<11sBBBHHHHHHHI",
416
+ self.label.upper().ljust(11)[:11].encode("ascii"),
417
+ ATTR_VOLUME, 0, 0, tm, d, d, 0, tm, d, 0, 0)
418
+ else:
419
+ out += dirent(b". ", ATTR_DIR, node.cluster, 0,
420
+ node.ts)
421
+ out += dirent(b".. ", ATTR_DIR,
422
+ parent_cluster if parent_cluster != 0 else 0,
423
+ 0, node.ts)
424
+ for c in node.children:
425
+ out += b"".join(self.entries_for(c, used))
426
+ if is_root:
427
+ cap = self.geo.root_entries * 32
428
+ if len(out) > cap:
429
+ raise ValueError("too many entries in root directory")
430
+ off = self.geo.reserved * SECTOR \
431
+ + self.geo.fat_sectors * SECTOR
432
+ self.image[off:off + len(out)] = out
433
+ else:
434
+ self.write_cluster_data(node.cluster, out)
435
+ for c in node.children:
436
+ if c.is_dir:
437
+ emit(c, 0 if is_root else node.cluster, False)
438
+ emit(root, 0, True)
439
+ self.write_boot_sector()
440
+ self.write_fat()
441
+
442
+ def write_boot_sector(self):
443
+ g = self.geo
444
+ b = bytearray(SECTOR)
445
+ b[0:3] = b"\xEB\xFE\x90"
446
+ b[3:11] = b"MSDOS5.0"
447
+ struct.pack_into("<H", b, 11, SECTOR) # bytes/sector
448
+ b[13] = g.sectors_per_cluster
449
+ struct.pack_into("<H", b, 14, g.reserved) # reserved sectors
450
+ b[16] = 1 # number of FATs
451
+ struct.pack_into("<H", b, 17, g.root_entries)
452
+ if g.total_sectors < 0x10000:
453
+ struct.pack_into("<H", b, 19, g.total_sectors)
454
+ else:
455
+ struct.pack_into("<I", b, 32, g.total_sectors)
456
+ b[21] = 0xF8 # media descriptor
457
+ struct.pack_into("<H", b, 22, g.fat_sectors)
458
+ struct.pack_into("<H", b, 24, 63) # sectors/track
459
+ struct.pack_into("<H", b, 26, 255) # heads
460
+ # BPB_HiddSec: volume begins at LBA 1 of CircuitPython's virtual
461
+ # MBR device (supervisor/shared/flash.c PART1_START_BLOCK).
462
+ struct.pack_into("<I", b, 28, 1)
463
+ b[36] = 0x80 # drive number
464
+ b[38] = 0x29 # ext boot signature
465
+ struct.pack_into("<I", b, 39, self.volume_id)
466
+ # oofatfs f_mkfs leaves the BPB label as NO NAME; the real label
467
+ # lives in the root directory entry (f_setlabel behavior).
468
+ # oofatfs writes literally "FAT " here for FAT12 and FAT16
469
+ b[43:62] = b"NO NAME " + b"FAT "
470
+ b[510] = 0x55
471
+ b[511] = 0xAA
472
+ self.image[0:SECTOR] = b
473
+
474
+ def write_fat(self):
475
+ g = self.geo
476
+ self.fat[0] = 0xF8 | (0xF00 if g.fat_type == 12 else 0xFF00)
477
+ self.fat[1] = 0xFFF if g.fat_type == 12 else 0xFFFF
478
+ raw = bytearray(g.fat_sectors * SECTOR)
479
+ if g.fat_type == 16:
480
+ for i, v in enumerate(self.fat):
481
+ struct.pack_into("<H", raw, i * 2, v & 0xFFFF)
482
+ else:
483
+ for i, v in enumerate(self.fat):
484
+ off = i * 3 // 2
485
+ if i % 2 == 0:
486
+ raw[off] = v & 0xFF
487
+ raw[off + 1] = (raw[off + 1] & 0xF0) | ((v >> 8) & 0x0F)
488
+ else:
489
+ raw[off] = (raw[off] & 0x0F) | ((v << 4) & 0xF0)
490
+ raw[off + 1] = (v >> 4) & 0xFF
491
+ off = g.reserved * SECTOR
492
+ self.image[off:off + len(raw)] = raw
493
+
494
+ def used_bytes(self):
495
+ """Bytes of image actually meaningful (metadata + allocated
496
+ clusters), for trimmed UF2 output."""
497
+ end_cluster = self.next_cluster
498
+ off = self.cluster_offset(end_cluster - 1) + self.geo.cluster_bytes \
499
+ if end_cluster > 2 else self.geo.data_start * SECTOR
500
+ return off
501
+
502
+
503
+ def image_blocks(image, target_addr, trim_to=None):
504
+ """Split an image into (address, payload) UF2 block payloads."""
505
+ data = image if trim_to is None else image[:trim_to]
506
+ # pad to erase-sector (4K) boundary so the bootloader erases/writes
507
+ # whole sectors deterministically
508
+ pad = (-len(data)) % 4096
509
+ data = bytes(data) + b"\xff" * pad
510
+ n = (len(data) + UF2_PAYLOAD - 1) // UF2_PAYLOAD
511
+ return [(target_addr + i * UF2_PAYLOAD,
512
+ data[i * UF2_PAYLOAD:(i + 1) * UF2_PAYLOAD]) for i in range(n)]
513
+
514
+
515
+ def read_uf2(path):
516
+ """Return (blocks, family_id) from an existing UF2 file."""
517
+ raw = open(path, "rb").read()
518
+ if len(raw) % SECTOR:
519
+ raise ValueError("%s is not a whole number of 512-byte UF2 blocks"
520
+ % path)
521
+ blocks = []
522
+ family = None
523
+ for i in range(len(raw) // SECTOR):
524
+ b = raw[i * SECTOR:(i + 1) * SECTOR]
525
+ m0, m1, flags, addr, size, _no, _total, fam = struct.unpack(
526
+ "<IIIIIIII", b[:32])
527
+ if m0 != UF2_MAGIC0 or m1 != UF2_MAGIC1:
528
+ raise ValueError("%s: block %d has a bad UF2 magic" % (path, i))
529
+ if flags & UF2_FLAG_FAMILY_ID:
530
+ if family is not None and fam != family:
531
+ raise ValueError("%s mixes family ids 0x%08x and 0x%08x"
532
+ % (path, family, fam))
533
+ family = fam
534
+ blocks.append((addr, b[32:32 + size]))
535
+ return blocks, family
536
+
537
+
538
+ def write_uf2_blocks(path, blocks, family_id):
539
+ """Write blocks as one UF2, numbering them across the whole file."""
540
+ total = len(blocks)
541
+ with open(path, "wb") as f:
542
+ for i, (addr, chunk) in enumerate(blocks):
543
+ head = struct.pack(
544
+ "<IIIIIIII",
545
+ UF2_MAGIC0, UF2_MAGIC1, UF2_FLAG_FAMILY_ID,
546
+ addr, len(chunk), i, total, family_id)
547
+ f.write(head + chunk.ljust(476, b"\x00")
548
+ + struct.pack("<I", UF2_MAGIC_END))
549
+ return total
550
+
551
+
552
+ def write_uf2(path, image, target_addr, family_id, trim_to=None):
553
+ return write_uf2_blocks(
554
+ path, image_blocks(image, target_addr, trim_to), family_id)
555
+
556
+
557
+ def parse_size(s):
558
+ return int(s, 0)
559
+
560
+
561
+ def main(argv=None):
562
+ p = argparse.ArgumentParser(
563
+ prog="folder2uf2",
564
+ description="Pack a folder into a CIRCUITPY FAT filesystem UF2 "
565
+ "for RP2040/RP2350 CircuitPython boards.")
566
+ p.add_argument("source", nargs="?", help="folder to pack")
567
+ p.add_argument("-o", "--output", help="output UF2 path (default: "
568
+ "<source>.uf2)")
569
+ p.add_argument("--board", choices=sorted(BOARDS) + sorted(ESP_PARTITIONS),
570
+ help="board name (sets flash offset, size, family id). "
571
+ "esp32_* targets write a raw image for esptool, not a UF2")
572
+ p.add_argument("--combine", metavar="FIRMWARE.UF2",
573
+ help="prepend a firmware UF2 so one file flashes both "
574
+ "firmware and filesystem")
575
+ p.add_argument("--flash-offset", type=parse_size,
576
+ help="CIRCUITPY drive start offset in flash (e.g. "
577
+ "0x100000)")
578
+ p.add_argument("--flash-size", type=parse_size,
579
+ help="total flash size (drive runs to end of flash)")
580
+ p.add_argument("--fs-size", type=parse_size,
581
+ help="filesystem size in bytes (overrides "
582
+ "flash-size - flash-offset)")
583
+ p.add_argument("--family-id", type=parse_size,
584
+ help="UF2 family id (default from --board)")
585
+ p.add_argument("--base-addr", type=parse_size, default=XIP_BASE,
586
+ help="flash XIP base address (default 0x10000000)")
587
+ p.add_argument("--label", default="CIRCUITPY",
588
+ help="volume label (default CIRCUITPY)")
589
+ p.add_argument("--volume-id", type=parse_size,
590
+ help="FAT volume serial number (default random)")
591
+ p.add_argument("--full", action="store_true",
592
+ help="write the entire filesystem region instead of "
593
+ "only the used portion (wipes residual data; larger "
594
+ "UF2)")
595
+ p.add_argument("--no-extra-files", action="store_true",
596
+ help="skip the .fseventsd/.metadata_never_index/"
597
+ ".Trashes files CircuitPython normally creates")
598
+ p.add_argument("--partition-table", metavar="PT.BIN",
599
+ help="ESP32: derive offset and size from a partition table "
600
+ "read off the chip at 0x8000")
601
+ p.add_argument("--storage-extend", action="store_true",
602
+ help="ESP32: extend the filesystem into the spare OTA "
603
+ "partition, matching CIRCUITPY_STORAGE_EXTEND. Requires "
604
+ "--partition-table")
605
+ p.add_argument("--img-out", help="also write the raw FAT image here")
606
+ p.add_argument("--self-extract", action="store_true",
607
+ help="emit a single self-extracting code.py instead of an "
608
+ "image. Drag it onto CIRCUITPY and it unpacks itself. "
609
+ "Works on any port, no bootloader or esptool needed")
610
+ p.add_argument("--list-boards", action="store_true",
611
+ help="list built-in boards and exit")
612
+ args = p.parse_args(argv)
613
+
614
+ if args.list_boards:
615
+ for name, (fam, off, tot) in sorted(BOARDS.items()):
616
+ print("%-28s family=0x%08x offset=0x%06x fs_size=%.1fMB"
617
+ % (name, fam, off, (tot - off) / 1e6))
618
+ for name, (off, size) in sorted(ESP_PARTITIONS.items()):
619
+ print("%-28s esptool offset=0x%06x fs_size=%.1fMB"
620
+ % (name, off, size / 1e6))
621
+ return 0
622
+
623
+ if args.self_extract:
624
+ from . import selfextract
625
+ text, raw, biggest = selfextract.build(args.source)
626
+ out = args.output or "code.py"
627
+ with open(out, "w") as f:
628
+ f.write(text)
629
+ print("%s: %d files, %d bytes of source packed into %d bytes"
630
+ % (out, text.count("\n#>"), raw, len(text)))
631
+ print("largest single file %d bytes, which bounds RAM on the board"
632
+ % biggest)
633
+ print("drag onto CIRCUITPY; it unpacks and reboots")
634
+ return 0
635
+
636
+ if not args.source:
637
+ p.error("source folder is required")
638
+ if not os.path.isdir(args.source):
639
+ p.error("source %r is not a directory" % args.source)
640
+
641
+ esp = args.board in ESP_PARTITIONS if args.board else False
642
+ if esp:
643
+ if args.combine:
644
+ p.error("--combine is UF2 only; ESP32 images are flashed with "
645
+ "esptool")
646
+ offset, part_size = ESP_PARTITIONS[args.board]
647
+ ota_size = 0
648
+ if args.partition_table:
649
+ offset, part_size, ota_size = parse_partition_table(
650
+ args.partition_table)
651
+ fs_size = part_size
652
+ if args.storage_extend:
653
+ if not args.partition_table:
654
+ p.error("--storage-extend needs --partition-table, since the "
655
+ "spare OTA partition size varies by board")
656
+ if not ota_size:
657
+ p.error("this partition table has no spare OTA slot, so "
658
+ "storage cannot extend")
659
+ fs_size = part_size + ota_size
660
+ if args.fs_size is not None:
661
+ fs_size = args.fs_size
662
+ geo = Geometry(fs_size // SECTOR)
663
+ root = build_tree(args.source, extra_files=not args.no_extra_files)
664
+ fb = FatBuilder(geo, label=args.label, volume_id=args.volume_id)
665
+ fb.layout(root)
666
+ out = args.output or os.path.basename(
667
+ os.path.abspath(args.source)) + ".bin"
668
+ data = fb.image if args.full else fb.image[:fb.used_bytes()]
669
+ with open(out, "wb") as f:
670
+ f.write(data)
671
+ print("%s: FAT%d, %d bytes, volume %dK at offset 0x%06x%s"
672
+ % (out, geo.fat_type, len(data), fs_size // 1024, offset,
673
+ " (storage extended)" if args.storage_extend else ""))
674
+ print("flash with: esptool --before no-reset --after no-reset "
675
+ "write-flash 0x%06x %s" % (offset, out))
676
+ return 0
677
+
678
+ family = args.family_id
679
+ offset = args.flash_offset
680
+ fs_size = args.fs_size
681
+ if args.board:
682
+ bfam, boff, btot = BOARDS[args.board]
683
+ family = family if family is not None else bfam
684
+ offset = offset if offset is not None else boff
685
+ if fs_size is None:
686
+ fs_size = (args.flash_size or btot) - offset
687
+ elif fs_size is None and args.flash_size is not None \
688
+ and offset is not None:
689
+ fs_size = args.flash_size - offset
690
+ if family is None or offset is None or fs_size is None:
691
+ p.error("need --board, or --flash-offset with --flash-size/"
692
+ "--fs-size and --family-id")
693
+ if fs_size <= 0 or fs_size % 4096:
694
+ p.error("filesystem size must be positive and 4K aligned")
695
+ if offset % 4096:
696
+ p.error("flash offset must be 4K aligned")
697
+
698
+ geo = Geometry(fs_size // SECTOR)
699
+ root = build_tree(args.source, extra_files=not args.no_extra_files)
700
+ fb = FatBuilder(geo, label=args.label, volume_id=args.volume_id)
701
+ fb.layout(root)
702
+
703
+ out = args.output or os.path.basename(
704
+ os.path.abspath(args.source)) + ".uf2"
705
+ trim = None if args.full else fb.used_bytes()
706
+ fs_addr = args.base_addr + offset
707
+ blocks = image_blocks(fb.image, fs_addr, trim_to=trim)
708
+
709
+ if args.combine:
710
+ fw_blocks, fw_family = read_uf2(args.combine)
711
+ if fw_family is not None and fw_family != family:
712
+ p.error("firmware family 0x%08x does not match 0x%08x; wrong "
713
+ "board?" % (fw_family, family))
714
+ fw_end = max(a + len(c) for a, c in fw_blocks)
715
+ if fw_end > fs_addr:
716
+ p.error("firmware reaches 0x%08x, past the filesystem start "
717
+ "0x%08x" % (fw_end, fs_addr))
718
+ blocks = fw_blocks + blocks
719
+ print("combined: %d firmware blocks + %d filesystem blocks"
720
+ % (len(fw_blocks), len(blocks) - len(fw_blocks)))
721
+ nblocks = write_uf2_blocks(out, blocks, family)
722
+ if args.img_out:
723
+ with open(args.img_out, "wb") as f:
724
+ f.write(fb.image)
725
+ used_kb = fb.used_bytes() / 1024
726
+ print("%s: FAT%d, %d sectors/cluster, %d clusters, %.0fKB used of "
727
+ "%.0fKB" % (out, geo.fat_type, geo.sectors_per_cluster,
728
+ geo.cluster_count, used_kb, fs_size / 1024))
729
+ lo = min(a for a, _ in blocks)
730
+ hi = max(a + len(c) for a, c in blocks)
731
+ print("UF2: %d blocks, flash 0x%08x..0x%08x, family 0x%08x"
732
+ % (nblocks, lo, hi, family))
733
+ return 0
734
+
folder2uf2/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Allow `python -m folder2uf2`."""
3
+ import sys
4
+
5
+ from . import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1,144 @@
1
+ """Generate a self-extracting CIRCUITPY installer as a single code.py.
2
+
3
+ The payload rides in trailing comment lines. CircuitPython's lexer reads
4
+ through a 255-byte buffer and discards comments without allocating, so the
5
+ file compiles cheaply however large the payload is. At run time the installer
6
+ reopens /code.py, walks past the header and inflates one member at a time, so
7
+ peak RAM is the largest single file rather than the whole tree.
8
+ """
9
+
10
+ import base64
11
+ import os
12
+ import zlib
13
+
14
+ MARKER = "# ---8<--- folder2uf2 payload, do not edit below this line"
15
+
16
+ STUB = '''\
17
+ # Self-extracting CircuitPython installer, generated by folder2uf2.
18
+ # Drag this onto CIRCUITPY. It unpacks itself and reboots.
19
+ import binascii
20
+ import microcontroller
21
+ import os
22
+ import storage
23
+ import time
24
+ import zlib
25
+
26
+ MARKER = "{marker}"
27
+
28
+
29
+ def _mkdirs(path):
30
+ parts = path.split("/")[:-1]
31
+ grown = ""
32
+ for part in parts:
33
+ if not part:
34
+ continue
35
+ grown += "/" + part
36
+ try:
37
+ os.mkdir(grown)
38
+ except OSError:
39
+ pass
40
+
41
+
42
+ def _members():
43
+ """Yield (path, data) one at a time, holding only one member in RAM.
44
+
45
+ Base64 is decoded line by line into a bytearray. Joining the lines into
46
+ one string first costs an extra 4/3 of the compressed size, which
47
+ overflows a 264KB RP2040 well before the decompression itself would.
48
+ """
49
+ with open("/code.py", "r") as f:
50
+ for line in f:
51
+ if line.rstrip("\\n") == MARKER:
52
+ break
53
+ path = None
54
+ buf = None
55
+ pos = 0
56
+ for line in f:
57
+ line = line.rstrip("\\n")
58
+ if not line.startswith("#"):
59
+ continue
60
+ body = line[1:]
61
+ if body.startswith(">"):
62
+ if path is not None:
63
+ # memoryview slice, so the exact length is passed
64
+ # without copying the buffer
65
+ yield path, zlib.decompress(memoryview(buf)[:pos])
66
+ n, _, path = body[1:].partition("|")
67
+ # preallocate: growing a bytearray reallocates and the spike
68
+ # is what overflows a 264KB RP2040
69
+ buf = bytearray((int(n) * 3) // 4 + 3)
70
+ pos = 0
71
+ elif path is not None:
72
+ chunk = binascii.a2b_base64(body)
73
+ buf[pos:pos + len(chunk)] = chunk
74
+ pos += len(chunk)
75
+ if path is not None:
76
+ yield path, zlib.decompress(memoryview(buf)[:pos])
77
+
78
+
79
+ print("folder2uf2 installer: unpacking")
80
+ # Let the host flush anything still cached before the drive disappears.
81
+ time.sleep({settle})
82
+ storage.unsafe_disable_usb_drive()
83
+
84
+ try:
85
+ deferred = None
86
+ count = 0
87
+ for path, data in _members():
88
+ if path == "code.py":
89
+ deferred = data
90
+ continue
91
+ _mkdirs(path)
92
+ with open("/" + path, "wb") as out:
93
+ out.write(data)
94
+ count += 1
95
+ print(" " + path)
96
+
97
+ # code.py last: until it lands, a crash leaves the installer rerunnable.
98
+ if deferred is not None:
99
+ with open("/code.py.new", "wb") as out:
100
+ out.write(deferred)
101
+ os.rename("/code.py.new", "/code.py")
102
+ count += 1
103
+ except Exception as e: # noqa: BLE001 - must not strand the user
104
+ # Give the drive back, or CIRCUITPY stays hidden and the installer
105
+ # reruns and fails on every boot, locking the user out entirely.
106
+ print("install failed:", e)
107
+ print("re-enabling CIRCUITPY so you can remove this file")
108
+ storage.enable_usb_drive()
109
+ raise
110
+
111
+ print("unpacked %d files, resetting" % count)
112
+ time.sleep(0.5)
113
+ # Hard reset, not supervisor.reload(): a soft reload leaves the USB drive
114
+ # disabled, so CIRCUITPY never comes back for the user.
115
+ microcontroller.reset()
116
+ '''
117
+
118
+
119
+ def build(src, settle=3.0, wrap=120):
120
+ """Return the text of a self-extracting code.py for the tree at src."""
121
+ out = [STUB.format(marker=MARKER, settle=settle), MARKER]
122
+ total_raw = 0
123
+ biggest = 0
124
+ for path in sorted(_walk(src)):
125
+ full = os.path.join(src, path)
126
+ raw = open(full, "rb").read()
127
+ total_raw += len(raw)
128
+ biggest = max(biggest, len(raw))
129
+ blob = base64.b64encode(zlib.compress(raw, 9)).decode("ascii")
130
+ out.append("#>%d|%s" % (len(blob), path.replace(os.sep, "/")))
131
+ for i in range(0, len(blob), wrap):
132
+ out.append("#" + blob[i:i + wrap])
133
+ return "\n".join(out) + "\n", total_raw, biggest
134
+
135
+
136
+ def _walk(src):
137
+ for root, dirs, files in os.walk(src):
138
+ dirs[:] = [d for d in dirs
139
+ if d not in (".fseventsd", ".Trashes", "__pycache__")]
140
+ for name in files:
141
+ if name.startswith("._") or name in (
142
+ ".metadata_never_index", ".Trash-1000", "boot_out.txt"):
143
+ continue
144
+ yield os.path.relpath(os.path.join(root, name), src)
@@ -0,0 +1,215 @@
1
+ Metadata-Version: 2.4
2
+ Name: folder2uf2
3
+ Version: 0.1.0
4
+ Summary: Ship a CircuitPython project as one file: a self-extracting code.py, a UF2 for RP2, or an image for ESP32
5
+ Author-email: Mikey Sklar <mikeysklar@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/mikeysklar/folder2uf2
8
+ Project-URL: Issues, https://github.com/mikeysklar/folder2uf2/issues
9
+ Keywords: circuitpython,uf2,rp2040,rp2350,esp32,adafruit,fat
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Embedded Systems
16
+ Classifier: Topic :: System :: Hardware
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Provides-Extra: test
21
+ Requires-Dist: pytest; extra == "test"
22
+ Dynamic: license-file
23
+
24
+ # folder2uf2
25
+
26
+ ## What it does
27
+
28
+ Ships a CircuitPython project as one file, so customers copy once.
29
+
30
+ ## Install
31
+
32
+ ```sh
33
+ pip install folder2uf2
34
+ ```
35
+
36
+ Python 3.8 or newer, standard library only. No dependencies.
37
+
38
+ ## Three ways to ship
39
+
40
+ | Output | Goes onto | Board must be |
41
+ |---|---|---|
42
+ | `code.py` | `CIRCUITPY` | running CircuitPython |
43
+ | `.uf2` | `RPI-RP2`, `RP2350` | in the UF2 bootloader |
44
+ | `.bin` | flashed by esptool | ESP32, any state |
45
+
46
+ ## Self-extract: one file, no bootloader
47
+
48
+ Pack the project. Output must be `code.py`, the name CircuitPython runs.
49
+
50
+ ```sh
51
+ folder2uf2 --self-extract -o code.py myproject/
52
+ ```
53
+
54
+ ### What your customer does
55
+
56
+ ```
57
+ 1. drag code.py onto CIRCUITPY
58
+ 2. CIRCUITPY disappears for about 4 seconds
59
+ 3. board reboots with code.py, lib/ and assets in place
60
+ ```
61
+
62
+ Works on every port, RP2 and ESP32 alike.
63
+
64
+ ### If it fails
65
+
66
+ The drive is handed back with an error. Nobody gets locked out.
67
+
68
+ ### How it works
69
+
70
+ `storage.unsafe_disable_usb_drive()` makes CIRCUITPY writable from `code.py`.
71
+
72
+ ```python
73
+ # payload rides in trailing comments, inflated one file at a time
74
+ # peak RAM is the largest single file, not the whole tree
75
+ # remount() cannot: MSC holds the block device lock while mounted
76
+ ```
77
+
78
+ ### Limits
79
+
80
+ Needs CircuitPython installed already. CIRCUITPY vanishes while it runs.
81
+
82
+ ```
83
+ zlib required: default wherever CIRCUITPY_FULL_BUILD is set
84
+ 60KB incompressible single file verified on a 264KB RP2040
85
+ ```
86
+
87
+ ## UF2, for RP2 boards
88
+
89
+ ```sh
90
+ # filesystem only
91
+ folder2uf2 --board adafruit_metro_rp2350 -o fs.uf2 myproject/
92
+
93
+ # firmware and filesystem together
94
+ folder2uf2 --board adafruit_metro_rp2350 \
95
+ --combine firmware.uf2 -o product.uf2 myproject/
96
+
97
+ # wipe residual data rather than writing only used sectors
98
+ folder2uf2 --board adafruit_metro_rp2350 --full -o fs.uf2 myproject/
99
+
100
+ # a board with no built-in profile
101
+ folder2uf2 --flash-offset 0x100000 --flash-size 0x1000000 \
102
+ --family-id 0xe48bff59 -o fs.uf2 myproject/
103
+
104
+ folder2uf2 --list-boards
105
+ ```
106
+
107
+ ## ESP32
108
+
109
+ tinyuf2 writes only `ota_0`, so ESP32 gets a raw image for esptool.
110
+
111
+ ```sh
112
+ folder2uf2 --board esp32_16mb -o fs.bin myproject/
113
+ esptool --before no-reset --after no-reset write-flash 0x450000 fs.bin
114
+ ```
115
+
116
+ ### Storage extend
117
+
118
+ Layouts differ, so read the table off your chip.
119
+
120
+ ```sh
121
+ esptool --before no-reset --after no-reset read-flash 0x8000 0xc00 pt.bin
122
+ folder2uf2 --board esp32_16mb --partition-table pt.bin --storage-extend -o fs.bin src/
123
+ ```
124
+
125
+ ```c
126
+ storage_extended = (_partition[0]->size < fatfs_bytes());
127
+ /* S3 16MB has that slot: 28032 sectors. S2 4MB does not: 1920. */
128
+ ```
129
+
130
+ ### Download mode
131
+
132
+ One-shot: one esptool run per entry, then power cycle.
133
+
134
+ ### Bootloader
135
+
136
+ Install the tinyuf2 bootloader first, per your board's learn guide.
137
+
138
+ ### Browser instead of esptool
139
+
140
+ Takes a file and an offset, so customers need no Python.
141
+
142
+ <https://adafruit.github.io/Adafruit_WebSerial_ESPTool/>
143
+
144
+ ## How it was tested
145
+
146
+ ### Self-extract, three boards
147
+
148
+ RP2040 is the tight one.
149
+
150
+ ```
151
+ Adafruit CircuitPython 10.3.0 on 2026-08-31; Adafruit Metro RP2040 with rp2040
152
+ Adafruit CircuitPython 10.3.0 on 2026-08-31; Adafruit Metro RP2350 with rp2350b
153
+ Adafruit CircuitPython 10.3.0 on 2026-08-31; Adafruit Metro ESP32S3 with ESP32S3
154
+
155
+ 60144 bytes of source packed into 84356 bytes
156
+ assets.bin 60000 bytes incompressible, md5 matched
157
+ lib/mypkg/__init__.py created, nested dir
158
+ code.py replaced by the product, printed its marker
159
+ ```
160
+
161
+ Drive returned unaided. All boards restored, identical.
162
+
163
+ ### What the RP2040 taught
164
+
165
+ Two MemoryErrors before it fit, both invisible on roomier chips.
166
+
167
+ ```
168
+ "".join(chunks) -> allocating 80037 bytes, failed
169
+ growing a bytearray -> allocating 49680 bytes, failed
170
+ preallocate + memoryview -> fits
171
+ ```
172
+
173
+ ### Combine, Metro RP2350
174
+
175
+ Before, then after one UF2 carrying the 10.3.0 release.
176
+
177
+ ```
178
+ Adafruit CircuitPython 10.3.0-alpha.4 on 2026-07-23; Adafruit Metro RP2350 with rp2350b
179
+
180
+ Adafruit CircuitPython 10.3.0 on 2026-08-31; Adafruit Metro RP2350 with rp2350b
181
+ code.py output:
182
+ FOLDER2UF2-COMBINE-OK marker 8f3a21
183
+ lib import works
184
+
185
+ restored by a second combined UF2, 108 files identical
186
+ ```
187
+
188
+ ### ESP32 image, Metro ESP32-S3
189
+
190
+ Partition table read from the chip, not assumed.
191
+
192
+ ```
193
+ ffat data fat 0x450000 0xBB0000 11968K
194
+ wrote 43520 bytes, hash verified
195
+ volume 28032 sectors, files intact
196
+ ```
197
+
198
+ ## Safety
199
+
200
+ Firmware is untouched unless you pass `--combine`.
201
+
202
+ ```
203
+ --self-extract writes only your files, leaving others alone
204
+ an ESP32 image sized wrong overflows, so read the table
205
+ ```
206
+
207
+ ## Credit
208
+
209
+ Idea from jepler's archived mkfatimg. Thanks @todbot for the requests.
210
+
211
+ https://github.com/adafruit/circuitpython/issues/10074
212
+
213
+ ## License
214
+
215
+ MIT
@@ -0,0 +1,9 @@
1
+ folder2uf2/__init__.py,sha256=Ayo_F7OL2KYgIH8RArVZFtUSS8_syf-MDU2cLSgR8QQ,29550
2
+ folder2uf2/__main__.py,sha256=AHgGk36Tizo_gBV9SSVbJ0sf7uhvbsdz-oLVjSZRiug,147
3
+ folder2uf2/selfextract.py,sha256=vJk1-oItWUmKZObxnU2RnJM_Rs4q_WlThx2nafAMPlM,4861
4
+ folder2uf2-0.1.0.dist-info/licenses/LICENSE,sha256=tR02FHnMCboe54oDaDzBViSWiaZgMKIY_gEz_jWCmdQ,1068
5
+ folder2uf2-0.1.0.dist-info/METADATA,sha256=c7aLhfANqgRa89nygL9sCq3SdLvVzH21bID9R4Y7sRo,5570
6
+ folder2uf2-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
7
+ folder2uf2-0.1.0.dist-info/entry_points.txt,sha256=2hbw76I9sN2WxNlnOH0AL0fERMPKha04aZy8U9YL3fM,47
8
+ folder2uf2-0.1.0.dist-info/top_level.txt,sha256=Ca-9jN_5ExTFOxdElraX5x9JpaZBzyyURc-QxKohDD8,11
9
+ folder2uf2-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ folder2uf2 = folder2uf2:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mikey Sklar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ folder2uf2