-
Notifications
You must be signed in to change notification settings - Fork 6
/
templating.py
225 lines (183 loc) · 8.38 KB
/
templating.py
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
import pathlib
import tempfile
from typing import List, Optional, Tuple
import funcy as fy
from cookiecutter.main import cookiecutter as _cookiecutter
from github import Github, GithubException
import ballet.util.git
from ballet.compat import PathLike
from ballet.exc import BalletError, ConfigurationError
from ballet.project import Project, detect_github_username
from ballet.util.fs import pwalk, synctree
from ballet.util.git import (
DEFAULT_BRANCH, push_branches_to_remote, switch_to_new_branch,)
from ballet.util.log import logger
from ballet.util.typing import Pathy
from ballet.validation.project_structure.checks import (
FEATURE_MODULE_NAME_REGEX, SUBPACKAGE_NAME_REGEX,)
TEMPLATES_PATH = pathlib.Path(__file__).resolve().parent.joinpath('templates')
FEATURE_TEMPLATE_PATH = TEMPLATES_PATH.joinpath('feature_template')
PROJECT_TEMPLATE_PATH = TEMPLATES_PATH.joinpath('project_template')
def _stringify_path(obj) -> str:
return str(obj) if isinstance(obj, PathLike) else obj
@fy.wraps(_cookiecutter)
def cookiecutter(*args, **kwargs) -> str:
"""Call cookiecutter.main.cookiecutter after stringifying paths
Return:
project directory path
"""
args = fy.walk(_stringify_path, args)
kwargs = fy.walk_values(_stringify_path, kwargs)
return _cookiecutter(*args, **kwargs)
def render_project_template(
project_template_path: Optional[Pathy] = None,
create_github_repo: bool = False,
github_token: Optional[str] = None,
**cc_kwargs
) -> str:
"""Generate a ballet project according to the project template
If creating the GitHub repo is requested and the process fails for any
reason, quickstart will complete successfully and users are instructed
to read the corresponding section of the Maintainer's Guide to continue
manually.
Args:
project_template_path: path to specific project template
create_github_repo: whether to act to create the desired repo on
GitHub after rendering the project. The repo will be owned by
either the user or an org that the user has relevant permissions
for, depending on what is entered during the quickstart prompts.
If True, then a valid github token must also be provided.
github_token: valid github token with appropriate permissions
**cc_kwargs: options for the cookiecutter template
"""
if project_template_path is None:
project_template_path = PROJECT_TEMPLATE_PATH
project_path = cookiecutter(project_template_path, **cc_kwargs)
if create_github_repo:
if github_token is None:
raise ValueError('Need to provide github token')
g = Github(github_token)
# need to get params from new project config
project = Project.from_path(project_path)
owner = project.config.get('github.github_owner')
name = project.config.get('project.project_slug')
# create repo on github
try:
github_repo = ballet.util.git.create_github_repo(g, owner, name)
logger.info(f'Created repo on GitHub at {github_repo.html_url}')
except GithubException:
logger.exception('Failed to create GitHub repo for this project')
logger.warning(
'Failed to create GitHub repo for this project...\n'
'did you specify the intended repo owner, and do you have'
' permissions to create a repo under that owner?\n'
'Try manually creating the repo: https://ballet.github.io/ballet/maintainer_guide.html#manual-repository-creation' # noqa E501
)
return project_path
# now push to remote
# we don't need to set up the remote, as it has already been setup in
# post_gen_hook.py
local_repo = project.repo
remote_name = project.config.get('github.remote')
branches = [DEFAULT_BRANCH]
try:
push_branches_to_remote(local_repo, remote_name, branches)
except BalletError:
logger.exception('Failed to push branches to GitHub repo')
logger.warning(
'Failed to push branches to GitHub repo...\n'
'Try manually pushing the branches: https://ballet.github.io/ballet/maintainer_guide.html#manual-repository-creation' # noqa E501
)
return project_path
return project_path
def render_feature_template(**cc_kwargs) -> str:
"""Create a stub for a new feature
Args:
**cc_kwargs: options for the cookiecutter template
"""
feature_template_path = FEATURE_TEMPLATE_PATH
return cookiecutter(feature_template_path, **cc_kwargs)
def _fail_if_feature_exists(dst: pathlib.Path) -> None:
subpackage_name, feature_name = str(dst.parent), str(dst.name)
if (
dst.is_file()
and fy.re_test(SUBPACKAGE_NAME_REGEX, subpackage_name)
and fy.re_test(FEATURE_MODULE_NAME_REGEX, feature_name)
):
raise FileExistsError(f'The feature already exists here: {dst}')
def start_new_feature(
contrib_dir: Pathy = None,
branching: bool = True,
**cc_kwargs
) -> List[Tuple[pathlib.Path, str]]:
"""Start a new feature within a ballet project
If run from default branch, by default will attempt to switch to a new
branch for this feature, given by `<username>/feature-<featurename>`. By
default, will prompt the user for input using cookiecutter's input
interface.
Renders the feature template into a temporary directory, then copies the
feature files into the proper path within the contrib directory.
Args:
contrib_dir: directory under which to place contributed features
branching: whether to attempt to manage branching
**cc_kwargs: options for the cookiecutter template
Raises:
ballet.exc.BalletError: the new feature has the same name as an
existing one
"""
if contrib_dir is not None:
try:
project = Project.from_path(contrib_dir, ascend=True)
default_username = detect_github_username(project)
except ConfigurationError:
default_username = 'username'
else:
project = Project.from_cwd()
contrib_dir = project.config.get('contrib.module_path')
default_username = detect_github_username(project)
# inject default username into context
cc_kwargs.setdefault('extra_context', {})
cc_kwargs['extra_context'].update({'_default_username': default_username})
with tempfile.TemporaryDirectory() as tempdir:
# render feature template
output_dir = tempdir
cc_kwargs['output_dir'] = output_dir
rendered_dir = render_feature_template(**cc_kwargs)
# clean pyc files from rendered dir
for path in pwalk(rendered_dir, topdown=False):
if path.suffix == '.pyc':
path.unlink()
if path.name == '__pycache__':
with fy.suppress(OSError):
path.rmdir()
# copy into contrib dir
src = rendered_dir
dst = contrib_dir
result = synctree(src, dst, onexist=_fail_if_feature_exists)
target_branch = None
if branching and project.on_master:
# try to set the target branch name
paths = [path for path, kind in result if kind == 'file']
for path in paths:
parts = pathlib.Path(path).parts
subpackage, module = parts[-2], parts[-1]
user_match = fy.re_find(SUBPACKAGE_NAME_REGEX, subpackage)
feature_match = fy.re_find(FEATURE_MODULE_NAME_REGEX, module)
if feature_match:
username = user_match['username']
featurename = feature_match['featurename'].replace('_', '-')
target_branch = f'{username}/feature-{featurename}'
if target_branch is not None:
switch_to_new_branch(project.repo, target_branch)
_log_start_new_feature_success(result)
_log_switch_to_new_branch(target_branch)
return result
def _log_start_new_feature_success(result: List[Tuple[pathlib.Path, str]]):
logger.info('Start new feature successful')
for (name, kind) in result:
if kind == 'file' and '__init__' not in str(name):
relname = pathlib.Path(name).relative_to(pathlib.Path.cwd())
logger.info(f'Created {relname}')
def _log_switch_to_new_branch(branch: Optional[str]):
if branch is not None:
logger.info(f'Switched to branch {branch}')