CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

CoCalc’s goal is to provide the best real-time collaborative environment for Jupyter Notebooks, LaTeX documents, and SageMath, scalable from individual use to large groups and classes.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/workspaces.py
Views: 486
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
16
import argparse, json, os, platform, shutil, subprocess, sys, time
17
18
from typing import Any, Optional, Callable, List
19
20
MAX_PACKAGE_LOCK_SIZE_MB = 5
21
22
23
def newest_file(path: str) -> str:
24
if platform.system() != 'Darwin':
25
# See https://gist.github.com/brwyatt/c21a888d79927cb476a4 for this Linux
26
# version:
27
cmd = 'find . -type f -printf "%C@ %p\n" | sort -rn | head -n 1 | cut -d" " -f2'
28
else:
29
# but we had to rewrite this as suggested at
30
# https://unix.stackexchange.com/questions/272491/bash-error-find-printf-unknown-primary-or-operator
31
# etc to work on MacOS.
32
cmd = 'find . -type f -print0 | xargs -0r stat -f "%Fc %N" | sort -rn | head -n 1 | cut -d" " -f2'
33
return os.popen(f'cd "{path}" && {cmd}').read().strip()
34
35
36
SUCCESSFUL_BUILD = ".successful-build"
37
38
39
def needs_build(package: str) -> bool:
40
# Code below was hopelessly naive, e.g, a failed build would not get retried.
41
# We only need to do a build if the newest file in the tree is not
42
# in the dist directory.
43
path = os.path.join(os.path.dirname(__file__), package)
44
if not os.path.exists(os.path.join(path, 'dist')):
45
return True
46
newest = newest_file(path)
47
return not newest.startswith('./' + SUCCESSFUL_BUILD)
48
49
50
def handle_path(s: str,
51
path: Optional[str] = None,
52
verbose: bool = True) -> None:
53
desc = s
54
if path is not None:
55
os.chdir(path)
56
desc += " # in '%s'" % path
57
if verbose:
58
print(desc)
59
60
61
def cmd(s: str,
62
path: Optional[str] = None,
63
verbose: bool = True,
64
noerr=False) -> None:
65
home: str = os.path.abspath(os.curdir)
66
try:
67
handle_path(s, path, verbose)
68
if os.system(s):
69
msg = f"Error executing '{s}'"
70
if noerr:
71
print(msg)
72
else:
73
raise RuntimeError(msg)
74
finally:
75
os.chdir(home)
76
77
78
def run(s: str, path: Optional[str] = None, verbose: bool = True) -> str:
79
home = os.path.abspath(os.curdir)
80
try:
81
handle_path(s, path, verbose)
82
a = subprocess.run(s, shell=True, stdout=subprocess.PIPE)
83
out = a.stdout.decode('utf8')
84
if a.returncode:
85
raise RuntimeError("Error executing '%s'" % s)
86
return out
87
finally:
88
os.chdir(home)
89
90
91
def thread_map(callable: Callable,
92
inputs: List[Any],
93
nb_threads: int = 10) -> List:
94
if len(inputs) == 0:
95
return []
96
if nb_threads == 1:
97
return [callable(x) for x in inputs]
98
from multiprocessing.pool import ThreadPool
99
tp = ThreadPool(nb_threads)
100
return tp.map(callable, inputs)
101
102
103
def all_packages() -> List[str]:
104
# Compute all the packages. Explicit order in some cases *does* matter as noted in comments,
105
# but we use "tsc --build", which automatically builds deps if not built.
106
v = [
107
'packages/', # top level workspace
108
'packages/cdn', # packages/hub assumes this is built
109
'packages/util',
110
'packages/sync',
111
'packages/sync-client',
112
'packages/sync-fs',
113
'packages/backend',
114
'packages/api-client',
115
'packages/jupyter',
116
'packages/comm',
117
'packages/project', # project depends on frontend for nbconvert (but NEVER vice versa again)
118
'packages/assets',
119
'packages/frontend', # static depends on frontend
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
if v == all_packages():
241
print("install all packages -- fast special case")
242
# much faster special case
243
cmd("cd packages && pnpm install")
244
return
245
246
# Do "pnpm i" not in parallel
247
for path in v:
248
c = "pnpm install "
249
if args.prod:
250
c += ' --prod '
251
cmd(c, path)
252
253
254
# Build all the packages that need to be built.
255
def build(args) -> None:
256
v = [package for package in packages(args) if needs_build(package)]
257
CUR = os.path.abspath('.')
258
259
def f(path: str) -> None:
260
if not args.parallel and path != 'packages/static':
261
# NOTE: in parallel mode we don't delete or there is no
262
# hope of this working.
263
dist = os.path.join(CUR, path, 'dist')
264
if os.path.exists(dist):
265
# clear dist/ dir
266
shutil.rmtree(dist, ignore_errors=True)
267
package_path = os.path.join(CUR, path)
268
if args.dev and '"build-dev"' in open(
269
os.path.join(CUR, path, 'package.json')).read():
270
cmd("pnpm run build-dev", package_path)
271
else:
272
cmd("pnpm run build", package_path)
273
# The build succeeded, so touch a file
274
# to indicate this, so we won't build again
275
# until something is newer than this file
276
cmd("touch " + SUCCESSFUL_BUILD, package_path)
277
278
if args.parallel:
279
thread_map(f, v)
280
else:
281
thread_map(f, v, 1)
282
283
284
def clean(args) -> None:
285
v = packages(args)
286
287
if args.dist_only:
288
folders = ['dist']
289
elif args.node_modules_only:
290
folders = ['node_modules']
291
else:
292
folders = ['node_modules', 'dist', SUCCESSFUL_BUILD]
293
294
paths = []
295
for path in v:
296
for x in folders:
297
y = os.path.abspath(os.path.join(path, x))
298
if os.path.exists(y):
299
paths.append(y)
300
301
def f(path):
302
print("rm -rf '%s'" % path)
303
shutil.rmtree(path, ignore_errors=True)
304
305
if (len(paths) == 0):
306
banner("No node_modules or dist directories")
307
else:
308
banner("Deleting " + ', '.join(paths))
309
thread_map(f, paths + ['packages/node_modules'], nb_threads=10)
310
311
if not args.node_modules_only:
312
banner("Running 'pnpm run clean' if it exists...")
313
314
def g(path):
315
# can only use --if-present with npm, but should be fine since clean is
316
# usually just "rm".
317
cmd("npm run clean --if-present", path)
318
319
thread_map(g, [os.path.abspath(path) for path in v],
320
nb_threads=3 if args.parallel else 1)
321
322
323
def delete_package_lock(args) -> None:
324
325
def f(path: str) -> None:
326
p = os.path.join(path, 'package-lock.json')
327
if os.path.exists(p):
328
os.unlink(p)
329
# See https://github.com/sagemathinc/cocalc/issues/6123
330
# If we don't delete node_modules, then package-lock.json may blow up in size.
331
node_modules = os.path.join(path, 'node_modules')
332
if os.path.exists(node_modules):
333
shutil.rmtree(node_modules, ignore_errors=True)
334
335
thread_map(f, [os.path.abspath(path) for path in packages(args)],
336
nb_threads=10)
337
338
339
def pnpm(args, noerr=False) -> None:
340
v = packages(args)
341
inputs: List[List[str]] = []
342
for path in v:
343
s = 'pnpm ' + ' '.join(['%s' % x for x in args.args])
344
inputs.append([s, os.path.abspath(path)])
345
346
def f(args) -> None:
347
# kwds to make mypy happy
348
kwds = {"noerr": noerr}
349
cmd(*args, **kwds)
350
351
if args.parallel:
352
thread_map(f, inputs, 3)
353
else:
354
thread_map(f, inputs, 1)
355
356
357
def pnpm_noerror(args) -> None:
358
pnpm(args, noerr=True)
359
360
361
def version_check(args):
362
cmd("scripts/check_npm_packages.py")
363
364
365
def node_version_check() -> None:
366
version = int(os.popen('node --version').read().split('.')[0][1:])
367
if version < 14:
368
err = f"CoCalc requires node.js v14, but you're using node v{version}."
369
if os.environ.get("COCALC_USERNAME",
370
'') == 'user' and 'COCALC_PROJECT_ID' in os.environ:
371
err += '\nIf you are using https://cocalc.com, put ". /cocalc/nvm/nvm.sh" in ~/.bashrc\nto get an appropriate version of node.'
372
raise RuntimeError(err)
373
374
375
def pnpm_version_check() -> None:
376
"""
377
Check if the pnpm utility is new enough
378
"""
379
version = os.popen('pnpm --version').read()
380
if int(version.split('.')[0]) < 7:
381
raise RuntimeError(
382
f"CoCalc requires pnpm version 7, but you're using pnpm v{version}."
383
)
384
385
386
def main() -> None:
387
node_version_check()
388
pnpm_version_check()
389
390
def packages_arg(parser):
391
parser.add_argument(
392
'--packages',
393
type=str,
394
default='',
395
help=
396
'(default: ""=everything) "foo,bar" means only the packages named foo and bar'
397
)
398
parser.add_argument(
399
'--exclude',
400
type=str,
401
default='',
402
help=
403
'(default: ""=exclude nothing) "foo,bar" means exclude foo and bar'
404
)
405
parser.add_argument(
406
'--parallel',
407
action="store_const",
408
const=True,
409
help=
410
'if given, do all in parallel; this will not work in some cases and may be ignored in others'
411
)
412
413
parser = argparse.ArgumentParser(prog='workspaces')
414
subparsers = parser.add_subparsers(help='sub-command help')
415
416
subparser = subparsers.add_parser(
417
'install', help='install node_modules deps for all packages')
418
subparser.add_argument('--prod',
419
action="store_const",
420
const=True,
421
help='only install prod deps (not dev ones)')
422
packages_arg(subparser)
423
subparser.set_defaults(func=install)
424
425
subparser = subparsers.add_parser(
426
'build', help='build all packages for which something has changed')
427
subparser.add_argument(
428
'--dev',
429
action="store_const",
430
const=True,
431
help="only build enough for development (saves time and space)")
432
packages_arg(subparser)
433
subparser.set_defaults(func=build)
434
435
subparser = subparsers.add_parser(
436
'clean', help='delete dist and node_modules folders')
437
packages_arg(subparser)
438
subparser.add_argument('--dist-only',
439
action="store_const",
440
const=True,
441
help="only delete dist directory")
442
subparser.add_argument('--node-modules-only',
443
action="store_const",
444
const=True,
445
help="only delete node_modules directory")
446
subparser.set_defaults(func=clean)
447
448
subparser = subparsers.add_parser('pnpm',
449
help='do "pnpm ..." in each package;')
450
packages_arg(subparser)
451
subparser.add_argument('args',
452
type=str,
453
nargs='*',
454
default='',
455
help='arguments to npm')
456
subparser.set_defaults(func=pnpm)
457
458
subparser = subparsers.add_parser(
459
'pnpm-noerr',
460
help=
461
'like "pnpm" but suppresses errors; e.g., use for "pnpm-noerr audit fix"'
462
)
463
packages_arg(subparser)
464
subparser.add_argument('args',
465
type=str,
466
nargs='*',
467
default='',
468
help='arguments to pnpm')
469
subparser.set_defaults(func=pnpm_noerror)
470
471
subparser = subparsers.add_parser(
472
'version-check', help='version consistency checks across packages')
473
subparser.set_defaults(func=version_check)
474
475
args = parser.parse_args()
476
if hasattr(args, 'func'):
477
args.func(args)
478
479
480
if __name__ == '__main__':
481
main()
482
483