123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510 |
- #
- # Atlas Package Cloner
- # (c) Copyright 2021 Andreas Rumpf
- #
- # See the file "copying.txt", included in this
- # distribution, for details about the copyright.
- #
- ## Simple tool to automate frequent workflows: Can "clone"
- ## a Nimble dependency and its dependencies recursively.
- import std/[parseopt, strutils, os, osproc, unicode, tables, sets, json, jsonutils]
- import parse_requires, osutils, packagesjson
- const
- Version = "0.1"
- Usage = "atlas - Nim Package Cloner Version " & Version & """
- (c) 2021 Andreas Rumpf
- Usage:
- atlas [options] [command] [arguments]
- Command:
- clone url|pkgname clone a package and all of its dependencies
- search keyw keywB... search for package that contains the given keywords
- extract file.nimble extract the requirements and custom commands from
- the given Nimble file
- Options:
- --keepCommits do not perform any `git checkouts`
- --cfgHere also create/maintain a nim.cfg in the current
- working directory
- --version show the version
- --help show this help
- """
- proc writeHelp() =
- stdout.write(Usage)
- stdout.flushFile()
- quit(0)
- proc writeVersion() =
- stdout.write(Version & "\n")
- stdout.flushFile()
- quit(0)
- const
- MockupRun = defined(atlasTests)
- TestsDir = "tools/atlas/tests"
- type
- PackageName = distinct string
- DepRelation = enum
- normal, strictlyLess, strictlyGreater
- Dependency = object
- name: PackageName
- url, commit: string
- rel: DepRelation # "requires x < 1.0" is silly, but Nimble allows it so we have too.
- AtlasContext = object
- projectDir, workspace: string
- hasPackageList: bool
- keepCommits: bool
- cfgHere: bool
- p: Table[string, string] # name -> url mapping
- processed: HashSet[string] # the key is (url / commit)
- errors: int
- when MockupRun:
- currentDir: string
- step: int
- mockupSuccess: bool
- const
- InvalidCommit = "<invalid commit>"
- ProduceTest = false
- type
- Command = enum
- GitDiff = "git diff",
- GitTags = "git show-ref --tags",
- GitRevParse = "git rev-parse",
- GitCheckout = "git checkout",
- GitPull = "git pull",
- GitCurrentCommit = "git log -n 1 --format=%H"
- GitMergeBase = "git merge-base"
- include testdata
- proc exec(c: var AtlasContext; cmd: Command; args: openArray[string]): (string, int) =
- when MockupRun:
- assert TestLog[c.step].cmd == cmd
- case cmd
- of GitDiff, GitTags, GitRevParse, GitPull, GitCurrentCommit:
- result = (TestLog[c.step].output, TestLog[c.step].exitCode)
- of GitCheckout:
- assert args[0] == TestLog[c.step].output
- of GitMergeBase:
- let tmp = TestLog[c.step].output.splitLines()
- assert tmp.len == 4, $tmp.len
- assert tmp[0] == args[0]
- assert tmp[1] == args[1]
- assert tmp[3] == ""
- result[0] = tmp[2]
- result[1] = TestLog[c.step].exitCode
- inc c.step
- else:
- var cmdLine = $cmd
- for i in 0..<args.len:
- cmdLine.add ' '
- cmdLine.add quoteShell(args[i])
- result = osproc.execCmdEx(cmdLine)
- when ProduceTest:
- echo "cmd ", cmd, " args ", args, " --> ", result
- proc cloneUrl(c: var AtlasContext; url, dest: string; cloneUsingHttps: bool): string =
- when MockupRun:
- result = ""
- else:
- result = osutils.cloneUrl(url, dest, cloneUsingHttps)
- when ProduceTest:
- echo "cloned ", url, " into ", dest
- template withDir*(c: var AtlasContext; dir: string; body: untyped) =
- when MockupRun:
- c.currentDir = dir
- body
- else:
- let oldDir = getCurrentDir()
- try:
- when ProduceTest:
- echo "Current directory is now ", dir
- setCurrentDir(dir)
- body
- finally:
- setCurrentDir(oldDir)
- proc extractRequiresInfo(c: var AtlasContext; nimbleFile: string): NimbleFileInfo =
- result = extractRequiresInfo(nimbleFile)
- when ProduceTest:
- echo "nimble ", nimbleFile, " info ", result
- proc toDepRelation(s: string): DepRelation =
- case s
- of "<": strictlyLess
- of ">": strictlyGreater
- else: normal
- proc isCleanGit(c: var AtlasContext; dir: string): string =
- result = ""
- let (outp, status) = exec(c, GitDiff, [])
- if outp.len != 0:
- result = "'git diff' not empty"
- elif status != 0:
- result = "'git diff' returned non-zero"
- proc message(c: var AtlasContext; category: string; p: PackageName; args: varargs[string]) =
- var msg = category & "(" & p.string & ")"
- for a in args:
- msg.add ' '
- msg.add a
- stdout.writeLine msg
- inc c.errors
- proc warn(c: var AtlasContext; p: PackageName; args: varargs[string]) =
- message(c, "[Warning] ", p, args)
- proc error(c: var AtlasContext; p: PackageName; args: varargs[string]) =
- message(c, "[Error] ", p, args)
- proc sameVersionAs(tag, ver: string): bool =
- const VersionChars = {'0'..'9', '.'}
- proc safeCharAt(s: string; i: int): char {.inline.} =
- if i >= 0 and i < s.len: s[i] else: '\0'
- let idx = find(tag, ver)
- if idx >= 0:
- # we found the version as a substring inside the `tag`. But we
- # need to watch out the the boundaries are not part of a
- # larger/different version number:
- result = safeCharAt(tag, idx-1) notin VersionChars and
- safeCharAt(tag, idx+ver.len) notin VersionChars
- proc versionToCommit(c: var AtlasContext; d: Dependency): string =
- let (outp, status) = exec(c, GitTags, [])
- if status == 0:
- var useNextOne = false
- for line in splitLines(outp):
- let commitsAndTags = strutils.splitWhitespace(line)
- if commitsAndTags.len == 2:
- case d.rel
- of normal:
- if commitsAndTags[1].sameVersionAs(d.commit):
- return commitsAndTags[0]
- of strictlyLess:
- if d.commit == InvalidCommit or not commitsAndTags[1].sameVersionAs(d.commit):
- return commitsAndTags[0]
- of strictlyGreater:
- if commitsAndTags[1].sameVersionAs(d.commit):
- useNextOne = true
- elif useNextOne:
- return commitsAndTags[0]
- return ""
- proc shortToCommit(c: var AtlasContext; short: string): string =
- let (cc, status) = exec(c, GitRevParse, [short])
- result = if status == 0: strutils.strip(cc) else: ""
- proc checkoutGitCommit(c: var AtlasContext; p: PackageName; commit: string) =
- let (_, status) = exec(c, GitCheckout, [commit])
- if status != 0:
- error(c, p, "could not checkout commit", commit)
- proc gitPull(c: var AtlasContext; p: PackageName) =
- let (_, status) = exec(c, GitPull, [])
- if status != 0:
- error(c, p, "could not 'git pull'")
- proc updatePackages(c: var AtlasContext) =
- if dirExists(c.workspace / PackagesDir):
- withDir(c, c.workspace / PackagesDir):
- gitPull(c, PackageName PackagesDir)
- else:
- withDir c, c.workspace:
- let err = cloneUrl(c, "https://github.com/nim-lang/packages", PackagesDir, false)
- if err != "":
- error c, PackageName(PackagesDir), err
- proc fillPackageLookupTable(c: var AtlasContext) =
- if not c.hasPackageList:
- c.hasPackageList = true
- when not MockupRun:
- updatePackages(c)
- let plist = getPackages(when MockupRun: TestsDir else: c.workspace)
- for entry in plist:
- c.p[unicode.toLower entry.name] = entry.url
- proc toUrl(c: var AtlasContext; p: string): string =
- if p.isUrl:
- result = p
- else:
- fillPackageLookupTable(c)
- result = c.p.getOrDefault(unicode.toLower p)
- if result.len == 0:
- inc c.errors
- proc toName(p: string): PackageName =
- if p.isUrl:
- result = PackageName splitFile(p).name
- else:
- result = PackageName p
- proc needsCommitLookup(commit: string): bool {.inline.} =
- '.' in commit or commit == InvalidCommit
- proc isShortCommitHash(commit: string): bool {.inline.} =
- commit.len >= 4 and commit.len < 40
- proc checkoutCommit(c: var AtlasContext; w: Dependency) =
- let dir = c.workspace / w.name.string
- withDir c, dir:
- if w.commit.len == 0 or cmpIgnoreCase(w.commit, "head") == 0:
- gitPull(c, w.name)
- else:
- let err = isCleanGit(c, dir)
- if err != "":
- warn c, w.name, err
- else:
- let requiredCommit =
- if needsCommitLookup(w.commit): versionToCommit(c, w)
- elif isShortCommitHash(w.commit): shortToCommit(c, w.commit)
- else: w.commit
- let (cc, status) = exec(c, GitCurrentCommit, [])
- let currentCommit = strutils.strip(cc)
- if requiredCommit == "" or status != 0:
- if requiredCommit == "" and w.commit == InvalidCommit:
- warn c, w.name, "package has no tagged releases"
- else:
- warn c, w.name, "cannot find specified version/commit", w.commit
- else:
- if currentCommit != requiredCommit:
- # checkout the later commit:
- # git merge-base --is-ancestor <commit> <commit>
- let (cc, status) = exec(c, GitMergeBase, [currentCommit, requiredCommit])
- let mergeBase = strutils.strip(cc)
- if status == 0 and (mergeBase == currentCommit or mergeBase == requiredCommit):
- # conflict resolution: pick the later commit:
- if mergeBase == currentCommit:
- checkoutGitCommit(c, w.name, requiredCommit)
- else:
- checkoutGitCommit(c, w.name, requiredCommit)
- when false:
- warn c, w.name, "do not know which commit is more recent:",
- currentCommit, "(current) or", w.commit, " =", requiredCommit, "(required)"
- proc findNimbleFile(c: AtlasContext; dep: Dependency): string =
- when MockupRun:
- result = TestsDir / dep.name.string & ".nimble"
- doAssert fileExists(result), "file does not exist " & result
- else:
- result = c.workspace / dep.name.string / (dep.name.string & ".nimble")
- if not fileExists(result):
- result = ""
- for x in walkFiles(c.workspace / dep.name.string / "*.nimble"):
- if result.len == 0:
- result = x
- else:
- # ambiguous .nimble file
- return ""
- proc addUniqueDep(c: var AtlasContext; work: var seq[Dependency];
- tokens: seq[string]) =
- let oldErrors = c.errors
- let url = toUrl(c, tokens[0])
- if oldErrors != c.errors:
- warn c, toName(tokens[0]), "cannot resolve package name"
- elif not c.processed.containsOrIncl(url / tokens[2]):
- work.add Dependency(name: toName(tokens[0]), url: url, commit: tokens[2],
- rel: toDepRelation(tokens[1]))
- proc collectNewDeps(c: var AtlasContext; work: var seq[Dependency];
- dep: Dependency; result: var seq[string];
- isMainProject: bool) =
- let nimbleFile = findNimbleFile(c, dep)
- if nimbleFile != "":
- let nimbleInfo = extractRequiresInfo(c, nimbleFile)
- for r in nimbleInfo.requires:
- var tokens: seq[string] = @[]
- for token in tokenizeRequires(r):
- tokens.add token
- if tokens.len == 1:
- # nimx uses dependencies like 'requires "sdl2"'.
- # Via this hack we map them to the first tagged release.
- # (See the `isStrictlySmallerThan` logic.)
- tokens.add "<"
- tokens.add InvalidCommit
- elif tokens.len == 2 and tokens[1].startsWith("#"):
- # Dependencies can also look like 'requires "sdl2#head"
- var commit = tokens[1][1 .. ^1]
- tokens[1] = "=="
- tokens.add commit
- if tokens.len >= 3 and cmpIgnoreCase(tokens[0], "nim") != 0:
- c.addUniqueDep work, tokens
- result.add dep.name.string / nimbleInfo.srcDir
- else:
- result.add dep.name.string
- proc clone(c: var AtlasContext; start: string): seq[string] =
- # non-recursive clone.
- let oldErrors = c.errors
- var work = @[Dependency(name: toName(start), url: toUrl(c, start), commit: "")]
- if oldErrors != c.errors:
- error c, toName(start), "cannot resolve package name"
- return
- c.projectDir = c.workspace / work[0].name.string
- result = @[]
- var i = 0
- while i < work.len:
- let w = work[i]
- let oldErrors = c.errors
- if not dirExists(c.workspace / w.name.string):
- withDir c, c.workspace:
- let err = cloneUrl(c, w.url, w.name.string, false)
- if err != "":
- error c, w.name, err
- if oldErrors == c.errors:
- if not c.keepCommits: checkoutCommit(c, w)
- # even if the checkout fails, we can make use of the somewhat
- # outdated .nimble file to clone more of the most likely still relevant
- # dependencies:
- collectNewDeps(c, work, w, result, i == 0)
- inc i
- const
- configPatternBegin = "############# begin Atlas config section ##########\n"
- configPatternEnd = "############# end Atlas config section ##########\n"
- proc patchNimCfg(c: var AtlasContext; deps: seq[string]; cfgHere: bool) =
- var paths = "--noNimblePath\n"
- if cfgHere:
- let cwd = getCurrentDir()
- for d in deps:
- let x = relativePath(c.workspace / d, cwd, '/')
- paths.add "--path:\"" & x & "\"\n"
- else:
- for d in deps:
- paths.add "--path:\"../" & d.replace("\\", "/") & "\"\n"
- var cfgContent = configPatternBegin & paths & configPatternEnd
- when MockupRun:
- assert readFile(TestsDir / "nim.cfg") == cfgContent
- c.mockupSuccess = true
- else:
- let cfg = if cfgHere: getCurrentDir() / "nim.cfg"
- else: c.projectDir / "nim.cfg"
- if not fileExists(cfg):
- writeFile(cfg, cfgContent)
- else:
- let content = readFile(cfg)
- let start = content.find(configPatternBegin)
- if start >= 0:
- cfgContent = content.substr(0, start-1) & cfgContent
- let theEnd = content.find(configPatternEnd, start)
- if theEnd >= 0:
- cfgContent.add content.substr(theEnd+len(configPatternEnd))
- else:
- cfgContent = content & "\n" & cfgContent
- if cfgContent != content:
- # do not touch the file if nothing changed
- # (preserves the file date information):
- writeFile(cfg, cfgContent)
- proc error*(msg: string) =
- when defined(debug):
- writeStackTrace()
- quit "[Error] " & msg
- proc main =
- var action = ""
- var args: seq[string] = @[]
- template singleArg() =
- if args.len != 1:
- error action & " command takes a single package name"
- template noArgs() =
- if args.len != 0:
- error action & " command takes no arguments"
- var c = AtlasContext(
- projectDir: getCurrentDir(),
- workspace: getCurrentDir())
- for kind, key, val in getopt():
- case kind
- of cmdArgument:
- if action.len == 0:
- action = key.normalize
- else:
- args.add key
- of cmdLongOption, cmdShortOption:
- case normalize(key)
- of "help", "h": writeHelp()
- of "version", "v": writeVersion()
- of "keepcommits": c.keepCommits = true
- of "cfghere": c.cfgHere = true
- else: writeHelp()
- of cmdEnd: assert false, "cannot happen"
- while c.workspace.len > 0 and dirExists(c.workspace / ".git"):
- c.workspace = c.workspace.parentDir()
- case action
- of "":
- error "No action."
- of "clone":
- singleArg()
- let deps = clone(c, args[0])
- patchNimCfg c, deps, false
- if c.cfgHere:
- patchNimCfg c, deps, true
- when MockupRun:
- if not c.mockupSuccess:
- error "There were problems."
- else:
- if c.errors > 0:
- error "There were problems."
- of "refresh":
- noArgs()
- updatePackages(c)
- of "search", "list":
- updatePackages(c)
- search getPackages(c.workspace), args
- of "extract":
- singleArg()
- if fileExists(args[0]):
- echo toJson(extractRequiresInfo(args[0]))
- else:
- error "File does not exist: " & args[0]
- else:
- error "Invalid action: " & action
- when isMainModule:
- main()
- when false:
- # some testing code for the `patchNimCfg` logic:
- var c = AtlasContext(
- projectDir: getCurrentDir(),
- workspace: getCurrentDir().parentDir)
- patchNimCfg(c, @[PackageName"abc", PackageName"xyz"])
- when false:
- assert sameVersionAs("v0.2.0", "0.2.0")
- assert sameVersionAs("v1", "1")
- assert sameVersionAs("1.90", "1.90")
- assert sameVersionAs("v1.2.3-zuzu", "1.2.3")
- assert sameVersionAs("foo-1.2.3.4", "1.2.3.4")
- assert not sameVersionAs("foo-1.2.3.4", "1.2.3")
- assert not sameVersionAs("foo", "1.2.3")
- assert not sameVersionAs("", "1.2.3")
|