Files
smartregistry/ts/pypi/helpers.pypi.ts

300 lines
7.7 KiB
TypeScript

/**
* Helper functions for PyPI registry
* Package name normalization, HTML generation, etc.
*/
import type { IPypiFile, IPypiPackageMetadata } from './interfaces.pypi.js';
/**
* Normalize package name according to PEP 503
* Lowercase and replace runs of [._-] with a single dash
* @param name - Package name
* @returns Normalized name
*/
export function normalizePypiPackageName(name: string): string {
return name
.toLowerCase()
.replace(/[-_.]+/g, '-');
}
/**
* Escape HTML special characters to prevent XSS
* @param str - String to escape
* @returns Escaped string
*/
export function escapeHtml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
* Generate PEP 503 compliant HTML for root index (all packages)
* @param packages - List of package names
* @returns HTML string
*/
export function generateSimpleRootHtml(packages: string[]): string {
const links = packages
.map(pkg => {
const normalized = normalizePypiPackageName(pkg);
return ` <a href="${escapeHtml(normalized)}/">${escapeHtml(pkg)}</a>`;
})
.join('\n');
return `<!DOCTYPE html>
<html>
<head>
<meta name="pypi:repository-version" content="1.0">
<title>Simple Index</title>
</head>
<body>
<h1>Simple Index</h1>
${links}
</body>
</html>`;
}
/**
* Generate PEP 503 compliant HTML for package index (file list)
* @param packageName - Package name (normalized)
* @param files - List of files
* @param baseUrl - Base URL for downloads
* @returns HTML string
*/
export function generateSimplePackageHtml(
packageName: string,
files: IPypiFile[],
baseUrl: string
): string {
const links = files
.map(file => {
// Build URL
let url = file.url;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
// Relative URL - make it absolute
url = `${baseUrl}/packages/${packageName}/${file.filename}`;
}
// Add hash fragment
const hashName = Object.keys(file.hashes)[0];
const hashValue = file.hashes[hashName];
const fragment = hashName && hashValue ? `#${hashName}=${hashValue}` : '';
// Build data attributes
const dataAttrs: string[] = [];
if (file['requires-python']) {
const escaped = escapeHtml(file['requires-python']);
dataAttrs.push(`data-requires-python="${escaped}"`);
}
if (file['gpg-sig'] !== undefined) {
dataAttrs.push(`data-gpg-sig="${file['gpg-sig'] ? 'true' : 'false'}"`);
}
if (file.yanked) {
const reason = typeof file.yanked === 'string' ? file.yanked : '';
if (reason) {
dataAttrs.push(`data-yanked="${escapeHtml(reason)}"`);
} else {
dataAttrs.push(`data-yanked=""`);
}
}
const dataAttrStr = dataAttrs.length > 0 ? ' ' + dataAttrs.join(' ') : '';
return ` <a href="${escapeHtml(url)}${fragment}"${dataAttrStr}>${escapeHtml(file.filename)}</a>`;
})
.join('\n');
return `<!DOCTYPE html>
<html>
<head>
<meta name="pypi:repository-version" content="1.0">
<title>Links for ${escapeHtml(packageName)}</title>
</head>
<body>
<h1>Links for ${escapeHtml(packageName)}</h1>
${links}
</body>
</html>`;
}
/**
* Parse filename to extract package info
* Supports wheel and sdist formats
* @param filename - Package filename
* @returns Parsed info or null
*/
export function parsePackageFilename(filename: string): {
name: string;
version: string;
filetype: 'bdist_wheel' | 'sdist';
pythonVersion?: string;
} | null {
// Wheel format: {distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl
const wheelMatch = filename.match(/^([a-zA-Z0-9_.-]+?)-([a-zA-Z0-9_.]+?)(?:-(\d+))?-([^-]+)-([^-]+)-([^-]+)\.whl$/);
if (wheelMatch) {
return {
name: wheelMatch[1],
version: wheelMatch[2],
filetype: 'bdist_wheel',
pythonVersion: wheelMatch[4],
};
}
// Sdist tar.gz format: {name}-{version}.tar.gz
const sdistTarMatch = filename.match(/^([a-zA-Z0-9_.-]+?)-([a-zA-Z0-9_.]+)\.tar\.gz$/);
if (sdistTarMatch) {
return {
name: sdistTarMatch[1],
version: sdistTarMatch[2],
filetype: 'sdist',
pythonVersion: 'source',
};
}
// Sdist zip format: {name}-{version}.zip
const sdistZipMatch = filename.match(/^([a-zA-Z0-9_.-]+?)-([a-zA-Z0-9_.]+)\.zip$/);
if (sdistZipMatch) {
return {
name: sdistZipMatch[1],
version: sdistZipMatch[2],
filetype: 'sdist',
pythonVersion: 'source',
};
}
return null;
}
/**
* Calculate hash digest for a buffer
* @param data - Data to hash
* @param algorithm - Hash algorithm (sha256, md5, blake2b)
* @returns Hex-encoded hash
*/
export async function calculateHash(data: Buffer, algorithm: 'sha256' | 'md5' | 'blake2b'): Promise<string> {
const crypto = await import('crypto');
let hash: any;
if (algorithm === 'blake2b') {
// Node.js uses 'blake2b512' for blake2b
hash = crypto.createHash('blake2b512');
} else {
hash = crypto.createHash(algorithm);
}
hash.update(data);
return hash.digest('hex');
}
/**
* Validate package name
* Must contain only ASCII letters, numbers, ., -, and _
* @param name - Package name
* @returns true if valid
*/
export function isValidPackageName(name: string): boolean {
return /^[a-zA-Z0-9._-]+$/.test(name);
}
/**
* Validate version string (basic check)
* @param version - Version string
* @returns true if valid
*/
export function isValidVersion(version: string): boolean {
// Basic check - allows numbers, letters, dots, hyphens, underscores
// More strict validation would follow PEP 440
return /^[a-zA-Z0-9._-]+$/.test(version);
}
/**
* Extract metadata from package metadata
* Filters and normalizes metadata fields
* @param metadata - Raw metadata object
* @returns Filtered metadata
*/
export function extractCoreMetadata(metadata: Record<string, any>): Record<string, any> {
const coreFields = [
'metadata-version',
'name',
'version',
'platform',
'supported-platform',
'summary',
'description',
'description-content-type',
'keywords',
'home-page',
'download-url',
'author',
'author-email',
'maintainer',
'maintainer-email',
'license',
'classifier',
'requires-python',
'requires-dist',
'requires-external',
'provides-dist',
'project-url',
'provides-extra',
];
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(metadata)) {
const normalizedKey = key.toLowerCase().replace(/_/g, '-');
if (coreFields.includes(normalizedKey)) {
result[normalizedKey] = value;
}
}
return result;
}
/**
* Generate JSON API response for package list (PEP 691)
* @param packages - List of package names
* @returns JSON object
*/
export function generateJsonRootResponse(packages: string[]): any {
return {
meta: {
'api-version': '1.0',
},
projects: packages.map(name => ({ name })),
};
}
/**
* Generate JSON API response for package files (PEP 691)
* @param packageName - Package name (normalized)
* @param files - List of files
* @returns JSON object
*/
export function generateJsonPackageResponse(packageName: string, files: IPypiFile[]): any {
return {
meta: {
'api-version': '1.0',
},
name: packageName,
files: files.map(file => ({
filename: file.filename,
url: file.url,
hashes: file.hashes,
'requires-python': file['requires-python'],
'dist-info-metadata': file['dist-info-metadata'],
'gpg-sig': file['gpg-sig'],
yanked: file.yanked,
size: file.size,
'upload-time': file['upload-time'],
})),
};
}