technologydata 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.
@@ -0,0 +1,430 @@
1
+ # SPDX-FileCopyrightText: technologydata contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """
6
+ Source class for representing bibliographic and web sources, with archiving support.
7
+
8
+ Examples
9
+ --------
10
+ >>> src = Source(title="Example Source", authors="The Authors")
11
+ >>> src._store_in_wayback()
12
+ >>> src.retrieve_from_wayback()
13
+
14
+ """
15
+
16
+ import logging
17
+ import pathlib
18
+ from typing import Annotated, Any
19
+
20
+ import pydantic
21
+ import requests
22
+ import savepagenow
23
+
24
+ import technologydata
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class Source(pydantic.BaseModel):
30
+ """
31
+ Represent a data source, including bibliographic and web information.
32
+
33
+ Attributes
34
+ ----------
35
+ title : str
36
+ Title of the source.
37
+ authors : str
38
+ Authors of the source.
39
+ url : Optional[str]
40
+ URL of the source.
41
+ url_archive : Optional[str]
42
+ Archived URL.
43
+ url_date : Optional[str]
44
+ Date the URL was accessed.
45
+ url_date_archive : Optional[str]
46
+ Date the URL was archived.
47
+
48
+ """
49
+
50
+ title: Annotated[str, pydantic.Field(description="Title of the source.")]
51
+ authors: Annotated[str, pydantic.Field(description="Authors of the source.")]
52
+ url: Annotated[str | None, pydantic.Field(description="URL of the source.")] = None
53
+ url_archive: Annotated[str | None, pydantic.Field(description="Archived URL.")] = (
54
+ None
55
+ )
56
+ url_date: Annotated[
57
+ str | None, pydantic.Field(description="Date the URL was accessed.")
58
+ ] = None
59
+ url_date_archive: Annotated[
60
+ str | None, pydantic.Field(description="Date the URL was archived.")
61
+ ] = None
62
+
63
+ def __eq__(self, other: object) -> bool:
64
+ """
65
+ Check for equality with another Source object.
66
+
67
+ Compares all attributes of the current instance with those of the other object.
68
+
69
+ Parameters
70
+ ----------
71
+ other : object
72
+ The object to compare with. Expected to be an instance of Source.
73
+
74
+ Returns
75
+ -------
76
+ bool
77
+ True if all non-None attributes are equal between self and other, False otherwise.
78
+ Returns False if other is not a Source instance.
79
+
80
+ """
81
+ if not isinstance(other, Source):
82
+ return NotImplemented
83
+
84
+ if not isinstance(other, Source):
85
+ logger.error("The object is not a Source instance.")
86
+ return False
87
+
88
+ for field in self.__class__.model_fields.keys():
89
+ value_self = getattr(self, field)
90
+ value_other = getattr(other, field)
91
+ if value_self != value_other:
92
+ return False
93
+ return True
94
+
95
+ def __hash__(self) -> int:
96
+ """
97
+ Return a hash value for the Source instance based on all attributes.
98
+
99
+ This method computes a combined hash of the instance's attributes to
100
+ uniquely identify the object in hash-based collections such as sets and dictionaries.
101
+
102
+ Returns
103
+ -------
104
+ int
105
+ The hash value of the Source instance.
106
+
107
+ """
108
+ # Retrieve all attribute values dynamically
109
+ attribute_values = tuple(
110
+ getattr(self, field) for field in self.__class__.model_fields.keys()
111
+ )
112
+ return hash(attribute_values)
113
+
114
+ def __str__(self) -> str:
115
+ """
116
+ Return a string representation of the Source, including all available attributes.
117
+
118
+ Returns
119
+ -------
120
+ str
121
+ A string detailing the source's information.
122
+
123
+ """
124
+ parts = [f"'{self.authors}': '{self.title}'"]
125
+ if self.url:
126
+ parts.append(f"from url '{self.url}'")
127
+ if self.url_date:
128
+ parts.append(f"last accessed on '{self.url_date}'")
129
+ if self.url_archive:
130
+ parts.append(f"archived at '{self.url_archive}'")
131
+ if self.url_date_archive:
132
+ parts.append(f"on '{self.url_date_archive}'.")
133
+ return ", ".join(parts)
134
+
135
+ def ensure_in_wayback(self) -> None:
136
+ """
137
+ Ensure that the source URL is archived in the Wayback Machine.
138
+
139
+ This method checks if the source's `url` attribute is set and whether
140
+ an archived URL or archive date is already present. If neither is available, it attempts to archive the
141
+ URL using the Wayback Machine and updates the corresponding attributes.
142
+
143
+ Returns
144
+ -------
145
+ None
146
+ This method updates the Source object's `url_archive` and `url_date_archive` attributes in place.
147
+
148
+ Raises
149
+ ------
150
+ ValueError
151
+ If the `url` attribute is not set (None or NaN).
152
+
153
+ Examples
154
+ --------
155
+ >>> from technologydata import Source
156
+ >>> source = Source(url="http://example.com", title="Example Site", authors="The Authors")
157
+ >>> source.ensure_in_wayback()
158
+ A new snapshot has been stored for the url http://example.com with timestamp 2023-10-01T12:00:00Z and Archive.org url http://web.archive.org/web/20231001120000/http://example.com.
159
+ >>> source.url_archive
160
+ 'http://web.archive.org/web/20231001120000/http://example.com'
161
+ >>> source.url_date_archive
162
+ '2023-10-01T12:00:00Z'
163
+
164
+ """
165
+ if self.url is None:
166
+ raise ValueError(
167
+ f"The url attribute of the source {self.title} is not set or contains a NaN value."
168
+ )
169
+
170
+ if self.url_archive is None and self.url_date_archive is None:
171
+ archived_info = self._store_in_wayback(self.url)
172
+ if archived_info is not None:
173
+ archived_url, new_capture_flag, timestamp = archived_info
174
+ if new_capture_flag:
175
+ logger.info(
176
+ f"A new snapshot has been stored for the url {self.url} with timestamp {timestamp} and Archive.org url {archived_url}."
177
+ )
178
+ else:
179
+ logger.info(
180
+ f"There is already a snapshot for the url {self.url} with timestamp {timestamp} and Archive.org url {archived_url}."
181
+ )
182
+ self.url_date_archive = timestamp
183
+ self.url_archive = archived_url
184
+
185
+ @staticmethod
186
+ def _store_in_wayback(
187
+ url_to_archive: str,
188
+ ) -> tuple[Any, bool | None, str | None] | None:
189
+ """
190
+ Store a snapshot of the given URL on the Wayback Machine and extract the timestamp.
191
+
192
+ The method captures the specified URL using the Wayback Machine and retrieves the
193
+ corresponding archive URL along with a formatted timestamp. The timestamp is extracted
194
+ from the archive URL and converted to a more readable format.
195
+
196
+ Parameters
197
+ ----------
198
+ url_to_archive : str
199
+ The URL that you want to archive on the Wayback Machine.
200
+
201
+ Returns
202
+ -------
203
+ tuple[str, bool, str] | None
204
+ A tuple containing the archive URL, a boolean indicating if a new capture was conducted (if the boolean is
205
+ True, archive.org conducted a new capture. If it is False, archive.org has returned a recently cached capture
206
+ instead, likely taken in the previous minutes) and the formatted timestamp (with format YYYY-MM-DD hh:mm:ss)
207
+ if the operation is successful. Returns None if the timestamp cannot be extracted due to a ValueError (e.g.,
208
+ if the expected substrings are not found in the archive URL).
209
+
210
+ Examples
211
+ --------
212
+ >>> from technologydata import Source
213
+ >>> some_url = "some_url"
214
+ >>> archived_info = Source._store_in_wayback(some_url)
215
+
216
+ """
217
+ archive_url = savepagenow.capture_or_cache(url_to_archive)
218
+ try:
219
+ # The timestamp is between "web/" and the next "/" afterward
220
+ # Find the starting index of "web/"
221
+ start_index = archive_url[0].index("web/") + len("web/")
222
+ # Find the ending index of the timestamp by locating the next "/" after the start_index
223
+ end_index = archive_url[0].index("/", start_index)
224
+ # Extract the timestamp substring
225
+ timestamp = archive_url[0][start_index:end_index]
226
+ output_timestamp = technologydata.Commons.change_datetime_format(
227
+ timestamp,
228
+ technologydata.DateFormatEnum.SOURCES_CSV,
229
+ )
230
+ return archive_url[0], archive_url[1], output_timestamp
231
+ except ValueError:
232
+ # If "web/" or next "/" not found, return empty string
233
+ return None
234
+
235
+ def retrieve_from_wayback(
236
+ self, download_directory: pathlib.Path
237
+ ) -> pathlib.Path | None:
238
+ """
239
+ Download a file from the Wayback Machine and save it to a specified path.
240
+
241
+ The method retrieves an archived file from the Wayback Machine using the URL
242
+ from the url_archive attribute of the instance. The file is saved in the
243
+ specified format based on its Content-Type field in the Response Header or the extension
244
+ that can be extracted from the URL.
245
+
246
+ Parameters
247
+ ----------
248
+ download_directory : pathlib.Path
249
+ The base path where the file will be saved.
250
+
251
+
252
+ Returns
253
+ -------
254
+ pathlib.Path | None
255
+ The specified path where the file is stored, or None if an error occurs.
256
+
257
+ Raises
258
+ ------
259
+ requests.exceptions.RequestException
260
+ If there is an issue with the HTTP request.
261
+
262
+ Notes
263
+ -----
264
+ - The attribute "url_archived" should contain a valid URL.
265
+
266
+ Examples
267
+ --------
268
+ >>> from technologydata import Source
269
+ >>> source = Source(title="example01", authors="The Authors")
270
+ >>> output_path = source.retrieve_from_wayback(pathlib.Path("base_path"))
271
+
272
+ """
273
+ if self.url_archive is None:
274
+ logger.error(
275
+ f"The url_archive attribute of source {self.title} is not set."
276
+ )
277
+ return None
278
+ if download_directory is None:
279
+ logger.error(f"The base path of the source {self.title} is not set.")
280
+ return None
281
+
282
+ source_title = technologydata.Commons.replace_special_characters(self.title)
283
+ save_path = self._get_save_path(
284
+ self.url_archive, download_directory, source_title
285
+ )
286
+
287
+ if save_path is None:
288
+ logger.warning(
289
+ f"It was not possible to determine a file path for the source {source_title}."
290
+ )
291
+ return None
292
+
293
+ if save_path.is_file():
294
+ logger.warning(
295
+ f"There is already a file stored at the path {save_path}. Not downloading or overwriting this file."
296
+ )
297
+ return None
298
+
299
+ storage_path = self._download_file(self.url_archive, save_path)
300
+ return storage_path
301
+
302
+ @staticmethod
303
+ def _get_save_path(
304
+ url_archived: str, source_path: pathlib.Path, source_title: str
305
+ ) -> pathlib.Path | None:
306
+ """
307
+ Determine the save path based on the content type or archived URL.
308
+
309
+ The method retrieves the content type of the archived URL and determines the appropriate
310
+ file extension based on the content type or based on the archived URL. It constructs the full save path using
311
+ the provided source path and source title.
312
+
313
+ Parameters
314
+ ----------
315
+ url_archived : str
316
+ The URL of the archived file from which the content type will be determined.
317
+ source_path : pathlib.Path
318
+ The base path where the file will be saved.
319
+ source_title : str
320
+ The title of the given source from sources.csv, used as the filename.
321
+
322
+ Returns
323
+ -------
324
+ pathlib.Path | None
325
+ The full path where the file should be saved, including the appropriate file extension,
326
+ or None if the content type is unsupported or an error occurs.
327
+
328
+ Raises
329
+ ------
330
+ ValueError
331
+ If the extension is not among the supported ones.
332
+
333
+ """
334
+ content_type = Source._get_content_type(url_archived)
335
+ if content_type is None:
336
+ return None
337
+
338
+ extension = technologydata.FileExtensionEnum.get_extension(
339
+ content_type
340
+ ) or technologydata.FileExtensionEnum.search_file_extension_in_url(url_archived)
341
+ if extension is None:
342
+ raise ValueError(
343
+ f"Unable to infer file extension from content type: {content_type} or URL: {url_archived}"
344
+ )
345
+
346
+ if source_path is not None and source_title is not None:
347
+ return pathlib.Path(source_path, source_title + extension)
348
+ else:
349
+ return None
350
+
351
+ @staticmethod
352
+ def _get_content_type(url_archived: str) -> Any:
353
+ """
354
+ Fetch the content type of the archived URL.
355
+
356
+ The method sends a HEAD request to the specified archived URL to retrieve the
357
+ Content-Type from the response headers. It returns the content type as a string
358
+ if the request is successful; otherwise, it logs an error and returns None.
359
+
360
+ Parameters
361
+ ----------
362
+ url_archived : str
363
+ The URL of the archived resource for which the content type is to be fetched.
364
+
365
+ Returns
366
+ -------
367
+ str | None
368
+ The Content-Type of the archived URL if the request is successful, or None
369
+ if an error occurs during the request.
370
+
371
+ Raises
372
+ ------
373
+ requests.exceptions.RequestException
374
+ If there is an issue with the HTTP request, an error is logged, and None is returned.
375
+
376
+ """
377
+ try:
378
+ response = requests.head(url_archived)
379
+ response.raise_for_status()
380
+ return response.headers.get("Content-Type")
381
+ except requests.exceptions.RequestException as e:
382
+ raise requests.exceptions.RequestException(
383
+ f"Failed to retrieve content type: {e}"
384
+ )
385
+
386
+ @staticmethod
387
+ def _download_file(
388
+ url_archived: str, save_path: pathlib.Path
389
+ ) -> pathlib.Path | None:
390
+ """
391
+ Download the file and save it to the specified path.
392
+
393
+ The method retrieves the content from the specified archived URL and saves it
394
+ to the provided file path. It handles HTTP errors and logs appropriate messages
395
+ based on the outcome of the download operation.
396
+
397
+ Parameters
398
+ ----------
399
+ url_archived : str
400
+ The URL of the archived file to be downloaded.
401
+
402
+ save_path : pathlib.Path
403
+ The path where the downloaded file will be saved, including the file name.
404
+
405
+ Returns
406
+ -------
407
+ pathlib.Path | None
408
+ The path where the file has been saved if the download is successful, or None
409
+ if an error occurs during the download process.
410
+
411
+ Raises
412
+ ------
413
+ requests.exceptions.RequestException
414
+ If there is an issue with the HTTP request, an error is logged, and None is returned.
415
+
416
+ """
417
+ try:
418
+ response = requests.get(url_archived)
419
+ response.raise_for_status() # Check for HTTP errors
420
+
421
+ with open(save_path, "wb") as file:
422
+ file.write(response.content)
423
+
424
+ logger.info(f"File downloaded successfully and saved to {save_path}")
425
+ return save_path
426
+ except requests.exceptions.RequestException as e:
427
+ requests.exceptions.RequestException(
428
+ f"An error occurred during file download: {e}"
429
+ )
430
+ return None
@@ -0,0 +1,243 @@
1
+ # SPDX-FileCopyrightText: technologydata contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """SourceCollection class for representing an iterable of Source Objects."""
6
+
7
+ import csv
8
+ import json
9
+ import pathlib
10
+ import re
11
+ from collections.abc import Iterator
12
+ from typing import Annotated, Self
13
+
14
+ import pandas
15
+ import pydantic
16
+ import pydantic_core
17
+
18
+ from technologydata.source import Source
19
+
20
+
21
+ class SourceCollection(pydantic.BaseModel):
22
+ """
23
+ Represent a collection of sources.
24
+
25
+ Attributes
26
+ ----------
27
+ sources : List[Source]
28
+ List of Source objects.
29
+
30
+ """
31
+
32
+ sources: Annotated[
33
+ list[Source], pydantic.Field(description="List of Source objects.")
34
+ ]
35
+
36
+ def __iter__(self) -> Iterator["Source"]: # type: ignore
37
+ """
38
+ Return an iterator over the list of Source objects.
39
+
40
+ Returns
41
+ -------
42
+ Iterator[Source]
43
+ An iterator over the Source objects contained in the collection.
44
+
45
+ """
46
+ return iter(self.sources)
47
+
48
+ def __len__(self) -> int:
49
+ """
50
+ Return the number of sources in this collection.
51
+
52
+ Returns
53
+ -------
54
+ int
55
+ The number of Source objects in the sources list.
56
+
57
+ """
58
+ return len(self.sources)
59
+
60
+ def __str__(self) -> str:
61
+ """
62
+ Return a string representation of the SourceCollection.
63
+
64
+ Returns
65
+ -------
66
+ str
67
+ A string representation of the SourceCollection, showing the number of sources.
68
+
69
+ """
70
+ sources_str = ", ".join(str(source) for source in self.sources)
71
+ return f"SourceCollection with {len(self.sources)} sources: {sources_str}"
72
+
73
+ def get(self, title: str, authors: str) -> Self:
74
+ """
75
+ Filter sources based on regex patterns for non-optional attributes.
76
+
77
+ Parameters
78
+ ----------
79
+ title : str
80
+ Regex pattern to filter titles.
81
+ authors : str
82
+ Regex pattern to filter authors.
83
+
84
+ Returns
85
+ -------
86
+ SourceCollection
87
+ A new SourceCollection with filtered sources.
88
+
89
+ """
90
+ filtered_sources = self.sources
91
+
92
+ if title is not None:
93
+ pattern_title = re.compile(title, re.IGNORECASE)
94
+ filtered_sources = [
95
+ s for s in filtered_sources if pattern_title.search(s.title)
96
+ ]
97
+
98
+ if authors is not None:
99
+ pattern_authors = re.compile(authors, re.IGNORECASE)
100
+ filtered_sources = [
101
+ s for s in filtered_sources if pattern_authors.search(s.authors)
102
+ ]
103
+
104
+ return SourceCollection(sources=filtered_sources) # type: ignore
105
+
106
+ def retrieve_all_from_wayback(
107
+ self, download_directory: pathlib.Path
108
+ ) -> list[pathlib.Path | None]:
109
+ """
110
+ Download archived files for all sources in the collection using retrieve_from_wayback.
111
+
112
+ Parameters
113
+ ----------
114
+ download_directory : pathlib.Path
115
+ The base directory where all files will be saved.
116
+
117
+ Returns
118
+ -------
119
+ list[pathlib.Path | None]
120
+ List of paths where each file was stored, or None if download failed for a source.
121
+
122
+ """
123
+ return [
124
+ source.retrieve_from_wayback(download_directory) for source in self.sources
125
+ ]
126
+
127
+ def to_dataframe(self) -> pandas.DataFrame:
128
+ """
129
+ Convert the SourceCollection to a pandas DataFrame.
130
+
131
+ Returns
132
+ -------
133
+ pd.DataFrame
134
+ A DataFrame containing the source data.
135
+
136
+ """
137
+ return pandas.DataFrame([source.model_dump() for source in self.sources])
138
+
139
+ def to_csv(self, **kwargs: pathlib.Path | str | bool) -> None:
140
+ """
141
+ Export the SourceCollection to a CSV file.
142
+
143
+ Parameters
144
+ ----------
145
+ **kwargs : dict
146
+ Additional keyword arguments passed to pandas.DataFrame.to_csv().
147
+ Common options include:
148
+ - path_or_buf : str or pathlib.Path or file-like object, optional
149
+ File path or object, if None, the result is returned as a string.
150
+ Default is None.
151
+ - sep : str
152
+ String of length 1. Field delimiter for the output file.
153
+ Default is ','.
154
+ - index : bool
155
+ Write row names (index). Default is True.
156
+ - encoding : str
157
+ String representing the encoding to use in the output file.
158
+ Default is 'utf-8'.
159
+
160
+ Notes
161
+ -----
162
+ The method converts the collection to a pandas DataFrame using
163
+ `self.to_dataframe()` and then writes it to a CSV file using the provided
164
+ kwargs.
165
+
166
+ """
167
+ default_kwargs = {
168
+ "sep": ",",
169
+ "index": False,
170
+ "encoding": "utf-8",
171
+ "quoting": csv.QUOTE_ALL,
172
+ }
173
+
174
+ # Merge default_kwargs with user-provided kwargs, giving precedence to user kwargs
175
+ merged_kwargs = {**default_kwargs, **kwargs}
176
+ output_dataframe = self.to_dataframe()
177
+ output_dataframe.to_csv(**merged_kwargs)
178
+
179
+ def to_json(
180
+ self,
181
+ file_path: pathlib.Path,
182
+ schema_path: pathlib.Path | None = None,
183
+ output_schema: bool = False,
184
+ ) -> None:
185
+ """
186
+ Export the SourceCollection to a JSON file, together with a data schema.
187
+
188
+ Parameters
189
+ ----------
190
+ file_path : pathlib.Path
191
+ The path to the JSON file to be created.
192
+ schema_path : pathlib.Path
193
+ The path to the JSON schema file to be created. By default, created with a `schema` suffix next to `file_path`.
194
+ output_schema : bool, default False
195
+ If True, generates a JSON schema file describing the data structure.
196
+ The schema will include field descriptions and type information.
197
+
198
+ """
199
+ if output_schema:
200
+ if schema_path is None:
201
+ schema_path = file_path.with_suffix(".schema.json")
202
+
203
+ # Export the model's schema with descriptions to a dict
204
+ schema = self.model_json_schema()
205
+
206
+ # Save the schema (which includes descriptions) to a JSON file
207
+ with open(schema_path, "w") as f:
208
+ json.dump(schema, f, indent=4)
209
+
210
+ with open(file_path, mode="w", encoding="utf-8") as jsonfile:
211
+ json_data = self.model_dump_json(indent=4) # Convert to JSON string
212
+ jsonfile.write(json_data)
213
+
214
+ @classmethod
215
+ def from_json(
216
+ cls,
217
+ file_path: pathlib.Path | str,
218
+ ) -> Self:
219
+ """
220
+ Import the SourceCollection from a JSON file.
221
+
222
+ Parameters
223
+ ----------
224
+ file_path : pathlib.Path | str
225
+ The path to the JSON file to be imported.
226
+
227
+ """
228
+ if isinstance(file_path, (pathlib.Path | str)):
229
+ file_path = pathlib.Path(file_path)
230
+ else:
231
+ raise TypeError("file_path must be a pathlib.Path or str")
232
+
233
+ json_data = None
234
+
235
+ # Load data from file if file_path is provided
236
+ with open(file_path, encoding="utf-8") as jsonfile:
237
+ json_data = jsonfile.read()
238
+
239
+ # pydantic_core.from_json return Any. Therefore, typing.cast makes sure that
240
+ # the output is indeed a TechnologyCollection
241
+ return cls.model_validate(
242
+ pydantic_core.from_json(json_data, allow_partial=True)
243
+ )
@@ -0,0 +1,5 @@
1
+ # SPDX-FileCopyrightText: technologydata contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """Provide models to create scenario-specific technology projections."""