Enter a PyPI package name to see all versions and compare changes between them.
+
Enter a PyPI package name to see all versions and compare changes between them. Wheels are used when available, with a fallback to the source distribution (try apple-fm-sdk).
@@ -238,29 +238,30 @@
PyPI Package Changelog
packageData = await response.json();
versionWheels = {};
- // Get all versions with wheel URLs
+ // Get all versions that ship a wheel or a source distribution (.tar.gz)
const releases = packageData.releases;
+ const hasDist = files => files.some(f => f.filename.endsWith('.whl') || f.filename.endsWith('.tar.gz'));
const versions = Object.keys(releases)
- .filter(v => {
- const files = releases[v];
- return files.some(f => f.filename.endsWith('.whl'));
- })
+ .filter(v => hasDist(releases[v]))
.sort(compareVersions)
.reverse();
if (versions.length === 0) {
- versionListEl.innerHTML = `
No wheel files found for "${escapeHtml(packageName)}"
`;
+ versionListEl.innerHTML = `
No wheel or source distribution files found for "${escapeHtml(packageName)}"
`;
return;
}
- // Store wheel URLs for each version
+ // Store a dist URL for each version, preferring a wheel but falling
+ // back to a source distribution (.tar.gz) when no wheel is available
versions.forEach(v => {
const wheel = releases[v].find(f => f.filename.endsWith('.whl'));
- if (wheel) {
+ const dist = wheel || releases[v].find(f => f.filename.endsWith('.tar.gz'));
+ if (dist) {
versionWheels[v] = {
- url: wheel.url,
- filename: wheel.filename,
- uploadTime: wheel.upload_time_iso_8601
+ url: dist.url,
+ filename: dist.filename,
+ uploadTime: dist.upload_time_iso_8601,
+ isSdist: !wheel
};
}
});
@@ -297,7 +298,7 @@
return files;
}
+async function fetchSdistContents(arrayBuffer) {
+ const tarBuffer = await gunzip(arrayBuffer);
+ const decoder = new TextDecoder('utf-8');
+ const files = {};
+ for (const entry of parseTar(tarBuffer)) {
+ // Strip the leading "-/" component so paths line up
+ // across versions when diffing source distributions
+ const name = entry.name.replace(/^[^/]+\//, '');
+ if (!name) continue;
+ if (isBinaryFilename(name)) {
+ files[name] = '[Binary file]';
+ continue;
+ }
+ try {
+ files[name] = classifyContent(name, decoder.decode(entry.data));
+ } catch (e) {
+ files[name] = '[Binary file]';
+ }
+ }
+ return files;
+}
+
+// Decompress gzip data using the browser's native DecompressionStream
+async function gunzip(arrayBuffer) {
+ if (typeof DecompressionStream === 'undefined') {
+ throw new Error('Your browser does not support gzip decompression (DecompressionStream)');
+ }
+ const stream = new Response(arrayBuffer).body.pipeThrough(new DecompressionStream('gzip'));
+ return new Response(stream).arrayBuffer();
+}
+
+// Parse a tar archive (ArrayBuffer) into [{name, data}], handling ustar
+// prefixes, GNU long names and PAX extended headers.
+function parseTar(buffer) {
+ const bytes = new Uint8Array(buffer);
+ const decoder = new TextDecoder('utf-8');
+ const entries = [];
+ let offset = 0;
+ let longName = null; // GNU 'L' long name
+ let paxName = null; // PAX 'x' path override
+
+ const readStr = (start, len) => {
+ let end = start;
+ const max = start + len;
+ while (end < max && bytes[end] !== 0) end++;
+ return decoder.decode(bytes.subarray(start, end));
+ };
+
+ while (offset + 512 <= bytes.length) {
+ // End of archive is marked by a zero-filled block
+ let allZero = true;
+ for (let i = 0; i < 512; i++) {
+ if (bytes[offset + i] !== 0) { allZero = false; break; }
+ }
+ if (allZero) break;
+
+ let name = readStr(offset, 100);
+ const size = parseInt(readStr(offset + 124, 12).trim(), 8) || 0;
+ const typeflag = bytes[offset + 156];
+ const magic = readStr(offset + 257, 6);
+ const prefix = readStr(offset + 345, 155);
+ if (prefix && magic.startsWith('ustar')) {
+ name = prefix + '/' + name;
+ }
+
+ const dataStart = offset + 512;
+ offset = dataStart + Math.ceil(size / 512) * 512;
+
+ if (typeflag === 0x4c) { // 'L' GNU long name
+ longName = decoder.decode(bytes.subarray(dataStart, dataStart + size)).replace(/\0+$/, '');
+ continue;
+ }
+ if (typeflag === 0x78 || typeflag === 0x67) { // 'x' or 'g' PAX header
+ const records = decoder.decode(bytes.subarray(dataStart, dataStart + size));
+ const m = records.match(/^\d+ path=(.*)$/m);
+ if (m) paxName = m[1];
+ continue;
+ }
+
+ const effectiveName = paxName || longName || name;
+ paxName = null;
+ longName = null;
+
+ // Only keep regular files ('0' or legacy '\0'); skip directories etc.
+ if (typeflag === 0x30 || typeflag === 0) {
+ entries.push({ name: effectiveName, data: bytes.subarray(dataStart, dataStart + size) });
+ }
+ }
+ return entries;
+}
+
function matchFiles(oldFiles, newFiles) {
// Returns array of {oldName, newName, oldContent, newContent}
// Handles renames for .dist-info files
diff --git a/zip-wheel-explorer.html b/zip-wheel-explorer.html
index cf9bd31..e377677 100644
--- a/zip-wheel-explorer.html
+++ b/zip-wheel-explorer.html
@@ -174,9 +174,9 @@
Package File Browser
-
+
-
Enter a PyPI package name like requests or llm-mistral (try it), or paste a direct URL to a .whl or .zip file.
+
Enter a PyPI package name like requests or llm-mistral (try it), or paste a direct URL to a .whl, .zip or .tar.gz file. Packages with no wheel fall back to the source distribution (try apple-fm-sdk).
`;
+ // Prefer a wheel, but fall back to a source distribution (.tar.gz)
+ const fileUrl = pypiData.urls?.find(u => u.url?.endsWith('.whl'))
+ || pypiData.urls?.find(u => u.url?.endsWith('.tar.gz'));
+ if (!fileUrl) {
+ fileListEl.innerHTML = `
No wheel (.whl) or source distribution (.tar.gz) found for "${escapeHtml(input)}"