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. Commercial Alternative to JupyterHub.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/workspaces.py
Views: 1250
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, e.g., typescript
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/nats',
113
'packages/backend',
114
'packages/api-client',
115
'packages/jupyter',
116
'packages/comm',
117
'packages/project',
118
'packages/assets',
119
'packages/frontend', # static depends on frontend; frontend depends on assets
120
'packages/static', # packages/hub assumes this is built (for webpack dev server)
121
'packages/server', # packages/next assumes this is built
122
'packages/database', # packages/next also assumes database is built (or at least the coffeescript in it is)
123
'packages/next',
124
'packages/hub', # hub won't build if next isn't already built
125
]
126
for x in os.listdir('packages'):
127
path = os.path.join("packages", x)
128
if path not in v and os.path.isdir(path) and os.path.exists(
129
os.path.join(path, 'package.json')):
130
v.append(path)
131
return v
132
133
134
def packages(args) -> List[str]:
135
v = all_packages()
136
# Filter to only the ones in packages (if given)
137
if args.packages:
138
packages = set(args.packages.split(','))
139
v = [x for x in v if x.split('/')[-1] in packages]
140
141
# Only take things not in exclude
142
if args.exclude:
143
exclude = set(args.exclude.split(','))
144
v = [x for x in v if x.split('/')[-1] not in exclude]
145
146
print("Packages: ", ', '.join(v))
147
return v
148
149
150
def package_json(package: str) -> dict:
151
return json.loads(open(f'{package}/package.json').read())
152
153
154
def write_package_json(package: str, x: dict) -> None:
155
open(f'{package}/package.json', 'w').write(json.dumps(x, indent=2))
156
157
158
def dependent_packages(package: str) -> List[str]:
159
# Get a list of the packages
160
# it depends on by reading package.json
161
x = package_json(package)
162
if "workspaces" not in x:
163
# no workspaces
164
return []
165
v: List[str] = []
166
for path in x["workspaces"]:
167
# path is a relative path
168
npath = os.path.normpath(os.path.join(package, path))
169
if npath != package:
170
v.append(npath)
171
return v
172
173
174
def get_package_version(package: str) -> str:
175
return package_json(package)["version"]
176
177
178
def get_package_npm_name(package: str) -> str:
179
return package_json(package)["name"]
180
181
182
def update_dependent_versions(package: str) -> None:
183
"""
184
Update the versions of all of the workspaces that this
185
package depends on. The versions are set to whatever the
186
current version is in the dependent packages package.json.
187
188
There is a problem here, if you are publishing two
189
packages A and B with versions vA and vB. If you first publish
190
A, then you set it as depending on B@vB. However, when you then
191
publish B you set its new version as vB+1, so A got published
192
with the wrong version. It's thus important to first
193
update all the versions of the packages that will be published
194
in a single phase, then update the dependent version numbers, and
195
finally actually publish the packages to npm. There will unavoidably
196
be an interval of time when some of the packages are impossible to
197
install (e.g., because A got published and depends on B@vB+1, but B
198
isn't yet published).
199
"""
200
x = package_json(package)
201
changed = False
202
for dependent in dependent_packages(package):
203
print(f"Considering '{dependent}'")
204
try:
205
package_version = '^' + get_package_version(dependent)
206
except:
207
print(f"Skipping '{dependent}' since package not available")
208
continue
209
npm_name = get_package_npm_name(dependent)
210
dev = npm_name in x.get("devDependencies", {})
211
if dev:
212
current_version = x.get("devDependencies", {}).get(npm_name, '')
213
else:
214
current_version = x.get("dependencies", {}).get(npm_name, '')
215
# print(dependent, npm_name, current_version, package_version)
216
if current_version != package_version:
217
print(
218
f"{package}: {dependent} changed from '{current_version}' to '{package_version}'"
219
)
220
x['devDependencies' if dev else 'dependencies'][
221
npm_name] = package_version
222
changed = True
223
if changed:
224
write_package_json(package, x)
225
226
227
def update_all_dependent_versions() -> None:
228
for package in all_packages():
229
update_dependent_versions(package)
230
231
232
def banner(s: str) -> None:
233
print("\n" + "=" * 70)
234
print("|| " + s)
235
print("=" * 70 + "\n")
236
237
238
def install(args) -> None:
239
v = packages(args)
240
241
# The trick we use to build only a subset of the packages in a pnpm workspace
242
# is to temporarily modify packages/pnpm-workspace.yaml to explicitly remove
243
# the packages that we do NOT want to build. This should be supported by
244
# pnpm via the --filter option but I can't figure that out in a way that doesn't
245
# break the global lockfile, so this is the hack we have for now.
246
ws = "packages/pnpm-workspace.yaml"
247
tmp = ws + ".tmp"
248
allp = all_packages()
249
try:
250
if v != allp:
251
shutil.copy(ws, tmp)
252
s = open(ws,'r').read() + '\n'
253
for package in allp:
254
if package not in v:
255
s += ' - "!%s"\n'%package.split('/')[-1]
256
257
open(ws,'w').write(s)
258
259
print("install packages")
260
# much faster special case
261
# see https://github.com/pnpm/pnpm/issues/6778 for why we put that confirm option in
262
c = "cd packages && pnpm install --config.confirmModulesPurge=false"
263
if args.prod:
264
args.dist_only = False
265
args.node_modules_only = True
266
args.parallel = True
267
clean(args)
268
c += " --prod"
269
cmd(c)
270
finally:
271
if os.path.exists(tmp):
272
shutil.move(tmp, ws)
273
274
275
# Build all the packages that need to be built.
276
def build(args) -> None:
277
v = [package for package in packages(args) if needs_build(package)]
278
CUR = os.path.abspath('.')
279
280
def f(path: str) -> None:
281
if not args.parallel and path != 'packages/static':
282
# NOTE: in parallel mode we don't delete or there is no
283
# hope of this working.
284
dist = os.path.join(CUR, path, 'dist')
285
if os.path.exists(dist):
286
# clear dist/ dir
287
shutil.rmtree(dist, ignore_errors=True)
288
package_path = os.path.join(CUR, path)
289
if args.dev and '"build-dev"' in open(
290
os.path.join(CUR, path, 'package.json')).read():
291
cmd("pnpm run build-dev", package_path)
292
else:
293
cmd("pnpm run build", package_path)
294
# The build succeeded, so touch a file
295
# to indicate this, so we won't build again
296
# until something is newer than this file
297
cmd("touch " + SUCCESSFUL_BUILD, package_path)
298
299
if args.parallel:
300
thread_map(f, v)
301
else:
302
thread_map(f, v, 1)
303
304
305
def clean(args) -> None:
306
v = packages(args)
307
308
if args.dist_only:
309
folders = ['dist']
310
elif args.node_modules_only:
311
folders = ['node_modules']
312
else:
313
folders = ['node_modules', 'dist', SUCCESSFUL_BUILD]
314
315
paths = []
316
for path in v:
317
for x in folders:
318
y = os.path.abspath(os.path.join(path, x))
319
if os.path.exists(y):
320
paths.append(y)
321
322
def f(path):
323
print("rm -rf '%s'" % path)
324
if not os.path.exists(path):
325
return
326
if os.path.isfile(path):
327
os.unlink(path)
328
return
329
shutil.rmtree(path, ignore_errors=True)
330
if os.path.exists(path):
331
shutil.rmtree(path, ignore_errors=True)
332
if os.path.exists(path):
333
raise RuntimeError(f'failed to delete {path}')
334
335
if (len(paths) == 0):
336
banner("No node_modules or dist directories")
337
else:
338
banner("Deleting " + ', '.join(paths))
339
thread_map(f, paths + ['packages/node_modules'], nb_threads=10)
340
341
if not args.node_modules_only:
342
banner("Running 'pnpm run clean' if it exists...")
343
344
def g(path):
345
# can only use --if-present with npm, but should be fine since clean is
346
# usually just "rm".
347
cmd("npm run clean --if-present", path)
348
349
thread_map(g, [os.path.abspath(path) for path in v],
350
nb_threads=3 if args.parallel else 1)
351
352
353
def delete_package_lock(args) -> None:
354
355
def f(path: str) -> None:
356
p = os.path.join(path, 'package-lock.json')
357
if os.path.exists(p):
358
os.unlink(p)
359
# See https://github.com/sagemathinc/cocalc/issues/6123
360
# If we don't delete node_modules, then package-lock.json may blow up in size.
361
node_modules = os.path.join(path, 'node_modules')
362
if os.path.exists(node_modules):
363
shutil.rmtree(node_modules, ignore_errors=True)
364
365
thread_map(f, [os.path.abspath(path) for path in packages(args)],
366
nb_threads=10)
367
368
369
def pnpm(args, noerr=False) -> None:
370
v = packages(args)
371
inputs: List[List[str]] = []
372
for path in v:
373
s = 'pnpm ' + ' '.join(['%s' % x for x in args.args])
374
inputs.append([s, os.path.abspath(path)])
375
376
def f(args) -> None:
377
# kwds to make mypy happy
378
kwds = {"noerr": noerr}
379
cmd(*args, **kwds)
380
381
if args.parallel:
382
thread_map(f, inputs, 3)
383
else:
384
thread_map(f, inputs, 1)
385
386
387
def pnpm_noerror(args) -> None:
388
pnpm(args, noerr=True)
389
390
391
def version_check(args):
392
cmd("scripts/check_npm_packages.py")
393
cmd("pnpm check-deps", './packages')
394
395
396
def node_version_check() -> None:
397
version = int(os.popen('node --version').read().split('.')[0][1:])
398
if version < 14:
399
err = f"CoCalc requires node.js v14, but you're using node v{version}."
400
if os.environ.get("COCALC_USERNAME",
401
'') == 'user' and 'COCALC_PROJECT_ID' in os.environ:
402
err += '\nIf you are using https://cocalc.com, put ". /cocalc/nvm/nvm.sh" in ~/.bashrc\nto get an appropriate version of node.'
403
raise RuntimeError(err)
404
405
406
def pnpm_version_check() -> None:
407
"""
408
Check if the pnpm utility is new enough
409
"""
410
version = os.popen('pnpm --version').read()
411
if int(version.split('.')[0]) < 7:
412
raise RuntimeError(
413
f"CoCalc requires pnpm version 7, but you're using pnpm v{version}."
414
)
415
416
417
def main() -> None:
418
node_version_check()
419
pnpm_version_check()
420
421
def packages_arg(parser):
422
parser.add_argument(
423
'--packages',
424
type=str,
425
default='',
426
help=
427
'(default: ""=everything) "foo,bar" means only the packages named foo and bar'
428
)
429
parser.add_argument(
430
'--exclude',
431
type=str,
432
default='',
433
help=
434
'(default: ""=exclude nothing) "foo,bar" means exclude foo and bar'
435
)
436
parser.add_argument(
437
'--parallel',
438
action="store_const",
439
const=True,
440
help=
441
'if given, do all in parallel; this will not work in some cases and may be ignored in others'
442
)
443
444
parser = argparse.ArgumentParser(prog='workspaces')
445
subparsers = parser.add_subparsers(help='sub-command help')
446
447
subparser = subparsers.add_parser(
448
'install', help='install node_modules deps for all packages')
449
subparser.add_argument('--prod',
450
action="store_const",
451
const=True,
452
help='only install prod deps (not dev ones)')
453
packages_arg(subparser)
454
subparser.set_defaults(func=install)
455
456
subparser = subparsers.add_parser(
457
'build', help='build all packages for which something has changed')
458
subparser.add_argument(
459
'--dev',
460
action="store_const",
461
const=True,
462
help="only build enough for development (saves time and space)")
463
packages_arg(subparser)
464
subparser.set_defaults(func=build)
465
466
subparser = subparsers.add_parser(
467
'clean', help='delete dist and node_modules folders')
468
packages_arg(subparser)
469
subparser.add_argument('--dist-only',
470
action="store_const",
471
const=True,
472
help="only delete dist directory")
473
subparser.add_argument('--node-modules-only',
474
action="store_const",
475
const=True,
476
help="only delete node_modules directory")
477
subparser.set_defaults(func=clean)
478
479
subparser = subparsers.add_parser('pnpm',
480
help='do "pnpm ..." in each package;')
481
packages_arg(subparser)
482
subparser.add_argument('args',
483
type=str,
484
nargs='*',
485
default='',
486
help='arguments to npm')
487
subparser.set_defaults(func=pnpm)
488
489
subparser = subparsers.add_parser(
490
'pnpm-noerr',
491
help=
492
'like "pnpm" but suppresses errors; e.g., use for "pnpm-noerr audit fix"'
493
)
494
packages_arg(subparser)
495
subparser.add_argument('args',
496
type=str,
497
nargs='*',
498
default='',
499
help='arguments to pnpm')
500
subparser.set_defaults(func=pnpm_noerror)
501
502
subparser = subparsers.add_parser(
503
'version-check', help='version consistency checks across packages')
504
subparser.set_defaults(func=version_check)
505
506
args = parser.parse_args()
507
if hasattr(args, 'func'):
508
args.func(args)
509
510
511
if __name__ == '__main__':
512
main()
513
514