Skip to content

Commit 023697a

Browse files
authored
Add support for running (and installing) the main CLI (#572)
With this `python -m gel` or `uvx gel` will run automatically download, cache and run the Gel CLI. In virtualenvs the `gel` command is automatically available once the `gel` package is installed without the need to download and run the CLI separately. Tested on Linux, native Windows and its various Posixish shells (MINGW, MSYS, Cygwin) and macOS.
1 parent e263f2b commit 023697a

File tree

3 files changed

+295
-0
lines changed

3 files changed

+295
-0
lines changed

gel/__main__.py

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#
2+
# This source file is part of the EdgeDB open source project.
3+
#
4+
# Copyright 2022-present MagicStack Inc. and the EdgeDB authors.
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
19+
20+
from .cli import main
21+
22+
if __name__ == "__main__":
23+
main()

gel/cli.py

+271
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
#
2+
# This source file is part of the EdgeDB open source project.
3+
#
4+
# Copyright 2016-present MagicStack Inc. and the EdgeDB authors.
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
19+
from typing import (
20+
NoReturn,
21+
Tuple,
22+
)
23+
24+
import os
25+
import os.path
26+
import pathlib
27+
import platform
28+
import shutil
29+
import ssl
30+
import stat
31+
import subprocess
32+
import sys
33+
import tempfile
34+
import time
35+
import urllib.request
36+
37+
38+
PACKAGE_URL_PREFIX = "https://packages.edgedb.com/dist"
39+
STRONG_CIPHERSUITES = ":".join([
40+
"TLS_AES_128_GCM_SHA256",
41+
"TLS_CHACHA20_POLY1305_SHA256",
42+
"TLS_AES_256_GCM_SHA384",
43+
"ECDHE-ECDSA-AES128-GCM-SHA256",
44+
"ECDHE-RSA-AES128-GCM-SHA256",
45+
"ECDHE-ECDSA-CHACHA20-POLY1305",
46+
"ECDHE-RSA-CHACHA20-POLY1305",
47+
"ECDHE-ECDSA-AES256-GCM-SHA384",
48+
"ECDHE-RSA-AES256-GCM-SHA384",
49+
])
50+
51+
52+
def _die(msg: str) -> NoReturn:
53+
print(f"error: {msg}", file=sys.stderr)
54+
sys.exit(1)
55+
56+
57+
def _warn(msg: str) -> NoReturn:
58+
print(f"warning: {msg}", file=sys.stderr)
59+
60+
61+
def _run_cli(path: str) -> NoReturn:
62+
cmd = [path] + sys.argv[1:]
63+
if os.name == "nt":
64+
result = subprocess.run(cmd)
65+
sys.exit(result.returncode)
66+
else:
67+
os.execv(path, cmd)
68+
69+
70+
def _real_mac_machine(machine: str) -> str:
71+
import ctypes
72+
import ctypes.util
73+
74+
def _sysctl(libc: ctypes.CDLL, name: str) -> str:
75+
size = ctypes.c_uint(0)
76+
libc.sysctlbyname(name, None, ctypes.byref(size), None, 0)
77+
buf = ctypes.create_string_buffer(size.value)
78+
libc.sysctlbyname(name, buf, ctypes.byref(size), None, 0)
79+
return buf.value
80+
81+
libc_path = ctypes.util.find_library("c")
82+
if not libc_path:
83+
_die("could not find the C library")
84+
libc = ctypes.CDLL(libc_path)
85+
if machine == "i386":
86+
# check for 32-bit emulation on a 64-bit x86 machine
87+
if _sysctl(libc, "hw.optional.x86_64") == "1":
88+
machine = "x86_64"
89+
elif machine == "x86_64":
90+
# check for Rosetta
91+
if _sysctl(libc, "sysctl.proc_translated") == "1":
92+
machine = "aarch64"
93+
94+
return machine
95+
96+
97+
def _platform() -> Tuple[str, str]:
98+
uname = platform.uname()
99+
uname_sys = uname.system
100+
machine = uname.machine.lower()
101+
if (
102+
uname_sys == "Windows"
103+
or uname_sys.startswith("CYGWIN_NT")
104+
or uname_sys.startswith("MINGW64_NT")
105+
or uname_sys.startswith("MSYS_NT")
106+
):
107+
os = "Windows"
108+
elif uname_sys == "Darwin":
109+
if machine == "i386" or machine == "x86_64":
110+
machine = _real_mac_machine(machine)
111+
os = "Darwin"
112+
elif uname_sys == "Linux":
113+
os = "Linux"
114+
else:
115+
_die(f"unsupported OS: {uname_sys}")
116+
117+
if machine in ("x86-64", "x64", "amd64"):
118+
machine = "x86_64"
119+
elif machine == "arm64":
120+
machine = "aarch64"
121+
122+
if machine not in ("x86_64", "aarch64") or (
123+
machine == "aarch64" and os not in ("Darwin", "Linux")
124+
):
125+
_die(f"unsupported hardware architecture: {machine}")
126+
127+
return os, machine
128+
129+
130+
def _download(url: str, dest: pathlib.Path) -> None:
131+
if not url.lower().startswith("https://"):
132+
_die(f"unexpected insecure URL: {url}")
133+
134+
# Create an SSL context with certificate verification enabled
135+
ssl_context = ssl.create_default_context()
136+
ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
137+
ssl_context.set_ciphers(STRONG_CIPHERSUITES)
138+
139+
try:
140+
# Open the URL with the SSL context
141+
with urllib.request.urlopen(url, context=ssl_context) as response:
142+
final_url = response.geturl()
143+
if not final_url.lower().startswith("https://"):
144+
_die("redirected to a non-HTTPS URL, download aborted.")
145+
146+
if response.status != 200:
147+
raise RuntimeError(f"{response.status}")
148+
149+
spinner_symbols = ['|', '/', '-', '\\']
150+
msg = "downloading Gel CLI"
151+
print(f"{msg}", end="\r")
152+
start = time.monotonic()
153+
154+
with open(str(dest), mode="wb") as file:
155+
i = 0
156+
while True:
157+
chunk = response.read(524288)
158+
if not chunk:
159+
break
160+
file.write(chunk)
161+
now = time.monotonic()
162+
if now - start > 0.2:
163+
print(f"\r{msg} {spinner_symbols[i]}", end="\r")
164+
start = now
165+
i = (i + 1) % len(spinner_symbols)
166+
167+
# clear
168+
print(f"{' ' * (len(msg) + 2)}", end="\r")
169+
170+
except Exception as e:
171+
_die(f"could not download Gel CLI: {e}")
172+
173+
174+
def _get_binary_cache_dir(os_name) -> pathlib.Path:
175+
home = pathlib.Path.home()
176+
if os_name == 'Windows':
177+
localappdata = os.environ.get('LOCALAPPDATA', '')
178+
if localappdata:
179+
base_cache_dir = pathlib.Path(localappdata)
180+
else:
181+
base_cache_dir = home / 'AppData' / 'Local'
182+
elif os_name == 'Linux':
183+
xdg_cache_home = os.environ.get('XDG_CACHE_HOME', '')
184+
if xdg_cache_home:
185+
base_cache_dir = pathlib.Path(xdg_cache_home)
186+
else:
187+
base_cache_dir = home / '.cache'
188+
elif os_name == 'Darwin':
189+
base_cache_dir = home / 'Library' / 'Caches'
190+
else:
191+
_die(f"unsupported OS: {os_name}")
192+
193+
cache_dir = base_cache_dir / "gel" / "bin"
194+
try:
195+
cache_dir.mkdir(parents=True, exist_ok=True)
196+
except OSError as e:
197+
_warn(f"could not create {cache_dir}: {e}")
198+
199+
try:
200+
cache_dir = pathlib.Path(tempfile.mkdtemp(prefix="gel"))
201+
except Exception as e:
202+
_die(f"could not create temporary directory: {e}")
203+
204+
return cache_dir
205+
206+
207+
def _get_mountpoint(path: pathlib.Path) -> pathlib.Path:
208+
path = path.resolve()
209+
if os.path.ismount(str(path)):
210+
return path
211+
else:
212+
for p in path.parents:
213+
if os.path.ismount(str(p)):
214+
return p
215+
216+
return p
217+
218+
219+
def _install_cli(os_name: str, arch: str, path: pathlib.Path) -> str:
220+
triple = f"{arch}"
221+
ext = ""
222+
if os_name == "Windows":
223+
triple += "-pc-windows-msvc"
224+
ext = ".exe"
225+
elif os_name == "Darwin":
226+
triple += "-apple-darwin"
227+
elif os_name == "Linux":
228+
triple += "-unknown-linux-musl"
229+
else:
230+
_die(f"unexpected OS: {os}")
231+
232+
url = f"{PACKAGE_URL_PREFIX}/{triple}/edgedb-cli{ext}"
233+
234+
if path.exists() and not path.is_file():
235+
_die(f"{path} exists but is not a regular file, "
236+
f"please remove it and try again")
237+
238+
_download(url, path)
239+
240+
try:
241+
path.chmod(
242+
stat.S_IRWXU
243+
| stat.S_IRGRP | stat.S_IXGRP
244+
| stat.S_IROTH | stat.S_IXOTH,
245+
)
246+
except OSError as e:
247+
_die(f"could not max {path!r} executable: {e}")
248+
249+
if not os.access(str(path), os.X_OK):
250+
_die(
251+
f"cannot execute {path!r} "
252+
f"(likely because {_get_mountpoint(path)} is mounted as noexec)"
253+
)
254+
255+
256+
def main() -> NoReturn:
257+
dev_cli = shutil.which("gel-dev")
258+
if dev_cli:
259+
path = pathlib.Path(dev_cli)
260+
else:
261+
os, arch = _platform()
262+
cache_dir = _get_binary_cache_dir(os)
263+
path = cache_dir / "gel"
264+
if not path.exists():
265+
_install_cli(os, arch, path)
266+
267+
_run_cli(path)
268+
269+
270+
if __name__ == "__main__":
271+
main()

setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,7 @@ def finalize_options(self):
367367
"edgedb-py=gel.codegen.cli:main",
368368
"gel-py=gel.codegen.cli:main",
369369
"gel-orm=gel.orm.cli:main",
370+
"gel=gel.cli:main",
370371
]
371372
}
372373
)

0 commit comments

Comments
 (0)