Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/workspaces.py
5578 views
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
n = os.system(s)
68
if n == 2:
69
raise KeyboardInterrupt
70
if n:
71
msg = f"Error executing '{s}'"
72
if noerr:
73
print(msg)
74
else:
75
raise RuntimeError(msg)
76
finally:
77
os.chdir(home)
78
79
80
def run(s: str, path: Optional[str] = None, verbose: bool = True) -> str:
81
home = os.path.abspath(os.curdir)
82
try:
83
handle_path(s, path, verbose)
84
a = subprocess.run(s, shell=True, stdout=subprocess.PIPE)
85
out = a.stdout.decode('utf8')
86
if a.returncode:
87
raise RuntimeError("Error executing '%s'" % s)
88
return out
89
finally:
90
os.chdir(home)
91
92
93
def thread_map(callable: Callable,
94
inputs: List[Any],
95
nb_threads: int = 10) -> List:
96
if len(inputs) == 0:
97
return []
98
if nb_threads == 1:
99
return [callable(x) for x in inputs]
100
from multiprocessing.pool import ThreadPool
101
tp = ThreadPool(nb_threads)
102
return tp.map(callable, inputs)
103
104
105
def all_packages() -> List[str]:
106
# Compute all the packages. Explicit order in some cases *does* matter as noted in comments,
107
# but we use "tsc --build", which automatically builds deps if not built.
108
v = [
109
'packages/', # top level workspace, e.g., typescript
110
'packages/cdn', # packages/hub assumes this is built
111
'packages/util',
112
'packages/sync',
113
'packages/sync-client',
114
'packages/sync-fs',
115
'packages/conat',
116
'packages/backend',
117
'packages/api-client',
118
'packages/jupyter',
119
'packages/comm',
120
'packages/project',
121
'packages/assets',
122
'packages/frontend', # static depends on frontend; frontend depends on assets
123
'packages/static', # packages/hub assumes this is built (for webpack dev server)
124
'packages/server', # packages/next assumes this is built
125
'packages/database', # packages/next also assumes database is built (or at least the coffeescript in it is)
126
'packages/file-server',
127
'packages/next',
128
'packages/hub', # hub won't build if next isn't already built
129
]
130
for x in os.listdir('packages'):
131
path = os.path.join("packages", x)
132
if path not in v and os.path.isdir(path) and os.path.exists(
133
os.path.join(path, 'package.json')):
134
v.append(path)
135
return v
136
137
138
def packages(args) -> List[str]:
139
v = all_packages()
140
# Filter to only the ones in packages (if given)
141
if args.packages:
142
packages = set(args.packages.split(','))
143
v = [x for x in v if x.split('/')[-1] in packages]
144
145
# Only take things not in exclude
146
if args.exclude:
147
exclude = set(args.exclude.split(','))
148
v = [x for x in v if x.split('/')[-1] not in exclude]
149
150
print("Packages: ", ', '.join(v))
151
return v
152
153
154
def package_json(package: str) -> dict:
155
return json.loads(open(f'{package}/package.json').read())
156
157
158
def write_package_json(package: str, x: dict) -> None:
159
open(f'{package}/package.json', 'w').write(json.dumps(x, indent=2))
160
161
162
def dependent_packages(package: str) -> List[str]:
163
# Get a list of the packages
164
# it depends on by reading package.json
165
x = package_json(package)
166
if "workspaces" not in x:
167
# no workspaces
168
return []
169
v: List[str] = []
170
for path in x["workspaces"]:
171
# path is a relative path
172
npath = os.path.normpath(os.path.join(package, path))
173
if npath != package:
174
v.append(npath)
175
return v
176
177
178
def get_package_version(package: str) -> str:
179
return package_json(package)["version"]
180
181
182
def get_package_npm_name(package: str) -> str:
183
return package_json(package)["name"]
184
185
186
def update_dependent_versions(package: str) -> None:
187
"""
188
Update the versions of all of the workspaces that this
189
package depends on. The versions are set to whatever the
190
current version is in the dependent packages package.json.
191
192
There is a problem here, if you are publishing two
193
packages A and B with versions vA and vB. If you first publish
194
A, then you set it as depending on B@vB. However, when you then
195
publish B you set its new version as vB+1, so A got published
196
with the wrong version. It's thus important to first
197
update all the versions of the packages that will be published
198
in a single phase, then update the dependent version numbers, and
199
finally actually publish the packages to npm. There will unavoidably
200
be an interval of time when some of the packages are impossible to
201
install (e.g., because A got published and depends on B@vB+1, but B
202
isn't yet published).
203
"""
204
x = package_json(package)
205
changed = False
206
for dependent in dependent_packages(package):
207
print(f"Considering '{dependent}'")
208
try:
209
package_version = '^' + get_package_version(dependent)
210
except:
211
print(f"Skipping '{dependent}' since package not available")
212
continue
213
npm_name = get_package_npm_name(dependent)
214
dev = npm_name in x.get("devDependencies", {})
215
if dev:
216
current_version = x.get("devDependencies", {}).get(npm_name, '')
217
else:
218
current_version = x.get("dependencies", {}).get(npm_name, '')
219
# print(dependent, npm_name, current_version, package_version)
220
if current_version != package_version:
221
print(
222
f"{package}: {dependent} changed from '{current_version}' to '{package_version}'"
223
)
224
x['devDependencies' if dev else 'dependencies'][
225
npm_name] = package_version
226
changed = True
227
if changed:
228
write_package_json(package, x)
229
230
231
def update_all_dependent_versions() -> None:
232
for package in all_packages():
233
update_dependent_versions(package)
234
235
236
def banner(s: str) -> None:
237
print("\n" + "=" * 70)
238
print("|| " + s)
239
print("=" * 70 + "\n")
240
241
242
def install(args) -> None:
243
v = packages(args)
244
245
# The trick we use to build only a subset of the packages in a pnpm workspace
246
# is to temporarily modify packages/pnpm-workspace.yaml to explicitly remove
247
# the packages that we do NOT want to build. This should be supported by
248
# pnpm via the --filter option but I can't figure that out in a way that doesn't
249
# break the global lockfile, so this is the hack we have for now.
250
ws = "packages/pnpm-workspace.yaml"
251
tmp = ws + ".tmp"
252
allp = all_packages()
253
try:
254
if v != allp:
255
shutil.copy(ws, tmp)
256
s = open(ws, 'r').read() + '\n'
257
for package in allp:
258
if package not in v:
259
s += ' - "!%s"\n' % package.split('/')[-1]
260
261
open(ws, 'w').write(s)
262
263
print("install packages")
264
# much faster special case
265
# see https://github.com/pnpm/pnpm/issues/6778 for why we put that confirm option in
266
# for the package-import-method, needed on zfs!, see https://github.com/pnpm/pnpm/issues/7024
267
c = "cd packages && pnpm install --config.confirmModulesPurge=false --package-import-method=hardlink"
268
if args.prod:
269
args.dist_only = False
270
args.node_modules_only = True
271
args.parallel = True
272
clean(args)
273
c += " --prod"
274
cmd(c)
275
finally:
276
if os.path.exists(tmp):
277
shutil.move(tmp, ws)
278
279
280
def test(args) -> None:
281
CUR = os.path.abspath('.')
282
flaky = []
283
fails = []
284
success = []
285
286
def status():
287
print("Status: ", {"flaky": flaky, "fails": fails, "success": success})
288
289
v = packages(args)
290
v.sort()
291
n = 0
292
for path in v:
293
n += 1
294
package_path = os.path.join(CUR, path)
295
if package_path.endswith('packages/'):
296
continue
297
298
def f():
299
print("\n" * 3)
300
print("*" * 40)
301
print("*")
302
status()
303
print(f"TESTING {n}/{len(v)}: {path}")
304
print("*")
305
print("*" * 40)
306
test_cmd = "pnpm run --if-present test"
307
if args.report:
308
test_cmd += " --reporters=default --reporters=jest-junit"
309
cmd(test_cmd, package_path)
310
success.append(path)
311
312
worked = False
313
for i in range(args.retries + 1):
314
try:
315
f()
316
worked = True
317
break
318
except KeyboardInterrupt:
319
print("SIGINT -- ending test suite")
320
status()
321
return
322
except Exception as err:
323
print(err)
324
flaky.append(path)
325
print(f"ERROR testing {path}")
326
if args.retries - i >= 1:
327
print(
328
f"Trying {path} again at most {args.retries - i} more times"
329
)
330
if not worked:
331
fails.append(path)
332
333
status()
334
if len(flaky) > 0:
335
print("Flaky test suites:", flaky)
336
337
if len(fails) == 0:
338
print("ALL TESTS PASSED!")
339
else:
340
print("TESTS failed in the following packages -- ", fails)
341
raise RuntimeError(f"Test Suite Failed {fails}")
342
343
344
# Build all the packages that need to be built.
345
def build(args) -> None:
346
v = [package for package in packages(args) if needs_build(package)]
347
CUR = os.path.abspath('.')
348
349
def f(path: str) -> None:
350
if not args.parallel and path != 'packages/static':
351
# NOTE: in parallel mode we don't delete or there is no
352
# hope of this working.
353
dist = os.path.join(CUR, path, 'dist')
354
if os.path.exists(dist):
355
# clear dist/ dir
356
shutil.rmtree(dist, ignore_errors=True)
357
package_path = os.path.join(CUR, path)
358
if args.dev and '"build-dev"' in open(
359
os.path.join(CUR, path, 'package.json')).read():
360
cmd("pnpm run build-dev", package_path)
361
else:
362
cmd("pnpm run build", package_path)
363
# The build succeeded, so touch a file
364
# to indicate this, so we won't build again
365
# until something is newer than this file
366
cmd("touch " + SUCCESSFUL_BUILD, package_path)
367
368
if args.parallel:
369
thread_map(f, v)
370
else:
371
thread_map(f, v, 1)
372
373
374
def clean(args) -> None:
375
v = packages(args)
376
377
if args.dist_only:
378
folders = ['dist']
379
elif args.node_modules_only:
380
folders = ['node_modules']
381
else:
382
folders = ['node_modules', 'dist', SUCCESSFUL_BUILD]
383
384
paths = []
385
for path in v:
386
for x in folders:
387
y = os.path.abspath(os.path.join(path, x))
388
if os.path.exists(y):
389
paths.append(y)
390
391
def f(path):
392
print("rm -rf '%s'" % path)
393
if not os.path.exists(path):
394
return
395
if os.path.isfile(path):
396
os.unlink(path)
397
return
398
shutil.rmtree(path, ignore_errors=True)
399
if os.path.exists(path):
400
shutil.rmtree(path, ignore_errors=True)
401
if os.path.exists(path):
402
raise RuntimeError(f'failed to delete {path}')
403
404
if (len(paths) == 0):
405
banner("No node_modules or dist directories")
406
else:
407
banner("Deleting " + ', '.join(paths))
408
thread_map(f, paths + ['packages/node_modules'], nb_threads=10)
409
410
if not args.node_modules_only:
411
banner("Running 'pnpm run clean' if it exists...")
412
413
def g(path):
414
# can only use --if-present with npm, but should be fine since clean is
415
# usually just "rm".
416
cmd("npm run clean --if-present", path)
417
418
thread_map(g, [os.path.abspath(path) for path in v],
419
nb_threads=3 if args.parallel else 1)
420
421
422
def delete_package_lock(args) -> None:
423
424
def f(path: str) -> None:
425
p = os.path.join(path, 'package-lock.json')
426
if os.path.exists(p):
427
os.unlink(p)
428
# See https://github.com/sagemathinc/cocalc/issues/6123
429
# If we don't delete node_modules, then package-lock.json may blow up in size.
430
node_modules = os.path.join(path, 'node_modules')
431
if os.path.exists(node_modules):
432
shutil.rmtree(node_modules, ignore_errors=True)
433
434
thread_map(f, [os.path.abspath(path) for path in packages(args)],
435
nb_threads=10)
436
437
438
def pnpm(args, noerr=False) -> None:
439
v = packages(args)
440
inputs: List[List[str]] = []
441
for path in v:
442
s = 'pnpm ' + ' '.join(['%s' % x for x in args.args])
443
inputs.append([s, os.path.abspath(path)])
444
445
def f(args) -> None:
446
# kwds to make mypy happy
447
kwds = {"noerr": noerr}
448
cmd(*args, **kwds)
449
450
if args.parallel:
451
thread_map(f, inputs, 3)
452
else:
453
thread_map(f, inputs, 1)
454
455
456
def pnpm_noerror(args) -> None:
457
pnpm(args, noerr=True)
458
459
460
def version_check(args):
461
cmd("scripts/check_npm_packages.py")
462
cmd("pnpm check-deps", './packages')
463
464
465
def node_version_check() -> None:
466
version = int(os.popen('node --version').read().split('.')[0][1:])
467
if version < 14:
468
err = f"CoCalc requires node.js v14, but you're using node v{version}."
469
if os.environ.get("COCALC_USERNAME",
470
'') == 'user' and 'COCALC_PROJECT_ID' in os.environ:
471
err += '\nIf you are using https://cocalc.com, put ". /cocalc/nvm/nvm.sh" in ~/.bashrc\nto get an appropriate version of node.'
472
raise RuntimeError(err)
473
474
475
def pnpm_version_check() -> None:
476
"""
477
Check if the pnpm utility is new enough
478
"""
479
version = os.popen('pnpm --version').read()
480
if int(version.split('.')[0]) < 7:
481
raise RuntimeError(
482
f"CoCalc requires pnpm version 7, but you're using pnpm v{version}."
483
)
484
485
486
def main() -> None:
487
node_version_check()
488
pnpm_version_check()
489
490
def packages_arg(parser):
491
parser.add_argument(
492
'--packages',
493
type=str,
494
default='',
495
help=
496
'(default: ""=everything) "foo,bar" means only the packages named foo and bar'
497
)
498
parser.add_argument(
499
'--exclude',
500
type=str,
501
default='',
502
help=
503
'(default: ""=exclude nothing) "foo,bar" means exclude foo and bar'
504
)
505
parser.add_argument(
506
'--parallel',
507
action="store_const",
508
const=True,
509
help=
510
'if given, do all in parallel; this will not work in some cases and may be ignored in others'
511
)
512
513
parser = argparse.ArgumentParser(prog='workspaces')
514
subparsers = parser.add_subparsers(help='sub-command help')
515
516
subparser = subparsers.add_parser(
517
'install', help='install node_modules deps for all packages')
518
subparser.add_argument('--prod',
519
action="store_const",
520
const=True,
521
help='only install prod deps (not dev ones)')
522
packages_arg(subparser)
523
subparser.set_defaults(func=install)
524
525
subparser = subparsers.add_parser(
526
'build', help='build all packages for which something has changed')
527
subparser.add_argument(
528
'--dev',
529
action="store_const",
530
const=True,
531
help="only build enough for development (saves time and space)")
532
packages_arg(subparser)
533
subparser.set_defaults(func=build)
534
535
subparser = subparsers.add_parser(
536
'clean', help='delete dist and node_modules folders')
537
packages_arg(subparser)
538
subparser.add_argument('--dist-only',
539
action="store_const",
540
const=True,
541
help="only delete dist directory")
542
subparser.add_argument('--node-modules-only',
543
action="store_const",
544
const=True,
545
help="only delete node_modules directory")
546
subparser.set_defaults(func=clean)
547
548
subparser = subparsers.add_parser('pnpm',
549
help='do "pnpm ..." in each package;')
550
packages_arg(subparser)
551
subparser.add_argument('args',
552
type=str,
553
nargs='*',
554
default='',
555
help='arguments to npm')
556
subparser.set_defaults(func=pnpm)
557
558
subparser = subparsers.add_parser(
559
'pnpm-noerr',
560
help=
561
'like "pnpm" but suppresses errors; e.g., use for "pnpm-noerr audit fix"'
562
)
563
packages_arg(subparser)
564
subparser.add_argument('args',
565
type=str,
566
nargs='*',
567
default='',
568
help='arguments to pnpm')
569
subparser.set_defaults(func=pnpm_noerror)
570
571
subparser = subparsers.add_parser(
572
'version-check', help='version consistency checks across packages')
573
subparser.set_defaults(func=version_check)
574
575
subparser = subparsers.add_parser('test', help='test all packages')
576
subparser.add_argument(
577
"-r",
578
"--retries",
579
type=int,
580
default=2,
581
help=
582
"how many times to retry a failed test suite before giving up; set to 0 to NOT retry"
583
)
584
subparser.add_argument('--report',
585
action="store_const",
586
const=True,
587
help='if given, generate test reports')
588
packages_arg(subparser)
589
subparser.set_defaults(func=test)
590
591
args = parser.parse_args()
592
if hasattr(args, 'func'):
593
args.func(args)
594
595
596
if __name__ == '__main__':
597
main()
598
599