pyCEURsemantify 0.0.1__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.
@@ -0,0 +1,7 @@
1
+ """
2
+ Created on 2026-10-01
3
+
4
+ @author: wf
5
+ """
6
+
7
+ __version__ = "0.0.1"
@@ -0,0 +1,174 @@
1
+ """
2
+ Created on 2026-10-01
3
+
4
+ @author: wf
5
+ """
6
+
7
+ import sys
8
+ from argparse import ArgumentParser, Namespace
9
+ from dataclasses import dataclass
10
+ from typing import List, Optional
11
+
12
+ from basemkit.base_cmd import BaseCmd
13
+
14
+ import ceursemantify
15
+ from ceursemantify.endpoint import SptEndpoint
16
+ from ceursemantify.generator import Generator
17
+ from ceursemantify.site import SiteConfig, SiteReport, VolumeRanges
18
+
19
+
20
+ @dataclass
21
+ class Version:
22
+ """
23
+ Version information for pyCEURsemantify
24
+ """
25
+
26
+ name: str = "pyCEURsemantify"
27
+ version: str = ceursemantify.__version__
28
+ date: str = "2026-10-01"
29
+ updated: str = "2026-10-13"
30
+ description: str = "Semantification of the CEUR-WS publication workflow"
31
+ authors: str = "Wolfgang Fahl"
32
+ doc_url: str = "https://github.com/ceurws/semantify"
33
+ chat_url: str = "https://github.com/ceurws/semantify/discussions"
34
+ cm_url: str = "https://github.com/ceurws/semantify"
35
+
36
+
37
+ class CeurSemantifyCmd(BaseCmd):
38
+ """
39
+ command line interface of pyCEURsemantify:
40
+ generate volume pages, rebuild year pages, serve the endpoint
41
+ """
42
+
43
+ def __init__(self):
44
+ """
45
+ constructor
46
+ """
47
+ super().__init__(Version())
48
+
49
+ def add_arguments(self, parser: ArgumentParser) -> None:
50
+ """
51
+ add the arguments to the given parser
52
+
53
+ Args:
54
+ parser: the argument parser
55
+ """
56
+ super().add_arguments(parser)
57
+ parser.add_argument(
58
+ "volumes",
59
+ nargs="*",
60
+ help="volume numbers and ranges to generate, e.g. 3889 3887-3912",
61
+ )
62
+ parser.add_argument(
63
+ "-y",
64
+ "--year",
65
+ type=int,
66
+ action="append",
67
+ dest="years",
68
+ help="rebuild the page of the given year from the volumes present (can be given several times)",
69
+ )
70
+ parser.add_argument(
71
+ "--regenerate",
72
+ action="store_true",
73
+ help="with --year: fetch and write the volumes present for the year again before rebuilding its page",
74
+ )
75
+ parser.add_argument(
76
+ "--embed",
77
+ nargs="+",
78
+ metavar="PATH",
79
+ help="embed the JSON-LD into the given volume pages of the proceedings chairs (index.html files or"
80
+ " volume directories) and write index.jsonld next to them",
81
+ )
82
+ parser.add_argument("--progress", action="store_true", help="show a progress bar while generating volumes")
83
+ parser.add_argument(
84
+ "--serve",
85
+ action="store_true",
86
+ help="load all JSON-LD files into memory and serve them as SPARQL endpoint",
87
+ )
88
+ parser.add_argument("--host", default="127.0.0.1", help="host of the endpoint (default: %(default)s)")
89
+ parser.add_argument("--port", type=int, default=9987, help="port of the endpoint (default: %(default)s)")
90
+ parser.add_argument(
91
+ "-o",
92
+ "--output",
93
+ default=".",
94
+ help="directory that holds the year directories (default: current directory)",
95
+ )
96
+ parser.add_argument("--config", help="site configuration yaml file (default: the packaged site.yaml)")
97
+
98
+ def show_report(self, title: str, report: SiteReport, args: Namespace) -> None:
99
+ """
100
+ show the given report: problems always, written files if verbose
101
+
102
+ Args:
103
+ title: what the report is about
104
+ report: the report
105
+ args: the parsed arguments
106
+ """
107
+ for problem in report.problems:
108
+ print(problem, file=sys.stderr)
109
+ if args.verbose:
110
+ for path in report.written:
111
+ print(path)
112
+ if not args.quiet:
113
+ print(f"{title}: {len(report.written)} files, {len(report.problems)} problems, {report.seconds:.1f} s")
114
+
115
+ def handle_args(self, args: Namespace) -> bool:
116
+ """
117
+ handle the parsed arguments
118
+
119
+ Args:
120
+ args: the parsed arguments
121
+
122
+ Returns:
123
+ bool: True if the arguments were handled
124
+ """
125
+ handled = super().handle_args(args)
126
+ if not handled:
127
+ config = SiteConfig.of_yaml(args.config)
128
+ generator = Generator.of_output(args.output, config)
129
+ if args.embed:
130
+ report = generator.embed_volumes(args.embed, progress=args.progress)
131
+ self.show_report(f"{len(args.embed)} volume pages", report, args)
132
+ handled = True
133
+ if args.volumes:
134
+ numbers = VolumeRanges.numbers(args.volumes)
135
+ report = generator.generate_volumes(numbers, progress=args.progress)
136
+ self.show_report(f"{len(numbers)} volumes", report, args)
137
+ handled = True
138
+ for year in args.years or []:
139
+ if args.regenerate:
140
+ report = generator.regenerate_year(year, progress=args.progress)
141
+ else:
142
+ report = generator.rebuild_year(year)
143
+ self.show_report(f"year {year}", report, args)
144
+ handled = True
145
+ if args.serve:
146
+ endpoint = SptEndpoint(generator.site)
147
+ load_report = endpoint.load()
148
+ if not args.quiet:
149
+ print(
150
+ f"endpoint: {load_report.files} files, {load_report.triples} triples, "
151
+ f"{load_report.seconds:.1f} s, http://{args.host}:{args.port}/npq/{endpoint.name}"
152
+ )
153
+ endpoint.serve(host=args.host, port=args.port)
154
+ handled = True
155
+ return handled
156
+
157
+
158
+ def main(argv: Optional[List[str]] = None) -> int:
159
+ """
160
+ command line entry point
161
+
162
+ Args:
163
+ argv: the command line arguments
164
+
165
+ Returns:
166
+ int: the exit code
167
+ """
168
+ cmd = CeurSemantifyCmd()
169
+ exit_code = cmd.run(argv)
170
+ return exit_code
171
+
172
+
173
+ if __name__ == "__main__":
174
+ sys.exit(main())
@@ -0,0 +1,204 @@
1
+ """
2
+ Created on 2026-10-01
3
+
4
+ @author: wf
5
+ """
6
+
7
+ import os
8
+ import re
9
+ from datetime import date
10
+ from typing import Any, Optional
11
+ from urllib.parse import urlparse
12
+
13
+
14
+ class Convert:
15
+ """
16
+ conversions of source values to the values of the research model
17
+
18
+ Each conversion is named in resources/ceurspt_mapping.yaml and raises a
19
+ ValueError for a value it can not convert.
20
+ """
21
+
22
+ @classmethod
23
+ def text(cls, value: Any) -> str:
24
+ """
25
+ normalize whitespace: line breaks and runs of blanks become one blank
26
+
27
+ Args:
28
+ value: the source text
29
+
30
+ Returns:
31
+ str: the single line text
32
+ """
33
+ text = " ".join(str(value).split())
34
+ return text
35
+
36
+ @classmethod
37
+ def integer(cls, value: Any) -> int:
38
+ """
39
+ convert the given value to an integer
40
+
41
+ Args:
42
+ value: the source value
43
+
44
+ Returns:
45
+ int: the integer
46
+ """
47
+ integer = int(value)
48
+ return integer
49
+
50
+ @classmethod
51
+ def volume_number(cls, value: Any) -> int:
52
+ """
53
+ get the volume number of a volume identifier such as Vol-3889
54
+
55
+ Args:
56
+ value: the volume identifier
57
+
58
+ Returns:
59
+ int: the volume number
60
+ """
61
+ match = re.fullmatch(r"Vol[-.]?(\d+)", str(value).strip())
62
+ if not match:
63
+ raise ValueError(f"not a volume identifier: {value}")
64
+ volume_number = int(match.group(1))
65
+ return volume_number
66
+
67
+ @classmethod
68
+ def iso_date(cls, value: Any) -> str:
69
+ """
70
+ check that the given value is an ISO date
71
+
72
+ Args:
73
+ value: the source date
74
+
75
+ Returns:
76
+ str: the date as YYYY-MM-DD
77
+ """
78
+ iso_date = date.fromisoformat(str(value).strip()).isoformat()
79
+ return iso_date
80
+
81
+ @classmethod
82
+ def last_path_parts(cls, value: Any, marker: str) -> str:
83
+ """
84
+ get the part of the path of an url that follows the given marker
85
+
86
+ Args:
87
+ value: the url or an identifier without url
88
+ marker: the path segment the identifier follows e.g. /pid/
89
+
90
+ Returns:
91
+ str: the identifier
92
+ """
93
+ identifier = str(value).strip()
94
+ path = urlparse(identifier).path
95
+ if marker in path:
96
+ identifier = path.split(marker, 1)[1]
97
+ return identifier
98
+
99
+ @classmethod
100
+ def wikidata_id(cls, value: Any) -> str:
101
+ """
102
+ get the Q-identifier of a Wikidata entity url or identifier
103
+
104
+ Args:
105
+ value: the entity url or Q-identifier
106
+
107
+ Returns:
108
+ str: the Q-identifier
109
+ """
110
+ wikidata_id = cls.last_path_parts(value, "/entity/")
111
+ if not re.fullmatch(r"Q\d+", wikidata_id):
112
+ raise ValueError(f"not a Wikidata identifier: {value}")
113
+ return wikidata_id
114
+
115
+ @classmethod
116
+ def dblp_person_id(cls, value: Any) -> str:
117
+ """
118
+ get the dblp person identifier of a dblp person url
119
+
120
+ Args:
121
+ value: the url e.g. https://dblp.org/pid/32/7870
122
+
123
+ Returns:
124
+ str: the identifier e.g. 32/7870
125
+ """
126
+ dblp_person_id = cls.last_path_parts(value, "/pid/")
127
+ return dblp_person_id
128
+
129
+ @classmethod
130
+ def dblp_record_id(cls, value: Any) -> str:
131
+ """
132
+ get the dblp publication identifier of a dblp record url
133
+
134
+ Args:
135
+ value: the url e.g. https://dblp.org/rec/conf/semtab/X24
136
+
137
+ Returns:
138
+ str: the identifier e.g. conf/semtab/X24
139
+ """
140
+ dblp_record_id = cls.last_path_parts(value, "/rec/")
141
+ return dblp_record_id
142
+
143
+ @classmethod
144
+ def file_stem(cls, value: Any) -> str:
145
+ """
146
+ get the file name without extension of an url
147
+
148
+ Args:
149
+ value: the url e.g. https://ceur-ws.org/Vol-3889/paper0.pdf
150
+
151
+ Returns:
152
+ str: the stem e.g. paper0
153
+ """
154
+ path = urlparse(str(value)).path
155
+ file_stem = os.path.splitext(os.path.basename(path))[0]
156
+ if not file_stem:
157
+ raise ValueError(f"no file name in: {value}")
158
+ return file_stem
159
+
160
+ @classmethod
161
+ def page_part(cls, value: Any, index: int) -> Optional[str]:
162
+ """
163
+ get the first or last page of a page range such as 1-11
164
+
165
+ Args:
166
+ value: the page range or a single page
167
+ index: 0 for the first page, -1 for the last page
168
+
169
+ Returns:
170
+ Optional[str]: the page
171
+ """
172
+ parts = [part.strip() for part in re.split(r"[-–—]+", str(value)) if part.strip()]
173
+ if not parts:
174
+ raise ValueError(f"no pages in: {value}")
175
+ page_part = parts[index]
176
+ return page_part
177
+
178
+ @classmethod
179
+ def page_start(cls, value: Any) -> Optional[str]:
180
+ """
181
+ get the first page of a page range
182
+
183
+ Args:
184
+ value: the page range
185
+
186
+ Returns:
187
+ Optional[str]: the first page
188
+ """
189
+ page_start = cls.page_part(value, 0)
190
+ return page_start
191
+
192
+ @classmethod
193
+ def page_end(cls, value: Any) -> Optional[str]:
194
+ """
195
+ get the last page of a page range
196
+
197
+ Args:
198
+ value: the page range
199
+
200
+ Returns:
201
+ Optional[str]: the last page
202
+ """
203
+ page_end = cls.page_part(value, -1)
204
+ return page_end
ceursemantify/embed.py ADDED
@@ -0,0 +1,84 @@
1
+ """
2
+ Created on 2026-10-13
3
+
4
+ @author: wf
5
+ """
6
+
7
+ import re
8
+
9
+ from ceursemantify.pages import Fence, PageState
10
+
11
+
12
+ class Embedder:
13
+ """
14
+ embed the JSON-LD into the volume page of the proceedings chair: the
15
+ state comment before the html element, the alternate link and the fence
16
+ in the head; the markup of the chair is otherwise untouched
17
+ """
18
+
19
+ alternate_link = '<link rel="alternate" type="application/ld+json" href="index.jsonld">'
20
+ state_pattern = re.compile(r"<!-- CEURSTATE=[^\n]*? -->\n?")
21
+ fence_pattern = re.compile(rf"<!--\n{Fence.fence}json-ld\n.*?\n{Fence.fence}\n-->\n?", re.DOTALL)
22
+ link_pattern = re.compile(re.escape(alternate_link) + r"\n?")
23
+ # the state comment goes after the doctype, before the html element otherwise
24
+ doctype_pattern = re.compile(r"<!DOCTYPE[^>]*>\n?", re.IGNORECASE)
25
+ html_pattern = re.compile(r"<html[\s>]", re.IGNORECASE)
26
+ head_pattern = re.compile(r"<head[^>]*>\n?", re.IGNORECASE)
27
+
28
+ def __init__(self, state: PageState):
29
+ """
30
+ constructor
31
+
32
+ Args:
33
+ state: the state the page announces
34
+ """
35
+ self.state = state
36
+
37
+ def strip(self, html_text: str) -> str:
38
+ """
39
+ remove the embedded parts from the given page
40
+
41
+ Args:
42
+ html_text: the html of a volume page
43
+
44
+ Returns:
45
+ str: the html without state comment, alternate link and fence
46
+ """
47
+ stripped = self.state_pattern.sub("", html_text)
48
+ stripped = self.fence_pattern.sub("", stripped)
49
+ stripped = self.link_pattern.sub("", stripped)
50
+ return stripped
51
+
52
+ def embed(self, html_text: str, jsonld_text: str, source: str) -> str:
53
+ """
54
+ embed the given JSON-LD into the given page, replacing an earlier embedding
55
+
56
+ Args:
57
+ html_text: the html of a volume page
58
+ jsonld_text: the pretty printed JSON-LD
59
+ source: what the JSON-LD was derived from e.g. the url of the volume
60
+
61
+ Returns:
62
+ str: the html with the JSON-LD embedded
63
+
64
+ Raises:
65
+ ValueError: if the page has no html or no head element
66
+ """
67
+ stripped = self.strip(html_text)
68
+ html_match = self.html_pattern.search(stripped)
69
+ head_match = self.head_pattern.search(stripped)
70
+ if html_match is None or head_match is None:
71
+ raise ValueError("no html or head element to embed into")
72
+ doctype_match = self.doctype_pattern.match(stripped)
73
+ state_at = doctype_match.end() if doctype_match else html_match.start()
74
+ state_comment = self.state.as_html(source, state="embedded")
75
+ inserted = f"{self.alternate_link}\n{Fence.as_html(jsonld_text)}\n"
76
+ embedded = (
77
+ stripped[:state_at]
78
+ + state_comment
79
+ + "\n"
80
+ + stripped[state_at : head_match.end()]
81
+ + inserted
82
+ + stripped[head_match.end() :]
83
+ )
84
+ return embedded
@@ -0,0 +1,72 @@
1
+ """
2
+ Created on 2026-10-01
3
+
4
+ @author: wf
5
+ """
6
+
7
+ import time
8
+ from dataclasses import dataclass
9
+
10
+ from lodstorage.npq_endpoint import NpqEndpoint
11
+ from rdflib import Graph
12
+
13
+ from ceursemantify.site import Site
14
+
15
+
16
+ @dataclass
17
+ class LoadReport:
18
+ """
19
+ what was loaded into the endpoint
20
+ """
21
+
22
+ files: int = 0
23
+ triples: int = 0
24
+ seconds: float = 0.0
25
+
26
+
27
+ class SptEndpoint:
28
+ """
29
+ single point of truth endpoint: the JSON-LD files of the site loaded
30
+ into memory on startup and served by the NPQ endpoint of pyLoDStorage,
31
+ see https://cr.bitplan.com/index.php/NPQ-FakeSparql
32
+ """
33
+
34
+ def __init__(self, site: Site, name: str = "ceur-ws"):
35
+ """
36
+ constructor
37
+
38
+ Args:
39
+ site: the directory tree with the JSON-LD files
40
+ name: the name the graph is served under: /npq/<name>
41
+ """
42
+ self.site = site
43
+ self.name = name
44
+ self.graph = Graph()
45
+
46
+ def load(self) -> LoadReport:
47
+ """
48
+ load all JSON-LD files of the site into the graph
49
+
50
+ Returns:
51
+ LoadReport: number of files and triples and the time used
52
+ """
53
+ report = LoadReport()
54
+ start = time.perf_counter()
55
+ for path in self.site.jsonld_paths():
56
+ self.graph.parse(path, format="json-ld")
57
+ report.files += 1
58
+ report.triples = len(self.graph)
59
+ report.seconds = time.perf_counter() - start
60
+ return report
61
+
62
+ def serve(self, host: str = "127.0.0.1", port: int = 9987) -> None:
63
+ """
64
+ serve the graph over the SPARQL protocol
65
+
66
+ Args:
67
+ host: the host to listen on
68
+ port: the port to listen on
69
+ """
70
+ endpoint = NpqEndpoint()
71
+ endpoint.add_graph(self.name, self.graph)
72
+ endpoint.run(host=host, port=port)