hatch-openzim 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 @@
1
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,19 @@
1
+ from hatchling.builders.hooks.plugin.interface import BuildHookInterface
2
+
3
+ from hatch_openzim.files_install import process as process_files_install
4
+
5
+
6
+ class OpenzimBuildHook(BuildHookInterface):
7
+ """Hatch build hook to perform custom openzim actions
8
+
9
+ This hook performs:
10
+ - files installation
11
+ """
12
+
13
+ PLUGIN_NAME = "openzim-build"
14
+
15
+ def initialize(self, version, build_data): # noqa: ARG002
16
+ if "toml-config" in self.config:
17
+ process_files_install(openzim_toml_location=self.config["toml-config"])
18
+ else:
19
+ process_files_install()
@@ -0,0 +1,236 @@
1
+ import shutil
2
+ import subprocess
3
+ import tempfile
4
+ import zipfile
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List
7
+ from urllib.request import urlopen
8
+
9
+ try:
10
+ import tomllib
11
+ except ImportError: # pragma: no cover
12
+ import toml as tomllib
13
+
14
+ from hatch_openzim.shared import logger
15
+
16
+ DEFAULT_OPENZIM_TOML_LOCATION = "openzim.toml"
17
+
18
+
19
+ def process(openzim_toml_location: str = DEFAULT_OPENZIM_TOML_LOCATION):
20
+ """performs openZIM operations on files, i.e. download and extract ZIPs
21
+
22
+ Configuration is read from openzim.toml which must be in root of the hatch project,
23
+ next to pyproject.toml
24
+
25
+ openzim_toml_location: location of the openzim.toml file with instructions about
26
+ files to install locally
27
+ """
28
+ config_path = Path(openzim_toml_location)
29
+ if not config_path.exists():
30
+ if openzim_toml_location != DEFAULT_OPENZIM_TOML_LOCATION:
31
+ raise Exception(f"File is missing at {openzim_toml_location}")
32
+ else:
33
+ return
34
+ config = tomllib.loads(config_path.read_text())
35
+ files_config = config.get("files", None)
36
+ if not files_config:
37
+ return
38
+
39
+ for section_name, section_data in files_config.items():
40
+ _process_section(section_name=section_name, section_data=section_data)
41
+
42
+
43
+ def _process_section(section_name: str, section_data: Dict[str, Any]):
44
+ """processes all actions required for one section (i.e. one target folder)"""
45
+
46
+ logger.info(f"Processing {section_name} section")
47
+ section_config = section_data.get("config", None)
48
+ if not section_config:
49
+ raise Exception("config table is mandatory")
50
+ base_target_dir = section_config.get("target_dir", None)
51
+ if not base_target_dir:
52
+ raise Exception("target_dir is mandatory in config table")
53
+ base_target_dir = Path(base_target_dir)
54
+ section_actions = section_data.get("actions", None)
55
+ if not section_actions:
56
+ logger.info(" No actions to process")
57
+ return
58
+ base_target_dir = Path(section_config["target_dir"])
59
+ base_target_dir.mkdir(parents=True, exist_ok=True)
60
+ logger.info(f" Installing files in {base_target_dir}")
61
+ for action_name, action_config in section_actions.items():
62
+ _process_one_action(base_target_dir, action_name, action_config)
63
+
64
+ execute_after = section_config.get("execute_after", None)
65
+ if execute_after:
66
+ _process_execute_after(base_target_dir=base_target_dir, actions=execute_after)
67
+
68
+ logger.info(" All done")
69
+
70
+
71
+ def _process_one_action(
72
+ base_target_dir: Path, action_name: str, action_data: Dict[str, Any]
73
+ ):
74
+ """processes one action (basically trigger the right kind of action)"""
75
+ logger.info(f" Processing {action_name} action")
76
+ source = action_data.get("source", None)
77
+ if not source:
78
+ raise Exception("source is not configured")
79
+ action = action_data.get("action", None)
80
+ if not action:
81
+ raise Exception("action is not configured")
82
+
83
+ if action == "get_file":
84
+ _process_get_file_action(
85
+ base_target_dir=base_target_dir, source=source, action_data=action_data
86
+ )
87
+
88
+ elif action == "extract_all":
89
+ _process_extract_all_action(
90
+ base_target_dir=base_target_dir, source=source, action_data=action_data
91
+ )
92
+
93
+ elif action == "extract_items":
94
+ _process_extract_items_action(
95
+ base_target_dir=base_target_dir, source=source, action_data=action_data
96
+ )
97
+
98
+ else:
99
+ raise Exception(f"Unsupported action '{action}'")
100
+
101
+ execute_after = action_data.get("execute_after", None)
102
+ if execute_after:
103
+ _process_execute_after(base_target_dir=base_target_dir, actions=execute_after)
104
+
105
+ logger.info(" Done")
106
+
107
+
108
+ def _process_execute_after(base_target_dir: Path, actions: List[str]):
109
+ """execute actions after file(s) installation"""
110
+
111
+ for action in actions:
112
+ logger.info(f" Executing '{action}'")
113
+ process = subprocess.run(
114
+ action,
115
+ shell=True, # noqa: S602 # nosec: B602
116
+ cwd=base_target_dir,
117
+ text=True,
118
+ check=True,
119
+ capture_output=True,
120
+ )
121
+ if process.stdout:
122
+ logger.info(f" stdout:\n{process.stdout}")
123
+
124
+
125
+ def _process_get_file_action(
126
+ base_target_dir: Path, source: str, action_data: Dict[str, Any]
127
+ ):
128
+ """downloads one file to a given location"""
129
+ target_file = action_data.get("target_file", None)
130
+ if not target_file:
131
+ raise Exception("target_file is mandatory when action='get_file'")
132
+ target_dir = action_data.get("target_dir", None)
133
+ local_dir = base_target_dir
134
+ if target_dir:
135
+ local_dir = base_target_dir / str(target_dir)
136
+ local_dir.mkdir(parents=True, exist_ok=True)
137
+ local_file = local_dir / str(target_file)
138
+ if local_file.exists():
139
+ logger.info(" Skipping, local_file is already present")
140
+ return
141
+ _download_file(source, local_file)
142
+
143
+
144
+ def _process_extract_all_action(
145
+ base_target_dir: Path, source: str, action_data: Dict[str, Any]
146
+ ):
147
+ """extacts all Zip content to a given location
148
+
149
+ Supports the remove attribute which allows to delete some Zip content
150
+ """
151
+ target_dir = action_data.get("target_dir", None)
152
+ if not target_dir:
153
+ raise Exception("target_dir is mandatory when action='extract_all'")
154
+ target_dir = base_target_dir / str(target_dir)
155
+ if target_dir.exists():
156
+ logger.info(" Skipping, target_dir is already present")
157
+ return
158
+ if not target_dir.parent.exists():
159
+ target_dir.parent.mkdir(parents=True, exist_ok=True)
160
+ _extract_zip_from_url(url=source, extract_to=target_dir)
161
+ if "remove" in action_data:
162
+ _remove_items(globs=action_data["remove"], directory=target_dir)
163
+
164
+
165
+ def _process_extract_items_action(
166
+ base_target_dir: Path, source: str, action_data: Dict[str, Any]
167
+ ):
168
+ """extacts some items of a content to some given locations
169
+
170
+ Supports the remove attribute which allows to delete some items content after
171
+ extraction
172
+ """
173
+ zip_paths = action_data.get("zip_paths", None)
174
+ if not zip_paths:
175
+ raise Exception("zip_paths is mandatory when action='extract_items'")
176
+ target_paths = action_data.get("target_paths", None)
177
+ if not target_paths:
178
+ raise Exception("target_paths is mandatory when action='extract_items'")
179
+ if len(zip_paths) != len(target_paths):
180
+ raise Exception(
181
+ f"zip_paths and target_paths must have same length ({len(zip_paths)} !="
182
+ f" {len(target_paths)})"
183
+ )
184
+
185
+ # do not re-install if asset has already been installed
186
+ if any(
187
+ (base_target_dir / str(target_path)).exists() for target_path in target_paths
188
+ ):
189
+ logger.info(" Skipping, at least one target path is already present")
190
+ return
191
+
192
+ with tempfile.TemporaryDirectory() as tempdir:
193
+ _extract_zip_from_url(url=source, extract_to=tempdir)
194
+ for index, zip_path in enumerate(zip_paths):
195
+ item_src = Path(tempdir) / str(zip_path)
196
+ item_dst = base_target_dir / str(target_paths[index])
197
+ shutil.move(src=str(item_src), dst=item_dst)
198
+
199
+ if "remove" in action_data:
200
+ _remove_items(globs=action_data["remove"], directory=base_target_dir)
201
+
202
+
203
+ def _remove_items(directory: Path, globs: List[str]):
204
+ """removes all files in directory matching one of the provided glob patterns"""
205
+ for pattern in globs:
206
+ matches = directory.glob(pattern)
207
+ for match in matches:
208
+ if match.is_file():
209
+ match.unlink()
210
+ elif match.is_dir(): # pragma: no branch
211
+ shutil.rmtree(match)
212
+
213
+
214
+ def _download_file(url, download_to):
215
+ """downloads a file to a given location"""
216
+ if not url.startswith(("http:", "https:")):
217
+ raise ValueError("URL must start with 'http:' or 'https:'")
218
+ with urlopen(url) as response, open(download_to, "wb") as file: # noqa: S310
219
+ file.write(response.read())
220
+
221
+
222
+ def _extract_zip_from_url(url, extract_to):
223
+ """downloads ZIP from URL and extract in given directory
224
+
225
+ Nota: the ZIP is temporarily saved on disk (there is no convenient function
226
+ to stream the web resource to the ZIP extractor in Python standard library)
227
+ """
228
+
229
+ if not url.startswith(("http:", "https:")):
230
+ raise ValueError("URL must start with 'http:' or 'https:'")
231
+ with urlopen(url) as response: # noqa: S310
232
+ with tempfile.NamedTemporaryFile(delete=False) as temp_zip:
233
+ shutil.copyfileobj(response, temp_zip)
234
+ with zipfile.ZipFile(temp_zip.name, "r") as zip_file:
235
+ zip_file.extractall(extract_to)
236
+ Path(temp_zip.name).unlink()
hatch_openzim/hooks.py ADDED
@@ -0,0 +1,16 @@
1
+ from typing import Type
2
+
3
+ from hatchling.plugin import hookimpl
4
+
5
+ from hatch_openzim.build_hook import OpenzimBuildHook
6
+ from hatch_openzim.metadata_hook import OpenzimMetadataHook
7
+
8
+
9
+ @hookimpl
10
+ def hatch_register_build_hook() -> Type[OpenzimBuildHook]:
11
+ return OpenzimBuildHook
12
+
13
+
14
+ @hookimpl
15
+ def hatch_register_metadata_hook() -> Type[OpenzimMetadataHook]:
16
+ return OpenzimMetadataHook
@@ -0,0 +1,63 @@
1
+ from pathlib import Path
2
+
3
+ from hatch_openzim.utils import get_github_info, get_python_versions
4
+
5
+
6
+ def update(root: str, config: dict, metadata: dict):
7
+ """Update the project table's metadata."""
8
+
9
+ # Check for absence of metadata we will set + presence in the dynamic property
10
+ for metadata_key in ["urls", "authors", "keywords", "license", "classifiers"]:
11
+ if config.get(f"preserve-{metadata_key}", False):
12
+ # Do not check if we intend to preserve the value set manually
13
+ continue
14
+
15
+ if metadata_key in metadata:
16
+ raise ValueError(
17
+ f"'{metadata_key}' must not be listed in the 'project' table when using"
18
+ " openzim metadata hook."
19
+ )
20
+ if metadata_key not in metadata.get("dynamic", []):
21
+ raise ValueError(
22
+ f"'{metadata_key}' must be listed in 'project.dynamic' when using "
23
+ "openzim metadata hook."
24
+ )
25
+
26
+ github_info = get_github_info(git_config_path=Path(root) / ".git/config")
27
+
28
+ organization = config.get("organization", github_info.organization)
29
+
30
+ if not config.get("preserve-urls", False):
31
+ metadata["urls"] = {
32
+ "Donate": "https://www.kiwix.org/en/support-us/",
33
+ "Homepage": github_info.homepage,
34
+ }
35
+
36
+ if not config.get("preserve-authors", False):
37
+ if str(organization).lower() in ("kiwix", "offspot"):
38
+ authors = [{"name": "Kiwix", "email": "dev@kiwix.org"}]
39
+ else:
40
+ authors = [{"name": "openZIM", "email": "dev@openzim.org"}]
41
+ authors.extend(config.get("additional-authors", []))
42
+ metadata["authors"] = authors
43
+
44
+ if not config.get("preserve-keywords", False):
45
+ if str(organization).lower() in ("kiwix", "offspot"):
46
+ keywords = ["kiwix"]
47
+ else:
48
+ keywords = ["openzim"]
49
+ if config.get("kind", "") == "scraper":
50
+ keywords.extend(["zim", "offline"])
51
+ keywords.extend(config.get("additional-keywords", []))
52
+ metadata["keywords"] = keywords
53
+
54
+ if not config.get("preserve-license", False):
55
+ metadata["license"] = {"text": "GPL-3.0-or-later"}
56
+
57
+ if not config.get("preserve-classifiers", False):
58
+ classifiers = [
59
+ "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)"
60
+ ]
61
+ for python_version in get_python_versions(metadata["requires-python"]):
62
+ classifiers.append(f"Programming Language :: Python :: {python_version}")
63
+ metadata["classifiers"] = classifiers
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from hatchling.metadata.plugin.interface import MetadataHookInterface
4
+
5
+ from hatch_openzim.metadata import update
6
+
7
+
8
+ class OpenzimMetadataHook(MetadataHookInterface):
9
+ """Hatch metadata hook to populate 'project' data
10
+
11
+ This hook populates:
12
+ - project urls
13
+ """
14
+
15
+ PLUGIN_NAME = "openzim-metadata"
16
+
17
+ def update(self, metadata: dict):
18
+ """Update the project table's metadata."""
19
+ update(root=self.root, config=self.config, metadata=metadata)
@@ -0,0 +1,16 @@
1
+ import logging
2
+ import os
3
+ import sys
4
+
5
+ DEFAULT_FORMAT = "%(name)s:%(levelname)s:%(message)s"
6
+
7
+ # create logger
8
+ logger = logging.getLogger("hatch_openzim")
9
+ logger.setLevel(logging.DEBUG)
10
+
11
+ # setup console logging
12
+ console_handler = logging.StreamHandler(sys.stdout)
13
+ console_handler.setFormatter(logging.Formatter(DEFAULT_FORMAT))
14
+ log_level = logging.getLevelName(os.getenv("HATCH_OPENZIM_LOG_LEVEL", "INFO"))
15
+ console_handler.setLevel(log_level)
16
+ logger.addHandler(console_handler)
hatch_openzim/utils.py ADDED
@@ -0,0 +1,80 @@
1
+ import configparser
2
+ import re
3
+ from collections import namedtuple
4
+ from pathlib import Path
5
+ from typing import List
6
+
7
+ from packaging.specifiers import SpecifierSet
8
+ from packaging.version import Version
9
+
10
+ from hatch_openzim.shared import logger
11
+
12
+ REMOTE_REGEXP = re.compile(
13
+ r"""^(?:git@|https:\/\/)github.com[:\/](?P<organization>.*?)\/"""
14
+ r"""(?P<repository>.*?)(?:.git)?$"""
15
+ )
16
+
17
+ GithubInfo = namedtuple("GithubInfo", ["homepage", "organization", "repository"])
18
+
19
+ DEFAULT_GITHUB_INFO = GithubInfo(
20
+ homepage="https://www.kiwix.org", organization=None, repository=None
21
+ )
22
+
23
+
24
+ def get_github_info(git_config_path: Path, remote: str = "origin") -> GithubInfo:
25
+ if not git_config_path.exists() or not git_config_path.is_file():
26
+ return DEFAULT_GITHUB_INFO
27
+
28
+ try:
29
+ config = configparser.ConfigParser()
30
+ config.read(git_config_path)
31
+ git_remote_url = config[f'remote "{remote}"']["url"]
32
+ match = REMOTE_REGEXP.match(git_remote_url)
33
+ if not match:
34
+ raise Exception(f"Unexpected remote url: {git_remote_url}")
35
+ return GithubInfo(
36
+ homepage=f"https://github.com/{match.group('organization')}/"
37
+ f"{match.group('repository')}",
38
+ organization=match.group("organization"),
39
+ repository=match.group("repository"),
40
+ )
41
+ except Exception as exc:
42
+ logger.error("Failed to read Github URL", exc_info=exc)
43
+ return DEFAULT_GITHUB_INFO
44
+
45
+
46
+ def get_python_versions(requires_python: str) -> List[str]:
47
+ """
48
+ Returns the list of major and major.minor versions compatible with the specifier
49
+
50
+ E.g. if requires_python is ">=3.10,<3.12", the result is "3", "3.10", "3.11"
51
+
52
+ Nota: this does not work for requirements overlapping 3.x and 4.x, or later, because
53
+ latest 3.x version is not yet known
54
+ """
55
+ specifier_set = SpecifierSet(requires_python)
56
+
57
+ last_py1_minor = 6
58
+ last_py2_minor = 7
59
+
60
+ major_versions = []
61
+ minor_versions = []
62
+ for major in range(1, 10): # this will work up to Python 10 ...
63
+ major_added = False
64
+ last_minor = 100 # this supposes we will never have Python x.100
65
+ if major == 1:
66
+ last_minor = last_py1_minor
67
+ elif major == 2: # noqa: PLR2004
68
+ last_minor = last_py2_minor
69
+ for minor in range(last_minor + 1):
70
+ if specifier_set.contains(Version(f"{major}.{minor}")):
71
+ if not major_added:
72
+ if len(major_versions) > 0 and major >= 4: # noqa: PLR2004
73
+ raise Exception(
74
+ "Multiple major versions is not supported for 3 and up"
75
+ )
76
+ major_added = True
77
+ major_versions.append(f"{major}")
78
+ minor_versions.append(f"{major}.{minor}")
79
+
80
+ return major_versions + minor_versions