CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/workspaces.py
Views: 687
1
#!/usr/bin/env python3
2
"""
3
PURPOSE: Automate building, installing, and publishing our modules.
4
This is like a little clone of "lerna" for our purposes.
5
6
NOTE: I wrote this initially using npm and with the goal of publishing
7
to npmjs.com. Now I don't care at all about publishing to npmjs.com,
8
and we're using pnpm. So this is being turned into a package just
9
for cleaning/installing/building.
10
11
TEST:
12
- This should always work: "mypy workspaces.py"
13
"""
14
15
import argparse, json, os, platform, shutil, subprocess, sys, time
16
17
from typing import Any, Optional, Callable, List
18
19
MAX_PACKAGE_LOCK_SIZE_MB = 5
20
21
22
def newest_file(path: str) -> str:
23
if platform.system() != 'Darwin':
24
# See https://gist.github.com/brwyatt/c21a888d79927cb476a4 for this Linux
25
# version:
26
cmd = 'find . -type f -printf "%C@ %p\n" | sort -rn | head -n 1 | cut -d" " -f2'
27
else:
28
# but we had to rewrite this as suggested at
29
# https://unix.stackexchange.com/questions/272491/bash-error-find-printf-unknown-primary-or-operator
30
# etc to work on MacOS.
31
cmd = 'find . -type f -print0 | xargs -0r stat -f "%Fc %N" | sort -rn | head -n 1 | cut -d" " -f2'
32
return os.popen(f'cd "{path}" && {cmd}').read().strip()
33
34
35
SUCCESSFUL_BUILD = ".successful-build"
36
37
38
def needs_build(package: str) -> bool:
39
# Code below was hopelessly naive, e.g, a failed build would not get retried.
40
# We only need to do a build if the newest file in the tree is not
41
# in the dist directory.
42
path = os.path.join(os.path.dirname(__file__), package)
43
if not os.path.exists(os.path.join(path, 'dist')):
44
return True
45
newest = newest_file(path)
46
return not newest.startswith('./' + SUCCESSFUL_BUILD)
47
48
49
def handle_path(s: str,
50
path: Optional[str] = None,
51
verbose: bool = True) -> None:
52
desc = s
53
if path is not None:
54
os.chdir(path)
55
desc += " # in '%s'" % path
56
if verbose:
57
print(desc)
58
59
60
def cmd(s: str,
61
path: Optional[str] = None,
62
verbose: bool = True,
63
noerr=False) -> None:
64
home: str = os.path.abspath(os.curdir)
65
try:
66
handle_path(s, path, verbose)
67
if os.system(s):
68
msg = f"Error executing '{s}'"
69
if noerr:
70
print(msg)
71
else:
72
raise RuntimeError(msg)
73
finally:
74
os.chdir(home)
75
76
77
def run(s: str, path: Optional[str] = None, verbose: bool = True) -> str:
78
home = os.path.abspath(os.curdir)
79
try:
80
handle_path(s, path, verbose)
81
a = subprocess.run(s, shell=True, stdout=subprocess.PIPE)
82
out = a.stdout.decode('utf8')
83
if a.returncode:
84
raise RuntimeError("Error executing '%s'" % s)
85
return out
86
finally:
87
os.chdir(home)
88
89
90
def thread_map(callable: Callable,
91
inputs: List[Any],
92
nb_threads: int = 10) -> List:
93
if len(inputs) == 0:
94
return []
95
if nb_threads == 1:
96
return [callable(x) for x in inputs]
97
from multiprocessing.pool import ThreadPool
98
tp = ThreadPool(nb_threads)
99
return tp.map(callable, inputs)
100
101
102
def all_packages() -> List[str]:
103
# Compute all the packages. Explicit order in some cases *does* matter as noted in comments,
104
# but we use "tsc --build", which automatically builds deps if not built.
105
v = [
106
'packages/', # top level workspace
107
'packages/cdn', # packages/hub assumes this is built
108
'packages/util',
109
'packages/sync',
110
'packages/sync-client',
111
'packages/sync-fs',
112
'packages/backend',
113
'packages/api-client',
114
'packages/jupyter',
115
'packages/comm',
116
'packages/assets',
117
'packages/frontend', # static depends on frontend; frontend depends on assets
118
'packages/project', # project depends on frontend for nbconvert (but NEVER vice versa again), which also depends on assets
119
'packages/static', # packages/hub assumes this is built (for webpack dev server)
120
'packages/server', # packages/next assumes this is built
121
'packages/database', # packages/next also assumes database is built (or at least the coffeescript in it is)
122
'packages/next',
123
'packages/hub', # hub won't build if next isn't already built
124
]
125
for x in os.listdir('packages'):
126
path = os.path.join("packages", x)
127
if path not in v and os.path.isdir(path) and os.path.exists(
128
os.path.join(path, 'package.json')):
129
v.append(path)
130
return v
131
132
133
def packages(args) -> List[str]:
134
v = all_packages()
135
# Filter to only the ones in packages (if given)
136
if args.packages:
137
packages = set(args.packages.split(','))
138
v = [x for x in v if x.split('/')[-1] in packages]
139
140
# Only take things not in exclude
141
if args.exclude:
142
exclude = set(args.exclude.split(','))
143
v = [x for x in v if x.split('/')[-1] not in exclude]
144
145
print("Packages: ", ', '.join(v))
146
return v
147
148
149
def package_json(package: str) -> dict:
150
return json.loads(open(f'{package}/package.json').read())
151
152
153
def write_package_json(package: str, x: dict) -> None:
154
open(f'{package}/package.json', 'w').write(json.dumps(x, indent=2))
155
156
157
def dependent_packages(package: str) -> List[str]:
158
# Get a list of the packages
159
# it depends on by reading package.json
160
x = package_json(package)
161
if "workspaces" not in x:
162
# no workspaces
163
return []
164
v: List[str] = []
165
for path in x["workspaces"]:
166
# path is a relative path
167
npath = os.path.normpath(os.path.join(package, path))
168
if npath != package:
169
v.append(npath)
170
return v
171
172
173
def get_package_version(package: str) -> str:
174
return package_json(package)["version"]
175
176
177
def get_package_npm_name(package: str) -> str:
178
return package_json(package)["name"]
179
180
181
def update_dependent_versions(package: str) -> None:
182
"""
183
Update the versions of all of the workspaces that this
184
package depends on. The versions are set to whatever the
185
current version is in the dependent packages package.json.
186
187
There is a problem here, if you are publishing two
188
packages A and B with versions vA and vB. If you first publish
189
A, then you set it as depending on B@vB. However, when you then
190
publish B you set its new version as vB+1, so A got published
191
with the wrong version. It's thus important to first
192
update all the versions of the packages that will be published
193
in a single phase, then update the dependent version numbers, and
194
finally actually publish the packages to npm. There will unavoidably
195
be an interval of time when some of the packages are impossible to
196
install (e.g., because A got published and depends on B@vB+1, but B
197
isn't yet published).
198
"""
199
x = package_json(package)
200
changed = False
201
for dependent in dependent_packages(package):
202
print(f"Considering '{dependent}'")
203
try:
204
package_version = '^' + get_package_version(dependent)
205
except:
206
print(f"Skipping '{dependent}' since package not available")
207
continue
208
npm_name = get_package_npm_name(dependent)
209
dev = npm_name in x.get("devDependencies", {})
210
if dev:
211
current_version = x.get("devDependencies", {}).get(npm_name, '')
212
else:
213
current_version = x.get("dependencies", {}).get(npm_name, '')
214
# print(dependent, npm_name, current_version, package_version)
215
if current_version != package_version:
216
print(
217
f"{package}: {dependent} changed from '{current_version}' to '{package_version}'"
218
)
219
x['devDependencies' if dev else 'dependencies'][
220
npm_name] = package_version
221
changed = True
222
if changed:
223
write_package_json(package, x)
224
225
226
def update_all_dependent_versions() -> None:
227
for package in all_packages():
228
update_dependent_versions(package)
229
230
231
def banner(s: str) -> None:
232
print("\n" + "=" * 70)
233
print("|| " + s)
234
print("=" * 70 + "\n")
235
236
237
def install(args) -> None:
238
v = packages(args)
239
if v == all_packages():
240
print("install all packages -- fast special case")
241
# much faster special case
242
cmd("cd packages && pnpm install")
243
return
244
245
# Do "pnpm i" not in parallel
246
for path in v:
247
c = "pnpm install "
248
if args.prod:
249
c += ' --prod '
250
cmd(c, path)
251
252
253
# Build all the packages that need to be built.
254
def build(args) -> None:
255
v = [package for package in packages(args) if needs_build(package)]
256
CUR = os.path.abspath('.')
257
258
def f(path: str) -> None:
259
if not args.parallel and path != 'packages/static':
260
# NOTE: in parallel mode we don't delete or there is no
261
# hope of this working.
262
dist = os.path.join(CUR, path, 'dist')
263
if os.path.exists(dist):
264
# clear dist/ dir
265
shutil.rmtree(dist, ignore_errors=True)
266
package_path = os.path.join(CUR, path)
267
if args.dev and '"build-dev"' in open(
268
os.path.join(CUR, path, 'package.json')).read():
269
cmd("pnpm run build-dev", package_path)
270
else:
271
cmd("pnpm run build", package_path)
272
# The build succeeded, so touch a file
273
# to indicate this, so we won't build again
274
# until something is newer than this file
275
cmd("touch " + SUCCESSFUL_BUILD, package_path)
276
277
if args.parallel:
278
thread_map(f, v)
279
else:
280
thread_map(f, v, 1)
281
282
283
def clean(args) -> None:
284
v = packages(args)
285
286
if args.dist_only:
287
folders = ['dist']
288
elif args.node_modules_only:
289
folders = ['node_modules']
290
else:
291
folders = ['node_modules', 'dist', SUCCESSFUL_BUILD]
292
293
paths = []
294
for path in v:
295
for x in folders:
296
y = os.path.abspath(os.path.join(path, x))
297
if os.path.exists(y):
298
paths.append(y)
299
300
def f(path):
301
print("rm -rf '%s'" % path)
302
if not os.path.exists(path):
303
return
304
if os.path.isfile(path):
305
os.unlink(path)
306
return
307
shutil.rmtree(path, ignore_errors=True)
308
if os.path.exists(path):
309
shutil.rmtree(path, ignore_errors=True)
310
if os.path.exists(path):
311
raise RuntimeError(f'failed to delete {path}')
312
313
if (len(paths) == 0):
314
banner("No node_modules or dist directories")
315
else:
316
banner("Deleting " + ', '.join(paths))
317
thread_map(f, paths + ['packages/node_modules'], nb_threads=10)
318
319
if not args.node_modules_only:
320
banner("Running 'pnpm run clean' if it exists...")
321
322
def g(path):
323
# can only use --if-present with npm, but should be fine since clean is
324
# usually just "rm".
325
cmd("npm run clean --if-present", path)
326
327
thread_map(g, [os.path.abspath(path) for path in v],
328
nb_threads=3 if args.parallel else 1)
329
330
331
def delete_package_lock(args) -> None:
332
333
def f(path: str) -> None:
334
p = os.path.join(path, 'package-lock.json')
335
if os.path.exists(p):
336
os.unlink(p)
337
# See https://github.com/sagemathinc/cocalc/issues/6123
338
# If we don't delete node_modules, then package-lock.json may blow up in size.
339
node_modules = os.path.join(path, 'node_modules')
340
if os.path.exists(node_modules):
341
shutil.rmtree(node_modules, ignore_errors=True)
342
343
thread_map(f, [os.path.abspath(path) for path in packages(args)],
344
nb_threads=10)
345
346
347
def pnpm(args, noerr=False) -> None:
348
v = packages(args)
349
inputs: List[List[str]] = []
350
for path in v:
351
s = 'pnpm ' + ' '.join(['%s' % x for x in args.args])
352
inputs.append([s, os.path.abspath(path)])
353
354
def f(args) -> None:
355
# kwds to make mypy happy
356
kwds = {"noerr": noerr}
357
cmd(*args, **kwds)
358
359
if args.parallel:
360
thread_map(f, inputs, 3)
361
else:
362
thread_map(f, inputs, 1)
363
364
365
def pnpm_noerror(args) -> None:
366
pnpm(args, noerr=True)
367
368
369
def version_check(args):
370
cmd("scripts/check_npm_packages.py")
371
372
373
def node_version_check() -> None:
374
version = int(os.popen('node --version').read().split('.')[0][1:])
375
if version < 14:
376
err = f"CoCalc requires node.js v14, but you're using node v{version}."
377
if os.environ.get("COCALC_USERNAME",
378
'') == 'user' and 'COCALC_PROJECT_ID' in os.environ:
379
err += '\nIf you are using https://cocalc.com, put ". /cocalc/nvm/nvm.sh" in ~/.bashrc\nto get an appropriate version of node.'
380
raise RuntimeError(err)
381
382
383
def pnpm_version_check() -> None:
384
"""
385
Check if the pnpm utility is new enough
386
"""
387
version = os.popen('pnpm --version').read()
388
if int(version.split('.')[0]) < 7:
389
raise RuntimeError(
390
f"CoCalc requires pnpm version 7, but you're using pnpm v{version}."
391
)
392
393
394
def main() -> None:
395
node_version_check()
396
pnpm_version_check()
397
398
def packages_arg(parser):
399
parser.add_argument(
400
'--packages',
401
type=str,
402
default='',
403
help=
404
'(default: ""=everything) "foo,bar" means only the packages named foo and bar'
405
)
406
parser.add_argument(
407
'--exclude',
408
type=str,
409
default='',
410
help=
411
'(default: ""=exclude nothing) "foo,bar" means exclude foo and bar'
412
)
413
parser.add_argument(
414
'--parallel',
415
action="store_const",
416
const=True,
417
help=
418
'if given, do all in parallel; this will not work in some cases and may be ignored in others'
419
)
420
421
parser = argparse.ArgumentParser(prog='workspaces')
422
subparsers = parser.add_subparsers(help='sub-command help')
423
424
subparser = subparsers.add_parser(
425
'install', help='install node_modules deps for all packages')
426
subparser.add_argument('--prod',
427
action="store_const",
428
const=True,
429
help='only install prod deps (not dev ones)')
430
packages_arg(subparser)
431
subparser.set_defaults(func=install)
432
433
subparser = subparsers.add_parser(
434
'build', help='build all packages for which something has changed')
435
subparser.add_argument(
436
'--dev',
437
action="store_const",
438
const=True,
439
help="only build enough for development (saves time and space)")
440
packages_arg(subparser)
441
subparser.set_defaults(func=build)
442
443
subparser = subparsers.add_parser(
444
'clean', help='delete dist and node_modules folders')
445
packages_arg(subparser)
446
subparser.add_argument('--dist-only',
447
action="store_const",
448
const=True,
449
help="only delete dist directory")
450
subparser.add_argument('--node-modules-only',
451
action="store_const",
452
const=True,
453
help="only delete node_modules directory")
454
subparser.set_defaults(func=clean)
455
456
subparser = subparsers.add_parser('pnpm',
457
help='do "pnpm ..." in each package;')
458
packages_arg(subparser)
459
subparser.add_argument('args',
460
type=str,
461
nargs='*',
462
default='',
463
help='arguments to npm')
464
subparser.set_defaults(func=pnpm)
465
466
subparser = subparsers.add_parser(
467
'pnpm-noerr',
468
help=
469
'like "pnpm" but suppresses errors; e.g., use for "pnpm-noerr audit fix"'
470
)
471
packages_arg(subparser)
472
subparser.add_argument('args',
473
type=str,
474
nargs='*',
475
default='',
476
help='arguments to pnpm')
477
subparser.set_defaults(func=pnpm_noerror)
478
479
subparser = subparsers.add_parser(
480
'version-check', help='version consistency checks across packages')
481
subparser.set_defaults(func=version_check)
482
483
args = parser.parse_args()
484
if hasattr(args, 'func'):
485
args.func(args)
486
487
488
if __name__ == '__main__':
489
main()
490
491