553 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			TypeScript
		
	
	
			
		
		
	
	
			553 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			TypeScript
		
	
	
import path from 'node:path';
 | 
						|
import express from 'express';
 | 
						|
import {Liquid} from 'liquidjs';
 | 
						|
import {RateLimit} from 'async-sema';
 | 
						|
import fileUpload from 'express-fileupload';
 | 
						|
 | 
						|
import Graph from 'graphology';
 | 
						|
import forceAtlas2 from 'graphology-layout-forceatlas2';
 | 
						|
import circular from 'graphology-layout/circular';
 | 
						|
import {assignLayout} from 'graphology-layout/utils';
 | 
						|
 | 
						|
import {singleSource, singleSourceLength} from 'graphology-shortest-path/unweighted';
 | 
						|
 | 
						|
import Pep440Version, {Pep440VersionRule} from './pep440version.ts';
 | 
						|
import {version} from 'bun';
 | 
						|
 | 
						|
const liquidEngine = new Liquid({root: path.resolve(__dirname, 'views/'), jsTruthy: true});
 | 
						|
 | 
						|
const app = express();
 | 
						|
 | 
						|
app.use(express.json()); // To support JSON-encoded bodies
 | 
						|
app.use(express.urlencoded()); // To support URL-encoded bodies
 | 
						|
app.use(fileUpload({
 | 
						|
  limits: { fileSize: 50 * 1024 * 1024 },
 | 
						|
}));
 | 
						|
 | 
						|
app.engine('liquid', liquidEngine.express());
 | 
						|
app.set('views', [path.join(__dirname, 'views')]);
 | 
						|
app.set('view engine', 'liquid');
 | 
						|
 | 
						|
app.use('/static', express.static('static'));
 | 
						|
 | 
						|
app.get('/', (request, res) => {
 | 
						|
	res.render('index');
 | 
						|
});
 | 
						|
 | 
						|
 | 
						|
declare class RateLimit {
 | 
						|
	constructor(max: number);
 | 
						|
}
 | 
						|
 | 
						|
const rateLimit: any = new RateLimit(5);
 | 
						|
 | 
						|
 | 
						|
 | 
						|
function extractInfoFromPythonDepString(depstring: string) {
 | 
						|
	const parts = depstring.split(';');
 | 
						|
 | 
						|
	const namever = parts[0];
 | 
						|
 | 
						|
	// https://stackoverflow.com/questions/24470567/what-are-the-requirements-for-naming-python-modules
 | 
						|
	const match = namever.match(/^(?<name>[a-zA-Z_\-][\w\-]*)((?<operator>.=)(?<version>.+))?/);
 | 
						|
 | 
						|
 | 
						|
	if (match !== null && match.groups !== undefined) {
 | 
						|
		let rule;
 | 
						|
 | 
						|
		if( match.groups.version ) {
 | 
						|
			const version = new Pep440Version(match.groups.version);
 | 
						|
 | 
						|
			rule = new Pep440VersionRule(
 | 
						|
				version, 
 | 
						|
				match.groups.operator
 | 
						|
			)
 | 
						|
		}
 | 
						|
		else {
 | 
						|
			rule = Pep440VersionRule.getRuleMatchingAnyVersion()
 | 
						|
		}
 | 
						|
 | 
						|
		return {
 | 
						|
			name: match.groups.name,
 | 
						|
			rule: rule
 | 
						|
		};
 | 
						|
	}
 | 
						|
	else {
 | 
						|
		return undefined;
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
async function getRawPackageInfo(packagename: string) {
 | 
						|
	await rateLimit();
 | 
						|
 | 
						|
	const pypires = await fetch(`https://pypi.org/pypi/${packagename}/json`);
 | 
						|
	const json = await pypires.json();
 | 
						|
 | 
						|
	return json;
 | 
						|
}
 | 
						|
 | 
						|
 | 
						|
interface PipReleaseFile {
 | 
						|
	size: number;
 | 
						|
	packagetype: string;
 | 
						|
	upload_time_iso_8601: string;
 | 
						|
	[key: string]: any;
 | 
						|
}
 | 
						|
 | 
						|
 | 
						|
 | 
						|
 | 
						|
 | 
						|
async function getFullPackageInfo(packagename: string, rule: Pep440VersionRule = Pep440VersionRule.getRuleMatchingAnyVersion(), fromGraph = new Graph()) {
 | 
						|
	const dependencyGraph = fromGraph;
 | 
						|
 | 
						|
	// Analyse the requested package
 | 
						|
 | 
						|
	const packageJson = await getRawPackageInfo(packagename);
 | 
						|
 | 
						|
	let longestDependencyChain = 0;
 | 
						|
	
 | 
						|
	//const dependenciesList = packageJson.info.requires_dist ?? [];
 | 
						|
	
 | 
						|
	const recurseDepsPackageInfo = async (packagename: string, rule: Pep440VersionRule = Pep440VersionRule.getRuleMatchingAnyVersion(), descPath: string = "") => {
 | 
						|
		console.log(`[Recurse] Going to scan package ${packagename}`);
 | 
						|
 | 
						|
		if( descPath == "" ) {
 | 
						|
			descPath = packagename;
 | 
						|
		}
 | 
						|
		else {
 | 
						|
			descPath += `/${packagename}`
 | 
						|
		}
 | 
						|
 | 
						|
		if( dependencyGraph.hasNode(packagename) ) {
 | 
						|
			// figure out if the current path taken to this package is shorter than the one in the graph
 | 
						|
 | 
						|
			// have to add the current package name manually as it isn't added yet
 | 
						|
			const paramPathParts: string[] = descPath.split('/');
 | 
						|
			const graphPathParts: string[] = dependencyGraph.getNodeAttribute(packagename, 'path').split('/');
 | 
						|
 | 
						|
			if( paramPathParts.length < graphPathParts.length ) {
 | 
						|
				// if so, change the graph path to the current one
 | 
						|
				dependencyGraph.setNodeAttribute(packagename, 'path', descPath)
 | 
						|
				console.log(`[Recurse] Found better way of getting to ${packagename}: ${descPath}`);
 | 
						|
			}
 | 
						|
 | 
						|
			const shortestDist = Math.min( paramPathParts.length, graphPathParts.length );
 | 
						|
			if( longestDependencyChain < shortestDist ) {
 | 
						|
				longestDependencyChain = shortestDist;
 | 
						|
			}
 | 
						|
		
 | 
						|
			console.log(`[Recurse] Already scanned ${packagename} ; skipping.`);
 | 
						|
			return;
 | 
						|
		}
 | 
						|
 | 
						|
		console.log(`[Recurse] Scanning package ${packagename}`);
 | 
						|
 | 
						|
		const json = await getRawPackageInfo(packagename);
 | 
						|
 | 
						|
		// ==================================================
 | 
						|
		// 1. get biggest file of highest possible version
 | 
						|
		// TODO: don't choose biggest file, choose in a smart way (like asking the user's OS and arch)
 | 
						|
 | 
						|
		const releasesEntries = Object.entries(json.releases as {[key: string]: PipReleaseFile[]});
 | 
						|
		
 | 
						|
		const releases: {[key: string]: PipReleaseFile[]} = {};
 | 
						|
 | 
						|
		for( const [version, data] of releasesEntries ) {
 | 
						|
			try {
 | 
						|
				releases[new Pep440Version(version).releaseString()] = data;
 | 
						|
			}
 | 
						|
			catch {
 | 
						|
				console.log(`Failed to parse version: ${version}`);
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		const versions = Object.keys(releases).map( v => {
 | 
						|
			console.log(`[Recurse] Parsing version ${v} for ${packagename}`);
 | 
						|
			return new Pep440Version(v);
 | 
						|
		}); 
 | 
						|
		const highestVersions = rule.getSortedMatchingVersions(versions);
 | 
						|
 | 
						|
		if( highestVersions.length === 0 ) {
 | 
						|
			throw new Error("MERDE!");
 | 
						|
		}
 | 
						|
 | 
						|
		
 | 
						|
		let highestVersion: undefined | Pep440Version = undefined;
 | 
						|
 | 
						|
		for( const version of highestVersions ) {
 | 
						|
			if( releases[version.releaseString()].length !== 0 ) {
 | 
						|
				highestVersion = version;
 | 
						|
				break;
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		if( highestVersion === undefined ) {
 | 
						|
			throw new Error("MERDE!");
 | 
						|
		}
 | 
						|
 | 
						|
		const highestRelease = releases[highestVersion.releaseString()];
 | 
						|
		const biggestFile = highestRelease.sort( (a, b) => b.size - a.size )[0];
 | 
						|
 | 
						|
		console.log(`[Recurse] ${packagename} has highest version ${highestVersion.releaseString()}`);
 | 
						|
 | 
						|
		// ==================================================
 | 
						|
		// 2. add to the graph
 | 
						|
 | 
						|
		const summary = json.summary;
 | 
						|
	
 | 
						|
		dependencyGraph.addNode(packagename, {
 | 
						|
			label: packagename,
 | 
						|
			size: 10,
 | 
						|
 | 
						|
			summary: summary,
 | 
						|
			version: highestVersion.releaseString(),
 | 
						|
			weight: biggestFile.size,
 | 
						|
			path: descPath
 | 
						|
		});
 | 
						|
 | 
						|
		
 | 
						|
 | 
						|
		/////////////////
 | 
						|
 | 
						|
		const dependenciesList = packageJson.info.requires_dist ?? [];
 | 
						|
 | 
						|
		console.log(`[Recurse] ${packagename} has deps:\n - ${dependenciesList.join('\n - ')}`);
 | 
						|
 | 
						|
		for( const depString of dependenciesList ) {
 | 
						|
			console.log(`[Recurse] ${packagename} depends on: ${depString}`);
 | 
						|
 | 
						|
			const info = extractInfoFromPythonDepString(depString);
 | 
						|
 | 
						|
			if( info === undefined ) continue;
 | 
						|
 | 
						|
			await recurseDepsPackageInfo(info.name, info.rule, descPath);
 | 
						|
 | 
						|
			if( dependencyGraph.hasEdge(packagename, info.name) === false ) {
 | 
						|
				dependencyGraph.addEdge(packagename, info.name);
 | 
						|
			}
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	await recurseDepsPackageInfo(packagename, rule);
 | 
						|
 | 
						|
	console.log(`Longest dependency chain is ${longestDependencyChain}`);
 | 
						|
 | 
						|
 | 
						|
 | 
						|
	//const paths = singleSource(dependencyGraph, packagename)
 | 
						|
 | 
						|
	
 | 
						|
 | 
						|
	const depthColors = [
 | 
						|
		"#D14D41",
 | 
						|
		"#DA702C",
 | 
						|
		"#D0A215",
 | 
						|
		"#879A39",
 | 
						|
		"#3AA99F",
 | 
						|
		"#4385BE",
 | 
						|
		"#8B7EC8",
 | 
						|
		"#CE5D97",
 | 
						|
	]
 | 
						|
 | 
						|
	const lengthmap = singleSourceLength(dependencyGraph, packagename);
 | 
						|
	const longestPathLength = Math.max(...Object.values(lengthmap))
 | 
						|
 | 
						|
	for( const [node, dist] of Object.entries(lengthmap) ) {
 | 
						|
		dependencyGraph.setNodeAttribute(node, 'color', depthColors[dist + 1] )
 | 
						|
		dependencyGraph.setNodeAttribute(node, 'actual_depth', dist )
 | 
						|
	}
 | 
						|
 | 
						|
 | 
						|
	dependencyGraph.forEachEdge((edge, attrs, source, target, sourceAttrs, targetAttrs) => {
 | 
						|
		const sourceDepth = sourceAttrs.actual_depth; //.path.split('/').length - 1;
 | 
						|
		const targetDepth = targetAttrs.actual_depth; //.path.split('/').length - 1;
 | 
						|
 | 
						|
		if( sourceDepth >= targetDepth ) {
 | 
						|
			dependencyGraph.setEdgeAttribute( edge, "color", "#B7B5AC")
 | 
						|
			dependencyGraph.setEdgeAttribute( edge, "size", 1)
 | 
						|
 | 
						|
			dependencyGraph.setEdgeAttribute( edge, "weight", 1)
 | 
						|
		}
 | 
						|
		else {
 | 
						|
			dependencyGraph.setEdgeAttribute( edge, "color", depthColors[ sourceDepth ])
 | 
						|
			dependencyGraph.setEdgeAttribute( edge, "size", 3 + 3 * (longestPathLength - sourceDepth))
 | 
						|
 | 
						|
			dependencyGraph.setEdgeAttribute( edge, "weight", 3 + 3 * (longestPathLength - sourceDepth))
 | 
						|
		}
 | 
						|
 | 
						|
		dependencyGraph.setEdgeAttribute( edge, "type", "arrow");
 | 
						|
	});
 | 
						|
 | 
						|
 | 
						|
	
 | 
						|
 | 
						|
 | 
						|
	
 | 
						|
 | 
						|
	//circular.assign(dependencyGraph, {scale: 100});
 | 
						|
	//forceAtlas2.assign(dependencyGraph, 100)
 | 
						|
 | 
						|
 | 
						|
	dependencyGraph.setNodeAttribute(packagename, 'size', 20)
 | 
						|
	//dependencyGraph.setNodeAttribute(packagename, 'x', 0)
 | 
						|
	//dependencyGraph.setNodeAttribute(packagename, 'y', 0)
 | 
						|
 | 
						|
 | 
						|
 | 
						|
	circular.assign(dependencyGraph, {scale: 100});
 | 
						|
 | 
						|
 | 
						|
	
 | 
						|
	const positions = forceAtlas2(dependencyGraph, {
 | 
						|
		iterations: 50,
 | 
						|
		settings: {
 | 
						|
			edgeWeightInfluence: 1,
 | 
						|
			...forceAtlas2.inferSettings(dependencyGraph),
 | 
						|
		}
 | 
						|
	});
 | 
						|
 | 
						|
	assignLayout(dependencyGraph, positions);
 | 
						|
 | 
						|
 | 
						|
	return dependencyGraph;
 | 
						|
}
 | 
						|
 | 
						|
app.get('/graph/:packagename', (req, res) => {
 | 
						|
	const packagename = req.params.packagename;
 | 
						|
 | 
						|
	res.render('graph', {
 | 
						|
		packagename: packagename,
 | 
						|
	})
 | 
						|
}); 
 | 
						|
 | 
						|
app.get('/api/graph/:packagename', async (req, res) => {
 | 
						|
	const packagename = req.params.packagename;
 | 
						|
 | 
						|
	const depGraph = await getFullPackageInfo(packagename);
 | 
						|
 | 
						|
	const serializedDepGraph = depGraph.export();
 | 
						|
 | 
						|
	let totalDownloadSize = 0;
 | 
						|
 | 
						|
	depGraph.forEachNode((node, attrs) => {
 | 
						|
		totalDownloadSize += attrs.weight;
 | 
						|
	})
 | 
						|
	
 | 
						|
	const units = [
 | 
						|
		'b',
 | 
						|
		'kb',
 | 
						|
		'Mb',
 | 
						|
		'Gb'
 | 
						|
	]
 | 
						|
 | 
						|
	let currentUnit = 0;
 | 
						|
	while( totalDownloadSize > 1000 ) {
 | 
						|
		totalDownloadSize = totalDownloadSize / 1000;
 | 
						|
		currentUnit++;
 | 
						|
	}
 | 
						|
 | 
						|
	const unit = units[currentUnit];
 | 
						|
 | 
						|
	res.json({
 | 
						|
		weight: totalDownloadSize,
 | 
						|
		weightUnit: unit,
 | 
						|
		graph: serializedDepGraph
 | 
						|
	});
 | 
						|
});
 | 
						|
 | 
						|
 | 
						|
app.post('/api/upload-requirements', async (req, res) => {
 | 
						|
	// get the uploaded requirements.txt file, from the input with the name "file"
 | 
						|
	const files = req.files;
 | 
						|
 | 
						|
	if( files === undefined || files === null ) {
 | 
						|
		res.status(400);
 | 
						|
		res.send("No file uploaded");
 | 
						|
		return;
 | 
						|
	}
 | 
						|
 | 
						|
	const file = files.requirementsFile;
 | 
						|
 | 
						|
	if( file === undefined || file === null ) {
 | 
						|
		res.status(400);
 | 
						|
		res.send("No file uploaded");
 | 
						|
		return;
 | 
						|
	}
 | 
						|
 | 
						|
	if( Array.isArray(file) ) {
 | 
						|
		res.status(400);
 | 
						|
		res.send("Don't send multiple files.");
 | 
						|
		return;
 | 
						|
	}
 | 
						|
 | 
						|
	// convert to string
 | 
						|
	const content = '' + file.data;
 | 
						|
	const infos = content.replaceAll('\r\n', '\n').split('\n').map(extractInfoFromPythonDepString);
 | 
						|
 | 
						|
	let dependencyGraph = new Graph();
 | 
						|
	for( const info of infos ) {
 | 
						|
		if( info === undefined ) {
 | 
						|
			console.log(`Got an undefined info !`)
 | 
						|
			continue;
 | 
						|
		}
 | 
						|
 | 
						|
		dependencyGraph = await getFullPackageInfo(info.name, info.rule, dependencyGraph);
 | 
						|
	}
 | 
						|
 | 
						|
	const serializedDepGraph = dependencyGraph.export();
 | 
						|
 | 
						|
 | 
						|
 | 
						|
 | 
						|
	const getHumanWeight = (weight: number) => {
 | 
						|
		const units = [
 | 
						|
			'b',
 | 
						|
			'kb',
 | 
						|
			'Mb',
 | 
						|
			'Gb'
 | 
						|
		]
 | 
						|
 | 
						|
		let currentUnit = 0;
 | 
						|
		while( weight > 1000 ) {
 | 
						|
			weight = weight / 1000;
 | 
						|
			currentUnit++;
 | 
						|
		}
 | 
						|
 | 
						|
		return `${Math.round(weight)}${units[currentUnit]}`
 | 
						|
	}
 | 
						|
 | 
						|
 | 
						|
 | 
						|
	let nbNodes = 0;
 | 
						|
	let totalDownloadSize = 0;
 | 
						|
 | 
						|
	dependencyGraph.forEachNode((node, attrs) => {
 | 
						|
		nbNodes++;
 | 
						|
		totalDownloadSize += attrs.weight;
 | 
						|
	})
 | 
						|
 | 
						|
 | 
						|
	let packageByWeight: {name: string, weight: number, humanWeight: string}[] = [];
 | 
						|
 | 
						|
	dependencyGraph.forEachNode((node, attrs) => {
 | 
						|
		packageByWeight.push({
 | 
						|
			name: node,
 | 
						|
			weight: attrs.weight,
 | 
						|
			humanWeight: getHumanWeight(attrs.weight),
 | 
						|
		})
 | 
						|
	});
 | 
						|
 | 
						|
	packageByWeight = packageByWeight.sort( (a, b) => b.weight - a.weight);
 | 
						|
 | 
						|
 | 
						|
 | 
						|
	const humanDownloadSize = getHumanWeight(totalDownloadSize);
 | 
						|
 | 
						|
	
 | 
						|
 | 
						|
	
 | 
						|
	res.render('fullgraph', {
 | 
						|
		//packagename: packagename,
 | 
						|
		nb_deps: nbNodes,
 | 
						|
		totalWeight: {
 | 
						|
			raw: totalDownloadSize,
 | 
						|
			value: humanDownloadSize,
 | 
						|
		},
 | 
						|
		graph: serializedDepGraph,
 | 
						|
		packageByWeight: packageByWeight
 | 
						|
	});
 | 
						|
})
 | 
						|
 | 
						|
 | 
						|
 | 
						|
/**
 | 
						|
	* Get info about a package
 | 
						|
	*/ /*
 | 
						|
async function getFullPackageInfo(packagename: string, versionRequirement={operator: "", version: ""}, seenBefore: Set<string> = new Set([])) {
 | 
						|
	const packagejson = await getRawPackageInfo(packagename);
 | 
						|
 | 
						|
	const name = packagejson.info.name;
 | 
						|
 | 
						|
	const summary = packagejson.info.summary;
 | 
						|
 | 
						|
	const dependenciesList = packagejson.info.requires_dist ?? [];
 | 
						|
 | 
						|
 | 
						|
	const builtDepsList: Map<string, {[key: string]: string]}>
 | 
						|
 | 
						|
 | 
						|
	for( const depString of dependenciesList ) {
 | 
						|
		const dep = extractInfoFromPythonDepString(depString);
 | 
						|
 | 
						|
		if( seenBefore.has(dep.name) ) {
 | 
						|
			continue;
 | 
						|
		}
 | 
						|
 | 
						|
		const json = await getRawPackageInfo(dep.name, dep.version)
 | 
						|
 | 
						|
 | 
						|
	}
 | 
						|
 | 
						|
 | 
						|
}*/
 | 
						|
 | 
						|
 | 
						|
 | 
						|
 | 
						|
 | 
						|
 | 
						|
/*
 | 
						|
app.post('/api/dependency-tree', async (request, res) => {
 | 
						|
	const packagename = request.body.package;
 | 
						|
	console.log("Requested package: ", packagename);
 | 
						|
 | 
						|
	const json = await getRawPackageInfo(packagename);
 | 
						|
 | 
						|
	const name = json.info.name;
 | 
						|
	const summary = json.info.summary;
 | 
						|
 | 
						|
	let deps = json.info.requires_dist ? json.info.requires_dist.map(e => extractInfoFromPythonDepString(e)) : [];
 | 
						|
	// avoid duplicates (sometimes packages list themselves as a dependency to create "dependency groups" (?))
 | 
						|
	deps = deps.filter( dep => dep.name !== packagename );
 | 
						|
 | 
						|
	const versions = Object.entries(json.releases).filter( ([k, v]) => v.length > 0 );
 | 
						|
 | 
						|
	// sort by size, preferring wheels
 | 
						|
	const versionsSorted = versions.map( ([versionNumber, filesArray]) => {
 | 
						|
			const wheels = filesArray.filter( file => file.packagetype === 'bdist_wheel' );
 | 
						|
 | 
						|
			if( wheels.length > 0 ) {
 | 
						|
				// sort by size
 | 
						|
				const biggestWheel = wheels.sort( (a, b) => b.size - a.size )[0];
 | 
						|
 | 
						|
				return [versionNumber, biggestWheel];
 | 
						|
			}
 | 
						|
			else {
 | 
						|
				const biggestFile = filesArray.sort( (a, b) => b.size - a.size )[0];
 | 
						|
 | 
						|
				return [versionNumber, biggestFile];
 | 
						|
			}
 | 
						|
		});
 | 
						|
 | 
						|
 | 
						|
	//const sorted = filesArray.sort( (a, b) => b.upload_time_iso8601 - a.upload_time_iso8601 );
 | 
						|
 | 
						|
	const [latestVersionNumber, latestVersion] = versions[0];
 | 
						|
	
 | 
						|
	const sortedFiles = latestVersion.sort((a, b) => b.size - a.size);
 | 
						|
	const size = sortedFiles[0].size;
 | 
						|
 | 
						|
	res.render('dependency', {
 | 
						|
		name,
 | 
						|
		summary,
 | 
						|
		weight: size,
 | 
						|
		dependencies: deps,
 | 
						|
		// DEBUG
 | 
						|
		has_parent: request.body.isfirst !== "true",
 | 
						|
	});
 | 
						|
});*/
 | 
						|
 | 
						|
app.listen(8080);
 | 
						|
 |