Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place. Commercial Alternative to JupyterHub.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place. Commercial Alternative to JupyterHub.
Path: blob/master/src/workspaces.py
Views: 1250
#!/usr/bin/env python31"""2PURPOSE: Automate building, installing, and publishing our modules.3This is like a little clone of "lerna" for our purposes.45NOTE: I wrote this initially using npm and with the goal of publishing6to npmjs.com. Now I don't care at all about publishing to npmjs.com,7and we're using pnpm. So this is being turned into a package just8for cleaning/installing/building.910TEST:11- This should always work: "mypy workspaces.py"12"""1314import argparse, json, os, platform, shutil, subprocess, sys, time1516from typing import Any, Optional, Callable, List1718MAX_PACKAGE_LOCK_SIZE_MB = 5192021def newest_file(path: str) -> str:22if platform.system() != 'Darwin':23# See https://gist.github.com/brwyatt/c21a888d79927cb476a4 for this Linux24# version:25cmd = 'find . -type f -printf "%C@ %p\n" | sort -rn | head -n 1 | cut -d" " -f2'26else:27# but we had to rewrite this as suggested at28# https://unix.stackexchange.com/questions/272491/bash-error-find-printf-unknown-primary-or-operator29# etc to work on MacOS.30cmd = 'find . -type f -print0 | xargs -0r stat -f "%Fc %N" | sort -rn | head -n 1 | cut -d" " -f2'31return os.popen(f'cd "{path}" && {cmd}').read().strip()323334SUCCESSFUL_BUILD = ".successful-build"353637def needs_build(package: str) -> bool:38# Code below was hopelessly naive, e.g, a failed build would not get retried.39# We only need to do a build if the newest file in the tree is not40# in the dist directory.41path = os.path.join(os.path.dirname(__file__), package)42if not os.path.exists(os.path.join(path, 'dist')):43return True44newest = newest_file(path)45return not newest.startswith('./' + SUCCESSFUL_BUILD)464748def handle_path(s: str,49path: Optional[str] = None,50verbose: bool = True) -> None:51desc = s52if path is not None:53os.chdir(path)54desc += " # in '%s'" % path55if verbose:56print(desc)575859def cmd(s: str,60path: Optional[str] = None,61verbose: bool = True,62noerr=False) -> None:63home: str = os.path.abspath(os.curdir)64try:65handle_path(s, path, verbose)66if os.system(s):67msg = f"Error executing '{s}'"68if noerr:69print(msg)70else:71raise RuntimeError(msg)72finally:73os.chdir(home)747576def run(s: str, path: Optional[str] = None, verbose: bool = True) -> str:77home = os.path.abspath(os.curdir)78try:79handle_path(s, path, verbose)80a = subprocess.run(s, shell=True, stdout=subprocess.PIPE)81out = a.stdout.decode('utf8')82if a.returncode:83raise RuntimeError("Error executing '%s'" % s)84return out85finally:86os.chdir(home)878889def thread_map(callable: Callable,90inputs: List[Any],91nb_threads: int = 10) -> List:92if len(inputs) == 0:93return []94if nb_threads == 1:95return [callable(x) for x in inputs]96from multiprocessing.pool import ThreadPool97tp = ThreadPool(nb_threads)98return tp.map(callable, inputs)99100101def all_packages() -> List[str]:102# Compute all the packages. Explicit order in some cases *does* matter as noted in comments,103# but we use "tsc --build", which automatically builds deps if not built.104v = [105'packages/', # top level workspace, e.g., typescript106'packages/cdn', # packages/hub assumes this is built107'packages/util',108'packages/sync',109'packages/sync-client',110'packages/sync-fs',111'packages/nats',112'packages/backend',113'packages/api-client',114'packages/jupyter',115'packages/comm',116'packages/project',117'packages/assets',118'packages/frontend', # static depends on frontend; frontend depends on assets119'packages/static', # packages/hub assumes this is built (for webpack dev server)120'packages/server', # packages/next assumes this is built121'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 built124]125for x in os.listdir('packages'):126path = os.path.join("packages", x)127if path not in v and os.path.isdir(path) and os.path.exists(128os.path.join(path, 'package.json')):129v.append(path)130return v131132133def packages(args) -> List[str]:134v = all_packages()135# Filter to only the ones in packages (if given)136if args.packages:137packages = set(args.packages.split(','))138v = [x for x in v if x.split('/')[-1] in packages]139140# Only take things not in exclude141if args.exclude:142exclude = set(args.exclude.split(','))143v = [x for x in v if x.split('/')[-1] not in exclude]144145print("Packages: ", ', '.join(v))146return v147148149def package_json(package: str) -> dict:150return json.loads(open(f'{package}/package.json').read())151152153def write_package_json(package: str, x: dict) -> None:154open(f'{package}/package.json', 'w').write(json.dumps(x, indent=2))155156157def dependent_packages(package: str) -> List[str]:158# Get a list of the packages159# it depends on by reading package.json160x = package_json(package)161if "workspaces" not in x:162# no workspaces163return []164v: List[str] = []165for path in x["workspaces"]:166# path is a relative path167npath = os.path.normpath(os.path.join(package, path))168if npath != package:169v.append(npath)170return v171172173def get_package_version(package: str) -> str:174return package_json(package)["version"]175176177def get_package_npm_name(package: str) -> str:178return package_json(package)["name"]179180181def update_dependent_versions(package: str) -> None:182"""183Update the versions of all of the workspaces that this184package depends on. The versions are set to whatever the185current version is in the dependent packages package.json.186187There is a problem here, if you are publishing two188packages A and B with versions vA and vB. If you first publish189A, then you set it as depending on B@vB. However, when you then190publish B you set its new version as vB+1, so A got published191with the wrong version. It's thus important to first192update all the versions of the packages that will be published193in a single phase, then update the dependent version numbers, and194finally actually publish the packages to npm. There will unavoidably195be an interval of time when some of the packages are impossible to196install (e.g., because A got published and depends on B@vB+1, but B197isn't yet published).198"""199x = package_json(package)200changed = False201for dependent in dependent_packages(package):202print(f"Considering '{dependent}'")203try:204package_version = '^' + get_package_version(dependent)205except:206print(f"Skipping '{dependent}' since package not available")207continue208npm_name = get_package_npm_name(dependent)209dev = npm_name in x.get("devDependencies", {})210if dev:211current_version = x.get("devDependencies", {}).get(npm_name, '')212else:213current_version = x.get("dependencies", {}).get(npm_name, '')214# print(dependent, npm_name, current_version, package_version)215if current_version != package_version:216print(217f"{package}: {dependent} changed from '{current_version}' to '{package_version}'"218)219x['devDependencies' if dev else 'dependencies'][220npm_name] = package_version221changed = True222if changed:223write_package_json(package, x)224225226def update_all_dependent_versions() -> None:227for package in all_packages():228update_dependent_versions(package)229230231def banner(s: str) -> None:232print("\n" + "=" * 70)233print("|| " + s)234print("=" * 70 + "\n")235236237def install(args) -> None:238v = packages(args)239240# The trick we use to build only a subset of the packages in a pnpm workspace241# is to temporarily modify packages/pnpm-workspace.yaml to explicitly remove242# the packages that we do NOT want to build. This should be supported by243# pnpm via the --filter option but I can't figure that out in a way that doesn't244# break the global lockfile, so this is the hack we have for now.245ws = "packages/pnpm-workspace.yaml"246tmp = ws + ".tmp"247allp = all_packages()248try:249if v != allp:250shutil.copy(ws, tmp)251s = open(ws,'r').read() + '\n'252for package in allp:253if package not in v:254s += ' - "!%s"\n'%package.split('/')[-1]255256open(ws,'w').write(s)257258print("install packages")259# much faster special case260# see https://github.com/pnpm/pnpm/issues/6778 for why we put that confirm option in261c = "cd packages && pnpm install --config.confirmModulesPurge=false"262if args.prod:263args.dist_only = False264args.node_modules_only = True265args.parallel = True266clean(args)267c += " --prod"268cmd(c)269finally:270if os.path.exists(tmp):271shutil.move(tmp, ws)272273274# Build all the packages that need to be built.275def build(args) -> None:276v = [package for package in packages(args) if needs_build(package)]277CUR = os.path.abspath('.')278279def f(path: str) -> None:280if not args.parallel and path != 'packages/static':281# NOTE: in parallel mode we don't delete or there is no282# hope of this working.283dist = os.path.join(CUR, path, 'dist')284if os.path.exists(dist):285# clear dist/ dir286shutil.rmtree(dist, ignore_errors=True)287package_path = os.path.join(CUR, path)288if args.dev and '"build-dev"' in open(289os.path.join(CUR, path, 'package.json')).read():290cmd("pnpm run build-dev", package_path)291else:292cmd("pnpm run build", package_path)293# The build succeeded, so touch a file294# to indicate this, so we won't build again295# until something is newer than this file296cmd("touch " + SUCCESSFUL_BUILD, package_path)297298if args.parallel:299thread_map(f, v)300else:301thread_map(f, v, 1)302303304def clean(args) -> None:305v = packages(args)306307if args.dist_only:308folders = ['dist']309elif args.node_modules_only:310folders = ['node_modules']311else:312folders = ['node_modules', 'dist', SUCCESSFUL_BUILD]313314paths = []315for path in v:316for x in folders:317y = os.path.abspath(os.path.join(path, x))318if os.path.exists(y):319paths.append(y)320321def f(path):322print("rm -rf '%s'" % path)323if not os.path.exists(path):324return325if os.path.isfile(path):326os.unlink(path)327return328shutil.rmtree(path, ignore_errors=True)329if os.path.exists(path):330shutil.rmtree(path, ignore_errors=True)331if os.path.exists(path):332raise RuntimeError(f'failed to delete {path}')333334if (len(paths) == 0):335banner("No node_modules or dist directories")336else:337banner("Deleting " + ', '.join(paths))338thread_map(f, paths + ['packages/node_modules'], nb_threads=10)339340if not args.node_modules_only:341banner("Running 'pnpm run clean' if it exists...")342343def g(path):344# can only use --if-present with npm, but should be fine since clean is345# usually just "rm".346cmd("npm run clean --if-present", path)347348thread_map(g, [os.path.abspath(path) for path in v],349nb_threads=3 if args.parallel else 1)350351352def delete_package_lock(args) -> None:353354def f(path: str) -> None:355p = os.path.join(path, 'package-lock.json')356if os.path.exists(p):357os.unlink(p)358# See https://github.com/sagemathinc/cocalc/issues/6123359# If we don't delete node_modules, then package-lock.json may blow up in size.360node_modules = os.path.join(path, 'node_modules')361if os.path.exists(node_modules):362shutil.rmtree(node_modules, ignore_errors=True)363364thread_map(f, [os.path.abspath(path) for path in packages(args)],365nb_threads=10)366367368def pnpm(args, noerr=False) -> None:369v = packages(args)370inputs: List[List[str]] = []371for path in v:372s = 'pnpm ' + ' '.join(['%s' % x for x in args.args])373inputs.append([s, os.path.abspath(path)])374375def f(args) -> None:376# kwds to make mypy happy377kwds = {"noerr": noerr}378cmd(*args, **kwds)379380if args.parallel:381thread_map(f, inputs, 3)382else:383thread_map(f, inputs, 1)384385386def pnpm_noerror(args) -> None:387pnpm(args, noerr=True)388389390def version_check(args):391cmd("scripts/check_npm_packages.py")392cmd("pnpm check-deps", './packages')393394395def node_version_check() -> None:396version = int(os.popen('node --version').read().split('.')[0][1:])397if version < 14:398err = f"CoCalc requires node.js v14, but you're using node v{version}."399if os.environ.get("COCALC_USERNAME",400'') == 'user' and 'COCALC_PROJECT_ID' in os.environ:401err += '\nIf you are using https://cocalc.com, put ". /cocalc/nvm/nvm.sh" in ~/.bashrc\nto get an appropriate version of node.'402raise RuntimeError(err)403404405def pnpm_version_check() -> None:406"""407Check if the pnpm utility is new enough408"""409version = os.popen('pnpm --version').read()410if int(version.split('.')[0]) < 7:411raise RuntimeError(412f"CoCalc requires pnpm version 7, but you're using pnpm v{version}."413)414415416def main() -> None:417node_version_check()418pnpm_version_check()419420def packages_arg(parser):421parser.add_argument(422'--packages',423type=str,424default='',425help=426'(default: ""=everything) "foo,bar" means only the packages named foo and bar'427)428parser.add_argument(429'--exclude',430type=str,431default='',432help=433'(default: ""=exclude nothing) "foo,bar" means exclude foo and bar'434)435parser.add_argument(436'--parallel',437action="store_const",438const=True,439help=440'if given, do all in parallel; this will not work in some cases and may be ignored in others'441)442443parser = argparse.ArgumentParser(prog='workspaces')444subparsers = parser.add_subparsers(help='sub-command help')445446subparser = subparsers.add_parser(447'install', help='install node_modules deps for all packages')448subparser.add_argument('--prod',449action="store_const",450const=True,451help='only install prod deps (not dev ones)')452packages_arg(subparser)453subparser.set_defaults(func=install)454455subparser = subparsers.add_parser(456'build', help='build all packages for which something has changed')457subparser.add_argument(458'--dev',459action="store_const",460const=True,461help="only build enough for development (saves time and space)")462packages_arg(subparser)463subparser.set_defaults(func=build)464465subparser = subparsers.add_parser(466'clean', help='delete dist and node_modules folders')467packages_arg(subparser)468subparser.add_argument('--dist-only',469action="store_const",470const=True,471help="only delete dist directory")472subparser.add_argument('--node-modules-only',473action="store_const",474const=True,475help="only delete node_modules directory")476subparser.set_defaults(func=clean)477478subparser = subparsers.add_parser('pnpm',479help='do "pnpm ..." in each package;')480packages_arg(subparser)481subparser.add_argument('args',482type=str,483nargs='*',484default='',485help='arguments to npm')486subparser.set_defaults(func=pnpm)487488subparser = subparsers.add_parser(489'pnpm-noerr',490help=491'like "pnpm" but suppresses errors; e.g., use for "pnpm-noerr audit fix"'492)493packages_arg(subparser)494subparser.add_argument('args',495type=str,496nargs='*',497default='',498help='arguments to pnpm')499subparser.set_defaults(func=pnpm_noerror)500501subparser = subparsers.add_parser(502'version-check', help='version consistency checks across packages')503subparser.set_defaults(func=version_check)504505args = parser.parse_args()506if hasattr(args, 'func'):507args.func(args)508509510if __name__ == '__main__':511main()512513514