-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathextension.py
More file actions
273 lines (209 loc) 路 9.67 KB
/
Copy pathextension.py
File metadata and controls
273 lines (209 loc) 路 9.67 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
"""Main logic to create custom extensions"""
from functools import partial, reduce, wraps
from pathlib import Path
from typing import List
from packaging.version import Version
from pyscaffold import dependencies as deps
from pyscaffold.actions import Action, ActionParams, ScaffoldOpts, Structure, invoke
from pyscaffold.extensions import Extension, include
from pyscaffold.extensions.cirrus import Cirrus
from pyscaffold.extensions.namespace import Namespace
from pyscaffold.extensions.no_skeleton import NoSkeleton
from pyscaffold.extensions.pre_commit import PreCommit
from pyscaffold.log import logger
from pyscaffold.operations import no_overwrite
from pyscaffold.structure import (
Leaf,
ResolvedLeaf,
merge,
reify_content,
reify_leaf,
resolve_leaf,
)
from pyscaffold.templates import get_template
from pyscaffold.update import ConfigUpdater, pyscaffold_version
from . import templates
PYSCAFFOLDEXT_NS = "pyscaffoldext"
EXTENSION_FILE_NAME = "extension"
NO_OVERWRITE = no_overwrite()
DOC_REQUIREMENTS = ["pyscaffold"]
TEST_DEPENDENCIES = (
"tox",
"pre-commit",
"setuptools_scm",
"virtualenv",
"configupdater",
"pytest",
"pytest-cov",
"pytest-xdist",
)
INVALID_PROJECT_NAME = (
"The prefix ``pyscaffoldext-`` will be added to the package name "
"(as in PyPI/pip install). "
"If that is not your intention, please use ``--force`` to overwrite."
)
"""Project name does not comply with convention of an extension"""
template = partial(get_template, relative_to=templates)
class NamespaceError(RuntimeError):
"""No additional namespace is allowed"""
DEFAULT_MESSAGE = (
"It's not possible to define a custom namespace "
"when using ``--custom-extension``."
)
def __init__(self, message=DEFAULT_MESSAGE, *args, **kwargs):
super().__init__(message, *args, **kwargs)
class CustomExtension(Extension):
"""Configures a project to start creating extensions"""
def augment_cli(self, parser):
"""Augments the command-line interface parser
A command line argument ``--FLAG`` where FLAG=``self.name`` is added
which appends ``self.activate`` to the list of extensions. As help
text the docstring of the extension class is used.
In most cases this method does not need to be overwritten.
Args:
parser: current parser object
"""
parser.add_argument(
self.flag,
help=self.help_text,
nargs=0,
action=include(NoSkeleton(), Namespace(), PreCommit(), self),
)
return self
def activate(self, actions: List[Action]) -> List[Action]:
"""Activate extension, see :obj:`~pyscaffold.extension.Extension.activate`."""
actions = self.register(actions, process_options, after="get_default_options")
actions = self.register(actions, add_doc_requirements)
actions = self.register(actions, add_files)
# Let's postpone adding CI, and just add Cirrus by default if the user has
# not chosen a different service
cirrus_actions = [a for a in Cirrus().activate(actions) if a not in actions]
add_ci = wraps(add_cirrus_ci)(partial(add_cirrus_ci, cirrus_actions))
return self.register(actions, add_ci, before="create_structure")
def process_options(struct: Structure, opts: ScaffoldOpts) -> ActionParams:
"""Process the given options enforcing policies and calculating derived ones.
Policies:
- Fixed ``namespace`` value of pyscaffoldext (and no extra namespace)
- The project name must start with ``pyscaffoldext-``.
- The package name shouldn't contain the redundant ``pyscaffoldext_`` in the
beginning of the name.
See :obj:`pyscaffold.actions.Action`.
"""
opts = opts.copy()
namespace = opts.setdefault("namespace", PYSCAFFOLDEXT_NS)
if namespace != PYSCAFFOLDEXT_NS:
raise NamespaceError()
if not opts["name"].startswith("pyscaffoldext-") and not opts["force"]:
logger.warning(INVALID_PROJECT_NAME)
opts["name"] = "pyscaffoldext-" + opts["name"]
project = opts["project_path"]
if not project.name.startswith("pyscaffoldext-"):
opts["project_path"] = project.parent / ("pyscaffoldext-" + project.name)
if opts["package"].startswith("pyscaffoldext_"):
opts["package"] = opts["package"].replace("pyscaffoldext_", "")
opts["requirements"] = deps.add(opts.get("requirements", []), get_requirements())
# set another derived parameter used in the templates
class_name = "".join(map(str.capitalize, opts["package"].split("_")))
return struct, {**opts, "extension_class_name": class_name}
def add_files(struct: Structure, opts: ScaffoldOpts) -> ActionParams:
"""Add custom extension files. See :obj:`pyscaffold.actions.Action`"""
files: Structure = {
"README.rst": (template("readme"), NO_OVERWRITE),
"CONTRIBUTING.rst": (template("contributing"), NO_OVERWRITE),
"setup.cfg": modify_setupcfg(struct["setup.cfg"], opts),
"src": {
opts["package"]: {
f"{EXTENSION_FILE_NAME}.py": (template("extension"), NO_OVERWRITE)
}
},
"tests": {
"__init__.py": ("", NO_OVERWRITE),
"conftest.py": (template("conftest"), NO_OVERWRITE),
"helpers.py": (template("helpers"), NO_OVERWRITE),
"test_custom_extension.py": (
template("test_custom_extension"),
NO_OVERWRITE,
),
},
}
return merge(struct, files), opts
def modify_setupcfg(definition: Leaf, opts: ScaffoldOpts) -> ResolvedLeaf:
"""Modify setup.cfg to add install_requires and pytest settings before it is
written.
See :obj:`pyscaffold.operations`.
"""
contents, original_op = resolve_leaf(definition)
if contents is None:
raise ValueError("File contents for setup.cfg should not be None")
setupcfg = ConfigUpdater()
setupcfg.read_string(reify_content(contents, opts))
modifiers = (add_pytest_requirements, add_entry_point)
new_setupcfg = reduce(lambda acc, fn: fn(acc, opts), modifiers, setupcfg)
return str(new_setupcfg), original_op
def add_entry_point(setupcfg: ConfigUpdater, opts: ScaffoldOpts) -> ConfigUpdater:
"""Adds the extension's entry_point to setup.cfg"""
entry_points_key = "options.entry_points"
if not setupcfg.has_section(entry_points_key):
setupcfg["options"].add_after.section(entry_points_key)
entry_points = setupcfg[entry_points_key]
entry_points.insert_at(0).option("pyscaffold.cli")
template = "{package} = {namespace}.{package}.{file_name}:{extension_class_name}"
value = template.format(file_name=EXTENSION_FILE_NAME, **opts)
entry_points["pyscaffold.cli"].set_values([value])
return setupcfg
def add_pytest_requirements(setupcfg: ConfigUpdater, _opts) -> ConfigUpdater:
"""Add [options.extras_require] testing requirements for py.test"""
extras_require = setupcfg["options.extras_require"]
extras_require["testing"].set_values(TEST_DEPENDENCIES)
return setupcfg
def add_doc_requirements(struct: Structure, opts: ScaffoldOpts) -> ActionParams:
"""In order to build the docs new requirements are necessary now.
The default ``tox.ini`` generated by PyScaffold should already include
``-e {toxinidir}/docs/requirements.txt`` in its dependencies. Therefore,
this action will make sure ``tox -e docs`` run without problems.
It is important to sort the requirements otherwise pre-commit will raise an error
for a newly generated file and that would correspond to a bad user experience.
"""
leaf = struct.get("docs", {}).get("requirements.txt")
original, file_op = reify_leaf(leaf, opts)
contents = original or ""
missing = [req for req in DOC_REQUIREMENTS if req not in contents]
requirements = [*contents.splitlines(), *missing]
# It is not trivial to sort the requirements because they include a comment header
j = (i for (i, line) in enumerate(requirements) if line and not is_commented(line))
comments_end = next(j, 0) # first element of the iterator is a non commented line
comments = requirements[:comments_end]
sorted_requirements = sorted(requirements[comments_end:])
new_contents = "\n".join([*comments, *sorted_requirements]) + "\n"
# ^ pre-commit requires a new line at the end of the file
files: Structure = {"docs": {"requirements.txt": (new_contents, file_op)}}
return merge(struct, files), opts
def add_cirrus_ci(
cirrus_actions: List[Action], struct: Structure, opts: ScaffoldOpts
) -> ActionParams:
"""Opportunistically add CirrusCI config if no other CI service was added."""
uses_github_actions = struct.get(".github", {}).get("workflows") is not None
other_ci_files = [".gitlab-ci.yml"]
if uses_github_actions or any(f in struct for f in other_ci_files):
return struct, opts
# No other CI service is active, let's add Cirrus + publish-package workflow
files = {
".github": {
"workflows": {
"publish-package.yml": (template("publish_package"), NO_OVERWRITE)
}
}
}
struct = merge(struct, files)
return reduce(invoke, cirrus_actions, (struct, opts))
def get_requirements() -> List[str]:
"""List of requirements for install_requires"""
current_version = Version(pyscaffold_version)
major, minor, *_ = current_version.base_version.split(".")
next_major = int(major) + 1
min_version = Version(f"{major}.{minor}")
if current_version.is_prerelease:
min_version = current_version
return [f"pyscaffold>={min_version.public},<{next_major}.0a0"]
def is_commented(line):
return line.strip().startswith("#")