Skip to content
This repository was archived by the owner on Jul 29, 2024. It is now read-only.

Commit 489dc2e

Browse files
authored
Avoid overwrite when regenerating project (#22)
2 parents 51209c0 + cca6cf5 commit 489dc2e

File tree

11 files changed

+607
-228
lines changed

11 files changed

+607
-228
lines changed

.github/workflows/standard.yaml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
# ----------------------------------------------------------------------
2424
action_contexts:
2525
name: "Display GitHub Action Contexts"
26-
uses: davidbrownell/dbrownell_DevTools/.github/workflows/callable_display_action_contexts.yaml@CI-v0.15.2
26+
uses: davidbrownell/dbrownell_DevTools/.github/workflows/callable_display_action_contexts.yaml@CI-v0.15.3
2727

2828
# ----------------------------------------------------------------------
2929
validate:
@@ -45,7 +45,7 @@ jobs:
4545

4646
name: Validate
4747

48-
uses: davidbrownell/dbrownell_DevTools/.github/workflows/callable_validate_python.yaml@CI-v0.15.2
48+
uses: davidbrownell/dbrownell_DevTools/.github/workflows/callable_validate_python.yaml@CI-v0.15.3
4949
with:
5050
operating_system: ${{ matrix.os }}
5151
python_version: ${{ matrix.python_version }}
@@ -57,7 +57,7 @@ jobs:
5757
name: Postprocess Coverage Info
5858
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
5959

60-
uses: davidbrownell/dbrownell_DevTools/.github/workflows/callable_package_python_coverage.yaml@CI-v0.15.2
60+
uses: davidbrownell/dbrownell_DevTools/.github/workflows/callable_package_python_coverage.yaml@CI-v0.15.3
6161
with:
6262
gist_id: 2f9d770d13e3a148424f374f74d41f4b
6363
gist_filename: PythonProjectBootstrapper_coverage.json
@@ -86,7 +86,7 @@ jobs:
8686

8787
name: Create Package
8888

89-
uses: davidbrownell/dbrownell_DevTools/.github/workflows/callable_create_python_package.yaml@CI-v0.15.2
89+
uses: davidbrownell/dbrownell_DevTools/.github/workflows/callable_create_python_package.yaml@CI-v0.15.3
9090
with:
9191
operating_system: ${{ matrix.os }}
9292
python_version: ${{ matrix.python_version }}
@@ -113,7 +113,7 @@ jobs:
113113

114114
name: Validate Package
115115

116-
uses: davidbrownell/dbrownell_DevTools/.github/workflows/callable_validate_python_package.yaml@CI-v0.15.2
116+
uses: davidbrownell/dbrownell_DevTools/.github/workflows/callable_validate_python_package.yaml@CI-v0.15.3
117117
with:
118118
operating_system: ${{ matrix.os }}
119119
python_version: ${{ matrix.python_version }}
@@ -137,7 +137,7 @@ jobs:
137137

138138
name: Create Binary
139139

140-
uses: davidbrownell/dbrownell_DevTools/.github/workflows/callable_create_python_binary.yaml@CI-v0.15.2
140+
uses: davidbrownell/dbrownell_DevTools/.github/workflows/callable_create_python_binary.yaml@CI-v0.15.3
141141
with:
142142
operating_system: ${{ matrix.os }}
143143
python_version: ${{ matrix.python_version }}
@@ -160,7 +160,7 @@ jobs:
160160

161161
name: Validate Binary
162162

163-
uses: davidbrownell/dbrownell_DevTools/.github/workflows/callable_validate_python_binary.yaml@CI-v0.15.2
163+
uses: davidbrownell/dbrownell_DevTools/.github/workflows/callable_validate_python_binary.yaml@CI-v0.15.3
164164
with:
165165
operating_system: ${{ matrix.os }}
166166
python_version: ${{ matrix.python_version }}
@@ -174,7 +174,7 @@ jobs:
174174

175175
name: Publish
176176

177-
uses: davidbrownell/dbrownell_DevTools/.github/workflows/callable_publish_python.yaml@CI-v0.15.2
177+
uses: davidbrownell/dbrownell_DevTools/.github/workflows/callable_publish_python.yaml@CI-v0.15.3
178178
with:
179179
release_sources_configuration_filename: .github/release_sources.yaml
180180
secrets:

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ dependencies = [
3939
"cookiecutter == 2.*",
4040
"dbrownell_Common",
4141
"rich == 13.*",
42-
"typer ~= 0.9"
42+
"typer ~= 0.9",
4343
]
4444

4545
dynamic = [
@@ -51,6 +51,7 @@ readme = "README.md"
5151
[project.optional-dependencies]
5252
dev = [
5353
"dbrownell_DevTools",
54+
"pyfakefs == 5.*",
5455
]
5556

5657
package = [

src/PythonProjectBootstrapper/EntryPoint.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
from dbrownell_Common.ContextlibEx import ExitStack
2222
from dbrownell_Common import PathEx
2323
from PythonProjectBootstrapper import __version__
24+
from PythonProjectBootstrapper.ProjectGenerationUtils import (
25+
CopyToOutputDir,
26+
DisplayPrompt,
27+
)
2428

2529
# The following imports are used in cookiecutter hooks. Import them here to
2630
# ensure that they are frozen when creating binaries,
@@ -166,8 +170,14 @@ def _ExecuteOutputDir(
166170
replay: bool,
167171
yes: bool,
168172
) -> None:
173+
if not (output_dir / ".git").is_dir():
174+
raise Exception(f"{output_dir} is not a git repository.")
175+
169176
project_dir = PathEx.EnsureDir(_project_root_dir / project.value)
170177

178+
# create temporary directory for cookiecutter output
179+
tmp_dir = PathEx.CreateTempDirectory()
180+
171181
# Does the project have a startup script? If so, invoke it dynamically.
172182
potential_startup_script = project_dir / "hooks" / "startup.py"
173183
if potential_startup_script.is_file():
@@ -177,21 +187,22 @@ def _ExecuteOutputDir(
177187

178188
execute_func = getattr(module, "Execute", None)
179189
if execute_func:
180-
if execute_func(project_dir, output_dir, yes=yes) is False:
190+
if execute_func(project_dir, tmp_dir, yes=yes) is False:
181191
return
182192

193+
# generate project in temporary directory so we can avoid overwriting files without user approval
183194
cookiecutter(
184195
str(project_dir),
185-
output_dir=str(output_dir),
196+
output_dir=str(tmp_dir),
186197
config_file=str(configuration_filename) if configuration_filename is not None else None,
187198
replay=replay,
188199
overwrite_if_exists=True,
189200
accept_hooks=True,
190201
)
191202

203+
CopyToOutputDir(src_dir=tmp_dir, dest_dir=output_dir)
204+
DisplayPrompt(output_dir=output_dir)
205+
192206

193-
# ----------------------------------------------------------------------
194-
# ----------------------------------------------------------------------
195-
# ----------------------------------------------------------------------
196207
if __name__ == "__main__":
197208
app() # pragma: no cover
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
# ----------------------------------------------------------------------
2+
# |
3+
# | Copyright (c) 2024 Scientific Software Engineering Center at Georgia Tech
4+
# | Distributed under the MIT License.
5+
# |
6+
# ----------------------------------------------------------------------
7+
import hashlib
8+
import itertools
9+
import os
10+
import sys
11+
from pathlib import Path
12+
13+
from rich import print # pylint: disable=redefined-builtin
14+
from rich.panel import Panel
15+
import yaml
16+
17+
from dbrownell_Common import PathEx
18+
from PythonProjectBootstrapper import __version__
19+
20+
# The following imports are used in cookiecutter hooks. Import them here to
21+
# ensure that they are frozen when creating binaries,
22+
import shutil # pylint: disable=unused-import, wrong-import-order
23+
import textwrap # pylint: disable=unused-import, wrong-import-order
24+
25+
26+
# ----------------------------------------------------------------------
27+
def GenerateFileHash(filepath: Path, hash_fn="sha256") -> str:
28+
PathEx.EnsureFile(filepath)
29+
30+
hasher = hashlib.new(hash_fn)
31+
with open(filepath, "rb") as file:
32+
while True:
33+
chunk = file.read(8192)
34+
if not chunk:
35+
break
36+
37+
hasher.update(chunk)
38+
39+
hash_value = hasher.hexdigest()
40+
return hash_value
41+
42+
43+
# ----------------------------------------------------------------------
44+
def CreateManifest(generated_dir: Path) -> dict[str, str]:
45+
manifest_dict: dict[str, str] = {}
46+
47+
for root, _, files in os.walk(generated_dir):
48+
root_path = Path(root)
49+
50+
for file in files:
51+
full_path = root_path / Path(file)
52+
rel_path = PathEx.CreateRelativePath(generated_dir, full_path)
53+
manifest_dict[rel_path.as_posix()] = GenerateFileHash(filepath=full_path)
54+
55+
return manifest_dict
56+
57+
58+
# ----------------------------------------------------------------------
59+
def ConditionallyRemoveUnchangedTemplateFiles(
60+
new_manifest_dict: dict[str, str],
61+
existing_manifest_dict: dict[str, str],
62+
output_dir: Path,
63+
) -> None:
64+
# Removes any template files no longer being generated as long as the file was never modified by the user
65+
66+
# files no longer in template
67+
removed_template_files: set[str] = set(existing_manifest_dict.keys()) - set(
68+
new_manifest_dict.keys()
69+
)
70+
71+
PathEx.EnsureDir(output_dir)
72+
73+
# remove files no longer in template if they are unchanged
74+
for removed_file_rel_path in removed_template_files:
75+
removed_full_path = output_dir / removed_file_rel_path
76+
77+
if removed_full_path.is_file():
78+
current_hash = GenerateFileHash(filepath=removed_full_path)
79+
original_hash = existing_manifest_dict[removed_file_rel_path]
80+
81+
if current_hash == original_hash:
82+
removed_full_path.unlink()
83+
84+
85+
# ----------------------------------------------------------------------
86+
def CopyToOutputDir(
87+
src_dir: Path,
88+
dest_dir: Path,
89+
) -> None:
90+
# Copies all generated files into the output directory and handles the creation/updating of the manifest file
91+
92+
PathEx.EnsureDir(src_dir)
93+
PathEx.EnsureDir(dest_dir)
94+
95+
# existing_manifest will be populated/updated as necessary and saved
96+
generated_manifest: dict[str, str] = CreateManifest(src_dir)
97+
existing_manifest: dict[str, str] = {}
98+
99+
potential_manifest: Path = dest_dir / ".manifest.yml"
100+
101+
# if this is not our first time generating, remove unwanted template files
102+
if potential_manifest.is_file():
103+
with open(potential_manifest, "r") as existing_manifest_file:
104+
existing_manifest = yaml.load(existing_manifest_file, Loader=yaml.Loader)
105+
106+
ConditionallyRemoveUnchangedTemplateFiles(
107+
new_manifest_dict=generated_manifest,
108+
existing_manifest_dict=existing_manifest,
109+
output_dir=dest_dir,
110+
)
111+
112+
merged_manifest = dict(existing_manifest)
113+
merged_manifest.update(generated_manifest)
114+
115+
# Ask user if they would like to overwrite their changes if any conflicts detected
116+
for rel_filepath, generated_hash in generated_manifest.items():
117+
output_dir_filepath: Path = dest_dir / rel_filepath
118+
119+
if output_dir_filepath.is_file():
120+
current_file_hash: str = GenerateFileHash(filepath=output_dir_filepath)
121+
122+
# Changes detected in file and file modified by xser (changes do not stem only from changes in the contents of the template file)
123+
124+
if rel_filepath in existing_manifest.keys() and current_file_hash not in (
125+
generated_hash,
126+
existing_manifest[rel_filepath],
127+
):
128+
while True:
129+
sys.stdout.write(
130+
f"\nWould you like to overwrite your changes in {str(output_dir_filepath)}? [yes/no]: "
131+
)
132+
overwrite = input().strip().lower()
133+
134+
if overwrite in ["yes", "y"]:
135+
break
136+
137+
# Here, we are copying the file from the output directory to the temporary directory in the case that the user answers "no"
138+
# to whether or not they would like to overwrite their changes. This implementation builds the final directory in the temporary directory then copies everything over.
139+
# This makes it much easier to copy over generated files since we do not need to case on whether we are copying over a directory or a file (for example if we generated an empty directory)
140+
141+
if overwrite in ["no", "n"]:
142+
merged_manifest[rel_filepath] = existing_manifest[rel_filepath]
143+
shutil.copy2(output_dir_filepath, src_dir / rel_filepath)
144+
break
145+
else:
146+
merged_manifest[rel_filepath] = generated_hash
147+
148+
# create and save manifest
149+
with open(potential_manifest, "w") as manifest_file:
150+
yaml.dump(merged_manifest, manifest_file)
151+
152+
# copy temporary directory to final output directory and remove temporary directory
153+
shutil.copytree(
154+
src_dir,
155+
dest_dir,
156+
dirs_exist_ok=True,
157+
ignore_dangling_symlinks=True,
158+
copy_function=shutil.copy,
159+
)
160+
shutil.rmtree(src_dir)
161+
162+
163+
# ----------------------------------------------------------------------
164+
def DisplayPrompt(output_dir: Path) -> None:
165+
PathEx.EnsureDir(output_dir)
166+
167+
prompt_text_path = PathEx.EnsureFile(output_dir / "prompt_text.yml")
168+
169+
with open(prompt_text_path, "r") as prompt_file:
170+
_prompts = yaml.load(prompt_file, Loader=yaml.Loader)
171+
172+
prompt_text_path.unlink()
173+
174+
# Display prompts
175+
border_colors = itertools.cycle(
176+
["yellow", "blue", "magenta", "cyan", "green"],
177+
)
178+
179+
sys.stdout.write("\n\n")
180+
181+
for prompt_index, ((_, title), prompt) in enumerate(sorted(_prompts.items())):
182+
print(
183+
Panel(
184+
prompt.rstrip(),
185+
border_style=next(border_colors),
186+
padding=1,
187+
title=f"[{prompt_index + 1}/{len(_prompts)}] {title}",
188+
title_align="left",
189+
),
190+
)
191+
192+
sys.stdout.write("\nPress <enter> to continue")
193+
input()
194+
sys.stdout.write("\n\n")
195+
196+
# Final prompt
197+
sys.stdout.write(
198+
textwrap.dedent(
199+
"""\
200+
The project has now been bootstrapped!
201+
202+
To begin development, run these commands:
203+
204+
1. cd "{output_dir}"
205+
2. Bootstrap{ext}
206+
3. {source}{prefix}Activate{ext}
207+
4. python Build.py pytest
208+
209+
210+
""",
211+
).format(
212+
output_dir=output_dir,
213+
ext=".cmd" if os.name == "nt" else ".sh",
214+
source="source " if os.name != "nt" else "",
215+
prefix="./" if os.name != "nt" else "",
216+
),
217+
)

0 commit comments

Comments
 (0)