sampledoc 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sampledoc/__init__.py ADDED
@@ -0,0 +1,89 @@
1
+ """Small, localized, reproducible sample office documents.
2
+
3
+ >>> import sampledoc
4
+ >>> sampledoc.languages()
5
+ ('en', 'cs', 'de', ...)
6
+ >>> data = sampledoc.document("text", "de") # the German report, as .odt bytes
7
+ >>> sampledoc.generate("out/", languages=["en"]) # every sample, written to disk
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Callable, Iterable
13
+ from os import PathLike
14
+ from pathlib import Path
15
+ from typing import NamedTuple
16
+
17
+ from . import _language, odf, ooxml, pdf, plain
18
+ from ._language import languages, load
19
+ from .names import file_names, manifest, query
20
+
21
+ __all__ = [
22
+ "SAMPLES",
23
+ "Sample",
24
+ "document",
25
+ "file_name",
26
+ "file_names",
27
+ "generate",
28
+ "languages",
29
+ "manifest",
30
+ "query",
31
+ ]
32
+
33
+ __version__ = "0.1.0"
34
+
35
+
36
+ class Sample(NamedTuple):
37
+ extension: str
38
+ build: Callable[[dict], bytes]
39
+ description: str
40
+
41
+
42
+ # Every kind of sample, by the name it is written under. The first three are the
43
+ # documents the screenshots open; the rest fill the folder around them.
44
+ SAMPLES: dict[str, Sample] = {
45
+ "text": Sample("odt", odf.odt, "a quarterly report with a table of costs"),
46
+ "sheet": Sample("ods", odf.ods, "a budget over two sheets"),
47
+ "slides": Sample("odp", odf.odp, "a three slide project plan"),
48
+ "word": Sample("docx", ooxml.docx, "a service agreement with an appendix"),
49
+ "cells": Sample("xlsx", ooxml.xlsx, "a small table of sales figures"),
50
+ "deck": Sample("pptx", ooxml.pptx, "a single titled, bulleted slide"),
51
+ "paper": Sample("pdf", pdf.pdf, "an invoice over two pages"),
52
+ "rows": Sample("csv", plain.csv, "a contact list"),
53
+ "notes": Sample("txt", plain.txt, "the report as plain text notes"),
54
+ }
55
+
56
+
57
+ def file_name(sample: str, language: str) -> str:
58
+ """The name a sample is written under, e.g. ``sample-text-de.odt``."""
59
+ return f"sample-{sample}-{language}.{SAMPLES[sample].extension}"
60
+
61
+
62
+ def document(sample: str, language: str = "en") -> bytes:
63
+ """One sample, as the bytes of its file. The same arguments give the same bytes."""
64
+ if sample not in SAMPLES:
65
+ raise KeyError(f"no sample called {sample!r}; there are {', '.join(SAMPLES)}")
66
+
67
+ return SAMPLES[sample].build(load(language))
68
+
69
+
70
+ def generate(
71
+ directory: str | PathLike,
72
+ languages: Iterable[str] | None = None,
73
+ samples: Iterable[str] | None = None,
74
+ ) -> list[Path]:
75
+ """Write samples into `directory`, creating it if need be.
76
+
77
+ Every sample in every language unless narrowed down. Returns the paths written.
78
+ """
79
+ target = Path(directory)
80
+ target.mkdir(parents=True, exist_ok=True)
81
+
82
+ written = []
83
+ for language in languages or _language.languages():
84
+ for sample in samples or SAMPLES:
85
+ path = target / file_name(sample, language)
86
+ path.write_bytes(document(sample, language))
87
+ written.append(path)
88
+
89
+ return written
sampledoc/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
sampledoc/_language.py ADDED
@@ -0,0 +1,37 @@
1
+ """The wording of the samples, one JSON file per language under `languages/`.
2
+
3
+ Adding a language is adding a file: copy `en.json`, translate the strings, keep
4
+ the keys. Nothing else in the package lists the languages.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from functools import cache
11
+ from importlib import resources
12
+
13
+ FALLBACK = "en"
14
+
15
+
16
+ @cache
17
+ def languages() -> tuple[str, ...]:
18
+ """Every language there is a file for, English first and the rest by code."""
19
+ found = sorted(
20
+ entry.name.removesuffix(".json")
21
+ for entry in resources.files(__package__).joinpath("languages").iterdir()
22
+ if entry.name.endswith(".json")
23
+ )
24
+ found.remove(FALLBACK)
25
+
26
+ return (FALLBACK, *found)
27
+
28
+
29
+ @cache
30
+ def load(language: str) -> dict:
31
+ """The wording for one language, as written in its file."""
32
+ if language not in languages():
33
+ raise KeyError(f"no samples written in {language!r}; there are {', '.join(languages())}")
34
+
35
+ text = resources.files(__package__).joinpath("languages", f"{language}.json").read_text("utf-8")
36
+
37
+ return json.loads(text)
sampledoc/_table.py ADDED
@@ -0,0 +1,109 @@
1
+ """The figures every sample that has a table takes its numbers from."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import zipfile
7
+
8
+ # 1980-01-01, what zip stores when it is given nothing: a rerun with the same
9
+ # words has to produce the same bytes, or every run is a commit
10
+ EPOCH = (1980, 1, 1, 0, 0, 0)
11
+
12
+ # One accent, used for the report's headings and the slide titles, so the
13
+ # documents read as one set.
14
+ ACCENT = "#1c6fd6"
15
+ RULE = "#d4d9e0"
16
+
17
+ # What the sheets add up: forty rows over six periods, so there is enough of it
18
+ # to look like a spreadsheet. The first twenty keep the first four figures they
19
+ # had, because the invoice and the .xlsx take slices off the front.
20
+ FIGURES = [
21
+ [1200, 1450, 1310, 1600, 1380, 1520],
22
+ [480, 620, 510, 470, 690, 540],
23
+ [3600, 900, 1200, 750, 830, 1150],
24
+ [820, 760, 930, 1010, 870, 940],
25
+ [540, 560, 580, 600, 610, 630],
26
+ [300, 450, 380, 520, 410, 470],
27
+ [1100, 1150, 1180, 1240, 1260, 1290],
28
+ [640, 640, 660, 680, 700, 700],
29
+ [420, 980, 350, 610, 1240, 380],
30
+ [260, 280, 270, 300, 290, 310],
31
+ [890, 910, 940, 980, 1000, 1030],
32
+ [1500, 400, 620, 350, 480, 390],
33
+ [340, 360, 350, 370, 380, 390],
34
+ [220, 220, 230, 230, 240, 240],
35
+ [180, 620, 210, 240, 190, 660],
36
+ [410, 430, 400, 450, 440, 460],
37
+ [760, 820, 690, 900, 850, 780],
38
+ [950, 480, 1100, 520, 560, 1180],
39
+ [280, 290, 300, 310, 320, 330],
40
+ [520, 540, 530, 560, 570, 590],
41
+ [1340, 1290, 1410, 1360, 1440, 1480],
42
+ [710, 730, 720, 750, 760, 780],
43
+ [190, 200, 190, 210, 200, 220],
44
+ [330, 330, 340, 340, 350, 350],
45
+ [860, 890, 1240, 910, 930, 960],
46
+ [240, 250, 260, 260, 270, 280],
47
+ [120, 130, 120, 140, 130, 150],
48
+ [460, 170, 180, 490, 180, 190],
49
+ [580, 610, 550, 640, 600, 670],
50
+ [150, 160, 170, 160, 180, 170],
51
+ [210, 230, 220, 250, 240, 260],
52
+ [390, 390, 400, 410, 410, 420],
53
+ [270, 1080, 310, 340, 290, 360],
54
+ [1620, 350, 380, 360, 400, 370],
55
+ [620, 650, 630, 680, 660, 700],
56
+ [110, 120, 110, 130, 120, 140],
57
+ [440, 450, 460, 470, 480, 490],
58
+ [980, 1020, 640, 1060, 1090, 720],
59
+ [560, 500, 590, 530, 610, 570],
60
+ [740, 770, 800, 790, 830, 860],
61
+ ]
62
+
63
+
64
+ def table(words: dict, columns: int = 4, rows: int = 0, scale: int = 1) -> tuple:
65
+ """The figures as rows of cells: a header, the items across as many periods
66
+ as are asked for with their totals, and a totals row under them.
67
+
68
+ Narrowed and shortened for the files that are not the budget, so the .xlsx
69
+ and the invoice hold their own figures rather than the .ods twice.
70
+
71
+ As wide as the header a language has words for, so one translated ahead of
72
+ the others comes out short rather than out of step.
73
+ """
74
+ periods = words["periods"][:columns]
75
+ columns = len(periods)
76
+ taken = FIGURES[:rows] if rows else FIGURES
77
+ names = words["rows"][: len(taken)]
78
+
79
+ head = [words["item"]] + periods + [words["total"]]
80
+ body = [
81
+ [name] + [value * scale for value in figures[:columns]] + [sum(figures[:columns]) * scale]
82
+ for name, figures in zip(names, taken, strict=False)
83
+ ]
84
+ foot = (
85
+ [words["total"]]
86
+ + [sum(line[column + 1] for line in body) for column in range(len(periods))]
87
+ + [sum(line[-1] for line in body)]
88
+ )
89
+
90
+ return head, body, foot
91
+
92
+
93
+ def zipped(parts: list[tuple[str, str, bool]]) -> bytes:
94
+ """A zip of (name, text, stored) parts in the order given, reproducibly.
95
+
96
+ Every entry carries the same date, permissions and creating system, so the
97
+ archive depends on nothing but its contents - not the clock, not the platform.
98
+ """
99
+ out = io.BytesIO()
100
+ with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as archive:
101
+ for name, text, stored in parts:
102
+ info = zipfile.ZipInfo(name, date_time=EPOCH)
103
+ info.external_attr = 0o644 << 16
104
+ # ZipInfo records the OS it was made on (0 on Windows, 3 elsewhere),
105
+ # and that byte would make the same samples differ by platform
106
+ info.create_system = 3
107
+ archive.writestr(info, text, compress_type=zipfile.ZIP_STORED if stored else zipfile.ZIP_DEFLATED)
108
+
109
+ return out.getvalue()
sampledoc/cli.py ADDED
@@ -0,0 +1,90 @@
1
+ """sampledoc [-o DIR] [-l LANG]... [-s SAMPLE]... [--locales FILE] [--no-names]"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+
9
+ from . import SAMPLES, __version__, file_name, generate, languages, manifest
10
+
11
+ NAMES_FILE = "screenshot-names.json"
12
+
13
+
14
+ def main(argv: list[str] | None = None) -> int:
15
+ parser = argparse.ArgumentParser(
16
+ prog="sampledoc",
17
+ description="Write small, localized, reproducible sample office documents.",
18
+ )
19
+ parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
20
+ parser.add_argument(
21
+ "-o",
22
+ "--output",
23
+ type=Path,
24
+ default=Path("."),
25
+ metavar="DIR",
26
+ help="where to write the samples (default: the current directory)",
27
+ )
28
+ parser.add_argument(
29
+ "-l",
30
+ "--language",
31
+ action="append",
32
+ choices=languages(),
33
+ metavar="LANG",
34
+ help="only this language; repeatable (default: all of them)",
35
+ )
36
+ parser.add_argument(
37
+ "-s",
38
+ "--sample",
39
+ action="append",
40
+ choices=list(SAMPLES),
41
+ metavar="SAMPLE",
42
+ help="only this kind of sample; repeatable (default: all of them)",
43
+ )
44
+ parser.add_argument(
45
+ "--locales",
46
+ type=Path,
47
+ metavar="FILE",
48
+ help='a JSON object mapping device locales to languages, e.g. {"de-DE": "de"}, '
49
+ f'copied into {NAMES_FILE} under "locales"',
50
+ )
51
+ parser.add_argument(
52
+ "--no-names",
53
+ action="store_true",
54
+ help=f"do not write {NAMES_FILE}",
55
+ )
56
+ parser.add_argument(
57
+ "--list",
58
+ action="store_true",
59
+ help="list the languages and samples there are, and exit",
60
+ )
61
+ args = parser.parse_args(argv)
62
+
63
+ if args.list:
64
+ print("languages:", " ".join(languages()))
65
+ print("samples:")
66
+ for name, sample in SAMPLES.items():
67
+ print(f" {name:<7} {file_name(name, 'LANG'):<22} {sample.description}")
68
+ return 0
69
+
70
+ locales = None
71
+ if args.locales:
72
+ locales = json.loads(args.locales.read_text(encoding="utf-8"))
73
+ if not isinstance(locales, dict):
74
+ parser.error(f"{args.locales} does not hold a JSON object")
75
+
76
+ written = generate(args.output, languages=args.language, samples=args.sample)
77
+
78
+ if not args.no_names:
79
+ try:
80
+ details = manifest(locales)
81
+ except ValueError as error:
82
+ parser.error(str(error))
83
+ (args.output / NAMES_FILE).write_text(
84
+ json.dumps(details, ensure_ascii=False, indent=1, sort_keys=True) + "\n", encoding="utf-8"
85
+ )
86
+
87
+ count = len(args.language or languages())
88
+ print(f"wrote {len(written)} documents in {count} languages to {args.output}")
89
+
90
+ return 0
@@ -0,0 +1,209 @@
1
+ {
2
+ "language": "cs",
3
+ "report": {
4
+ "title": "Čtvrtletní zpráva",
5
+ "lead": "Tým splnil všechny cíle druhého čtvrtletí a nová verze vyšla včas.",
6
+ "sections": [
7
+ [
8
+ "Nejdůležitější",
9
+ [
10
+ "Náklady zůstaly pod rozpočtem a k projektu se připojili dva noví partneři.",
11
+ "Nová verze oslovila za první týden více lidí než ta předchozí za měsíc.",
12
+ "Podpora odpověděla na devět z deseti dotazů týž den."
13
+ ]
14
+ ],
15
+ [
16
+ "Náklady a rozpočet",
17
+ [
18
+ "Výdaje za software s novými licencemi vzrostly, cestovné opět kleslo.",
19
+ "Hardware byl jednou vyměněn a podpora zůstala po celé čtvrtletí stabilní.",
20
+ "Dva servery přešly k novému poskytovateli bez jediného dne výpadku."
21
+ ]
22
+ ],
23
+ [
24
+ "Příští čtvrtletí",
25
+ [
26
+ "Zářijová verze je poslední plánovaná letos.",
27
+ "V podpoře jsou volná dvě místa, v designu jedno.",
28
+ "Stěhování kanceláře je v listopadu a rozpočet na ně je schválený."
29
+ ]
30
+ ],
31
+ [
32
+ "Lidé",
33
+ [
34
+ "Na verzi pracovalo šest lidí, dva z nich jsou tu první rok.",
35
+ "Zástupy na dobu dovolených se domluvily v dubnu a vydržely celé léto.",
36
+ "Všichni absolvovali školení, které nová licence vyžaduje."
37
+ ]
38
+ ],
39
+ [
40
+ "Rizika",
41
+ [
42
+ "Listopadové stěhování je jediný termín, který nelze posunout.",
43
+ "Jeden dodavatel dosud nepodepsal nové podmínky a urgujeme ho.",
44
+ "Náklady na hosting v lednu vzrostou, pokud se smlouva neobnoví dříve."
45
+ ]
46
+ ]
47
+ ],
48
+ "closing": "Další schůzka je na konci července.",
49
+ "sheets": [
50
+ "Přehled",
51
+ "Náklady"
52
+ ],
53
+ "item": "Položka",
54
+ "total": "Celkem",
55
+ "periods": [
56
+ "Led",
57
+ "Úno",
58
+ "Bře",
59
+ "Dub",
60
+ "Kvě",
61
+ "Čvn"
62
+ ],
63
+ "rows": [
64
+ "Software",
65
+ "Cestovné",
66
+ "Hardware",
67
+ "Marketing",
68
+ "Podpora",
69
+ "Školení",
70
+ "Licence",
71
+ "Hosting",
72
+ "Akce",
73
+ "Kancelář",
74
+ "Cloud",
75
+ "Nábor",
76
+ "Právní služby",
77
+ "Pojištění",
78
+ "Občerstvení",
79
+ "Doprava",
80
+ "Reklama",
81
+ "Poradenství",
82
+ "Údržba",
83
+ "Energie",
84
+ "Vybavení",
85
+ "Předplatné",
86
+ "Telefonie",
87
+ "Internet",
88
+ "Bezpečnost",
89
+ "Zálohování",
90
+ "Domény",
91
+ "Certifikáty",
92
+ "Tisk",
93
+ "Papírnictví",
94
+ "Poštovné",
95
+ "Úklid",
96
+ "Opravy",
97
+ "Nábytek",
98
+ "Sklad",
99
+ "Bankovní poplatky",
100
+ "Členské příspěvky",
101
+ "Konference",
102
+ "Překlady",
103
+ "Design"
104
+ ],
105
+ "slides": [
106
+ [
107
+ "Plán projektu",
108
+ [
109
+ "Cíle čtvrtletí",
110
+ "Rozpočet a náklady",
111
+ "Další kroky"
112
+ ]
113
+ ],
114
+ [
115
+ "Harmonogram",
116
+ [
117
+ "Verze v červnu",
118
+ "Hodnocení v červenci",
119
+ "Plánování v srpnu"
120
+ ]
121
+ ],
122
+ [
123
+ "Tým",
124
+ [
125
+ "Dva noví partneři",
126
+ "Podpora ve třech jazycích",
127
+ "Školení na podzim"
128
+ ]
129
+ ]
130
+ ]
131
+ },
132
+ "contract": {
133
+ "title": "Smlouva o poskytování služeb",
134
+ "lead": "Tato smlouva se uzavírá mezi oběma níže uvedenými stranami.",
135
+ "clauses": [
136
+ "Dodavatel poskytuje software popsaný v příloze po dobu jednoho roku.",
137
+ "Platba je splatná do třiceti dnů od vystavení každé faktury.",
138
+ "Kterákoli strana může smlouvu ukončit s tříměsíční výpovědní lhůtou.",
139
+ "Změny této smlouvy jsou platné pouze písemně.",
140
+ "Dodavatel udržuje službu dostupnou v pracovní dny.",
141
+ "Obě strany zachovávají mlčenlivost o tom, co se o sobě dozvědí.",
142
+ "Řídí se rakouským právem; místně příslušný je soud ve Vídni.",
143
+ "Dodavatel uchovává zálohu dat zákazníka po dobu třiceti dnů.",
144
+ "Požadavky na podporu jsou zodpovězeny do jednoho pracovního dne.",
145
+ "Zákazník určí jednu osobu, která smí schvalovat změny.",
146
+ "Ceny platí první rok a každý podzim se přehodnocují.",
147
+ "Žádná strana nesmí smlouvu bez souhlasu postoupit třetí straně.",
148
+ "Příloha uvádí zahrnutý software a verzi, od které platí.",
149
+ "Tato smlouva nahrazuje všechna dřívější ujednání mezi stranami."
150
+ ]
151
+ },
152
+ "invoice": {
153
+ "number": "Faktura 2026-014",
154
+ "issued": "Vystaveno 12. června 2026",
155
+ "due": "Splatnost do 30 dnů",
156
+ "billed_to": "Odběratel",
157
+ "subtotal": "Mezisoučet",
158
+ "vat": "DPH 20 %",
159
+ "amount_due": "K úhradě",
160
+ "thanks": "Děkujeme za spolupráci.",
161
+ "quantity": "Množ.",
162
+ "unit_price": "Cena za ks"
163
+ },
164
+ "contacts": {
165
+ "headers": [
166
+ "Jméno a příjmení",
167
+ "Tým",
168
+ "E-mail",
169
+ "Telefon"
170
+ ],
171
+ "roles": [
172
+ "Design",
173
+ "Podpora",
174
+ "Prodej",
175
+ "Vývoj"
176
+ ]
177
+ },
178
+ "files": {
179
+ "text": "Čtvrtletní zpráva",
180
+ "sheet": "Rozpočet",
181
+ "slides": "Plán projektu",
182
+ "word": "Smlouva",
183
+ "cells": "Čísla prodejů",
184
+ "deck": "Setkání týmu",
185
+ "paper": "Faktura",
186
+ "rows": "Kontakty",
187
+ "notes": "Poznámky"
188
+ },
189
+ "fillers": {
190
+ "meeting": "Zápis z porady",
191
+ "letter": "Dopis pronajímateli",
192
+ "travel": "Plán cesty",
193
+ "reading": "Seznam ke čtení",
194
+ "household": "Rodinný rozpočet",
195
+ "hours": "Odpracované hodiny",
196
+ "stocktake": "Inventura",
197
+ "kickoff": "Zahájení projektu",
198
+ "course": "Slidy ke kurzu",
199
+ "lease": "Nájemní smlouva",
200
+ "resume": "Životopis",
201
+ "application": "Přihláška",
202
+ "expenses": "Výdaje",
203
+ "inventory": "Zásoby",
204
+ "review": "Čtvrtletní hodnocení",
205
+ "ticket": "Jízdenka",
206
+ "warranty": "Záruka",
207
+ "manual": "Návod"
208
+ }
209
+ }