flm-citations 0.2.4__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 Philippe Faist
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.1
2
+ Name: flm-citations
3
+ Version: 0.2.4
4
+ Summary: Support for citations in FLM (see flm package)
5
+ License: MIT
6
+ Author: Philippe Faist
7
+ Requires-Python: >=3.8,<4.0
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.8
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Requires-Dist: PyYAML (>=6.0,<7.0)
15
+ Requires-Dist: arxiv (>=1.4.2,<2.0.0)
16
+ Requires-Dist: backoff (>=2.1.2,<3.0.0)
17
+ Requires-Dist: citeproc-py (>=0.6.0,<0.7.0)
18
+ Requires-Dist: flm-core (>=0.3.0a10)
19
+ Requires-Dist: requests (>=2.28.1,<3.0.0)
@@ -0,0 +1,25 @@
1
+
2
+
3
+ ### FIXME: Merge feature config of imported configs don't work yet ... :/
4
+ ### Need to fix this in flm.main.run(). Probably all $import's should be
5
+ ### processed first, before merging anything.
6
+
7
+ flm_default_import_config = {
8
+ 'flm': {
9
+ 'features': {
10
+ 'flm_citations': {},
11
+ # {
12
+ # 'sources': [
13
+ # {
14
+ # '$defaults': True,
15
+ # },
16
+ # ],
17
+ # },
18
+ },
19
+ },
20
+ }
21
+
22
+
23
+ def FeatureClass(*args, **kwargs):
24
+ from .feature import FeatureClass as _FeatureClass
25
+ return _FeatureClass(*args, **kwargs)
@@ -0,0 +1,72 @@
1
+ import re
2
+
3
+ _escape_chars = {
4
+ '\\': r'\textbackslash',
5
+ '%': r'\%',
6
+ '#': r'\#',
7
+ '&': r'\&',
8
+ '$': r'\$',
9
+ '{': r'\{',
10
+ '}': r'\}',
11
+ }
12
+
13
+ def _multiple_replace(s, rep_dict):
14
+ # https://stackoverflow.com/a/15448887/1694896
15
+ pattern = re.compile("|".join([
16
+ re.escape(k)
17
+ for k in sorted(rep_dict.keys(),key=len,reverse=True)
18
+ ]), flags=re.DOTALL)
19
+ return pattern.sub(lambda x: rep_dict[x.group(0)], s)
20
+
21
+ def preformat(text):
22
+ # if isinstance(text, list): # ???
23
+ # return [preformat(z) for z in text]
24
+ text = str(text)
25
+
26
+ s = _multiple_replace(text, _escape_chars)
27
+
28
+ return s
29
+
30
+
31
+ class FLMCommandWrapper(str):
32
+ cmd = None
33
+
34
+ @classmethod
35
+ def _wrap(cls, text):
36
+ text = preformat(text)
37
+ return f'\\{cls.cmd}{{{text}}}'
38
+
39
+ def __new__(cls, text):
40
+ return super(FLMCommandWrapper, cls).__new__(cls, cls._wrap(text))
41
+
42
+
43
+ class Italic(FLMCommandWrapper):
44
+ cmd = 'textit'
45
+
46
+
47
+ class Oblique(Italic):
48
+ pass
49
+
50
+
51
+ class Bold(FLMCommandWrapper):
52
+ cmd = 'textbf'
53
+
54
+
55
+ class Light(FLMCommandWrapper):
56
+ cmd = 'textit' # italic instead
57
+
58
+
59
+ class Underline(FLMCommandWrapper):
60
+ cmd = 'textit' # italic instead
61
+
62
+
63
+ class Superscript(FLMCommandWrapper):
64
+ cmd = 'textit' # italic instead
65
+
66
+
67
+ class Subscript(FLMCommandWrapper):
68
+ cmd = 'textit' # italic instead
69
+
70
+
71
+ class SmallCaps(FLMCommandWrapper):
72
+ cmd = 'textit' # italic instead
@@ -0,0 +1,169 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <style xmlns="http://purl.org/net/xbiblio/csl" class="in-text" version="1.0" demote-non-dropping-particle="sort-only" default-locale="en-US">
3
+ <info>
4
+ <title>American Physical Society - et al. (if more than 3 authors)</title>
5
+ <title-short>APS-et-al</title-short>
6
+ <id>http://www.zotero.org/styles/american-physical-society-et-al</id>
7
+ <link href="http://www.zotero.org/styles/american-physical-society-et-al" rel="self"/>
8
+ <link href="http://www.zotero.org/styles/american-physics-society" rel="template"/>
9
+ <link href="https://cdn.journals.aps.org/files/styleguide-pr.pdf" rel="documentation"/>
10
+ <author>
11
+ <name>Jonas Lähnemann</name>
12
+ <email>jonas@pdi-berlin.de</email>
13
+ <name>Philippe Faist</name><!-- Patched (Feb 2023) -->
14
+ </author>
15
+ <category citation-format="numeric"/>
16
+ <category field="physics"/>
17
+ <summary>Short variant (first author et al., if more than 3 authors) of the American Physical Society (APS) citation style. Volume is bold and year comes last in normal brackets. Patched by Philippe Faist in Feb 2023.</summary>
18
+ <updated>2020-10-01T22:47:43+00:00</updated>
19
+ <rights license="http://creativecommons.org/licenses/by-sa/3.0/">This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 License</rights>
20
+ </info>
21
+ <macro name="author">
22
+ <names variable="author">
23
+ <name delimiter=", " initialize-with=". " and="text"/>
24
+ <label form="long" prefix=", " suffix=" "/>
25
+ <!-- <et-al font-style="italic"/> -->
26
+ <substitute>
27
+ <names variable="editor"/>
28
+ <names variable="translator"/>
29
+ </substitute>
30
+ </names>
31
+ </macro>
32
+ <macro name="editor">
33
+ <names variable="editor">
34
+ <label form="verb" suffix=" "/>
35
+ <name delimiter=", " initialize-with=". " and="text"/>
36
+ <!-- <et-al font-style="italic"/> -->
37
+ </names>
38
+ </macro>
39
+ <macro name="year-date">
40
+ <choose>
41
+ <if variable="issued">
42
+ <date variable="issued">
43
+ <date-part name="year"/>
44
+ </date>
45
+ </if>
46
+ <else>
47
+ <!-- <text term="no date" form="short"/> -->
48
+ </else>
49
+ </choose>
50
+ </macro>
51
+ <macro name="day-date">
52
+ <choose>
53
+ <if variable="issued">
54
+ <date variable="issued">
55
+ <date-part name="day" suffix=" "/>
56
+ <date-part name="month" form="long" suffix=" "/>
57
+ <date-part name="year"/>
58
+ </date>
59
+ </if>
60
+ <else>
61
+ <text term="no date" form="short"/>
62
+ </else>
63
+ </choose>
64
+ </macro>
65
+ <macro name="publisher">
66
+ <group prefix="(" suffix=")" delimiter=", ">
67
+ <text variable="publisher"/>
68
+ <text variable="publisher-place" text-case="title"/>
69
+ <text macro="year-date"/>
70
+ </group>
71
+ </macro>
72
+ <macro name="edition">
73
+ <choose>
74
+ <if is-numeric="edition">
75
+ <group delimiter=" ">
76
+ <number variable="edition" form="ordinal"/>
77
+ <text term="edition" form="short"/>
78
+ </group>
79
+ </if>
80
+ <else>
81
+ <text variable="edition"/>
82
+ </else>
83
+ </choose>
84
+ </macro>
85
+ <citation collapse="citation-number">
86
+ <sort>
87
+ <key variable="citation-number"/>
88
+ </sort>
89
+ <layout delimiter="," prefix="&#160;[" suffix="]">
90
+ <text variable="citation-number"/>
91
+ </layout>
92
+ </citation>
93
+ <bibliography et-al-min="7" et-al-use-first="1" entry-spacing="0" second-field-align="flush">
94
+ <layout suffix=".">
95
+ <!-- PATCH Feb 2023 (Philippe Faist): remove citation number -->
96
+ <!-- <text variable="citation-number" prefix="[" suffix="]"/> -->
97
+ <group delimiter=", "><!-- added -->
98
+ <text macro="author" /> <!-- suffix=", "/> -->
99
+ <choose>
100
+ <if type="bill book graphic legal_case legislation motion_picture report song" match="any">
101
+ <group delimiter=" ">
102
+ <group delimiter=", ">
103
+ <text variable="title" text-case="title" font-style="italic"/>
104
+ <text macro="edition"/>
105
+ </group>
106
+ <group delimiter=", ">
107
+ <text macro="publisher"/>
108
+ <group delimiter=" ">
109
+ <label variable="page" form="short"/>
110
+ <text variable="page"/>
111
+ </group>
112
+ </group>
113
+ </group>
114
+ </if>
115
+ <else-if type="chapter paper-conference" match="any">
116
+ <group delimiter=" ">
117
+ <text term="in"/>
118
+ <group delimiter=", ">
119
+ <text variable="container-title" form="short" text-case="title" font-style="italic"/>
120
+ <text macro="editor"/>
121
+ <text macro="edition"/>
122
+ </group>
123
+ <group delimiter=", ">
124
+ <text macro="publisher"/>
125
+ <group delimiter=" ">
126
+ <label variable="page" form="short"/>
127
+ <text variable="page"/>
128
+ </group>
129
+ </group>
130
+ </group>
131
+ </else-if>
132
+ <else-if type="patent">
133
+ <group delimiter=" ">
134
+ <text variable="number"/>
135
+ <text macro="day-date" prefix="(" suffix=")"/>
136
+ </group>
137
+ </else-if>
138
+ <else-if type="thesis">
139
+ <group delimiter=", ">
140
+ <text variable="title" text-case="title"/>
141
+ <text variable="genre"/>
142
+ <text variable="publisher"/>
143
+ <text macro="year-date"/>
144
+ </group>
145
+ </else-if>
146
+ <else>
147
+ <group delimiter=" ">
148
+ <choose>
149
+ <if type="article-journal">
150
+ <text variable="container-title" form="short"/>
151
+ </if>
152
+ <else>
153
+ <text variable="container-title"/>
154
+ </else>
155
+ </choose>
156
+ <group delimiter=", ">
157
+ <text variable="volume" font-weight="bold"/>
158
+ <group delimiter=" ">
159
+ <text variable="page-first" form="short"/>
160
+ <text macro="year-date" prefix="(" suffix=")"/>
161
+ </group>
162
+ </group>
163
+ </group>
164
+ </else>
165
+ </choose>
166
+ </group><!-- added -->
167
+ </layout>
168
+ </bibliography>
169
+ </style>
@@ -0,0 +1,195 @@
1
+ import re
2
+
3
+ import logging
4
+ logger = logging.getLogger(__name__)
5
+
6
+ import arxiv
7
+ import yaml
8
+
9
+ from .base import CitationSourceBase
10
+
11
+
12
+ _rx_arxiv_id_from_entryid = re.compile(
13
+ # note: <arxivid> does NOT include the version number
14
+ r'https?://arxiv.org/abs/(?P<arxivid>.*?)(?P<versionnumstr>v(?P<versionnum>\d+))?$',
15
+ flags=re.IGNORECASE
16
+ )
17
+
18
+ _rx_ver = re.compile(r'v\d+$')
19
+
20
+ class CitationSourceArxiv(CitationSourceBase):
21
+
22
+ def __init__(self, **kwargs):
23
+
24
+ self.chain_to_doi = kwargs.get('chain_to_doi', True)
25
+
26
+ override_options = {
27
+ 'chains_to_sources': ['doi'] if self.chain_to_doi else [],
28
+ 'source_name': 'ArXiv API citation info source',
29
+ }
30
+ default_options = {
31
+ 'chunk_query_delay_ms': 3100,
32
+ 'cite_prefix': 'arxiv',
33
+ 'use_requests': True,
34
+ }
35
+
36
+ super().__init__(
37
+ override_options,
38
+ kwargs,
39
+ default_options,
40
+ )
41
+
42
+ self.override_arxiv_dois_file = self.options.get('override_arxiv_dois_file', None)
43
+ self.override_arxiv_dois = self.options.get('override_arxiv_dois', {})
44
+
45
+ # silence some arxiv.arxiv messages
46
+ if not self.options.get('keep_arxiv_arxiv_logging_info_output', False):
47
+ logging.getLogger('arxiv.arxiv').setLevel(level=logging.WARNING)
48
+
49
+
50
+ def source_initialize_run(self):
51
+
52
+ self._arxiv_to_doi_override = {}
53
+ if self.override_arxiv_dois_file:
54
+ content = self.fetch_url(self.override_arxiv_dois_file)
55
+ data = yaml.safe_load(content)
56
+ self._arxiv_to_doi_override.update(data)
57
+
58
+ self._arxiv_to_doi_override.update(self.override_arxiv_dois)
59
+
60
+ self.data_for_versionless_arxivid = {
61
+ arxivid: []
62
+ for arxivid in self.cite_key_list
63
+ if not _rx_ver.search(arxivid)
64
+ }
65
+
66
+ def retrieve_chunk(self, arxivid_list):
67
+
68
+ if not arxivid_list:
69
+ return {}
70
+
71
+ #
72
+ # fetch meta-info from the arxiv for all encountered arXiv IDs, and
73
+ # build the associated citation text endnotes.
74
+ #
75
+ big_slow_client = arxiv.Client(
76
+ page_size=len(arxivid_list) + 100,
77
+ delay_seconds=4,
78
+ num_retries=5
79
+ )
80
+ searchobj = arxiv.Search(
81
+ id_list=arxivid_list,
82
+ )
83
+ for result in big_slow_client.results(searchobj):
84
+ #
85
+ # build citation from the arxiv meta-information
86
+ #
87
+
88
+ m = _rx_arxiv_id_from_entryid.match(result.entry_id)
89
+ if m is None:
90
+ logger.warning(f"Unable to parse arXiv ID from {result.entry_id!r}")
91
+ continue
92
+
93
+ arxivid = m.group('arxivid').lower()
94
+ versionnum = m.group('versionnum')
95
+ arxivver = int(versionnum) if versionnum else None
96
+
97
+ logger.debug(
98
+ f"Processing received information for ‘{arxivid}’ (got v{arxivver})"
99
+ )
100
+
101
+ doi = None
102
+ if result.doi is not None and result.doi:
103
+ doi = str(result.doi)
104
+
105
+ # override the given DOI in exceptional cases where it might be
106
+ # missing, incomplete, or incorrect:
107
+ if arxivid in self._arxiv_to_doi_override:
108
+ override_doi = self._arxiv_to_doi_override[arxivid]
109
+ logger.debug(f"Overriding reported DOI (‘{doi}’) for arXiv "
110
+ f"item ‘{arxivid}’ with manually specified DOI "
111
+ f"‘{override_doi}’ given in citation hints")
112
+ doi = override_doi
113
+
114
+ # construct CSL-JSON entry from the retrieved metadata
115
+ citeprocjsond = {
116
+ 'type': 'article-journal',
117
+ 'title': result.title,
118
+ 'author': [
119
+ {'name': a.name}
120
+ for a in result.authors
121
+ ],
122
+ 'published': {
123
+ 'date-parts': [[
124
+ result.published.year,
125
+ result.published.month,
126
+ result.published.day,
127
+ ]],
128
+ },
129
+ 'doi': doi,
130
+ 'arxivid': arxivid,
131
+ 'arxiv_version_number': arxivver,
132
+ }
133
+
134
+ if arxivid in self.cite_key_list:
135
+ self.citation_manager.store_citation(self.cite_prefix, arxivid, citeprocjsond)
136
+
137
+ if arxivid in self.data_for_versionless_arxivid:
138
+ self.data_for_versionless_arxivid[arxivid].append( citeprocjsond )
139
+
140
+ return
141
+
142
+ def source_finalize_run(self):
143
+
144
+ # all the queries for ids ran, now figure out which versions to link to
145
+ # when a non-versioned key is provided
146
+
147
+ for arxivid, versionslist in self.data_for_versionless_arxivid.items():
148
+
149
+ if not versionslist:
150
+ logger.error(
151
+ f"No arXiv data received for arXiv id ‘{arxivid}’, what happened?!?"
152
+ )
153
+ raise ValueError(f"No arXiv data found for ‘{arxivid}’")
154
+
155
+ best = None
156
+ for current in versionslist:
157
+ if best is None and current is not None:
158
+ best = current
159
+ continue
160
+ if current is None:
161
+ continue
162
+ # "arxiv_version_number === null" only happens if arXiv API
163
+ # responded with an article whose ID didn't have a version
164
+ # number -> use that one directly as it is the answer to our
165
+ # query!
166
+ if best['arxiv_version_number'] is None:
167
+ continue
168
+ if current['arxiv_version_number'] is None:
169
+ best = current
170
+ continue
171
+ if current['arxiv_version_number'] >= best['arxiv_version_number']:
172
+ best = current
173
+ continue
174
+
175
+ self._store_citation(arxivid, best)
176
+
177
+ def _store_citation(self, arxivid, csljsondata):
178
+ doi = csljsondata.get('doi', None)
179
+ if doi and self.chain_to_doi:
180
+ # chain to DOI
181
+ self.citation_manager.store_citation_chained(
182
+ self.cite_prefix, arxivid,
183
+ 'doi', doi,
184
+ {
185
+ 'arxivid': arxivid,
186
+ },
187
+ )
188
+ else:
189
+ self.citation_manager.store_citation(self.cite_prefix, arxivid, csljsondata)
190
+
191
+
192
+
193
+
194
+ # for when using shorthane naming
195
+ CitationSourceClass = CitationSourceArxiv
@@ -0,0 +1,145 @@
1
+ import time
2
+ from urllib.parse import urlparse
3
+
4
+ import logging
5
+ logger = logging.getLogger(__name__)
6
+
7
+ import requests
8
+
9
+
10
+ class CitationSourceBase:
11
+
12
+ def __init__(self, override_options, kwargs, default_options):
13
+ super().__init__()
14
+
15
+ self.options = dict(default_options)
16
+ self.options.update(kwargs)
17
+ self.options.update(override_options)
18
+
19
+ self.chunk_size = self.options.get('chunk_size', 512)
20
+ self.chunk_query_delay_ms = self.options.get('chunk_query_delay_ms', 1000)
21
+
22
+ self.cite_prefix = self.options['cite_prefix']
23
+
24
+ self.chains_to_sources = self.options.get('chains_to_sources', [])
25
+ self.source_name = self.options.get('source_name', '<unknown source>')
26
+
27
+ self.requests_session = None
28
+ if self.options.get('use_requests', False):
29
+ if 'requests_session' in self.options:
30
+ self.requests_session = self.options['requests_session']
31
+ else:
32
+ self.requests_session = requests.Session()
33
+
34
+ self.cite_key_list = None
35
+
36
+ self.citation_manager = None
37
+
38
+ def set_citation_manager(self, citation_manager):
39
+ self.citation_manager = citation_manager
40
+
41
+
42
+ def retrieve_citations(self, cite_key_list):
43
+
44
+ self.cite_key_list = cite_key_list
45
+
46
+ self.source_initialize_run()
47
+
48
+ total_retrieved = 0
49
+ remaining_keys = list(cite_key_list)
50
+
51
+ last_chunk_query_monotonic_s = None
52
+
53
+ if len(remaining_keys):
54
+ logger.info(f"{self.source_name}: there are "
55
+ f"{len(remaining_keys)} citation(s) to query")
56
+ else:
57
+ logger.debug(f"{self.source_name}: there are no citations to query")
58
+
59
+ while len(remaining_keys) > 0:
60
+
61
+ # if applicable, wait before another chunk query call
62
+ if (self.chunk_query_delay_ms and last_chunk_query_monotonic_s is not None):
63
+ dt_s = (time.monotonic() - last_chunk_query_monotonic_s)
64
+ dt_s_to_wait = (self.chunk_query_delay_ms/1000) - dt_s
65
+ if dt_s_to_wait > 0:
66
+ time.sleep(dt_s_to_wait)
67
+
68
+ # create a chunk of IDs to query
69
+ chunk_ids, remaining_keys = \
70
+ remaining_keys[:self.chunk_size], remaining_keys[self.chunk_size:]
71
+
72
+ last_chunk_query_monotonic_s = time.monotonic()
73
+ self.retrieve_chunk(chunk_ids)
74
+
75
+ # bookkeeping & logging
76
+ total_retrieved += len(chunk_ids)
77
+ logger.info(f"{self.source_name}: {total_retrieved}/{len(cite_key_list)}")
78
+
79
+ self.source_finalize_run()
80
+
81
+
82
+ # helper for fetching URLs
83
+ def fetch_url(self, url, binary=False, json=False, **kwargs):
84
+
85
+ if url is None:
86
+ raise ValueError(f"fetch_url(): url is None!")
87
+
88
+ logger.debug(f"Fetching URL ‘{url}’ ...")
89
+
90
+ p = urlparse(url)
91
+
92
+ if not p.scheme or p.scheme == 'file':
93
+ # Read a local file.
94
+ if binary:
95
+ open_args, open_kwargs = ('rb',), {}
96
+ else:
97
+ open_args, open_kwargs = ('r',), { 'encoding': 'utf-8' }
98
+ with open(p.path, *open_args, **open_kwargs) as f:
99
+ return f.read()
100
+
101
+ req_kwargs = {}
102
+
103
+ method = kwargs.pop('method', 'get')
104
+ reqfn = getattr(self.requests_session, method)
105
+
106
+ if method == 'post':
107
+ req_kwargs['data'] = kwargs.pop('body', None)
108
+
109
+ # e.g., headers etc.
110
+ req_kwargs.update(kwargs)
111
+
112
+ # fire!
113
+ r = reqfn(url)
114
+
115
+ if not r.ok:
116
+ # raise errors
117
+ r.raise_for_status()
118
+
119
+ if binary:
120
+ return r.content
121
+
122
+ r.encoding = 'utf-8'
123
+
124
+ if json:
125
+ return r.json()
126
+
127
+ return r.text
128
+
129
+
130
+
131
+ # -------------
132
+
133
+ # can be reimplemented
134
+
135
+ def source_initialize_run(self):
136
+ pass
137
+
138
+ def source_finalize_run(self):
139
+ pass
140
+
141
+
142
+ # must be reimplemented
143
+
144
+ def retrieve_chunk(self, chunk_keys):
145
+ raise RuntimeError("The method retrieve_chunk() must be reimplemented by subclasses!")