Skip to content

Author index

gutenberg_kg.authors

Author provenance — build corpus/authors/ from reference.md files.

Entry point for callers: :func:build. The CLI wrapper lives at gutenberg_kg.cli.cmd_authors and exposes this as gutenkg authors.

author_lines(meta)

The list lines of a reference.md ## Author section.

The first author gets the Name line and the provenance lines, which describe that one person. Each further author gets a Co-author line.

Parameters:

Name Type Description Default
meta dict

Book metadata. authors is used when present, else a single author.

required

Returns:

Type Description
list[str]

The lines, without the heading; empty when there is no author.

Source code in src/gutenberg_kg/authors.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
def author_lines(meta: dict) -> list[str]:
    """The list lines of a ``reference.md`` ``## Author`` section.

    The first author gets the Name line and the provenance lines, which
    describe that one person. Each further author gets a Co-author line.

    :param meta: Book metadata. ``authors`` is used when present, else a
        single ``author``.
    :return: The lines, without the heading; empty when there is no author.
    """
    authors = meta.get("authors") or ([meta["author"]] if meta.get("author") else [])
    if not authors:
        return []
    lines = [f"- **Name**: {authors[0]}"]
    if meta.get("author_birth"):
        lines.append(f"- **Born**: {meta['author_birth']}")
    if meta.get("author_death"):
        lines.append(f"- **Died**: {meta['author_death']}")
    if meta.get("author_url"):
        lines.append(f"- **Wikipedia**: {meta['author_url']}")
    if meta.get("author_agent_id"):
        lines.append(f"- **Gutenberg Agent ID**: {meta['author_agent_id']}")
    lines += [f"- **Co-author**: {name}" for name in authors[1:]]
    return lines

build(refresh=False, dry_run=False)

Rebuild corpus/authors/ from all reference.md files.

Parameters:

Name Type Description Default
refresh bool

If True, re-fetch the Gutenberg RDF for every book with a Gutenberg ID and rewrite its reference.md Author section from it: every creator in catalog order (translators and editors excluded), and the first creator's Born/Died/Wikipedia/Agent ID. Books whose fetch fails, or whose RDF lists no creator, are left as they are.

False
dry_run bool

If True, print what would happen without writing any files.

False

Returns:

Type Description
int

0 on success (always, unless an unhandled exception propagates).

Source code in src/gutenberg_kg/authors.py
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
def build(refresh: bool = False, dry_run: bool = False) -> int:
    """Rebuild ``corpus/authors/`` from all ``reference.md`` files.

    :param refresh: If True, re-fetch the Gutenberg RDF for every book with a
        Gutenberg ID and rewrite its ``reference.md`` Author section from it:
        every creator in catalog order (translators and editors excluded), and
        the first creator's Born/Died/Wikipedia/Agent ID. Books whose fetch
        fails, or whose RDF lists no creator, are left as they are.
    :param dry_run: If True, print what would happen without writing any files.
    :return: ``0`` on success (always, unless an unhandled exception propagates).
    """
    if dry_run:
        print("[DRY RUN — no files will be written]\n")

    # 1. Scan reference.md files
    ref_files = sorted(CORPUS_ROOT.glob("*/*/reference.md"))
    print(f"Found {len(ref_files)} reference files across corpus/\n")

    metas: list[dict] = [parse_reference(ref) for ref in ref_files]

    # 2. Optional refresh
    if refresh:
        # Imported here: gutenberg imports this module at load time.
        from gutenberg_kg.gutenberg import _fetch_rdf_author  # noqa: PLC0415

        targets = [m for m in metas if m.get("ebook_id")]
        print(f"--- Refreshing authors from the Gutenberg RDF ({len(targets)} books) ---")
        fetched = rewritten = 0
        for m in targets:
            time.sleep(0.3)  # polite rate-limiting
            fresh = _fetch_rdf_author(m["ebook_id"])
            if not fresh.get("authors"):
                continue
            fetched += 1
            if rewrite_author_section(m["_path"], fresh, dry_run=dry_run):
                rewritten += 1
                print(
                    f"  [{m['title']}] {m.get('author') or '(none)'} -> {credit(fresh['authors'])}"
                )
                m.update(_author_fields(fresh))
        print(f"\n  RDF with creators: {fetched}  reference.md rewritten: {rewritten}\n")

    # 3. Group by author
    # One page per person, so a co-written book is listed under each author.
    # Born/Died/Wikipedia describe the first author only; a co-author's entry
    # drops them rather than borrow another person's dates.
    authors_books: dict[str, list[dict]] = defaultdict(list)
    skipped_no_author = 0
    for m in metas:
        people = m.get("authors") or []
        if not people:
            skipped_no_author += 1
            continue
        for i, person in enumerate(people):
            authors_books[person].append(m if i == 0 else {**m, **_NO_PROVENANCE})

    print(f"--- Building author pages ({len(authors_books)} unique authors) ---")
    if skipped_no_author:
        print(f"  (skipped {skipped_no_author} books with no author field)\n")

    # 4. Write per-author pages
    for author, books in sorted(authors_books.items()):
        out_path = write_author_page(author, books, dry_run=dry_run)
        tag = "[dry]" if dry_run else "[+]"
        n = len(books)
        suffix = "s" if n != 1 else ""
        print(f"  {tag} {author} ({n} work{suffix}) → {out_path.relative_to(REPO_ROOT)}")

    # 5. Remove pages for authors no longer in the corpus, such as a name
    # whose spelling changed. Only a directory holding nothing but a
    # generated author.md is removed; anything else there is left alone.
    live = {_slugify(author) for author in authors_books}
    stale = sorted(
        d
        for d in AUTHORS_DIR.glob("*/")
        if d.name not in live and [f.name for f in d.iterdir()] == ["author.md"]
    )
    for d in stale:
        tag = "[dry]" if dry_run else "[-]"
        print(f"  {tag} stale {d.relative_to(REPO_ROOT)}")
        if not dry_run:
            (d / "author.md").unlink()
            d.rmdir()

    # 6. Write index
    print()
    index_path = write_index(authors_books, dry_run=dry_run)
    tag = "[dry]" if dry_run else "[+]"
    print(f"  {tag} index → {index_path.relative_to(REPO_ROOT)}")
    print(f"\nDone. {len(authors_books)} author pages, {len(metas)} books.\n")
    return 0

credit(names)

Join author names into one byline.

Parameters:

Name Type Description Default
names list[str]

Display names, primary author first.

required

Returns:

Type Description
str

"A", "A and B", or "A, B, and C"; "" for none.

Source code in src/gutenberg_kg/authors.py
86
87
88
89
90
91
92
93
94
def credit(names: list[str]) -> str:
    """Join author names into one byline.

    :param names: Display names, primary author first.
    :return: ``"A"``, ``"A and B"``, or ``"A, B, and C"``; ``""`` for none.
    """
    if len(names) <= 2:
        return " and ".join(names)
    return f"{', '.join(names[:-1])}, and {names[-1]}"

display_name(heading)

Turn a Gutenberg catalog heading into a name in reading order.

Gutenberg files names library-style, "Surname, Forenames, Title". Splitting on the first comma alone turned "Tolstoy, Leo, graf" into "Leo, graf Tolstoy" and "Marcus Aurelius, Emperor of Rome" into "Emperor of Rome Marcus Aurelius". The rules, from the corpus's own names:

  • A parenthesized fuller form is dropped: "Wells, H. G. (Herbert George)" is "H. G. Wells".
  • A part with a date in it is dropped: "Sunzi, active 6th century B.C." is "Sunzi".
  • A peerage keeps its title: "Byron, George Gordon Byron, Baron" is "George Gordon Byron, Baron Byron".
  • Any other trailing title is dropped: "Tolstoy, Leo, graf" is "Leo Tolstoy".
  • A second part starting lowercase is an epithet and follows the name: "Pliny, the Younger" is "Pliny the Younger".
  • A second part containing " of " is a title: "Marcus Aurelius, Emperor of Rome" is "Marcus Aurelius".
  • Otherwise the forenames come first: "Dumas, Alexandre" is "Alexandre Dumas".

Parameters:

Name Type Description Default
heading str

A name as Gutenberg's RDF or OPDS feed gives it.

required

Returns:

Type Description
str

The name in reading order; the heading unchanged when nothing in it survives the rules.

Source code in src/gutenberg_kg/authors.py
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
def display_name(heading: str) -> str:
    """Turn a Gutenberg catalog heading into a name in reading order.

    Gutenberg files names library-style, ``"Surname, Forenames, Title"``.
    Splitting on the first comma alone turned "Tolstoy, Leo, graf" into
    "Leo, graf Tolstoy" and "Marcus Aurelius, Emperor of Rome" into
    "Emperor of Rome Marcus Aurelius". The rules, from the corpus's own names:

    - A parenthesized fuller form is dropped: "Wells, H. G. (Herbert George)"
      is "H. G. Wells".
    - A part with a date in it is dropped: "Sunzi, active 6th century B.C."
      is "Sunzi".
    - A peerage keeps its title: "Byron, George Gordon Byron, Baron" is
      "George Gordon Byron, Baron Byron".
    - Any other trailing title is dropped: "Tolstoy, Leo, graf" is
      "Leo Tolstoy".
    - A second part starting lowercase is an epithet and follows the name:
      "Pliny, the Younger" is "Pliny the Younger".
    - A second part containing " of " is a title: "Marcus Aurelius, Emperor of
      Rome" is "Marcus Aurelius".
    - Otherwise the forenames come first: "Dumas, Alexandre" is
      "Alexandre Dumas".

    :param heading: A name as Gutenberg's RDF or OPDS feed gives it.
    :return: The name in reading order; the heading unchanged when nothing
        in it survives the rules.
    """
    name = re.sub(r"\s*\([^)]*\)", "", heading)
    parts = [
        part.strip()
        for part in name.split(",")
        if part.strip()
        and not re.search(r"\d", part)
        and not part.strip().startswith(("active ", "fl. "))
    ]
    if not parts:
        return heading.strip()
    head, rest = parts[0], parts[1:]
    if not rest:
        return head
    given, extra = rest[0], rest[1:]
    peerage = next((p for p in extra if p in _PEERAGE_RANKS or p.endswith(" of")), None)
    if peerage:
        return f"{given}, {peerage} {head}"
    if given[0].islower():
        return f"{head} {given}"
    if " of " in given:
        return head
    return f"{given} {head}"

parse_reference(path)

Extract author + book metadata from a reference.md file.

Source code in src/gutenberg_kg/authors.py
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
def parse_reference(path: Path) -> dict:
    """Extract author + book metadata from a ``reference.md`` file."""
    text = path.read_text(encoding="utf-8")
    meta: dict = {"_path": path}

    meta["title"] = _field(r"^# Reference:\s*(.+)$", text) or path.parent.name
    eid = _field(r"\*\*Project Gutenberg ID\*\*:\s*(\d+)", text)
    meta["ebook_id"] = int(eid) if eid else None

    # Internet Archive items have an identifier here instead of a Gutenberg ID.
    # It is a string, globally unique and immutable, so it keys an IA book the
    # same way ebook_id keys a Gutenberg one -- for download idempotence, for
    # the catalog, and for duplicate detection. ia.write_reference has always
    # written it; nothing read it until now, which is why every IA-facing check
    # had to exempt itself rather than key on something.
    meta["ia_id"] = _field(r"\*\*Internet Archive ID\*\*:\s*(\S+)", text)

    # Genre is the grandparent dir name (corpus/<genre>/<book>/reference.md)
    meta["genre"] = path.parent.parent.name

    # "author" is the byline every consumer displays; "authors" lists the
    # people, first author first, for the per-author index.
    # An Internet Archive reference records the item's creator as an Author
    # line under Publication, not a Name line under Author.
    name = _field(r"\*\*Name\*\*:\s*(.+)$", text) or _field(r"^- \*\*Author\*\*:\s*(.+)$", text)
    co_authors = re.findall(r"^- \*\*Co-author\*\*:\s*(.+?)\s*$", text, re.MULTILINE)
    meta["authors"] = ([name] if name else []) + co_authors
    meta["author"] = credit(meta["authors"]) or None
    meta["author_birth"] = _field(r"\*\*Born\*\*:\s*(.+)$", text)
    meta["author_death"] = _field(r"\*\*Died\*\*:\s*(.+)$", text)
    meta["author_url"] = _field(r"\*\*Wikipedia\*\*:\s*(.+)$", text)
    aid = _field(r"\*\*Gutenberg Agent ID\*\*:\s*(\d+)", text)
    meta["author_agent_id"] = int(aid) if aid else None

    return meta

rewrite_author_section(path, meta, dry_run=False)

Replace the ## Author list in a reference.md with meta's.

Only the run of author lines starting at - **Name**: is replaced; the rest of the file, including the Gutenberg Published line that follows the section, is left byte for byte. A file with no Name line, or a meta with no author (a failed fetch returns none), is never touched, so a network error cannot erase an author.

Parameters:

Name Type Description Default
path Path

The reference.md to update.

required
meta dict

Fresh metadata, as gutenberg._fetch_rdf_author returns it.

required
dry_run bool

Report the change without writing it.

False

Returns:

Type Description
bool

True iff the file was (or would be) changed.

Source code in src/gutenberg_kg/authors.py
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
def rewrite_author_section(path: Path, meta: dict, dry_run: bool = False) -> bool:
    """Replace the ``## Author`` list in a ``reference.md`` with *meta*'s.

    Only the run of author lines starting at ``- **Name**:`` is replaced; the
    rest of the file, including the Gutenberg Published line that follows the
    section, is left byte for byte. A file with no Name line, or a *meta* with
    no author (a failed fetch returns none), is never touched, so a network
    error cannot erase an author.

    :param path: The ``reference.md`` to update.
    :param meta: Fresh metadata, as ``gutenberg._fetch_rdf_author`` returns it.
    :param dry_run: Report the change without writing it.
    :return: True iff the file was (or would be) changed.
    """
    new = author_lines(meta)
    if not new:
        return False
    lines = path.read_text(encoding="utf-8").split("\n")
    start = next((i for i, line in enumerate(lines) if line.startswith("- **Name**:")), None)
    if start is None:
        return False
    end = start
    while end < len(lines) and _AUTHOR_LINE.match(lines[end]):
        end += 1
    if lines[start:end] == new:
        return False
    lines[start:end] = new
    if not dry_run:
        path.write_text("\n".join(lines), encoding="utf-8")
    return True

write_author_page(author, books, dry_run=False)

Write corpus/authors/<slug>/author.md and return its path.

Source code in src/gutenberg_kg/authors.py
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
def write_author_page(
    author: str,
    books: list[dict],
    dry_run: bool = False,
) -> Path:
    """Write ``corpus/authors/<slug>/author.md`` and return its path."""
    slug = _slugify(author)
    out_dir = AUTHORS_DIR / slug
    out_path = out_dir / "author.md"

    births = {b["author_birth"] for b in books if b.get("author_birth")}
    deaths = {b["author_death"] for b in books if b.get("author_death")}
    urls = {b["author_url"] for b in books if b.get("author_url")}
    agents = {b["author_agent_id"] for b in books if b.get("author_agent_id")}

    birth = next(iter(births), None)
    death = next(iter(deaths), None)
    url = next(iter(urls), None)
    agent = next(iter(agents), None)

    lines = [f"# {author}", ""]

    if birth or death:
        era = f"{birth or '?'} – {death or '?'}"
        lines += [f"*{era}*", ""]

    if url:
        lines.append(f"- **Wikipedia**: {url}")
    if agent:
        lines.append(f"- **Gutenberg Agent ID**: {agent}")
    if url or agent:
        lines.append("")

    lines += ["## Works in Corpus", "", "| Title | Genre |", "|-------|-------|"]
    for b in sorted(books, key=lambda x: x.get("title", "")):
        lines.append(f"| {b['title']} | {b['genre']} |")
    lines.append("")

    content = "\n".join(lines)
    if not dry_run:
        out_dir.mkdir(parents=True, exist_ok=True)
        out_path.write_text(content, encoding="utf-8")
    return out_path

write_index(authors_books, dry_run=False)

Write corpus/authors/index.md and return its path.

Source code in src/gutenberg_kg/authors.py
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
def write_index(authors_books: dict[str, list[dict]], dry_run: bool = False) -> Path:
    """Write ``corpus/authors/index.md`` and return its path."""
    out_path = AUTHORS_DIR / "index.md"

    lines = [
        "# Author Index",
        "",
        "| Author | Born | Died | Works |",
        "|--------|------|------|------:|",
    ]
    for author in sorted(authors_books):
        books = authors_books[author]
        birth = next((b["author_birth"] for b in books if b.get("author_birth")), "—")
        death = next((b["author_death"] for b in books if b.get("author_death")), "—")
        slug = _slugify(author)
        lines.append(f"| [{author}]({slug}/author.md) | {birth} | {death} | {len(books)} |")
    lines.append("")

    content = "\n".join(lines)
    if not dry_run:
        AUTHORS_DIR.mkdir(parents=True, exist_ok=True)
        out_path.write_text(content, encoding="utf-8")
    return out_path