zotkit 0.5.2__tar.gz → 0.7.0__tar.gz
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.
- {zotkit-0.5.2/zotkit.egg-info → zotkit-0.7.0}/PKG-INFO +64 -3
- {zotkit-0.5.2 → zotkit-0.7.0}/README.md +61 -2
- {zotkit-0.5.2 → zotkit-0.7.0}/pyproject.toml +4 -1
- zotkit-0.7.0/tests/test_audit.py +51 -0
- zotkit-0.7.0/tests/test_dedup.py +154 -0
- zotkit-0.7.0/tests/test_enrich.py +167 -0
- zotkit-0.7.0/tests/test_export.py +132 -0
- zotkit-0.7.0/tests/test_fetch_warning.py +84 -0
- zotkit-0.7.0/tests/test_metadata.py +276 -0
- zotkit-0.7.0/tests/test_show.py +81 -0
- {zotkit-0.5.2 → zotkit-0.7.0}/zotkit/__init__.py +1 -1
- zotkit-0.7.0/zotkit/audit.py +53 -0
- {zotkit-0.5.2 → zotkit-0.7.0}/zotkit/cli.py +240 -11
- {zotkit-0.5.2 → zotkit-0.7.0}/zotkit/core.py +37 -6
- {zotkit-0.5.2 → zotkit-0.7.0}/zotkit/enrich.py +51 -0
- zotkit-0.7.0/zotkit/export.py +73 -0
- {zotkit-0.5.2 → zotkit-0.7.0}/zotkit/metadata.py +24 -13
- {zotkit-0.5.2 → zotkit-0.7.0/zotkit.egg-info}/PKG-INFO +64 -3
- {zotkit-0.5.2 → zotkit-0.7.0}/zotkit.egg-info/SOURCES.txt +9 -0
- {zotkit-0.5.2 → zotkit-0.7.0}/zotkit.egg-info/requires.txt +3 -0
- {zotkit-0.5.2 → zotkit-0.7.0}/LICENSE +0 -0
- {zotkit-0.5.2 → zotkit-0.7.0}/setup.cfg +0 -0
- {zotkit-0.5.2 → zotkit-0.7.0}/zotkit.egg-info/dependency_links.txt +0 -0
- {zotkit-0.5.2 → zotkit-0.7.0}/zotkit.egg-info/entry_points.txt +0 -0
- {zotkit-0.5.2 → zotkit-0.7.0}/zotkit.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: zotkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Headless Zotero library management: Web API CRUD plus direct WebDAV attachment upload/download — no desktop app required
|
|
5
5
|
Author: Shawn
|
|
6
6
|
License-Expression: MIT
|
|
@@ -16,6 +16,8 @@ Description-Content-Type: text/markdown
|
|
|
16
16
|
License-File: LICENSE
|
|
17
17
|
Requires-Dist: pyzotero
|
|
18
18
|
Requires-Dist: httpx
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest; extra == "dev"
|
|
19
21
|
Dynamic: license-file
|
|
20
22
|
|
|
21
23
|
# zotkit
|
|
@@ -37,7 +39,7 @@ server**, zotkit can **upload and download the files themselves** by speaking
|
|
|
37
39
|
Zotero's WebDAV storage format directly — a capability the Web API itself does not
|
|
38
40
|
provide. The format is documented in [docs/webdav-format.md](docs/webdav-format.md).
|
|
39
41
|
|
|
40
|
-
Built for servers, scripts, and **LLM agents**:
|
|
42
|
+
Built for servers, scripts, and **LLM agents**: bulk writes are dry-run by default,
|
|
41
43
|
batched, and version-checked, and you can define a tag taxonomy that is *enforced in
|
|
42
44
|
code* so an agent (or a tired human) can't pollute your library with inconsistent tags.
|
|
43
45
|
|
|
@@ -101,6 +103,8 @@ Optionally, copy [`conventions.example.toml`](conventions.example.toml) to
|
|
|
101
103
|
```bash
|
|
102
104
|
zotkit find --title "boson sampling" # search by title/tag/collection
|
|
103
105
|
zotkit find --tag status:to-read
|
|
106
|
+
zotkit show AB12CD34 EF56GH78 # one line per key (read-only);
|
|
107
|
+
# --json for the full item data
|
|
104
108
|
|
|
105
109
|
zotkit create --arxiv 2401.12345 # fetch arXiv metadata, dry-run preview
|
|
106
110
|
zotkit create --arxiv 2401.12345 --apply --tags field:ai # create + download & attach the PDF
|
|
@@ -109,17 +113,26 @@ zotkit create --arxiv 2401.12345 1706.03762 math/0211159 --apply # batch: one
|
|
|
109
113
|
zotkit create --doi 10.1038/nature14539 # fetch CrossRef metadata by DOI (no PDF —
|
|
110
114
|
# usually paywalled; attach manually after)
|
|
111
115
|
|
|
112
|
-
zotkit create --file papers.json # batch from JSON: dry-run preview
|
|
116
|
+
zotkit create --file papers.json # batch from JSON: dry-run preview —
|
|
117
|
+
# flags items already in the library
|
|
113
118
|
zotkit create --file papers.json --apply # create (dedups by DOI/title)
|
|
114
119
|
zotkit attach --from papers.created.json --all # upload the PDFs (WebDAV or Zotero
|
|
115
120
|
# Storage — auto-detected from .env)
|
|
116
121
|
|
|
117
122
|
zotkit enrich --key AB12CD34 EF56GH78 # fill missing fields on existing items
|
|
118
123
|
# in place (dry-run; --apply to write)
|
|
124
|
+
zotkit enrich --missing abstract --apply # …or batch: every item missing an
|
|
125
|
+
# abstract (also --missing doi, --all)
|
|
126
|
+
zotkit audit # read-only health report: what's missing
|
|
127
|
+
# where, stamp hygiene (--json for agents)
|
|
128
|
+
zotkit abstract --key AB12CD34 --source cnki --file abs.txt # paste an abstract
|
|
129
|
+
# with provenance (owner's tool)
|
|
119
130
|
|
|
120
131
|
zotkit attach --key AB12CD34 --pdf paper.pdf # single attach
|
|
121
132
|
zotkit fetch --key AB12CD34 --out downloads # download attachments (same auto-detection)
|
|
122
133
|
|
|
134
|
+
zotkit export --key AB12CD34 EF56GH78 -o refs.bib # BibTeX; cite key = item key
|
|
135
|
+
|
|
123
136
|
zotkit tag AB12CD34 topic:qaoa prio:high # validated against conventions.toml
|
|
124
137
|
zotkit status AB12CD34 read # replaces the status: tag
|
|
125
138
|
zotkit move AB12CD34 "Algorithms" # or "Parent :: Child"; --add keeps old home
|
|
@@ -141,6 +154,14 @@ daemon-free CLI — no translation-server). CrossRef requests identify themselve
|
|
|
141
154
|
to the polite pool with a contact address; that should be reachable, so if you
|
|
142
155
|
distribute a tool built on zotkit, set your own via `ZOTKIT_MAILTO`.
|
|
143
156
|
|
|
157
|
+
**Duplicates**: `create` checks every candidate against the library by exact DOI and
|
|
158
|
+
by normalized title, and skips the ones already there — that has always been the
|
|
159
|
+
`--apply` behavior, and the dry run now runs the same check up front, printing
|
|
160
|
+
`!! already in library as <KEY> — --apply will skip it (use --no-dedup to force)`
|
|
161
|
+
so you see the collision before writing anything. The apply path names the existing
|
|
162
|
+
item's key in its skip messages too. `--no-dedup` turns the check off and creates
|
|
163
|
+
the item regardless.
|
|
164
|
+
|
|
144
165
|
**Version of record**: when arXiv reports a *journal* DOI (the paper was formally
|
|
145
166
|
published), `--arxiv` builds the journal record from CrossRef instead of a
|
|
146
167
|
`preprint`: proper item
|
|
@@ -159,6 +180,29 @@ gates `enrich --rebuild-record`. Items whose abstract zotkit
|
|
|
159
180
|
wrote also carry an `abstract-source: arxiv|crossref` line in Extra, naming where
|
|
160
181
|
it actually came from.
|
|
161
182
|
|
|
183
|
+
### Looking items up: `show` and `export`
|
|
184
|
+
|
|
185
|
+
`zotkit show K [K …]` is the read-only lookup by item key: one line per key —
|
|
186
|
+
`KEY · itemType · FirstAuthor Year · Title · DOI-or-arXiv-id` — or `--json` for the
|
|
187
|
+
full item data. An unknown key is reported on stderr and the exit code is 1, while
|
|
188
|
+
the remaining keys still print. This deliberately supersedes requests for a
|
|
189
|
+
`find --key` flag and a `verify --manifest` subcommand: verifying a manifest is a
|
|
190
|
+
loop over `show` on the project side, and manifest-file parsing is too
|
|
191
|
+
project-specific to belong in zotkit.
|
|
192
|
+
|
|
193
|
+
`zotkit export --key K [K …]` emits BibTeX for those items, to stdout or to `-o
|
|
194
|
+
refs.bib`; keys are also accepted on stdin, one per line, so it composes with
|
|
195
|
+
whatever produced them. Every entry's cite key is **always** rewritten to the
|
|
196
|
+
Zotero item key — that is the point of the command: item keys are the identity in
|
|
197
|
+
the manifest model, and author-year cite keys don't join against them. An unknown
|
|
198
|
+
key errors on stderr (exit 1) without stopping the other entries from exporting.
|
|
199
|
+
|
|
200
|
+
`zotkit fetch` still defaults to `./downloads` and prints the absolute path of every
|
|
201
|
+
file it saves. If you omit `--out` while the current directory sits inside a git
|
|
202
|
+
repository, it warns on stderr and suggests an explicit destination: loose PDF
|
|
203
|
+
copies committed into a repo break a library-of-record setup, where the library is
|
|
204
|
+
the one place a file lives.
|
|
205
|
+
|
|
162
206
|
### Enriching existing items
|
|
163
207
|
|
|
164
208
|
`zotkit enrich --key K [K …]` completes incomplete items — missing abstracts,
|
|
@@ -181,6 +225,23 @@ published (journal DOI present) to the journal record **in the same item**:
|
|
|
181
225
|
itemType, published title/date/venue/volume/pages — with `arXiv: <id>` appended
|
|
182
226
|
to Extra and attachments untouched.
|
|
183
227
|
|
|
228
|
+
Instead of `--key`, `--all` runs over every top-level scholarly item and
|
|
229
|
+
`--missing abstract` / `--missing doi` (repeatable) over just the gaps —
|
|
230
|
+
batching and arXiv/CrossRef rate limiting stay internal, so no pacing or
|
|
231
|
+
batch-splitting on the caller's side. **`zotkit audit`** is the read-only
|
|
232
|
+
companion: counts and key lists for missing abstracts, missing DOI/arXiv id,
|
|
233
|
+
missing PDF attachments, and `abstract-source` stamp hygiene (`--json` for
|
|
234
|
+
agents).
|
|
235
|
+
|
|
236
|
+
For text enrich *can't* fetch — CNKI, publisher pages, your own summary —
|
|
237
|
+
**`zotkit abstract --key K --source <slug>`** stores a pasted abstract
|
|
238
|
+
(stdin or `--file`) with provenance: the text is cleaned (hidden tags,
|
|
239
|
+
` `, soft hyphens, hard-wrapped lines) and an `abstract-source: <slug>`
|
|
240
|
+
line records where it came from (`cnki`, `ssrn`, `publisher`, `manual` = your
|
|
241
|
+
own words — any lowercase slug). It is the owner's tool, so unlike enrich it
|
|
242
|
+
may *replace* an existing abstract — but only with `--force`, which also
|
|
243
|
+
rewrites the stamp line in place; all other Extra lines stay untouched.
|
|
244
|
+
|
|
184
245
|
Item JSON for `zotkit create --file` (a list, one object per reference):
|
|
185
246
|
|
|
186
247
|
```json
|
|
@@ -17,7 +17,7 @@ server**, zotkit can **upload and download the files themselves** by speaking
|
|
|
17
17
|
Zotero's WebDAV storage format directly — a capability the Web API itself does not
|
|
18
18
|
provide. The format is documented in [docs/webdav-format.md](docs/webdav-format.md).
|
|
19
19
|
|
|
20
|
-
Built for servers, scripts, and **LLM agents**:
|
|
20
|
+
Built for servers, scripts, and **LLM agents**: bulk writes are dry-run by default,
|
|
21
21
|
batched, and version-checked, and you can define a tag taxonomy that is *enforced in
|
|
22
22
|
code* so an agent (or a tired human) can't pollute your library with inconsistent tags.
|
|
23
23
|
|
|
@@ -81,6 +81,8 @@ Optionally, copy [`conventions.example.toml`](conventions.example.toml) to
|
|
|
81
81
|
```bash
|
|
82
82
|
zotkit find --title "boson sampling" # search by title/tag/collection
|
|
83
83
|
zotkit find --tag status:to-read
|
|
84
|
+
zotkit show AB12CD34 EF56GH78 # one line per key (read-only);
|
|
85
|
+
# --json for the full item data
|
|
84
86
|
|
|
85
87
|
zotkit create --arxiv 2401.12345 # fetch arXiv metadata, dry-run preview
|
|
86
88
|
zotkit create --arxiv 2401.12345 --apply --tags field:ai # create + download & attach the PDF
|
|
@@ -89,17 +91,26 @@ zotkit create --arxiv 2401.12345 1706.03762 math/0211159 --apply # batch: one
|
|
|
89
91
|
zotkit create --doi 10.1038/nature14539 # fetch CrossRef metadata by DOI (no PDF —
|
|
90
92
|
# usually paywalled; attach manually after)
|
|
91
93
|
|
|
92
|
-
zotkit create --file papers.json # batch from JSON: dry-run preview
|
|
94
|
+
zotkit create --file papers.json # batch from JSON: dry-run preview —
|
|
95
|
+
# flags items already in the library
|
|
93
96
|
zotkit create --file papers.json --apply # create (dedups by DOI/title)
|
|
94
97
|
zotkit attach --from papers.created.json --all # upload the PDFs (WebDAV or Zotero
|
|
95
98
|
# Storage — auto-detected from .env)
|
|
96
99
|
|
|
97
100
|
zotkit enrich --key AB12CD34 EF56GH78 # fill missing fields on existing items
|
|
98
101
|
# in place (dry-run; --apply to write)
|
|
102
|
+
zotkit enrich --missing abstract --apply # …or batch: every item missing an
|
|
103
|
+
# abstract (also --missing doi, --all)
|
|
104
|
+
zotkit audit # read-only health report: what's missing
|
|
105
|
+
# where, stamp hygiene (--json for agents)
|
|
106
|
+
zotkit abstract --key AB12CD34 --source cnki --file abs.txt # paste an abstract
|
|
107
|
+
# with provenance (owner's tool)
|
|
99
108
|
|
|
100
109
|
zotkit attach --key AB12CD34 --pdf paper.pdf # single attach
|
|
101
110
|
zotkit fetch --key AB12CD34 --out downloads # download attachments (same auto-detection)
|
|
102
111
|
|
|
112
|
+
zotkit export --key AB12CD34 EF56GH78 -o refs.bib # BibTeX; cite key = item key
|
|
113
|
+
|
|
103
114
|
zotkit tag AB12CD34 topic:qaoa prio:high # validated against conventions.toml
|
|
104
115
|
zotkit status AB12CD34 read # replaces the status: tag
|
|
105
116
|
zotkit move AB12CD34 "Algorithms" # or "Parent :: Child"; --add keeps old home
|
|
@@ -121,6 +132,14 @@ daemon-free CLI — no translation-server). CrossRef requests identify themselve
|
|
|
121
132
|
to the polite pool with a contact address; that should be reachable, so if you
|
|
122
133
|
distribute a tool built on zotkit, set your own via `ZOTKIT_MAILTO`.
|
|
123
134
|
|
|
135
|
+
**Duplicates**: `create` checks every candidate against the library by exact DOI and
|
|
136
|
+
by normalized title, and skips the ones already there — that has always been the
|
|
137
|
+
`--apply` behavior, and the dry run now runs the same check up front, printing
|
|
138
|
+
`!! already in library as <KEY> — --apply will skip it (use --no-dedup to force)`
|
|
139
|
+
so you see the collision before writing anything. The apply path names the existing
|
|
140
|
+
item's key in its skip messages too. `--no-dedup` turns the check off and creates
|
|
141
|
+
the item regardless.
|
|
142
|
+
|
|
124
143
|
**Version of record**: when arXiv reports a *journal* DOI (the paper was formally
|
|
125
144
|
published), `--arxiv` builds the journal record from CrossRef instead of a
|
|
126
145
|
`preprint`: proper item
|
|
@@ -139,6 +158,29 @@ gates `enrich --rebuild-record`. Items whose abstract zotkit
|
|
|
139
158
|
wrote also carry an `abstract-source: arxiv|crossref` line in Extra, naming where
|
|
140
159
|
it actually came from.
|
|
141
160
|
|
|
161
|
+
### Looking items up: `show` and `export`
|
|
162
|
+
|
|
163
|
+
`zotkit show K [K …]` is the read-only lookup by item key: one line per key —
|
|
164
|
+
`KEY · itemType · FirstAuthor Year · Title · DOI-or-arXiv-id` — or `--json` for the
|
|
165
|
+
full item data. An unknown key is reported on stderr and the exit code is 1, while
|
|
166
|
+
the remaining keys still print. This deliberately supersedes requests for a
|
|
167
|
+
`find --key` flag and a `verify --manifest` subcommand: verifying a manifest is a
|
|
168
|
+
loop over `show` on the project side, and manifest-file parsing is too
|
|
169
|
+
project-specific to belong in zotkit.
|
|
170
|
+
|
|
171
|
+
`zotkit export --key K [K …]` emits BibTeX for those items, to stdout or to `-o
|
|
172
|
+
refs.bib`; keys are also accepted on stdin, one per line, so it composes with
|
|
173
|
+
whatever produced them. Every entry's cite key is **always** rewritten to the
|
|
174
|
+
Zotero item key — that is the point of the command: item keys are the identity in
|
|
175
|
+
the manifest model, and author-year cite keys don't join against them. An unknown
|
|
176
|
+
key errors on stderr (exit 1) without stopping the other entries from exporting.
|
|
177
|
+
|
|
178
|
+
`zotkit fetch` still defaults to `./downloads` and prints the absolute path of every
|
|
179
|
+
file it saves. If you omit `--out` while the current directory sits inside a git
|
|
180
|
+
repository, it warns on stderr and suggests an explicit destination: loose PDF
|
|
181
|
+
copies committed into a repo break a library-of-record setup, where the library is
|
|
182
|
+
the one place a file lives.
|
|
183
|
+
|
|
142
184
|
### Enriching existing items
|
|
143
185
|
|
|
144
186
|
`zotkit enrich --key K [K …]` completes incomplete items — missing abstracts,
|
|
@@ -161,6 +203,23 @@ published (journal DOI present) to the journal record **in the same item**:
|
|
|
161
203
|
itemType, published title/date/venue/volume/pages — with `arXiv: <id>` appended
|
|
162
204
|
to Extra and attachments untouched.
|
|
163
205
|
|
|
206
|
+
Instead of `--key`, `--all` runs over every top-level scholarly item and
|
|
207
|
+
`--missing abstract` / `--missing doi` (repeatable) over just the gaps —
|
|
208
|
+
batching and arXiv/CrossRef rate limiting stay internal, so no pacing or
|
|
209
|
+
batch-splitting on the caller's side. **`zotkit audit`** is the read-only
|
|
210
|
+
companion: counts and key lists for missing abstracts, missing DOI/arXiv id,
|
|
211
|
+
missing PDF attachments, and `abstract-source` stamp hygiene (`--json` for
|
|
212
|
+
agents).
|
|
213
|
+
|
|
214
|
+
For text enrich *can't* fetch — CNKI, publisher pages, your own summary —
|
|
215
|
+
**`zotkit abstract --key K --source <slug>`** stores a pasted abstract
|
|
216
|
+
(stdin or `--file`) with provenance: the text is cleaned (hidden tags,
|
|
217
|
+
` `, soft hyphens, hard-wrapped lines) and an `abstract-source: <slug>`
|
|
218
|
+
line records where it came from (`cnki`, `ssrn`, `publisher`, `manual` = your
|
|
219
|
+
own words — any lowercase slug). It is the owner's tool, so unlike enrich it
|
|
220
|
+
may *replace* an existing abstract — but only with `--force`, which also
|
|
221
|
+
rewrites the stamp line in place; all other Extra lines stay untouched.
|
|
222
|
+
|
|
164
223
|
Item JSON for `zotkit create --file` (a list, one object per reference):
|
|
165
224
|
|
|
166
225
|
```json
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "zotkit"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.0"
|
|
8
8
|
description = "Headless Zotero library management: Web API CRUD plus direct WebDAV attachment upload/download — no desktop app required"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -20,6 +20,9 @@ classifiers = [
|
|
|
20
20
|
]
|
|
21
21
|
dependencies = ["pyzotero", "httpx"]
|
|
22
22
|
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = ["pytest"]
|
|
25
|
+
|
|
23
26
|
[project.urls]
|
|
24
27
|
Repository = "https://github.com/oldantique/zotkit"
|
|
25
28
|
Issues = "https://github.com/oldantique/zotkit/issues"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Offline tests for zotkit.audit bucketing, on a fixture library."""
|
|
2
|
+
import types
|
|
3
|
+
|
|
4
|
+
from zotkit.audit import audit
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _top(key, itype="journalArticle", ab="", extra="", doi="", url=""):
|
|
8
|
+
return {"key": key, "data": {"itemType": itype, "abstractNote": ab,
|
|
9
|
+
"extra": extra, "DOI": doi, "url": url}}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
TOPS = [
|
|
13
|
+
_top("OK111111", ab="a", extra="abstract-source: arxiv", doi="10.1/x"), # healthy
|
|
14
|
+
_top("NOABS111", doi="10.1/y"), # missing abstract
|
|
15
|
+
_top("NOID1111", ab="a"), # unstamped + missing id
|
|
16
|
+
_top("ARXID111", extra="arXiv: 2401.00001"), # id via Extra, missing abstract
|
|
17
|
+
_top("STALE111", extra="abstract-source: cnki", doi="10.1/z"), # stale stamp
|
|
18
|
+
_top("NOTE1111", itype="note"), # excluded
|
|
19
|
+
_top("ATT11111", itype="attachment"), # excluded
|
|
20
|
+
]
|
|
21
|
+
ATTS = [
|
|
22
|
+
{"key": "A1", "data": {"itemType": "attachment", "parentItem": "OK111111",
|
|
23
|
+
"contentType": "application/pdf"}},
|
|
24
|
+
{"key": "A2", "data": {"itemType": "attachment", "parentItem": "NOABS111",
|
|
25
|
+
"contentType": "text/html"}},
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _fixture_zot():
|
|
30
|
+
return types.SimpleNamespace(z=types.SimpleNamespace(
|
|
31
|
+
everything=lambda x: x,
|
|
32
|
+
top=lambda: list(TOPS),
|
|
33
|
+
items=lambda itemType=None: list(ATTS)))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_audit_buckets():
|
|
37
|
+
rep = audit(_fixture_zot())
|
|
38
|
+
b = rep["buckets"]
|
|
39
|
+
assert rep["total"] == 5 # notes/attachments excluded
|
|
40
|
+
assert b["missing-abstract"] == ["NOABS111", "ARXID111", "STALE111"]
|
|
41
|
+
assert b["missing-identifier"] == ["NOID1111"]
|
|
42
|
+
# an html attachment does not count as a PDF
|
|
43
|
+
assert b["missing-pdf"] == ["NOABS111", "NOID1111", "ARXID111", "STALE111"]
|
|
44
|
+
assert b["unstamped-abstract"] == ["NOID1111"]
|
|
45
|
+
assert b["stale-stamp"] == ["STALE111"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_audit_keys_in_library_order():
|
|
49
|
+
rep = audit(_fixture_zot())
|
|
50
|
+
assert rep["keys"] == ["OK111111", "NOABS111", "NOID1111", "ARXID111",
|
|
51
|
+
"STALE111"]
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Offline tests for the create-path dedup predicate: the shared
|
|
2
|
+
`dedup_maps`/`duplicate_key` helper, the existing_key recorded by
|
|
3
|
+
`Zot.create_items`, and the dry-run `!!` notice. No network, no live library —
|
|
4
|
+
the Zotero client is faked."""
|
|
5
|
+
from zotkit.cli import dup_notice
|
|
6
|
+
from zotkit.core import Zot, dedup_maps, duplicate_key
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _lib(*rows):
|
|
10
|
+
"""rows: (key, title, DOI) -> pyzotero-shaped top-level items."""
|
|
11
|
+
return [{"key": k, "data": {"title": t, "DOI": doi, "itemType": "journalArticle"}}
|
|
12
|
+
for k, t, doi in rows]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
LIBRARY = _lib(
|
|
16
|
+
("AAAA1111", "Attention Is All You Need", "10.5555/ATT"),
|
|
17
|
+
("BBBB2222", "Deep Residual Learning, Revisited!", ""),
|
|
18
|
+
("CCCC3333", "Some Other Paper", "10.1103/PhysRevLett.116.061102"),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------- the helper ----------
|
|
23
|
+
|
|
24
|
+
def test_doi_match_is_case_and_whitespace_insensitive():
|
|
25
|
+
doi_map, title_map = dedup_maps(LIBRARY)
|
|
26
|
+
d = {"title": "totally different title", "DOI": " 10.5555/att "}
|
|
27
|
+
assert duplicate_key(d, doi_map, title_map) == "AAAA1111"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_normalized_title_match():
|
|
31
|
+
doi_map, title_map = dedup_maps(LIBRARY)
|
|
32
|
+
d = {"title": "deep residual learning revisited"}
|
|
33
|
+
assert duplicate_key(d, doi_map, title_map) == "BBBB2222"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_doi_wins_when_doi_and_title_point_at_different_items():
|
|
37
|
+
doi_map, title_map = dedup_maps(LIBRARY)
|
|
38
|
+
d = {"title": "Some Other Paper", "DOI": "10.5555/ATT"}
|
|
39
|
+
assert duplicate_key(d, doi_map, title_map) == "AAAA1111"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_no_match_returns_none():
|
|
43
|
+
doi_map, title_map = dedup_maps(LIBRARY)
|
|
44
|
+
d = {"title": "A Brand New Paper", "DOI": "10.9999/new.1"}
|
|
45
|
+
assert duplicate_key(d, doi_map, title_map) is None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_items_without_doi_do_not_poison_the_doi_map():
|
|
49
|
+
doi_map, _ = dedup_maps(LIBRARY)
|
|
50
|
+
assert "" not in doi_map
|
|
51
|
+
assert set(doi_map) == {"10.5555/att", "10.1103/physrevlett.116.061102"}
|
|
52
|
+
# an incoming item with no DOI falls through to the title check only
|
|
53
|
+
_, title_map = dedup_maps(LIBRARY)
|
|
54
|
+
assert duplicate_key({"title": "A Brand New Paper"}, doi_map, title_map) is None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_unknown_doi_still_matches_on_title():
|
|
58
|
+
doi_map, title_map = dedup_maps(LIBRARY)
|
|
59
|
+
d = {"title": "attention is all you need", "DOI": "10.9999/unseen"}
|
|
60
|
+
assert duplicate_key(d, doi_map, title_map) == "AAAA1111"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_first_writer_wins_on_duplicate_library_rows():
|
|
64
|
+
doi_map, title_map = dedup_maps(_lib(("KEY00001", "T", "10.1/x"),
|
|
65
|
+
("KEY00002", "T", "10.1/x")))
|
|
66
|
+
assert duplicate_key({"title": "T", "DOI": "10.1/X"}, doi_map, title_map) == "KEY00001"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------- create_items records the existing key ----------
|
|
70
|
+
|
|
71
|
+
class FakeZ:
|
|
72
|
+
def __init__(self, existing):
|
|
73
|
+
self._existing = existing
|
|
74
|
+
self.created_payloads = []
|
|
75
|
+
|
|
76
|
+
def top(self):
|
|
77
|
+
return "TOP"
|
|
78
|
+
|
|
79
|
+
def everything(self, _):
|
|
80
|
+
return self._existing
|
|
81
|
+
|
|
82
|
+
def item_template(self, itemtype):
|
|
83
|
+
return {"itemType": itemtype, "title": "", "DOI": "", "tags": [],
|
|
84
|
+
"collections": []}
|
|
85
|
+
|
|
86
|
+
def create_items(self, payloads):
|
|
87
|
+
self.created_payloads.append(payloads)
|
|
88
|
+
return {"successful": {str(i): {"key": f"NEW0000{i}"}
|
|
89
|
+
for i in range(len(payloads))}, "failed": {}}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _zot(existing):
|
|
93
|
+
z = Zot.__new__(Zot)
|
|
94
|
+
z.z = FakeZ(existing)
|
|
95
|
+
z.conventions = None
|
|
96
|
+
return z
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_create_items_records_existing_key_in_skipped_meta():
|
|
100
|
+
z = _zot(LIBRARY)
|
|
101
|
+
out = z.create_items([{"itemType": "journalArticle", "title": "Fresh One"},
|
|
102
|
+
{"itemType": "journalArticle",
|
|
103
|
+
"title": "attention is all you need!"}])
|
|
104
|
+
created = [r for r in out if r.get("key")]
|
|
105
|
+
skipped = [r for r in out if r.get("skipped")]
|
|
106
|
+
assert [c["title"] for c in created] == ["Fresh One"]
|
|
107
|
+
assert skipped == [{"title": "attention is all you need!",
|
|
108
|
+
"skipped": "duplicate", "existing_key": "AAAA1111"}]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_create_items_skips_on_doi_and_names_that_item():
|
|
112
|
+
z = _zot(LIBRARY)
|
|
113
|
+
out = z.create_items([{"itemType": "journalArticle", "title": "Renamed Paper",
|
|
114
|
+
"DOI": "10.5555/ATT"}])
|
|
115
|
+
assert out == [{"title": "Renamed Paper", "skipped": "duplicate",
|
|
116
|
+
"existing_key": "AAAA1111"}]
|
|
117
|
+
assert z.z.created_payloads == [] # nothing sent to the API
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_create_items_no_dedup_creates_everything():
|
|
121
|
+
z = _zot(LIBRARY)
|
|
122
|
+
out = z.create_items([{"itemType": "journalArticle",
|
|
123
|
+
"title": "Attention Is All You Need"}], dedup=False)
|
|
124
|
+
assert len(out) == 1 and out[0]["key"] == "NEW00000"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------- the dry-run notice ----------
|
|
128
|
+
|
|
129
|
+
def test_dup_notice_shape():
|
|
130
|
+
doi_map, title_map = dedup_maps(LIBRARY)
|
|
131
|
+
msg = dup_notice({"title": "Attention Is All You Need"}, doi_map, title_map)
|
|
132
|
+
assert msg == ("!! already in library as AAAA1111 — --apply will skip it "
|
|
133
|
+
"(use --no-dedup to force)")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_dup_notice_none_for_new_item():
|
|
137
|
+
doi_map, title_map = dedup_maps(LIBRARY)
|
|
138
|
+
assert dup_notice({"title": "Brand New", "DOI": "10.9/z"}, doi_map, title_map) is None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_dup_notice_with_empty_maps_never_warns():
|
|
142
|
+
assert dup_notice({"title": "Attention Is All You Need"}, {}, {}) is None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_dup_notice_names_the_doi_match_not_the_title_match():
|
|
146
|
+
doi_map, title_map = dedup_maps(LIBRARY)
|
|
147
|
+
msg = dup_notice({"title": "Some Other Paper", "DOI": "10.5555/att"},
|
|
148
|
+
doi_map, title_map)
|
|
149
|
+
assert "AAAA1111" in msg and "CCCC3333" not in msg
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_empty_library_yields_empty_maps():
|
|
153
|
+
assert dedup_maps([]) == ({}, {})
|
|
154
|
+
assert duplicate_key({"title": "Anything"}, {}, {}) is None
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Offline tests for zotkit.enrich: the version-of-record gate in plan_enrich
|
|
2
|
+
and the set_abstract guard/force/stamp matrix. No network, no live library —
|
|
3
|
+
the Zotero client and CrossRef fetch are faked."""
|
|
4
|
+
import types
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from zotkit import enrich
|
|
9
|
+
from zotkit import metadata as md
|
|
10
|
+
|
|
11
|
+
SSRN_ITEM = {"itemType": "journalArticle", "title": "Some Working Paper",
|
|
12
|
+
"publicationTitle": "SSRN Electronic Journal",
|
|
13
|
+
"DOI": "10.2139/ssrn.2407199", "creators": []}
|
|
14
|
+
PRL_ITEM = {"itemType": "journalArticle", "title": "GW150914",
|
|
15
|
+
"publicationTitle": "Physical Review Letters",
|
|
16
|
+
"DOI": "10.1103/PhysRevLett.116.061102", "creators": []}
|
|
17
|
+
|
|
18
|
+
SSRN_PREPRINT = {"itemType": "preprint", "title": "Some Working Paper",
|
|
19
|
+
"abstractNote": "has one", "creators": [],
|
|
20
|
+
"DOI": "10.2139/ssrn.2407199", "extra": "", "url": "",
|
|
21
|
+
"archiveID": "", "tags": [], "collections": [],
|
|
22
|
+
"relations": {}, "repository": "SSRN", "date": ""}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _read_only_zot(item_data):
|
|
26
|
+
return types.SimpleNamespace(z=types.SimpleNamespace(
|
|
27
|
+
item=lambda k: {"data": dict(item_data), "key": k, "version": 1}))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _plan(monkeypatch, item_data, fetched, rebuild):
|
|
31
|
+
monkeypatch.setattr(md, "fetch_doi", lambda doi: dict(fetched))
|
|
32
|
+
return enrich.plan_enrich(_read_only_zot(item_data), "KEY00001",
|
|
33
|
+
rebuild_record=rebuild)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------- plan_enrich: version-of-record gate ----------
|
|
37
|
+
|
|
38
|
+
def test_ssrn_doi_never_rebuilds(monkeypatch):
|
|
39
|
+
p = _plan(monkeypatch, SSRN_PREPRINT, SSRN_ITEM, rebuild=True)
|
|
40
|
+
assert p["rebuild"] is None
|
|
41
|
+
assert any("no version-of-record upgrade" in n and "repository DOI" in n
|
|
42
|
+
for n in p["notes"])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_repository_doi_still_fills_missing(monkeypatch):
|
|
46
|
+
p = _plan(monkeypatch, SSRN_PREPRINT, dict(SSRN_ITEM, date="2024-01-15"),
|
|
47
|
+
rebuild=False)
|
|
48
|
+
assert p["fills"].get("date") == "2024-01-15"
|
|
49
|
+
assert not any("version-of-record" in n for n in p["notes"])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_genuine_journal_doi_rebuilds(monkeypatch):
|
|
53
|
+
real_preprint = dict(SSRN_PREPRINT, DOI="10.1103/PhysRevLett.116.061102",
|
|
54
|
+
repository="arXiv")
|
|
55
|
+
p = _plan(monkeypatch, real_preprint, PRL_ITEM, rebuild=True)
|
|
56
|
+
assert p["rebuild"] == "journalArticle"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_layer2_guard_blocks_disguised_record(monkeypatch):
|
|
60
|
+
# non-blacklist DOI prefix, but the fetched record is the SSRN pseudo-journal
|
|
61
|
+
disguised = dict(SSRN_ITEM, DOI="10.9999/fake.1")
|
|
62
|
+
odd_preprint = dict(SSRN_PREPRINT, DOI="10.9999/fake.1")
|
|
63
|
+
p = _plan(monkeypatch, odd_preprint, disguised, rebuild=True)
|
|
64
|
+
assert p["rebuild"] is None
|
|
65
|
+
assert any("pseudo-journal" in n for n in p["notes"])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_no_identifier_skips_without_fetching(monkeypatch):
|
|
69
|
+
monkeypatch.setattr(md, "fetch_doi",
|
|
70
|
+
lambda doi: pytest.fail("must not fetch CrossRef"))
|
|
71
|
+
noid = dict(SSRN_PREPRINT, DOI="")
|
|
72
|
+
p = enrich.plan_enrich(_read_only_zot(noid), "KEY00001",
|
|
73
|
+
rebuild_record=False)
|
|
74
|
+
assert p["status"] == "needs-identifier"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------- set_abstract: guard / force / stamp matrix ----------
|
|
78
|
+
|
|
79
|
+
class FakeZ:
|
|
80
|
+
def __init__(self, data):
|
|
81
|
+
self._data = data
|
|
82
|
+
self.updated = None
|
|
83
|
+
|
|
84
|
+
def item(self, key):
|
|
85
|
+
return {"key": key, "version": 7, "data": dict(self._data)}
|
|
86
|
+
|
|
87
|
+
def update_item(self, item):
|
|
88
|
+
self.updated = item
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def zot_with(data):
|
|
92
|
+
return types.SimpleNamespace(z=FakeZ(data))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
BASE = {"itemType": "journalArticle", "title": "T", "abstractNote": "",
|
|
96
|
+
"extra": "", "key": "K1", "version": 7}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_fresh_item_writes_cleaned_abstract_and_stamp():
|
|
100
|
+
z = zot_with(BASE)
|
|
101
|
+
r = enrich.set_abstract(z, "K1", "some text\nwrapped", "cnki")
|
|
102
|
+
d = z.z.updated["data"]
|
|
103
|
+
assert d["abstractNote"] == "some text wrapped" # unwrap applied to paste
|
|
104
|
+
assert d["extra"] == "abstract-source: cnki"
|
|
105
|
+
assert r["replaced"] is False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_existing_abstract_refused_without_force():
|
|
109
|
+
z = zot_with(dict(BASE, abstractNote="old text"))
|
|
110
|
+
with pytest.raises(md.MetadataError, match="--force"):
|
|
111
|
+
enrich.set_abstract(z, "K1", "new", "manual")
|
|
112
|
+
assert z.z.updated is None # nothing written
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_stale_stamp_alone_refused_without_force():
|
|
116
|
+
z = zot_with(dict(BASE, extra="abstract-source: arxiv"))
|
|
117
|
+
with pytest.raises(md.MetadataError, match="--force"):
|
|
118
|
+
enrich.set_abstract(z, "K1", "new", "manual")
|
|
119
|
+
assert z.z.updated is None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_force_replaces_abstract_and_rewrites_stamp_in_place():
|
|
123
|
+
z = zot_with(dict(BASE, abstractNote="old",
|
|
124
|
+
extra="arXiv: 2401.00001\nabstract-source: arxiv\n"
|
|
125
|
+
"Citation Key: x"))
|
|
126
|
+
r = enrich.set_abstract(z, "K1", "new text", "publisher", force=True)
|
|
127
|
+
d = z.z.updated["data"]
|
|
128
|
+
assert d["abstractNote"] == "new text"
|
|
129
|
+
assert d["extra"] == ("arXiv: 2401.00001\nabstract-source: publisher\n"
|
|
130
|
+
"Citation Key: x")
|
|
131
|
+
assert d["extra"].count("abstract-source:") == 1
|
|
132
|
+
assert r["replaced"] is True
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_force_on_unstamped_item_appends_stamp_keeping_lines():
|
|
136
|
+
z = zot_with(dict(BASE, abstractNote="old", extra="Some: line"))
|
|
137
|
+
enrich.set_abstract(z, "K1", "new", "manual", force=True)
|
|
138
|
+
d = z.z.updated["data"]
|
|
139
|
+
assert d["extra"] == "Some: line\nabstract-source: manual"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_empty_after_cleaning_errors_and_writes_nothing():
|
|
143
|
+
z = zot_with(BASE)
|
|
144
|
+
with pytest.raises(md.MetadataError, match="empty"):
|
|
145
|
+
enrich.set_abstract(z, "K1", " \n\u00ad ", "manual")
|
|
146
|
+
assert z.z.updated is None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_open_vocabulary_slug_accepted():
|
|
150
|
+
z = zot_with(BASE)
|
|
151
|
+
enrich.set_abstract(z, "K1", "x", "some-new-source2")
|
|
152
|
+
assert z.z.updated is not None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@pytest.mark.parametrize("bad", ["CNKI", "with space", "", "-lead", "中文"])
|
|
156
|
+
def test_bad_slug_refused(bad):
|
|
157
|
+
z = zot_with(BASE)
|
|
158
|
+
with pytest.raises(md.MetadataError):
|
|
159
|
+
enrich.set_abstract(z, "K1", "x", bad)
|
|
160
|
+
assert z.z.updated is None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_item_type_without_abstract_field_refused():
|
|
164
|
+
z = zot_with({"itemType": "note", "extra": "", "key": "K1"})
|
|
165
|
+
with pytest.raises(md.MetadataError, match="no abstract field"):
|
|
166
|
+
enrich.set_abstract(z, "K1", "x", "manual")
|
|
167
|
+
assert z.z.updated is None
|