linktools-setup 0.0.2__tar.gz → 0.0.3__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.
@@ -1,14 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: linktools-setup
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: linktools toolkit
5
5
  Author-email: Hu Ji <669898595@qq.com>
6
6
  License-Expression: Apache-2.0
7
7
  Project-URL: Homepage, https://github.com/linktools-toolkit/linktools-setup
8
8
  Project-URL: Repository, https://github.com/linktools-toolkit/linktools-setup.git
9
- Requires-Python: >=3.6
9
+ Requires-Python: >=3.7
10
10
  License-File: LICENSE
11
11
  Requires-Dist: pyyaml
12
12
  Requires-Dist: jinja2
13
13
  Requires-Dist: setuptools<77.0.0,>=64.0.0
14
+ Requires-Dist: tomli>=1.2.3; python_version < "3.11"
14
15
  Dynamic: license-file
@@ -5,10 +5,15 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "linktools-setup"
7
7
  description = "linktools toolkit"
8
- requires-python = ">=3.6"
8
+ requires-python = ">=3.7"
9
9
  authors = [{name = "Hu Ji", email = "669898595@qq.com"}]
10
10
  license = "Apache-2.0"
11
- dependencies = ["pyyaml", "jinja2", "setuptools>=64.0.0,<77.0.0"]
11
+ dependencies = [
12
+ "pyyaml",
13
+ "jinja2",
14
+ "setuptools>=64.0.0,<77.0.0",
15
+ "tomli>=1.2.3; python_version < '3.11'",
16
+ ]
12
17
  dynamic = ["version"]
13
18
 
14
19
  [project.urls]
@@ -18,6 +23,9 @@ Repository = "https://github.com/linktools-toolkit/linktools-setup.git"
18
23
  [project.entry-points."setuptools.finalize_distribution_options"]
19
24
  linktools_setup = "linktools_setup.setup:finalize_distribution_options"
20
25
 
26
+ [project.entry-points."setuptools.file_finders"]
27
+ linktools = "linktools_setup.setup:find_linktools_files"
28
+
21
29
  [tool.setuptools.packages.find]
22
30
  where = ["src"]
23
31
  include = ["linktools_setup*"]
@@ -1,21 +1,27 @@
1
1
  #!/usr/bin/env python3
2
2
  # -*- coding: utf-8 -*-
3
3
  import os
4
+ from typing import Any
5
+
6
+ _CONFIG_FILE = "linktools.yml"
7
+
8
+ if not os.path.isfile(_CONFIG_FILE):
9
+ raise RuntimeError("missing Linktools config: %s" % os.path.abspath(_CONFIG_FILE))
4
10
 
5
11
  from setuptools import build_meta as _build_meta
6
12
  from setuptools.build_meta import * # noqa
7
13
 
8
14
 
9
- def get_requires_for_build_editable(*args, **kwargs):
15
+ def get_requires_for_build_editable(*args: Any, **kwargs: Any) -> Any:
10
16
  os.environ["SETUP_EDITABLE_MODE"] = "true"
11
17
  return _build_meta.get_requires_for_build_editable(*args, **kwargs)
12
18
 
13
19
 
14
- def prepare_metadata_for_build_editable(*args, **kwargs):
20
+ def prepare_metadata_for_build_editable(*args: Any, **kwargs: Any) -> Any:
15
21
  os.environ["SETUP_EDITABLE_MODE"] = "true"
16
22
  return _build_meta.prepare_metadata_for_build_editable(*args, **kwargs)
17
23
 
18
24
 
19
- def build_editable(*args, **kwargs):
25
+ def build_editable(*args: Any, **kwargs: Any) -> Any:
20
26
  os.environ["SETUP_EDITABLE_MODE"] = "true"
21
27
  return _build_meta.build_editable(*args, **kwargs)
@@ -0,0 +1,585 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ import abc
4
+ import hashlib
5
+ import json
6
+ import logging
7
+ import os
8
+ import pkgutil
9
+ from importlib.util import module_from_spec
10
+ from pathlib import Path
11
+ from typing import Any, Dict, Iterable, Iterator, List, Optional, Sequence
12
+
13
+ import setuptools
14
+ import yaml
15
+ from jinja2 import Template
16
+
17
+ try:
18
+ import tomllib
19
+ except ImportError:
20
+ import tomli as tomllib
21
+
22
+ logger = logging.getLogger("linktools_setup")
23
+
24
+ _CONFIG_FILE = "linktools.yml"
25
+ _PROJECT_FILE = "pyproject.toml"
26
+ _MODULE_COMMAND_KEY = "__command__"
27
+ _CAPABILITY_GROUP = "linktools_capability"
28
+ _SCRIPTS_GROUP = "linktools_scripts"
29
+ _DEFAULT_SCRIPT_OBJECT = "command"
30
+ _DEFAULT_SCRIPT_ATTR = "main"
31
+ _SCRIPT_FIELDS = {"capability", "console", "gui", "commands"}
32
+ _SCRIPT_ITEM_FIELDS = {"name", "path", "module", "object", "attr"}
33
+ _CONVERT_FIELDS = {"type", "source", "dest"}
34
+ _CONVERT_TYPES = {"jinja2", "yml2json"}
35
+
36
+
37
+ class _UniqueKeyLoader(yaml.SafeLoader):
38
+ pass
39
+
40
+
41
+ def _construct_unique_mapping(
42
+ loader: _UniqueKeyLoader,
43
+ node: yaml.nodes.MappingNode,
44
+ deep: bool = False,
45
+ ) -> Dict[Any, Any]:
46
+ mapping: Dict[Any, Any] = {}
47
+ for key_node, value_node in node.value:
48
+ key = loader.construct_object(key_node, deep=deep)
49
+ try:
50
+ duplicated = key in mapping
51
+ except TypeError as exc:
52
+ raise ValueError("mapping keys must be scalar values") from exc
53
+ if duplicated:
54
+ raise ValueError("duplicate YAML key: %s" % key)
55
+ mapping[key] = loader.construct_object(value_node, deep=deep)
56
+ return mapping
57
+
58
+
59
+ _UniqueKeyLoader.add_constructor(
60
+ yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
61
+ _construct_unique_mapping,
62
+ )
63
+
64
+
65
+ class EntryPoint(abc.ABC):
66
+
67
+ @abc.abstractmethod
68
+ def as_script(self) -> str:
69
+ pass
70
+
71
+
72
+ class ScriptEntryPoint(EntryPoint):
73
+
74
+ def __init__(
75
+ self,
76
+ name: str,
77
+ module: str,
78
+ object: Optional[str],
79
+ attr: Optional[str],
80
+ ):
81
+ self.name = name
82
+ self.module = module
83
+ self.object = object
84
+ self.attr = attr
85
+
86
+ def as_script(self) -> str:
87
+ name = self.name.replace("_", "-")
88
+ value = self.module
89
+ if self.object:
90
+ value = "%s:%s" % (value, self.object)
91
+ if self.attr:
92
+ value = "%s.%s" % (value, self.attr)
93
+ return "%s = %s" % (name, value)
94
+
95
+
96
+ class SubScriptEntryPoint(ScriptEntryPoint):
97
+ pass
98
+
99
+
100
+ class ModuleEntryPoint(EntryPoint):
101
+
102
+ def __init__(self, name: str, module: str):
103
+ self.name = name
104
+ self.module = module
105
+
106
+ def as_script(self) -> str:
107
+ name = self.name.replace("_", "-")
108
+ return "%s = %s" % (name, self.module)
109
+
110
+
111
+ class SetupConfig:
112
+
113
+ def __init__(self, root_path: Optional[Path] = None):
114
+ self.root_path = (root_path or Path.cwd()).resolve()
115
+ self.path = self.root_path / _CONFIG_FILE
116
+ self._config = self._load()
117
+
118
+ def _load(self) -> Dict[str, Any]:
119
+ try:
120
+ with self.path.open("r", encoding="utf-8") as file:
121
+ data = yaml.load(file, Loader=_UniqueKeyLoader)
122
+ except FileNotFoundError as exc:
123
+ raise ValueError("missing Linktools config: %s" % self.path) from exc
124
+ except (OSError, ValueError, yaml.YAMLError) as exc:
125
+ raise ValueError("invalid Linktools config %s: %s" % (self.path, exc)) from exc
126
+
127
+ config = self._require_mapping(data, "config")
128
+
129
+ version = config.get("version")
130
+ if not isinstance(version, str) or not version.strip():
131
+ raise ValueError("config.version must be a non-empty string")
132
+ version = version.strip()
133
+ if version.startswith("v"):
134
+ raise ValueError("config.version must not start with 'v'")
135
+ config["version"] = version
136
+
137
+ name = config.get("name")
138
+ if name is not None and (not isinstance(name, str) or not name.strip()):
139
+ raise ValueError("config.name must be a non-empty string")
140
+ if isinstance(name, str):
141
+ config["name"] = name.strip()
142
+
143
+ for key in ("dependencies", "dev-dependencies", "release-dependencies"):
144
+ config[key] = self._require_string_list(config.get(key, []), "config.%s" % key)
145
+
146
+ optional = self._require_mapping(
147
+ config.get("optional-dependencies", {}),
148
+ "config.optional-dependencies",
149
+ )
150
+ normalized_optional: Dict[str, List[str]] = {}
151
+ for extra, requirements in optional.items():
152
+ if not isinstance(extra, str) or not extra:
153
+ raise ValueError("config.optional-dependencies keys must be non-empty strings")
154
+ normalized_optional[extra] = self._require_string_list(
155
+ requirements,
156
+ "config.optional-dependencies.%s" % extra,
157
+ )
158
+ config["optional-dependencies"] = normalized_optional
159
+
160
+ config["scripts"] = self._validate_scripts(config.get("scripts", {}))
161
+ config["convert"] = self._validate_convert(config.get("convert", []))
162
+ return config
163
+
164
+ def __getitem__(self, key: str) -> Any:
165
+ return self._config[key]
166
+
167
+ @staticmethod
168
+ def _require_mapping(value: Any, label: str) -> Dict[str, Any]:
169
+ if not isinstance(value, dict):
170
+ raise ValueError("%s must be a mapping" % label)
171
+ if not all(isinstance(key, str) for key in value):
172
+ raise ValueError("%s field names must be strings" % label)
173
+ return dict(value)
174
+
175
+ @staticmethod
176
+ def _reject_unknown(value: Dict[str, Any], allowed: set, label: str) -> None:
177
+ unknown = sorted(set(value) - allowed)
178
+ if unknown:
179
+ raise ValueError("%s has unknown field(s): %s" % (label, ", ".join(unknown)))
180
+
181
+ @staticmethod
182
+ def _require_string_list(value: Any, label: str) -> List[str]:
183
+ if not isinstance(value, list):
184
+ raise ValueError("%s must be a list of strings" % label)
185
+ result: List[str] = []
186
+ for item in value:
187
+ if not isinstance(item, str) or not item:
188
+ raise ValueError("%s must be a list of non-empty strings" % label)
189
+ result.append(item)
190
+ return result
191
+
192
+ @staticmethod
193
+ def _require_optional_string(value: Any, label: str) -> Optional[str]:
194
+ if value is None:
195
+ return None
196
+ if not isinstance(value, str) or not value:
197
+ raise ValueError("%s must be a non-empty string or null" % label)
198
+ return value
199
+
200
+ def _resolve_path(self, value: Any, label: str) -> Path:
201
+ if not isinstance(value, str) or not value:
202
+ raise ValueError("%s must be a non-empty relative path" % label)
203
+ if os.path.isabs(value):
204
+ raise ValueError("%s must be relative: %s" % (label, value))
205
+ path = (self.root_path / value).resolve()
206
+ self._require_inside_root(path, label)
207
+ return path
208
+
209
+ def _resolve_source(self, value: Any, label: str, directory: bool = False) -> str:
210
+ path = self._resolve_path(value, label)
211
+ if directory:
212
+ if path.exists() and not path.is_dir():
213
+ raise ValueError("%s must be a directory: %s" % (label, value))
214
+ elif not path.is_file():
215
+ raise ValueError("%s file does not exist: %s" % (label, value))
216
+ return value
217
+
218
+ def _resolve_dest(self, value: Any, label: str) -> str:
219
+ self._resolve_path(value, label)
220
+ return value
221
+
222
+ def _require_inside_root(self, path: Path, label: str) -> None:
223
+ try:
224
+ inside = os.path.commonpath((str(self.root_path), str(path))) == str(self.root_path)
225
+ except ValueError:
226
+ inside = False
227
+ if not inside:
228
+ raise ValueError("%s escapes project root: %s" % (label, path))
229
+
230
+ def _validate_scripts(self, value: Any) -> Dict[str, Any]:
231
+ scripts = self._require_mapping(value, "config.scripts")
232
+ self._reject_unknown(scripts, _SCRIPT_FIELDS, "config.scripts")
233
+
234
+ capability = scripts.get("capability")
235
+ if capability is not None:
236
+ if not isinstance(capability, str) or not capability:
237
+ raise ValueError("config.scripts.capability must be a non-empty string")
238
+
239
+ for group in ("console", "gui", "commands"):
240
+ if group not in scripts:
241
+ continue
242
+ raw_items = scripts[group]
243
+ items = raw_items if isinstance(raw_items, list) else [raw_items]
244
+ if not items:
245
+ raise ValueError("config.scripts.%s must not be empty" % group)
246
+ normalized = []
247
+ for index, raw_item in enumerate(items):
248
+ label = "config.scripts.%s[%d]" % (group, index)
249
+ item = self._require_mapping(raw_item, label)
250
+ self._reject_unknown(item, _SCRIPT_ITEM_FIELDS, label)
251
+ has_name = "name" in item
252
+ has_path = "path" in item
253
+ if has_name == has_path:
254
+ raise ValueError("%s must define exactly one of name or path" % label)
255
+
256
+ module = item.get("module")
257
+ if not isinstance(module, str) or not module:
258
+ raise ValueError("%s.module must be a non-empty string" % label)
259
+
260
+ if has_name:
261
+ name = item.get("name")
262
+ if not isinstance(name, str) or not name:
263
+ raise ValueError("%s.name must be a non-empty string" % label)
264
+ else:
265
+ item["path"] = self._resolve_source(
266
+ item.get("path"),
267
+ "%s.path" % label,
268
+ directory=True,
269
+ )
270
+
271
+ if "object" in item:
272
+ item["object"] = self._require_optional_string(
273
+ item.get("object"),
274
+ "%s.object" % label,
275
+ )
276
+ if "attr" in item:
277
+ item["attr"] = self._require_optional_string(
278
+ item.get("attr"),
279
+ "%s.attr" % label,
280
+ )
281
+ normalized.append(item)
282
+ scripts[group] = normalized if isinstance(raw_items, list) else normalized[0]
283
+ return scripts
284
+
285
+ def _validate_convert(self, value: Any) -> List[Dict[str, str]]:
286
+ if not isinstance(value, list):
287
+ raise ValueError("config.convert must be a list")
288
+ result: List[Dict[str, str]] = []
289
+ for index, raw_item in enumerate(value):
290
+ label = "config.convert[%d]" % index
291
+ item = self._require_mapping(raw_item, label)
292
+ self._reject_unknown(item, _CONVERT_FIELDS, label)
293
+ convert_type = item.get("type")
294
+ if convert_type not in _CONVERT_TYPES:
295
+ raise ValueError("%s.type must be one of: %s" % (
296
+ label,
297
+ ", ".join(sorted(_CONVERT_TYPES)),
298
+ ))
299
+ source = self._resolve_source(item.get("source"), "%s.source" % label)
300
+ dest = self._resolve_dest(item.get("dest"), "%s.dest" % label)
301
+ result.append({
302
+ "type": convert_type,
303
+ "source": source,
304
+ "dest": dest,
305
+ })
306
+ return result
307
+
308
+ def get(self, *keys: str, default: Any = None) -> Any:
309
+ value: Any = self._config
310
+ for key in keys:
311
+ if not isinstance(value, dict) or key not in value:
312
+ return default
313
+ value = value[key]
314
+ return value
315
+
316
+
317
+ class SetupContext:
318
+
319
+ def __init__(self, dist: setuptools.Distribution):
320
+ self._dist = dist
321
+ self.config = SetupConfig()
322
+ self.project = self._load_project()
323
+ self.groups = {
324
+ "capability": _CAPABILITY_GROUP,
325
+ "scripts": _SCRIPTS_GROUP,
326
+ }
327
+ self.release = os.environ.get("RELEASE", "false").lower() in ("true", "1", "yes")
328
+ self.develop = os.environ.get("SETUP_EDITABLE_MODE", "false").lower() in ("true", "1", "yes")
329
+ self.version = self._fill_version()
330
+ self._fill_dependencies()
331
+ self._fill_entry_points()
332
+
333
+ def _load_project(self) -> Dict[str, Any]:
334
+ path = self.config.root_path / _PROJECT_FILE
335
+ try:
336
+ with path.open("rb") as file:
337
+ data = tomllib.load(file)
338
+ except (OSError, tomllib.TOMLDecodeError) as exc:
339
+ raise ValueError("invalid project config %s: %s" % (path, exc)) from exc
340
+ project = data.get("project")
341
+ if not isinstance(project, dict):
342
+ raise ValueError("pyproject.toml must define [project]")
343
+ return dict(project)
344
+
345
+ def get_template_context(self) -> Dict[str, Any]:
346
+ return {
347
+ key: value
348
+ for key, value in vars(self).items()
349
+ if not key.startswith("_")
350
+ }
351
+
352
+ def _fill_version(self) -> str:
353
+ version = self._dist.metadata.version
354
+ if not version:
355
+ version = self.config.get("version")
356
+ self._dist.metadata.version = version
357
+ return version
358
+
359
+ def _fill_dependencies(self) -> None:
360
+ dist_install_requires = self._dist.install_requires = self._dist.metadata.install_requires = \
361
+ getattr(self._dist.metadata, "install_requires", None) or []
362
+ dist_extras_require = self._dist.extras_require = self._dist.metadata.extras_require = \
363
+ getattr(self._dist.metadata, "extras_require", None) or {}
364
+
365
+ install_requires: List[str] = []
366
+ install_requires.extend(self.config.get("dependencies", default=[]))
367
+ if self.develop:
368
+ install_requires.extend(self.config.get("dev-dependencies", default=[]))
369
+ if self.release:
370
+ install_requires.extend(self.config.get("release-dependencies", default=[]))
371
+
372
+ extras_require = {
373
+ name: list(requirements)
374
+ for name, requirements in self.config.get(
375
+ "optional-dependencies",
376
+ default={},
377
+ ).items()
378
+ }
379
+ if extras_require:
380
+ all_requires = extras_require.setdefault("all", [])
381
+ for name, requirements in extras_require.items():
382
+ if name != "all":
383
+ all_requires.extend(requirements)
384
+
385
+ dist_install_requires.extend(install_requires)
386
+ dist_extras_require.update(extras_require)
387
+
388
+ def _entry_points(self) -> Dict[str, List[str]]:
389
+ entry_points = getattr(self._dist.metadata, "entry_points", None) or {}
390
+ self._dist.entry_points = self._dist.metadata.entry_points = entry_points
391
+ return entry_points
392
+
393
+ def _fill_entry_points(self) -> None:
394
+ scripts = self.config.get("scripts", "console")
395
+ if scripts:
396
+ console_scripts = self._entry_points().setdefault("console_scripts", [])
397
+ for entry_point in self._parse_scripts(scripts):
398
+ if isinstance(entry_point, ScriptEntryPoint):
399
+ console_scripts.append(entry_point.as_script())
400
+
401
+ scripts = self.config.get("scripts", "gui")
402
+ if scripts:
403
+ gui_scripts = self._entry_points().setdefault("gui_scripts", [])
404
+ for entry_point in self._parse_scripts(scripts):
405
+ if isinstance(entry_point, ScriptEntryPoint):
406
+ gui_scripts.append(entry_point.as_script())
407
+
408
+ scripts = self.config.get("scripts", "commands")
409
+ if scripts:
410
+ entry_points = self._entry_points()
411
+ console_scripts = entry_points.setdefault("console_scripts", [])
412
+ linktools_scripts = entry_points.setdefault(_SCRIPTS_GROUP, [])
413
+ for entry_point in self._parse_scripts(scripts):
414
+ if isinstance(entry_point, ScriptEntryPoint):
415
+ console_scripts.append(entry_point.as_script())
416
+ if not isinstance(entry_point, SubScriptEntryPoint):
417
+ linktools_scripts.append(entry_point.as_script())
418
+
419
+ capability = self.config.get("scripts", "capability")
420
+ if capability:
421
+ linktools_module = self._entry_points().setdefault(
422
+ _CAPABILITY_GROUP,
423
+ [],
424
+ )
425
+ linktools_module.append(ModuleEntryPoint(
426
+ name="module-%s" % self.get_md5(capability),
427
+ module=capability,
428
+ ).as_script())
429
+
430
+ def _parse_scripts(self, scripts: Any) -> Iterator[EntryPoint]:
431
+ items = scripts if isinstance(scripts, list) else [scripts]
432
+ for script in items:
433
+ yield from self._parse_script(script)
434
+
435
+ def _parse_script(self, script: Dict[str, Any]) -> Iterator[EntryPoint]:
436
+ if "name" in script:
437
+ yield ScriptEntryPoint(
438
+ name=script["name"],
439
+ module=script["module"],
440
+ object=script.get("object", _DEFAULT_SCRIPT_OBJECT),
441
+ attr=script.get("attr", _DEFAULT_SCRIPT_ATTR),
442
+ )
443
+ return
444
+
445
+ module = script["module"].rstrip(".")
446
+ path = str((self.config.root_path / script["path"]).resolve())
447
+ yield ModuleEntryPoint(
448
+ name="module-%s" % self.get_md5(script["path"]),
449
+ module=module,
450
+ )
451
+ yield from self._iter_module_scripts(
452
+ path=path,
453
+ prefix="%s." % module,
454
+ object=script.get("object", _DEFAULT_SCRIPT_OBJECT),
455
+ attr=script.get("attr", _DEFAULT_SCRIPT_ATTR),
456
+ )
457
+
458
+ def _iter_module_scripts(
459
+ self,
460
+ path: str,
461
+ prefix: str,
462
+ object: Optional[str],
463
+ attr: Optional[str],
464
+ parents: Optional[Sequence[str]] = None,
465
+ ) -> Iterator[EntryPoint]:
466
+ for module_info in pkgutil.iter_modules([path]):
467
+ if module_info.ispkg:
468
+ spec = module_info.module_finder.find_spec(module_info.name)
469
+ if spec is None or spec.loader is None:
470
+ raise ValueError("cannot load command package: %s" % module_info.name)
471
+ module = module_from_spec(spec)
472
+ spec.loader.exec_module(module)
473
+ items = list(parents or [])
474
+ items.append(getattr(module, _MODULE_COMMAND_KEY, module_info.name))
475
+ yield from self._iter_module_scripts(
476
+ path=os.path.join(path, module_info.name),
477
+ prefix="%s%s." % (prefix, module_info.name),
478
+ object=object,
479
+ attr=attr,
480
+ parents=items,
481
+ )
482
+ elif not module_info.name.startswith("_"):
483
+ yield SubScriptEntryPoint(
484
+ name="%s-%s" % ("-".join(parents), module_info.name)
485
+ if parents else module_info.name,
486
+ module="%s%s" % (prefix, module_info.name),
487
+ object=object,
488
+ attr=attr,
489
+ )
490
+
491
+ def convert_files(self) -> None:
492
+ context = self.get_template_context()
493
+ for item in self.config.get("convert", default=[]):
494
+ source = self.config.root_path / item["source"]
495
+ dest = self.config.root_path / item["dest"]
496
+ dest.parent.mkdir(parents=True, exist_ok=True)
497
+ if item["type"] == "jinja2":
498
+ with source.open("r", encoding="utf-8") as fd_in:
499
+ rendered = Template(fd_in.read()).render(**context)
500
+ dest.write_text(rendered, encoding="utf-8")
501
+ elif item["type"] == "yml2json":
502
+ with source.open("r", encoding="utf-8") as fd_in:
503
+ data = yaml.safe_load(fd_in)
504
+ if not isinstance(data, dict):
505
+ raise ValueError("yml2json source must contain a mapping: %s" % item["source"])
506
+ result = {}
507
+ for key, value in data.items():
508
+ if not isinstance(key, str):
509
+ raise ValueError("yml2json source keys must be strings: %s" % item["source"])
510
+ if not key.startswith("$"):
511
+ result[key] = value
512
+ with dest.open("w", encoding="utf-8") as fd_out:
513
+ json.dump(result, fd_out)
514
+
515
+ @staticmethod
516
+ def get_md5(data: Any) -> str:
517
+ if isinstance(data, str):
518
+ data = data.encode()
519
+ if not isinstance(data, bytes):
520
+ raise TypeError("data must be str or bytes")
521
+ digest = hashlib.md5()
522
+ digest.update(data)
523
+ return digest.hexdigest()
524
+
525
+
526
+ def _finder_path(dirname: str, relative: str) -> str:
527
+ normalized = os.path.normpath(relative)
528
+ if dirname:
529
+ return os.path.join(dirname, normalized)
530
+ return normalized
531
+
532
+
533
+ def find_linktools_files(dirname: str) -> Iterable[str]:
534
+ root = Path(dirname or ".")
535
+ config_path = root / _CONFIG_FILE
536
+ if not config_path.is_file():
537
+ return []
538
+
539
+ result = [_finder_path(dirname, _CONFIG_FILE)]
540
+ try:
541
+ with config_path.open("r", encoding="utf-8") as file:
542
+ data = yaml.safe_load(file)
543
+ if not isinstance(data, dict):
544
+ return result
545
+ convert = data.get("convert", [])
546
+ if not isinstance(convert, list):
547
+ return result
548
+
549
+ root_resolved = root.resolve()
550
+ for item in convert:
551
+ if not isinstance(item, dict):
552
+ continue
553
+ source = item.get("source")
554
+ if not isinstance(source, str) or not source or os.path.isabs(source):
555
+ continue
556
+ normalized = os.path.normpath(source)
557
+ if normalized == os.pardir or normalized.startswith(os.pardir + os.sep):
558
+ continue
559
+ candidate = (root_resolved / normalized).resolve()
560
+ try:
561
+ inside = os.path.commonpath((str(root_resolved), str(candidate))) == str(root_resolved)
562
+ except ValueError:
563
+ inside = False
564
+ if inside and candidate.is_file():
565
+ result.append(_finder_path(dirname, normalized))
566
+ except (OSError, ValueError, TypeError, yaml.YAMLError) as exc:
567
+ logger.warning("cannot inspect %s for source files: %s", config_path, exc)
568
+ return result
569
+
570
+
571
+ def finalize_distribution_options(dist: setuptools.Distribution) -> None:
572
+ if not (Path.cwd() / _CONFIG_FILE).is_file():
573
+ return
574
+ context = SetupContext(dist)
575
+ context.convert_files()
576
+
577
+
578
+ if __name__ == "__main__":
579
+ context = SetupContext(setuptools.Distribution())
580
+ scripts = {"name": "ct-cntr", "module": "linktools_cntr.__main__"}
581
+ print([
582
+ entry_point.as_script()
583
+ for entry_point in context._parse_scripts(scripts)
584
+ if not isinstance(entry_point, SubScriptEntryPoint)
585
+ ])
@@ -1,14 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: linktools-setup
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: linktools toolkit
5
5
  Author-email: Hu Ji <669898595@qq.com>
6
6
  License-Expression: Apache-2.0
7
7
  Project-URL: Homepage, https://github.com/linktools-toolkit/linktools-setup
8
8
  Project-URL: Repository, https://github.com/linktools-toolkit/linktools-setup.git
9
- Requires-Python: >=3.6
9
+ Requires-Python: >=3.7
10
10
  License-File: LICENSE
11
11
  Requires-Dist: pyyaml
12
12
  Requires-Dist: jinja2
13
13
  Requires-Dist: setuptools<77.0.0,>=64.0.0
14
+ Requires-Dist: tomli>=1.2.3; python_version < "3.11"
14
15
  Dynamic: license-file
@@ -1,5 +1,4 @@
1
1
  LICENSE
2
- MANIFEST.in
3
2
  pyproject.toml
4
3
  setup.py
5
4
  src/linktools_setup/__init__.py
@@ -1,2 +1,5 @@
1
+ [setuptools.file_finders]
2
+ linktools = linktools_setup.setup:find_linktools_files
3
+
1
4
  [setuptools.finalize_distribution_options]
2
5
  linktools_setup = linktools_setup.setup:finalize_distribution_options
@@ -1,3 +1,6 @@
1
1
  pyyaml
2
2
  jinja2
3
3
  setuptools<77.0.0,>=64.0.0
4
+
5
+ [:python_version < "3.11"]
6
+ tomli>=1.2.3
@@ -1,2 +0,0 @@
1
- global-exclude *.pyc
2
- global-exclude *.user
@@ -1,298 +0,0 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- import abc
4
- import hashlib
5
- import json
6
- import logging
7
- import os
8
- import pkgutil
9
- import re
10
- from importlib.util import module_from_spec
11
- from pathlib import Path
12
- from typing import Any, Optional
13
-
14
- import setuptools
15
- import yaml
16
- from jinja2 import Template
17
- from setuptools.config.pyprojecttoml import load_file
18
-
19
- logger = logging.getLogger("linktools_setup")
20
-
21
-
22
- class EntryPoint(abc.ABC):
23
-
24
- @abc.abstractmethod
25
- def as_script(self) -> str:
26
- pass
27
-
28
-
29
- class ScriptEntryPoint(EntryPoint):
30
-
31
- def __init__(self, name: str, module: str, object: Optional[str], attr: Optional[str]):
32
- self.name = name
33
- self.module = module
34
- self.object = object
35
- self.attr = attr
36
-
37
- def as_script(self) -> str:
38
- name = self.name.replace('_', '-')
39
- value = self.module
40
- if self.object:
41
- value = f"{value}:{self.object}"
42
- if self.attr:
43
- value = f"{value}.{self.attr}"
44
- return f"{name} = {value}"
45
-
46
-
47
- class SubScriptEntryPoint(ScriptEntryPoint):
48
- pass
49
-
50
-
51
- class ModuleEntryPoint(EntryPoint):
52
-
53
- def __init__(self, name: str, module: str):
54
- self.name = name
55
- self.module = module
56
-
57
- def as_script(self) -> str:
58
- name = self.name.replace('_', '-')
59
- value = self.module
60
- return f"{name} = {value}"
61
-
62
-
63
- class SetupConst:
64
-
65
- def __init__(self):
66
- self.module_command_key = "__command__"
67
- self.capability_entrypoint = "linktools_capability"
68
- self.scripts_entrypoint = "linktools_scripts"
69
- self.default_script_object = "command"
70
- self.default_script_attr = "main"
71
-
72
-
73
- class SetupConfig:
74
-
75
- def __init__(self):
76
- self._config = load_file("pyproject.toml")
77
-
78
- def get(self, *keys: str, default=None) -> Any:
79
- missing = object()
80
- value = self._config
81
- try:
82
- for key in keys:
83
- value = value.get(key, missing)
84
- if value is missing:
85
- return default
86
- except Exception as e:
87
- logger.warning(f"get {keys} failed: {e}")
88
- return default
89
- return value
90
-
91
-
92
- class SetupContext:
93
-
94
- def __init__(self, dist: setuptools.Distribution):
95
- self.dist = dist
96
- self.const = SetupConst()
97
- self.config = SetupConfig()
98
- self.release = os.environ.get("RELEASE", "false").lower() in ("true", "1", "yes")
99
- self.develop = os.environ.get("SETUP_EDITABLE_MODE", "false").lower() in ("true", "1", "yes")
100
- self.version = self._fill_version()
101
- self._fill_dependencies()
102
- self._fill_entry_points()
103
-
104
- def _fill_version(self):
105
- version = self.dist.metadata.version
106
- if not version:
107
- version = os.environ.get("VERSION", None)
108
- if not version:
109
- file = self.config.get("tool", "linktools", "version", "file")
110
- if file:
111
- path = os.path.abspath(file)
112
- if os.path.isfile(path):
113
- with open(path, encoding="utf-8") as fd:
114
- version = fd.read().strip()
115
- if not version:
116
- version = "0.0.1"
117
- if version.startswith("v"):
118
- version = version[len("v"):]
119
- if not self.release:
120
- items = []
121
- for item in version.split("."):
122
- find = re.findall(r"^\d+", item)
123
- if find:
124
- items.append(int(find[0]))
125
- version = ".".join(map(str, items))
126
- version = f"{version}.post100.dev0"
127
- self.dist.metadata.version = version
128
- return version
129
-
130
- def _fill_dependencies(self):
131
- file = self.config.get("tool", "linktools", "dependencies", "file")
132
- if file:
133
- dist_install_requires = self.dist.install_requires = self.dist.metadata.install_requires = \
134
- getattr(self.dist.metadata, "install_requires", None) or []
135
- dist_extras_require = self.dist.extras_require = self.dist.metadata.extras_require = \
136
- getattr(self.dist.metadata, "extras_require", None) or {}
137
-
138
- install_requires, extras_require = [], {}
139
- with open(file, "rt", encoding="utf-8") as fd:
140
- data = yaml.safe_load(fd)
141
- # install_requires = dependencies + dev-dependencies
142
- install_requires.extend(data.get("dependencies", []))
143
- if self.develop:
144
- install_requires.extend(data.get("dev-dependencies", []))
145
- if self.release:
146
- install_requires.extend(data.get("release-dependencies", []))
147
- # extras_require = optional-dependencies
148
- extras_require.update(data.get("optional-dependencies", {}))
149
- if extras_require:
150
- all_requires = extras_require.setdefault("all", [])
151
- for requires in extras_require.values():
152
- all_requires.extend(requires)
153
-
154
- dist_install_requires.extend(install_requires)
155
- dist_extras_require.update(extras_require)
156
-
157
- def _fill_entry_points(self):
158
- scripts = self.config.get("tool", "linktools", "scripts", "console")
159
- if scripts:
160
- dist_entry_points = self.dist.entry_points = self.dist.metadata.entry_points = \
161
- getattr(self.dist.metadata, "entry_points", None) or {}
162
- console_scripts = dist_entry_points.setdefault("console_scripts", [])
163
- for entry_point in self._parse_scripts(scripts):
164
- if isinstance(entry_point, ScriptEntryPoint):
165
- console_scripts.append(entry_point.as_script())
166
-
167
- scripts = self.config.get("tool", "linktools", "scripts", "gui")
168
- if scripts:
169
- dist_entry_points = self.dist.entry_points = self.dist.metadata.entry_points = \
170
- getattr(self.dist.metadata, "entry_points", None) or {}
171
- console_scripts = dist_entry_points.setdefault("gui_scripts", [])
172
- for entry_point in self._parse_scripts(scripts):
173
- if isinstance(entry_point, ScriptEntryPoint):
174
- console_scripts.append(entry_point.as_script())
175
-
176
- scripts = self.config.get("tool", "linktools", "scripts", "commands")
177
- if scripts:
178
- dist_entry_points = self.dist.entry_points = self.dist.metadata.entry_points = \
179
- getattr(self.dist.metadata, "entry_points", None) or {}
180
- console_scripts = dist_entry_points.setdefault("console_scripts", [])
181
- linktools_scripts = dist_entry_points.setdefault(self.const.scripts_entrypoint, [])
182
- for entry_point in self._parse_scripts(scripts):
183
- if isinstance(entry_point, ScriptEntryPoint):
184
- console_scripts.append(entry_point.as_script())
185
- if not isinstance(entry_point, SubScriptEntryPoint):
186
- linktools_scripts.append(entry_point.as_script())
187
-
188
- capability = self.config.get("tool", "linktools", "scripts", "capability")
189
- if capability:
190
- dist_entry_points = self.dist.entry_points = self.dist.metadata.entry_points = \
191
- getattr(self.dist.metadata, "entry_points", None) or {}
192
- linktools_module = dist_entry_points.setdefault(self.const.capability_entrypoint, [])
193
- linktools_module.append(ModuleEntryPoint(
194
- name=f"module-{self.get_md5(capability)}",
195
- module=capability,
196
- ).as_script())
197
-
198
- def _parse_scripts(self, scripts):
199
- if not isinstance(scripts, (list, tuple, set)):
200
- scripts = [scripts]
201
- for script in scripts:
202
- yield from self._parse_script(script)
203
-
204
- def _parse_script(self, script):
205
- if "name" in script:
206
- yield ScriptEntryPoint(
207
- name=script.get("name"),
208
- module=script.get("module"),
209
- object=script.get("object", self.const.default_script_object),
210
- attr=script.get("object", self.const.default_script_attr)
211
- )
212
- elif "path" in script:
213
- module = script.get("module").rstrip(".")
214
- path = script.get("path")
215
- if not os.path.isfile(path):
216
- yield ModuleEntryPoint(
217
- name=f"module-{self.get_md5(path)}",
218
- module=module,
219
- )
220
- yield from self._iter_module_scripts(
221
- path=path,
222
- prefix=f"{module}.",
223
- object=script.get("object", self.const.default_script_object),
224
- attr=script.get("object", self.const.default_script_attr),
225
- )
226
-
227
- def _iter_module_scripts(self, path, prefix, object, attr, parents=None):
228
- for module_info in pkgutil.iter_modules([path]):
229
- if module_info.ispkg:
230
- spec = module_info.module_finder.find_spec(module_info.name)
231
- module = module_from_spec(spec)
232
- spec.loader.exec_module(module)
233
- items = list(parents or [])
234
- items.append(getattr(module, self.const.module_command_key, module_info.name))
235
- yield from self._iter_module_scripts(
236
- path=os.path.join(path, module_info.name),
237
- prefix=f"{prefix}{module_info.name}.",
238
- object=object,
239
- attr=attr,
240
- parents=items,
241
- )
242
- elif not module_info.name.startswith("_"):
243
- yield SubScriptEntryPoint(
244
- name=f"{'-'.join(parents)}-{module_info.name}" if parents else module_info.name,
245
- module=f"{prefix}{module_info.name}",
246
- object=object,
247
- attr=attr,
248
- )
249
-
250
- def convert_files(self):
251
- convert = self.config.get("tool", "linktools", "convert")
252
- if convert:
253
- for item in convert:
254
- type = item.get("type")
255
- source = item.get("source")
256
- dest = item.get("dest")
257
- if type == "jinja2":
258
- with open(source, "rt", encoding="utf-8") as fd_in:
259
- Path(dest).parent.mkdir(parents=True, exist_ok=True)
260
- with open(dest, "wt", encoding="utf-8") as fd_out:
261
- fd_out.write(Template(fd_in.read()).render(
262
- metadata=self.dist.metadata,
263
- **{k: v for k, v in vars(self).items() if k[0] not in "_"},
264
- ))
265
- elif type == "yml2json":
266
- with open(source, "rb") as fd_in:
267
- Path(dest).parent.mkdir(parents=True, exist_ok=True)
268
- with open(dest, "wt") as fd_out:
269
- json.dump({
270
- key: value
271
- for key, value in yaml.safe_load(fd_in).items()
272
- if key[0] not in ("$",)
273
- }, fd_out)
274
-
275
- def get_md5(self, data):
276
- if isinstance(data, str):
277
- data = data.encode()
278
- m = hashlib.md5()
279
- m.update(data)
280
- return m.hexdigest()
281
-
282
-
283
- def finalize_distribution_options(dist: setuptools.Distribution) -> None:
284
- context = SetupContext(dist)
285
- context.convert_files()
286
-
287
-
288
- if __name__ == '__main__':
289
- context = SetupContext(setuptools.Distribution())
290
-
291
- # scripts = [{
292
- # "path": os.path.expanduser("~/Projects/linktools/src/linktools/cli/commands"),
293
- # "module": "linktools.cli.commands",
294
- # }]
295
- scripts = {"name": "ct-cntr", "module": "linktools_cntr.__main__"}
296
- # print([ep.as_script() for ep in context._parse_scripts(scripts)])
297
- # print([ep.as_script() for ep in context._parse_scripts(scripts) if isinstance(ep, ScriptEntryPoint)])
298
- print([ep.as_script() for ep in context._parse_scripts(scripts) if not isinstance(ep, SubScriptEntryPoint)])
File without changes