Skip to content

Commit 5e7bac1

Browse files
committed
micropython/mip: Add a new mip library for on-device installation.
Riffing on "pip", "mip installs packages". This is a replacement for the previous `upip` tool for on-device installation of packages. This work was funded through GitHub Sponsors. Signed-off-by: Jim Mussared <[email protected]>
1 parent 58a93f3 commit 5e7bac1

File tree

3 files changed

+174
-1
lines changed

3 files changed

+174
-1
lines changed

README.md

-1
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,3 @@ Future plans (and new contributor ideas)
2929

3030
* Develop a set of example programs using these libraries.
3131
* Develop more MicroPython libraries for common tasks.
32-
* Provide a replacement for the previous `upip` tool.

micropython/mip/manifest.py

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
metadata(version="0.1.0", description="On-device package installer for network-capable boards")
2+
3+
require("urequests")
4+
5+
module("mip.py")

micropython/mip/mip.py

+169
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# MicroPython package installer
2+
# MIT license; Copyright (c) 2022 Jim Mussared
3+
4+
import urequests as requests
5+
import sys
6+
7+
8+
_PACKAGE_INDEX = const("https://micropython.org/pi/v2")
9+
_CHUNK_SIZE = 128
10+
11+
12+
# This implements os.makedirs(os.dirname(path))
13+
def _ensure_path_exists(path):
14+
import os
15+
16+
split = path.split("/")
17+
18+
# Handle paths starting with "/".
19+
if not split[0]:
20+
split.pop(0)
21+
split[0] = "/" + split[0]
22+
23+
prefix = ""
24+
for i in range(len(split) - 1):
25+
prefix += split[i]
26+
try:
27+
os.stat(prefix)
28+
except:
29+
os.mkdir(prefix)
30+
prefix += "/"
31+
32+
33+
# Copy from src (stream) to dest (function-taking-bytes)
34+
def _chunk(src, dest):
35+
buf = memoryview(bytearray(_CHUNK_SIZE))
36+
while True:
37+
n = src.readinto(buf)
38+
if n == 0:
39+
break
40+
dest(buf if n == _CHUNK_SIZE else buf[:n])
41+
42+
43+
# Check if the specified path exists and matches the hash.
44+
def _check_exists(path, short_hash):
45+
import os
46+
47+
try:
48+
import binascii
49+
import hashlib
50+
51+
with open(path, "rb") as f:
52+
hs256 = hashlib.sha256()
53+
_chunk(f, hs256.update)
54+
existing_hash = str(binascii.hexlify(hs256.digest())[: len(short_hash)], "utf-8")
55+
return existing_hash == short_hash
56+
except:
57+
return False
58+
59+
60+
def _rewrite_url(url, branch=None):
61+
if not branch:
62+
branch = "HEAD"
63+
if url.startswith("github:"):
64+
url = url[7:].split("/")
65+
url = (
66+
"https://raw.githubusercontent.com/"
67+
+ url[0]
68+
+ "/"
69+
+ url[1]
70+
+ "/"
71+
+ branch
72+
+ "/"
73+
+ "/".join(url[2:])
74+
)
75+
return url
76+
77+
78+
def _download_file(url, dest):
79+
response = requests.get(url)
80+
try:
81+
if response.status_code != 200:
82+
print("Error", response.status_code, "requesting", url)
83+
return False
84+
85+
print("Copying:", dest)
86+
_ensure_path_exists(dest)
87+
with open(dest, "wb") as f:
88+
_chunk(response.raw, f.write)
89+
90+
return True
91+
finally:
92+
response.close()
93+
94+
95+
def _install_json(package_json_url, index, target, version, mpy):
96+
response = requests.get(_rewrite_url(package_json_url, version))
97+
try:
98+
if response.status_code != 200:
99+
print("Package not found:", package_json_url)
100+
return False
101+
102+
package_json = response.json()
103+
finally:
104+
response.close()
105+
for target_path, short_hash in package_json.get("hashes", ()):
106+
fs_target_path = target + "/" + target_path
107+
if _check_exists(fs_target_path, short_hash):
108+
print("Exists:", fs_target_path)
109+
else:
110+
file_url = "{}/file/{}/{}".format(index, short_hash[:2], short_hash)
111+
if not _download_file(file_url, fs_target_path):
112+
print("File not found: {} {}".format(target_path, short_hash))
113+
return False
114+
for target_path, url in package_json.get("urls", ()):
115+
fs_target_path = target + "/" + target_path
116+
if not _download_file(_rewrite_url(url, version), fs_target_path):
117+
print("File not found: {} {}".format(target_path, url))
118+
return False
119+
for dep, dep_version in package_json.get("deps", ()):
120+
if not _install_package(dep, index, target, dep_version, mpy):
121+
return False
122+
return True
123+
124+
125+
def _install_package(package, index, target, version, mpy):
126+
if (
127+
package.startswith("http://")
128+
or package.startswith("https://")
129+
or package.startswith("github:")
130+
):
131+
if package.endswith(".py") or package.endswith(".mpy"):
132+
print("Downloading {} to {}".format(package, target))
133+
return _download_file(
134+
_rewrite_url(package, version), target + "/" + package.rsplit("/")[-1]
135+
)
136+
else:
137+
if not package.endswith(".json"):
138+
if not package.endswith("/"):
139+
package += "/"
140+
package += "package.json"
141+
print("Installing {} to {}".format(package, target))
142+
else:
143+
if not version:
144+
version = "latest"
145+
print("Installing {} ({}) from {} to {}".format(package, version, index, target))
146+
147+
mpy_version = (
148+
sys.implementation._mpy & 0xFF if mpy and hasattr(sys.implementation, "_mpy") else "py"
149+
)
150+
151+
package = "{}/package/{}/{}/{}.json".format(index, mpy_version, package, version)
152+
153+
return _install_json(package, index, target, version, mpy)
154+
155+
156+
def install(package, index=_PACKAGE_INDEX, target=None, version=None, mpy=True):
157+
if not target:
158+
for p in sys.path:
159+
if p.endswith("/lib"):
160+
target = p
161+
break
162+
else:
163+
print("Unable to find lib dir in sys.path")
164+
return
165+
166+
if _install_package(package, index.rstrip("/"), target, version, mpy):
167+
print("Done")
168+
else:
169+
print("Package may be partially installed")

0 commit comments

Comments
 (0)