Skip to content

Commit b6bcefc

Browse files
authored
Merge pull request #5 from agoose77/feat-add-metadata-hook
2 parents 30693c2 + 2e4fef4 commit b6bcefc

File tree

7 files changed

+473
-86
lines changed

7 files changed

+473
-86
lines changed

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,29 @@ The [version source plugin](https://hatch.pypa.io/latest/plugins/version-source/
7171
|---------------| --- |---------------|--------------------------------------------|
7272
| `path` | `str` | `package.json` | Relative path to the `package.json` file. |
7373

74+
75+
## Metadata hook
76+
77+
The [metadata hook plugin](https://hatch.pypa.io/dev/plugins/metadata-hook/reference/) name is `nodejs`.
78+
79+
- ***pyproject.toml***
80+
81+
```toml
82+
[tool.hatch.metadata.hooks.nodejs]
83+
```
84+
85+
- ***hatch.toml***
86+
87+
```toml
88+
[metadata.hooks.nodejs]
89+
```
90+
91+
### Version source options
92+
93+
| Option | Type | Default | Description |
94+
|----------|-----------------|---------|---------------------------------------------------------------------|
95+
| `fields` | `list` of `str` | None | Optional list of fields to take from the generated metadata object. |
96+
7497
## License
7598

7699
`hatch-nodejs-version` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.

hatch_nodejs_version/hooks.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,14 @@
44
from hatchling.plugin import hookimpl
55

66
from .version_source import NodeJSVersionSource
7+
from .metadata_source import NodeJSMetadataHook
78

89

910
@hookimpl
1011
def hatch_register_version_source():
1112
return NodeJSVersionSource
13+
14+
15+
@hookimpl
16+
def hatch_register_metadata_hook():
17+
return NodeJSMetadataHook
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# SPDX-FileCopyrightText: 2022-present Angus Hollands <[email protected]>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
from __future__ import annotations
5+
6+
import json
7+
import os.path
8+
import re
9+
import urllib.parse
10+
from typing import Any
11+
12+
from hatchling.metadata.plugin.interface import MetadataHookInterface
13+
14+
AUTHOR_PATTERN = r"^([^<(]+?)?[ \t]*(?:<([^>(]+?)>)?[ \t]*(?:\(([^)]+?)\)|$)"
15+
REPOSITORY_PATTERN = r"^(?:(gist|bitbucket|gitlab|github):)?(.*?)$"
16+
REPOSITORY_TABLE = {
17+
"gitlab": "https://gitlab.com",
18+
"github": "https://github.com",
19+
"gist": "https://gist.github.com",
20+
"bitbucket": "https://bitbucket.org",
21+
}
22+
23+
24+
class NodeJSMetadataHook(MetadataHookInterface):
25+
PLUGIN_NAME = "nodejs"
26+
27+
def __init__(self, *args, **kwargs):
28+
super().__init__(*args, **kwargs)
29+
30+
self.__path = None
31+
self.__fields = None
32+
self.__contributors_as_maintainers = None
33+
34+
@property
35+
def path(self) -> str:
36+
if self.__path is None:
37+
version_file = self.config.get("path", "package.json")
38+
if not isinstance(version_file, str):
39+
raise TypeError(
40+
"Option `path` for build hook `{}` must be a string".format(
41+
self.PLUGIN_NAME
42+
)
43+
)
44+
45+
self.__path = version_file
46+
47+
return self.__path
48+
49+
@property
50+
def fields(self) -> None | set[str]:
51+
if self.__fields is None:
52+
fields = self.config.get("fields", None)
53+
if fields is None:
54+
self.__fields = None
55+
else:
56+
if not (
57+
isinstance(fields, list) and all(isinstance(f, str) for f in fields)
58+
):
59+
raise TypeError(
60+
"Option `fields` for build hook `{}` must be a list of strings".format(
61+
self.PLUGIN_NAME
62+
)
63+
)
64+
self.__fields = set(fields)
65+
return self.__fields
66+
67+
@property
68+
def contributors_as_maintainers(self) -> bool:
69+
if self.__contributors_as_maintainers is None:
70+
self.__contributors_as_maintainers = self.config.get(
71+
"contributors-as-maintainers", True
72+
)
73+
return self.__contributors_as_maintainers
74+
75+
def load_package_data(self):
76+
path = os.path.normpath(os.path.join(self.root, self.path))
77+
if not os.path.isfile(path):
78+
raise OSError(f"file does not exist: {self.path}")
79+
80+
with open(path, "r", encoding="utf-8") as f:
81+
return json.load(f)
82+
83+
def _parse_bugs(self, bugs: str | dict[str, str]) -> str | None:
84+
if isinstance(bugs, str):
85+
return bugs
86+
87+
if "url" not in bugs:
88+
return None
89+
90+
return bugs["url"]
91+
92+
def _parse_person(self, person: dict[str, str]) -> dict[str, str]:
93+
if {"url", "email"} & person.keys():
94+
result = {"name": person["name"]}
95+
if "email" in person:
96+
result["email"] = person["email"]
97+
else:
98+
match = re.match(AUTHOR_PATTERN, person["name"])
99+
if match is None:
100+
raise ValueError(f"Invalid author name: {person['name']}")
101+
name, email, _ = match.groups()
102+
result = {"name": name}
103+
if email is not None:
104+
result["email"] = email
105+
106+
return result
107+
108+
def _parse_repository(self, repository: str | dict[str, str]) -> str:
109+
if isinstance(repository, str):
110+
match = re.match(REPOSITORY_PATTERN, repository)
111+
if match is None:
112+
raise ValueError(f"Invalid repository string: {repository}")
113+
kind, identifier = match.groups()
114+
if kind is None:
115+
kind = "github"
116+
return urllib.parse.urljoin(REPOSITORY_TABLE[kind], identifier)
117+
118+
return repository["url"]
119+
120+
def update(self, metadata: dict[str, Any]):
121+
package = self.load_package_data()
122+
123+
new_metadata = {"name": package["name"]}
124+
125+
authors = None
126+
maintainers = None
127+
128+
if "author" in package:
129+
authors = [self._parse_person(package["author"])]
130+
131+
if "contributors" in package:
132+
contributors = [
133+
self._parse_person(p) for p in package["contributors"]
134+
]
135+
if self.contributors_as_maintainers:
136+
maintainers = contributors
137+
else:
138+
authors = [*(authors or []), *contributors]
139+
140+
if authors is not None:
141+
new_metadata['authors'] = authors
142+
143+
if maintainers is not None:
144+
new_metadata['maintainers'] = maintainers
145+
146+
if "keywords" in package:
147+
new_metadata["keywords"] = package["keywords"]
148+
149+
if "description" in package:
150+
new_metadata["description"] = package["description"]
151+
152+
if "license" in package:
153+
new_metadata["license"] = package["license"]
154+
155+
# Construct URLs
156+
urls = {}
157+
if "homepage" in package:
158+
urls["homepage"] = package["homepage"]
159+
if "bugs" in package:
160+
bugs_url = self._parse_bugs(package["bugs"])
161+
if bugs_url is not None:
162+
urls["bug tracker"] = bugs_url
163+
if "repository" in package:
164+
urls["repository"] = self._parse_repository(package["repository"])
165+
166+
# Write URLs
167+
if urls:
168+
new_metadata["urls"] = urls
169+
170+
# Only use required metadata
171+
metadata.update(
172+
{
173+
k: v
174+
for k, v in new_metadata.items()
175+
if (self.fields is None or k in self.fields)
176+
}
177+
)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ authors = [
1010
{name = "Angus Hollands", email = "[email protected]"},
1111
]
1212
dependencies = []
13-
requires-python = ">=3.6"
13+
requires-python = ">= 3.7"
1414
readme = "README.md"
1515
license = {text = "MIT"}
1616

tests/conftest.py

Lines changed: 27 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
#
44
# SPDX-License-Identifier: MIT
55
import errno
6+
import os
67
import shutil
78
import stat
89
import tempfile
910
from contextlib import contextmanager
10-
import os
11+
import pathlib
12+
1113
import pytest
1214

1315

@@ -21,6 +23,27 @@ def create_file(path, contents):
2123
f.write(contents)
2224

2325

26+
@contextmanager
27+
def create_project(directory):
28+
project_dir = directory / "my-app"
29+
os.mkdir(project_dir)
30+
31+
package_dir = project_dir / "my_app"
32+
os.mkdir(package_dir)
33+
34+
touch_file(package_dir / "__init__.py")
35+
touch_file(package_dir / "foo.py")
36+
touch_file(package_dir / "bar.py")
37+
touch_file(package_dir / "baz.py")
38+
39+
origin = os.getcwd()
40+
os.chdir(project_dir)
41+
try:
42+
yield project_dir
43+
finally:
44+
os.chdir(origin)
45+
46+
2447
def handle_remove_readonly(func, path, exc): # no cov
2548
# PermissionError: [WinError 5] Access is denied: '...\\.git\\...'
2649
if func in (os.rmdir, os.remove, os.unlink) and exc[1].errno == errno.EACCES:
@@ -34,62 +57,13 @@ def handle_remove_readonly(func, path, exc): # no cov
3457
def temp_dir():
3558
directory = tempfile.mkdtemp()
3659
try:
37-
directory = os.path.realpath(directory)
60+
directory = pathlib.Path(os.path.realpath(directory))
3861
yield directory
3962
finally:
4063
shutil.rmtree(directory, ignore_errors=False, onerror=handle_remove_readonly)
4164

4265

43-
@contextmanager
44-
def create_project(directory, metadata, version):
45-
project_dir = os.path.join(directory, "my-app")
46-
os.mkdir(project_dir)
47-
48-
project_file = os.path.join(project_dir, "pyproject.toml")
49-
create_file(project_file, metadata)
50-
51-
package_dir = os.path.join(project_dir, "my_app")
52-
os.mkdir(package_dir)
53-
54-
package_file = os.path.join(project_dir, "package.json")
55-
package = f"""
56-
{{
57-
"name": "my-awesome-package",
58-
"version": "{version}"
59-
}}
60-
"""
61-
create_file(package_file, package)
62-
63-
other_package_file = os.path.join(project_dir, "other-package.json")
64-
create_file(other_package_file, package)
65-
66-
touch_file(os.path.join(package_dir, "__init__.py"))
67-
touch_file(os.path.join(package_dir, "foo.py"))
68-
touch_file(os.path.join(package_dir, "bar.py"))
69-
touch_file(os.path.join(package_dir, "baz.py"))
70-
71-
origin = os.getcwd()
72-
os.chdir(project_dir)
73-
try:
74-
yield project_dir
75-
finally:
76-
os.chdir(origin)
77-
78-
7966
@pytest.fixture
80-
def new_project(temp_dir, request):
81-
with create_project(
82-
temp_dir,
83-
"""\
84-
[build-system]
85-
requires = ["hatchling", "hatch-vcs"]
86-
build-backend = "hatchling.build"
87-
[project]
88-
name = "my-app"
89-
dynamic = ["version"]
90-
[tool.hatch.version]
91-
source = "nodejs"
92-
""",
93-
request.param,
94-
) as project:
67+
def project(temp_dir):
68+
with create_project(temp_dir) as project:
9569
yield project

0 commit comments

Comments
 (0)