Guides / Archive member selection
JSON in a ZIP to CSV: Select One Exact Member, Reject Duplicate Names
Short answer: List the ZIP members, choose the complete member name and require exactly one match before parsing its JSON. Do not take the first .json file or match only its basename. Read the chosen member directly from the archive, validate its records, then create a separate CSV destination.
A ZIP can contain both exports/orders.json and archive/orders.json. Those names identify different members. It can also contain multiple entries with the exact same name; this recipe rejects that ambiguity rather than quietly selecting a winner. This is a local Python workflow, not a browser ZIP-support claim.
The selection problem in one archive
exports/orders.json [{"id":"O1","text":"current orders"}]
archive/orders.json [{"id":"O9","text":"archived orders"}]Our original fixture intentionally gives two members the basename orders.json. A successful conversion of the wrong member would still produce a plausible CSV. Specify which full name is authorized before converting, and retain that selection in your handoff notes.
The official infolist reference describes per-member ZipInfo records. ZipFile.open can read one such record directly and documents duplicate-name archives. Our rejection policy is an explicit application choice.
Eight original local runs
Executed on 2026-10-26. Successful outputs were parsed with csv.DictReader and compared against the exact selected records. Rejected cases created no destination, except the existing-destination case, where the original CSV contents were checked unchanged.
select-current
Archive: two-directories.zip; requested member: exports/orders.json.
id,text
O1,current orders
select-archive
Archive: two-directories.zip; requested member: archive/orders.json.
id,text
O9,archived orders
basename-only
Archive: two-directories.zip; requested member: orders.json.
Expected exactly one member named: orders.jsonduplicate-exact-name
Archive: duplicate-exact-name.zip; requested member: exports/orders.json.
Expected exactly one member named: exports/orders.jsoninvalid-selected-json
Archive: invalid-selected-json.zip; requested member: exports/orders.json.
Expecting value: line 1 column 2 (char 1)directory
Archive: directory-entry.zip; requested member: exports/.
Selected member is a directorymember-policy-limit
Archive: member-policy-limit.zip; requested member: exports/orders.json.
Demo decoded-member cap exceededexisting-destination
Archive: two-directories.zip; requested member: exports/orders.json.
[Errno 17] File exists: 'F:\\CodexWork\\jsontocsvtool\\drafts\\2026-10-26-zip-member\\existing-destination.csv'The current and archived selections produced different, expected identifiers, O1 and O9. The basename-only request did not choose either one. The duplicate-name fixture was rejected even though each entry separately held valid records. A different valid member did not rescue invalid JSON in the selected member.
Inspect first, then convert one member
Download the standalone exporter, two-directory archive, duplicate-name archive and the exact current-selection CSV. The archive fixtures are synthetic, with no private account data.
python -m zipfile -l zip-member-two-directories.zip
python zip-json-member-export.py zip-member-two-directories.zip current-new.csv --member exports/orders.jsonUse a fresh output filename. The member name remains an archive lookup key; this script never uses it as a filesystem extraction destination. Reading without extraction avoids creating a directory tree from member paths, but is not a promise that arbitrary archives are harmless.
Selection and refusal code
import argparse, csv, json, zipfile
from pathlib import Path
def reject_constant(token):
raise ValueError("Non-JSON constant: " + token)
def unique_keys(pairs):
row = {}
for key, value in pairs:
if key in row:
raise ValueError("Duplicate JSON key: " + key)
row[key] = value
return row
def export(source, member, target):
source, target = Path(source), Path(target)
if source.resolve() == target.resolve():
raise ValueError("Source and destination must differ")
if source.stat().st_size > 2_000_000:
raise ValueError("Demo archive cap exceeded")
with zipfile.ZipFile(source, "r") as archive:
entries = archive.infolist()
if len(entries) > 50:
raise ValueError("Demo member-count cap exceeded")
matches = [entry for entry in entries if entry.filename == member]
if len(matches) != 1:
raise ValueError("Expected exactly one member named: " + member)
info = matches[0]
if info.is_dir():
raise ValueError("Selected member is a directory")
if info.flag_bits & 1:
raise ValueError("Encrypted member is outside this recipe")
if info.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED):
raise ValueError("Unsupported compression for this recipe")
if info.file_size > 1_000_000:
raise ValueError("Demo decoded-member cap exceeded")
with archive.open(info, "r") as stream:
raw = stream.read(1_000_001)
if len(raw) > 1_000_000:
raise ValueError("Demo decoded-member cap exceeded")
rows = json.loads(raw.decode("utf-8", errors="strict"),
object_pairs_hook=unique_keys, parse_constant=reject_constant)
if not isinstance(rows, list):
raise ValueError("Expected an array")
for row in rows:
if not isinstance(row, dict) or set(row) != {"id", "text"}:
raise ValueError("Expected exact id/text objects")
if not all(isinstance(row[k], str) for k in ("id", "text")):
raise ValueError("id and text must be strings")
with target.open("x", encoding="utf-8", newline="") as f:
w = csv.writer(f, lineterminator="\n")
w.writerow(["id", "text"])
w.writerows((row["id"], row["text"]) for row in rows)
if __name__ == "__main__":
p = argparse.ArgumentParser()
p.add_argument("source")
p.add_argument("target")
p.add_argument("--member", required=True)
args = p.parse_args()
try:
export(args.source, args.member, args.target)
except (ValueError, OSError, RuntimeError, NotImplementedError,
zipfile.BadZipFile, RecursionError) as e:
p.exit(1, str(e) + "\n")
Scope and limits before handing off
The demonstration caps the input archive at 2,000,000 bytes, its directory at 50 members and the selected decoded member at 1,000,000 bytes. These are chosen local policies, not service limits or performance measurements. It checks both the declared member size and a bounded read. Reading the central directory still precedes its member-count check, so this is not a general defense against hostile archives or decompression attacks.
The selected member must use stored or deflate compression, be unencrypted and contain UTF-8 JSON with exact id/text string objects. Directories, duplicate JSON keys, bare nonstandard constants and other shapes are refused. No other members are parsed or validated; a successful selected export says nothing about the integrity of unselected files.
Do not combine all JSON members automatically: their schemas, dates or row units may differ. If you need an aggregate, specify the participating member names and combination policy as a separate workflow. Keep the ZIP unchanged and record the member name beside the output. Correct CSV encoding also does not neutralize spreadsheet formulas in untrusted text.
This page concerns archive-member identity. The gzip workflow concerns one compressed stream, while the UTF-16 source guide addresses a different decoding contract. Neither should be silently substituted for the member policy above. Finish with row and field verification against the selected records, not against a guessed archive-wide total.