Skip to content

Commit

Permalink
Dependency tree
Browse files Browse the repository at this point in the history
Signed-off-by: Prabhu Subramanian <[email protected]>
  • Loading branch information
prabhu committed Jul 5, 2023
1 parent ec01348 commit c0d3a3b
Show file tree
Hide file tree
Showing 5 changed files with 271 additions and 48 deletions.
75 changes: 75 additions & 0 deletions contrib/piptree.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import importlib.metadata as importlib_metadata
import json
import sys

from pip._internal.metadata import pkg_resources


def frozen_req_from_dist(dist):
try:
from pip._internal.operations.freeze import FrozenRequirement
except ImportError:
from pip import FrozenRequirement
try:
from pip._internal import metadata

dist = metadata.pkg_resources.Distribution(dist)
try:
fr = FrozenRequirement.from_dist(dist)
except TypeError:
fr = FrozenRequirement.from_dist(dist, [])
return str(fr).strip()
except ImportError:
pass


def get_installed_distributions():
dists = pkg_resources.Environment.from_paths(None).iter_installed_distributions(
local_only=False,
skip=(),
user_only=False,
)
return [d._dist for d in dists]


def find_deps(idx, reqs):
freqs = []
for r in reqs:
d = idx.get(r.key)
r.project_name = d.project_name if d is not None else r.project_name
specs = sorted(r.specs, reverse=True)
specs_str = ",".join(["".join(sp) for sp in specs]) if specs else ""
freqs.append(
{
"name": r.project_name,
"version": importlib_metadata.version(r.key),
"versionSpecifiers": specs_str,
}
)
return freqs


def main(argv):
out_file = "piptree.json" if len(argv) < 2 else argv[-1]
tree = []
pkgs = get_installed_distributions()
idx = {p.key: p for p in pkgs}
for p in pkgs:
fr = frozen_req_from_dist(p)
tmpA = fr.split("==")
version = ""
if len(tmpA) == 2:
version = tmpA[1]
tree.append(
{
"name": tmpA[0],
"version": version,
"dependencies": find_deps(idx, p.requires()),
}
)
with open(out_file, mode="w", encoding="utf-8") as fp:
json.dump(tree, fp)


if __name__ == "__main__":
main(sys.argv)
46 changes: 34 additions & 12 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import {
parseBdistMetadata,
readZipEntry,
parsePiplockData,
executePipFreezeInVenv,
getPipFrozenTree,
parseReqFile,
getPyModules,
parseSetupPyFile,
Expand Down Expand Up @@ -1984,6 +1984,8 @@ export const createNodejsBom = async (path, options) => {
export const createPythonBom = async (path, options) => {
let allImports = {};
let metadataFilename = "";
let dependencies = [];
let pkgList = [];
const pipenvMode = existsSync(join(path, "Pipfile"));
const poetryFiles = getAllFiles(
path,
Expand All @@ -2009,7 +2011,6 @@ export const createPythonBom = async (path, options) => {
path,
(options.multiProject ? "**/" : "") + "*.egg-info"
);
let pkgList = [];
const setupPy = join(path, "setup.py");
const pyProjectFile = join(path, "pyproject.toml");
const pyProjectMode = existsSync(pyProjectFile);
Expand Down Expand Up @@ -2087,11 +2088,17 @@ export const createPythonBom = async (path, options) => {
let frozen = false;
// Attempt to pip freeze in a virtualenv to improve precision
if (options.installDeps) {
const dlist = await executePipFreezeInVenv(basePath, f);
if (dlist && dlist.length) {
pkgList = pkgList.concat(dlist);
const pkgMap = await getPipFrozenTree(basePath, f);
if (pkgMap.pkgList && pkgMap.pkgList.length) {
pkgList = pkgList.concat(pkgMap.pkgList);
frozen = true;
}
if (pkgMap.dependenciesList) {
dependencies = mergeDependencies(
dependencies,
pkgMap.dependenciesList
);
}
}
// Fallback to parsing manually
if (!pkgList.length || !frozen) {
Expand Down Expand Up @@ -2123,18 +2130,32 @@ export const createPythonBom = async (path, options) => {
}
// Use atom in requirements, setup.py and pyproject.toml mode
if (requirementsMode || setupPyMode || pyProjectMode) {
let dlist = undefined;
/**
* The order of preference is pyproject.toml (newer) and then setup.py
*/
if (options.installDeps) {
if (pyProjectMode) {
dlist = await executePipFreezeInVenv(path, pyProjectFile);
const pkgMap = await getPipFrozenTree(path, pyProjectFile);
if (pkgMap.pkgList && pkgMap.pkgList.length) {
pkgList = pkgList.concat(pkgMap.pkgList);
}
if (pkgMap.dependenciesList) {
dependencies = mergeDependencies(
dependencies,
pkgMap.dependenciesList
);
}
} else if (setupPyMode) {
dlist = await executePipFreezeInVenv(path, setupPy);
}
if (dlist && dlist.length) {
pkgList = pkgList.concat(dlist);
const pkgMap = await getPipFrozenTree(path, setupPy);
if (pkgMap.pkgList && pkgMap.pkgList.length) {
pkgList = pkgList.concat(pkgMap.pkgList);
}
if (pkgMap.dependenciesList) {
dependencies = mergeDependencies(
dependencies,
pkgMap.dependenciesList
);
}
}
}
// Get the imported modules and a dedupe list of packages
Expand All @@ -2158,7 +2179,8 @@ export const createPythonBom = async (path, options) => {
return buildBomNSData(options, pkgList, "pypi", {
allImports,
src: path,
filename: metadataFilename
filename: metadataFilename,
dependencies
});
};

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"scripts": {
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --inject-globals false",
"watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch --inject-globals false",
"lint": "eslint index.js utils.js binary.js server.js docker.js *.test.js bin/cdxgen.js",
"lint": "eslint index.js utils.js binary.js server.js docker.js piptree.js *.test.js bin/cdxgen.js",
"pretty": "prettier --write *.js data/*.json bin/cdxgen.js --trailing-comma=none"
},
"engines": {
Expand Down
125 changes: 125 additions & 0 deletions piptree.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* The idea behind this plugin came from the excellent pipdeptree package
* https://github.com/tox-dev/pipdeptree
*
* We use the internal pip api to construct the dependency tree for modern python + pip environments
*/
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { spawnSync } from "node:child_process";

const PIP_TREE_PLUGIN_CONTENT = `
import importlib.metadata as importlib_metadata
import json
import sys
from pip._internal.metadata import pkg_resources
def frozen_req_from_dist(dist):
try:
from pip._internal.operations.freeze import FrozenRequirement
except ImportError:
from pip import FrozenRequirement
try:
from pip._internal import metadata
dist = metadata.pkg_resources.Distribution(dist)
try:
fr = FrozenRequirement.from_dist(dist)
except TypeError:
fr = FrozenRequirement.from_dist(dist, [])
return str(fr).strip()
except ImportError:
pass
def get_installed_distributions():
dists = pkg_resources.Environment.from_paths(None).iter_installed_distributions(
local_only=False,
skip=(),
user_only=False,
)
return [d._dist for d in dists]
def find_deps(idx, reqs):
freqs = []
for r in reqs:
d = idx.get(r.key)
r.project_name = d.project_name if d is not None else r.project_name
specs = sorted(r.specs, reverse=True)
specs_str = ",".join(["".join(sp) for sp in specs]) if specs else ""
freqs.append(
{
"name": r.project_name,
"version": importlib_metadata.version(r.key),
"versionSpecifiers": specs_str,
}
)
return freqs
def main(argv):
out_file = "piptree.json" if len(argv) < 2 else argv[-1]
tree = []
pkgs = get_installed_distributions()
idx = {p.key: p for p in pkgs}
for p in pkgs:
fr = frozen_req_from_dist(p)
tmpA = fr.split("==")
version = ""
if len(tmpA) == 2:
version = tmpA[1]
tree.append(
{
"name": tmpA[0],
"version": version,
"dependencies": find_deps(idx, p.requires()),
}
)
with open(out_file, mode="w", encoding="utf-8") as fp:
json.dump(tree, fp)
if __name__ == "__main__":
main(sys.argv)
`;

/**
* Execute the piptree plugin and return the generated tree as json object
*/
export const getTreeWithPlugin = (env, python_cmd, basePath) => {
let tree = undefined;
const tempDir = mkdtempSync(join(tmpdir(), "cdxgen-piptree-"));
const pipPlugin = join(tempDir, "piptree.py");
const pipTreeJson = join(tempDir, "piptree.json");
const pipPluginArgs = [pipPlugin, pipTreeJson];
writeFileSync(pipPlugin, PIP_TREE_PLUGIN_CONTENT);
const result = spawnSync(python_cmd, pipPluginArgs, {
cwd: basePath,
encoding: "utf-8",
env
});
if (result.status !== 0 || result.error) {
console.log(result.stdout, result.stderr);
}
if (existsSync(pipTreeJson)) {
tree = JSON.parse(
readFileSync(pipTreeJson, {
encoding: "utf-8"
})
);
}
if (rmSync) {
rmSync(tempDir, { recursive: true, force: true });
}
return tree;
};
Loading

0 comments on commit c0d3a3b

Please sign in to comment.