Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/workspaces.py
Views: 687
#!/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 workspace106'packages/cdn', # packages/hub assumes this is built107'packages/util',108'packages/sync',109'packages/sync-client',110'packages/sync-fs',111'packages/backend',112'packages/api-client',113'packages/jupyter',114'packages/comm',115'packages/assets',116'packages/frontend', # static depends on frontend; frontend depends on assets117'packages/project', # project depends on frontend for nbconvert (but NEVER vice versa again), which also depends on assets118'packages/static', # packages/hub assumes this is built (for webpack dev server)119'packages/server', # packages/next assumes this is built120'packages/database', # packages/next also assumes database is built (or at least the coffeescript in it is)121'packages/next',122'packages/hub', # hub won't build if next isn't already built123]124for x in os.listdir('packages'):125path = os.path.join("packages", x)126if path not in v and os.path.isdir(path) and os.path.exists(127os.path.join(path, 'package.json')):128v.append(path)129return v130131132def packages(args) -> List[str]:133v = all_packages()134# Filter to only the ones in packages (if given)135if args.packages:136packages = set(args.packages.split(','))137v = [x for x in v if x.split('/')[-1] in packages]138139# Only take things not in exclude140if args.exclude:141exclude = set(args.exclude.split(','))142v = [x for x in v if x.split('/')[-1] not in exclude]143144print("Packages: ", ', '.join(v))145return v146147148def package_json(package: str) -> dict:149return json.loads(open(f'{package}/package.json').read())150151152def write_package_json(package: str, x: dict) -> None:153open(f'{package}/package.json', 'w').write(json.dumps(x, indent=2))154155156def dependent_packages(package: str) -> List[str]:157# Get a list of the packages158# it depends on by reading package.json159x = package_json(package)160if "workspaces" not in x:161# no workspaces162return []163v: List[str] = []164for path in x["workspaces"]:165# path is a relative path166npath = os.path.normpath(os.path.join(package, path))167if npath != package:168v.append(npath)169return v170171172def get_package_version(package: str) -> str:173return package_json(package)["version"]174175176def get_package_npm_name(package: str) -> str:177return package_json(package)["name"]178179180def update_dependent_versions(package: str) -> None:181"""182Update the versions of all of the workspaces that this183package depends on. The versions are set to whatever the184current version is in the dependent packages package.json.185186There is a problem here, if you are publishing two187packages A and B with versions vA and vB. If you first publish188A, then you set it as depending on B@vB. However, when you then189publish B you set its new version as vB+1, so A got published190with the wrong version. It's thus important to first191update all the versions of the packages that will be published192in a single phase, then update the dependent version numbers, and193finally actually publish the packages to npm. There will unavoidably194be an interval of time when some of the packages are impossible to195install (e.g., because A got published and depends on B@vB+1, but B196isn't yet published).197"""198x = package_json(package)199changed = False200for dependent in dependent_packages(package):201print(f"Considering '{dependent}'")202try:203package_version = '^' + get_package_version(dependent)204except:205print(f"Skipping '{dependent}' since package not available")206continue207npm_name = get_package_npm_name(dependent)208dev = npm_name in x.get("devDependencies", {})209if dev:210current_version = x.get("devDependencies", {}).get(npm_name, '')211else:212current_version = x.get("dependencies", {}).get(npm_name, '')213# print(dependent, npm_name, current_version, package_version)214if current_version != package_version:215print(216f"{package}: {dependent} changed from '{current_version}' to '{package_version}'"217)218x['devDependencies' if dev else 'dependencies'][219npm_name] = package_version220changed = True221if changed:222write_package_json(package, x)223224225def update_all_dependent_versions() -> None:226for package in all_packages():227update_dependent_versions(package)228229230def banner(s: str) -> None:231print("\n" + "=" * 70)232print("|| " + s)233print("=" * 70 + "\n")234235236def install(args) -> None:237v = packages(args)238if v == all_packages():239print("install all packages -- fast special case")240# much faster special case241cmd("cd packages && pnpm install")242return243244# Do "pnpm i" not in parallel245for path in v:246c = "pnpm install "247if args.prod:248c += ' --prod '249cmd(c, path)250251252# Build all the packages that need to be built.253def build(args) -> None:254v = [package for package in packages(args) if needs_build(package)]255CUR = os.path.abspath('.')256257def f(path: str) -> None:258if not args.parallel and path != 'packages/static':259# NOTE: in parallel mode we don't delete or there is no260# hope of this working.261dist = os.path.join(CUR, path, 'dist')262if os.path.exists(dist):263# clear dist/ dir264shutil.rmtree(dist, ignore_errors=True)265package_path = os.path.join(CUR, path)266if args.dev and '"build-dev"' in open(267os.path.join(CUR, path, 'package.json')).read():268cmd("pnpm run build-dev", package_path)269else:270cmd("pnpm run build", package_path)271# The build succeeded, so touch a file272# to indicate this, so we won't build again273# until something is newer than this file274cmd("touch " + SUCCESSFUL_BUILD, package_path)275276if args.parallel:277thread_map(f, v)278else:279thread_map(f, v, 1)280281282def clean(args) -> None:283v = packages(args)284285if args.dist_only:286folders = ['dist']287elif args.node_modules_only:288folders = ['node_modules']289else:290folders = ['node_modules', 'dist', SUCCESSFUL_BUILD]291292paths = []293for path in v:294for x in folders:295y = os.path.abspath(os.path.join(path, x))296if os.path.exists(y):297paths.append(y)298299def f(path):300print("rm -rf '%s'" % path)301if not os.path.exists(path):302return303if os.path.isfile(path):304os.unlink(path)305return306shutil.rmtree(path, ignore_errors=True)307if os.path.exists(path):308shutil.rmtree(path, ignore_errors=True)309if os.path.exists(path):310raise RuntimeError(f'failed to delete {path}')311312if (len(paths) == 0):313banner("No node_modules or dist directories")314else:315banner("Deleting " + ', '.join(paths))316thread_map(f, paths + ['packages/node_modules'], nb_threads=10)317318if not args.node_modules_only:319banner("Running 'pnpm run clean' if it exists...")320321def g(path):322# can only use --if-present with npm, but should be fine since clean is323# usually just "rm".324cmd("npm run clean --if-present", path)325326thread_map(g, [os.path.abspath(path) for path in v],327nb_threads=3 if args.parallel else 1)328329330def delete_package_lock(args) -> None:331332def f(path: str) -> None:333p = os.path.join(path, 'package-lock.json')334if os.path.exists(p):335os.unlink(p)336# See https://github.com/sagemathinc/cocalc/issues/6123337# If we don't delete node_modules, then package-lock.json may blow up in size.338node_modules = os.path.join(path, 'node_modules')339if os.path.exists(node_modules):340shutil.rmtree(node_modules, ignore_errors=True)341342thread_map(f, [os.path.abspath(path) for path in packages(args)],343nb_threads=10)344345346def pnpm(args, noerr=False) -> None:347v = packages(args)348inputs: List[List[str]] = []349for path in v:350s = 'pnpm ' + ' '.join(['%s' % x for x in args.args])351inputs.append([s, os.path.abspath(path)])352353def f(args) -> None:354# kwds to make mypy happy355kwds = {"noerr": noerr}356cmd(*args, **kwds)357358if args.parallel:359thread_map(f, inputs, 3)360else:361thread_map(f, inputs, 1)362363364def pnpm_noerror(args) -> None:365pnpm(args, noerr=True)366367368def version_check(args):369cmd("scripts/check_npm_packages.py")370371372def node_version_check() -> None:373version = int(os.popen('node --version').read().split('.')[0][1:])374if version < 14:375err = f"CoCalc requires node.js v14, but you're using node v{version}."376if os.environ.get("COCALC_USERNAME",377'') == 'user' and 'COCALC_PROJECT_ID' in os.environ:378err += '\nIf you are using https://cocalc.com, put ". /cocalc/nvm/nvm.sh" in ~/.bashrc\nto get an appropriate version of node.'379raise RuntimeError(err)380381382def pnpm_version_check() -> None:383"""384Check if the pnpm utility is new enough385"""386version = os.popen('pnpm --version').read()387if int(version.split('.')[0]) < 7:388raise RuntimeError(389f"CoCalc requires pnpm version 7, but you're using pnpm v{version}."390)391392393def main() -> None:394node_version_check()395pnpm_version_check()396397def packages_arg(parser):398parser.add_argument(399'--packages',400type=str,401default='',402help=403'(default: ""=everything) "foo,bar" means only the packages named foo and bar'404)405parser.add_argument(406'--exclude',407type=str,408default='',409help=410'(default: ""=exclude nothing) "foo,bar" means exclude foo and bar'411)412parser.add_argument(413'--parallel',414action="store_const",415const=True,416help=417'if given, do all in parallel; this will not work in some cases and may be ignored in others'418)419420parser = argparse.ArgumentParser(prog='workspaces')421subparsers = parser.add_subparsers(help='sub-command help')422423subparser = subparsers.add_parser(424'install', help='install node_modules deps for all packages')425subparser.add_argument('--prod',426action="store_const",427const=True,428help='only install prod deps (not dev ones)')429packages_arg(subparser)430subparser.set_defaults(func=install)431432subparser = subparsers.add_parser(433'build', help='build all packages for which something has changed')434subparser.add_argument(435'--dev',436action="store_const",437const=True,438help="only build enough for development (saves time and space)")439packages_arg(subparser)440subparser.set_defaults(func=build)441442subparser = subparsers.add_parser(443'clean', help='delete dist and node_modules folders')444packages_arg(subparser)445subparser.add_argument('--dist-only',446action="store_const",447const=True,448help="only delete dist directory")449subparser.add_argument('--node-modules-only',450action="store_const",451const=True,452help="only delete node_modules directory")453subparser.set_defaults(func=clean)454455subparser = subparsers.add_parser('pnpm',456help='do "pnpm ..." in each package;')457packages_arg(subparser)458subparser.add_argument('args',459type=str,460nargs='*',461default='',462help='arguments to npm')463subparser.set_defaults(func=pnpm)464465subparser = subparsers.add_parser(466'pnpm-noerr',467help=468'like "pnpm" but suppresses errors; e.g., use for "pnpm-noerr audit fix"'469)470packages_arg(subparser)471subparser.add_argument('args',472type=str,473nargs='*',474default='',475help='arguments to pnpm')476subparser.set_defaults(func=pnpm_noerror)477478subparser = subparsers.add_parser(479'version-check', help='version consistency checks across packages')480subparser.set_defaults(func=version_check)481482args = parser.parse_args()483if hasattr(args, 'func'):484args.func(args)485486487if __name__ == '__main__':488main()489490491