plone.meta 2.0.0__py3-none-any.whl

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.
plone/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __import__("pkg_resources").declare_namespace(__name__)
plone/meta/__init__.py ADDED
File without changes
@@ -0,0 +1,639 @@
1
+ from .shared.call import call
2
+ from .shared.git import get_branch_name
3
+ from .shared.git import git_branch
4
+ from .shared.git import git_server_url
5
+ from .shared.path import change_dir
6
+ from functools import cached_property
7
+ from importlib.metadata import version
8
+
9
+ import argparse
10
+ import collections
11
+ import configparser
12
+ import editorconfig
13
+ import jinja2
14
+ import pathlib
15
+ import re
16
+ import shutil
17
+ import tomlkit
18
+ import validate_pyproject
19
+ import yaml
20
+
21
+
22
+ META_HINT = """\
23
+ # Generated from:
24
+ # https://github.com/plone/meta/tree/main/src/plone/meta/{config_type}
25
+ # See the inline comments on how to expand/tweak this configuration file"""
26
+ META_HINT_MARKDOWN = """\
27
+ <!--
28
+ Generated from:
29
+ https://github.com/plone/meta/tree/main/src/plone/meta/{config_type}
30
+ See the inline comments on how to expand/tweak this configuration file
31
+ --> """
32
+ DEFAULT = object()
33
+
34
+ MXDEV_CONSTRAINTS = "constraints-mxdev.txt"
35
+ PLONE_CONSTRAINTS = "https://dist.plone.org/release/6.0-dev/constraints.txt"
36
+
37
+ DOCKER_IMAGE = "python:3.11-bullseye"
38
+
39
+ # Rather than pointing configured repositories to `plone.meta`'s `main` branch
40
+ # to get their GHA workflows, point them to an ever evolving branch.
41
+ #
42
+ # This has a few benefits:
43
+ # - configured repositories do not point to outdated GHA workflows
44
+ # - `plone.meta` can do breaking changes on GHA workflows and roll them gradually
45
+ GHA_DEFAULT_REF = "2.x"
46
+
47
+ GHA_DEFAULT_JOBS = [
48
+ "qa",
49
+ "test",
50
+ "coverage",
51
+ "dependencies",
52
+ "release_ready",
53
+ "circular",
54
+ ]
55
+
56
+ GITLAB_DEFAULT_JOBS = [
57
+ "lint",
58
+ "release-ready",
59
+ "dependencies",
60
+ "circular-dependencies",
61
+ "testing",
62
+ "coverage",
63
+ ]
64
+
65
+
66
+ def handle_command_line_arguments():
67
+ """Parse command line options"""
68
+ parser = argparse.ArgumentParser(description="Use configuration for a package.")
69
+ parser.add_argument(
70
+ "path", type=pathlib.Path, help="path to the repository to be configured"
71
+ )
72
+ parser.add_argument(
73
+ "--commit-msg",
74
+ dest="commit_msg",
75
+ metavar="MSG",
76
+ help="Use MSG as commit message instead of an artificial one.",
77
+ )
78
+ parser.add_argument(
79
+ "--no-commit",
80
+ dest="commit",
81
+ action="store_false",
82
+ default=True,
83
+ help="Prevent automatic committing of changes.",
84
+ )
85
+ parser.add_argument(
86
+ "--push",
87
+ dest="push",
88
+ action="store_true",
89
+ default=False,
90
+ help="Push changes directly.",
91
+ )
92
+ parser.add_argument(
93
+ "-t",
94
+ "--type",
95
+ choices=[
96
+ "default",
97
+ ],
98
+ default="default",
99
+ dest="type",
100
+ help="type of the configuration to be used, see README.md. "
101
+ "Only required when running on a repository for the first time.",
102
+ )
103
+ parser.add_argument(
104
+ "--tox",
105
+ dest="run_tox",
106
+ action="store_true",
107
+ default=False,
108
+ help="Whether to run tox after configuring the repository.",
109
+ )
110
+ parser.add_argument(
111
+ "--branch",
112
+ dest="branch_name",
113
+ default=None,
114
+ help="Define a git branch name to be used for the changes. "
115
+ "If not given it is constructed automatically and includes "
116
+ 'the configuration type. Use "current" to update the current branch.',
117
+ )
118
+ parser.add_argument(
119
+ "--track",
120
+ dest="track_package",
121
+ action="store_true",
122
+ default=False,
123
+ help="Whether to add the package being configured in packages.txt.",
124
+ )
125
+
126
+ args = parser.parse_args()
127
+ return args
128
+
129
+
130
+ class PackageConfiguration:
131
+
132
+ def __init__(self, args):
133
+ self.args = args
134
+ self.path = args.path.absolute()
135
+ self.meta_cfg = {}
136
+
137
+ if not (self.path / ".git").exists():
138
+ raise ValueError(
139
+ f"{self.path!r} does not point to a git clone of a repository!"
140
+ )
141
+
142
+ self.meta_cfg = self._read_meta_configuration()
143
+ self.meta_cfg["meta"]["template"] = self.config_type
144
+ self.meta_cfg["meta"]["commit-id"] = self._get_version()
145
+
146
+ with change_dir(self.path):
147
+ server_url = git_server_url()
148
+ self.is_github = "github" in server_url
149
+ self.is_gitlab = "gitlab" in server_url
150
+ if not self.is_github and not self.is_gitlab:
151
+ self.print_warning(
152
+ "CI configuration",
153
+ "The repository is not hosted in github nor in gitlab, no CI configuration will be done!",
154
+ )
155
+
156
+ def _get_version(self):
157
+ return version("plone.meta")
158
+
159
+ def _read_meta_configuration(self):
160
+ """Read and update meta configuration"""
161
+ meta_toml_path = self.path / ".meta.toml"
162
+ if meta_toml_path.exists():
163
+ with open(meta_toml_path, "rb") as meta_f:
164
+ meta_cfg = tomlkit.load(meta_f)
165
+ meta_cfg = collections.defaultdict(dict, **meta_cfg)
166
+ else:
167
+ meta_cfg = collections.defaultdict(dict)
168
+ return meta_cfg
169
+
170
+ @cached_property
171
+ def config_type(self):
172
+ value = self.meta_cfg["meta"].get("template") or self.args.type
173
+ if value is None:
174
+ raise ValueError(
175
+ "Configuration type not set. Please use `--type` to select it."
176
+ )
177
+ return value
178
+
179
+ @cached_property
180
+ def config_type_path(self):
181
+ return pathlib.Path(__file__).parent / self.config_type
182
+
183
+ @cached_property
184
+ def default_path(self):
185
+ return pathlib.Path(__file__).parent / "default"
186
+
187
+ @cached_property
188
+ def jinja_env(self):
189
+ return jinja2.Environment(
190
+ loader=jinja2.FileSystemLoader([self.config_type_path, self.default_path]),
191
+ variable_start_string="%(",
192
+ variable_end_string=")s",
193
+ keep_trailing_newline=True,
194
+ trim_blocks=True,
195
+ lstrip_blocks=True,
196
+ )
197
+
198
+ @cached_property
199
+ def branch_name(self):
200
+ return get_branch_name(self.args.branch_name, self.config_type)
201
+
202
+ def _add_project_to_config_type_list(self):
203
+ """Add the current project to packages.txt if it is not there"""
204
+ with open(self.config_type_path / "packages.txt") as f:
205
+ known_packages = f.read().splitlines()
206
+
207
+ if self.path.name in known_packages:
208
+ print(
209
+ f"{self.path.name} is already configured "
210
+ "for this config type, updating."
211
+ )
212
+ return
213
+ print(f"{self.path.name} is not yet configured for this config type, adding.")
214
+ with open(self.config_type_path / "packages.txt", "a") as f:
215
+ f.write(f"{self.path.name}\n")
216
+
217
+ def cfg_option(self, section, name=None, default=DEFAULT):
218
+ """Read a value from `self.meta_cfg`, default to `[]` if not existing."""
219
+ if default == DEFAULT:
220
+ default = []
221
+ if name is None:
222
+ # Get the entire section.
223
+ return self.meta_cfg[section]
224
+ return self.meta_cfg[section].get(name, default)
225
+
226
+ def _get_options_for(self, section, names):
227
+ """Get all given named options from a given section"""
228
+ options = {}
229
+ for name in names:
230
+ options[name] = self.cfg_option(section, name, "")
231
+ return options
232
+
233
+ def _test_cfg(self):
234
+ """Setup testing configuration."""
235
+ options = self._get_options_for("tox", ("test_runner", "test_path"))
236
+ path = options.get("test_path")
237
+ if not path:
238
+ if (self.path / "tests").exists():
239
+ path = "/tests"
240
+ elif (self.path / "src").exists():
241
+ path = "/src"
242
+ else:
243
+ path = ""
244
+ options["test_path"] = path
245
+ runner = options.get("test_runner", "zope.testrunner")
246
+ options["test_runner"] = runner
247
+ return options
248
+
249
+ def warn_on_setup_cfg(self):
250
+ """Warn if setup.cfg has sections that we define in other files"""
251
+ setup_file = pathlib.Path(self.path / "setup.cfg")
252
+ if not setup_file.exists():
253
+ return
254
+ content = setup_file.read_text()
255
+ sections_outside = ("check-manifest", "flake8", "bdist_wheel")
256
+ prefix = "setup.cfg cleanup"
257
+ print()
258
+ for section_name in sections_outside:
259
+ if f"[{section_name}]" in content:
260
+ self.print_warning(prefix, f"please remove [{section_name}] section")
261
+
262
+ def print_warning(self, prefix, message):
263
+ print(f"*** {prefix}: {message}\n")
264
+
265
+ def editorconfig(self):
266
+ options = self._get_options_for("editorconfig", ("extra_lines",))
267
+ return self.copy_with_meta(
268
+ "editorconfig.j2", self.path / ".editorconfig", **options
269
+ )
270
+
271
+ def gitignore(self):
272
+ options = self._get_options_for("gitignore", ("extra_lines",))
273
+ return self.copy_with_meta("gitignore.j2", self.path / ".gitignore", **options)
274
+
275
+ def pre_commit_config(self):
276
+ options = self._get_options_for(
277
+ "pre_commit",
278
+ (
279
+ "zpretty_extra_lines",
280
+ "codespell_extra_lines",
281
+ "flake8_extra_lines",
282
+ "extra_lines",
283
+ "i18ndude_extra_lines",
284
+ ),
285
+ )
286
+
287
+ return self.copy_with_meta(
288
+ "pre-commit-config.yaml.j2",
289
+ self.path / ".pre-commit-config.yaml",
290
+ **options,
291
+ )
292
+
293
+ def pyproject_toml(self):
294
+ files = []
295
+
296
+ options = self._get_options_for(
297
+ "pyproject",
298
+ (
299
+ "codespell_ignores",
300
+ "codespell_skip",
301
+ "dependencies_ignores",
302
+ "dependencies_mappings",
303
+ "check_manifest_ignores",
304
+ "towncrier_issue_format",
305
+ "towncrier_extra_lines",
306
+ "isort_extra_lines",
307
+ "black_extra_lines",
308
+ "check_manifest_extra_lines",
309
+ "extra_lines",
310
+ ),
311
+ )
312
+
313
+ options["changes_extension"] = "rst"
314
+ if (self.path / "CHANGES.md").exists():
315
+ options["changes_extension"] = "md"
316
+
317
+ options["news_folder_exists"] = False
318
+ news = self.path / "news"
319
+ if news.exists():
320
+ options["news_folder_exists"] = True
321
+ if options["changes_extension"] == "md":
322
+ destination = news / ".changelog_template.jinja"
323
+ shutil.copy(
324
+ self.config_type_path / "changelog_template.jinja", destination
325
+ )
326
+ files.append(destination)
327
+ else:
328
+ # only add the `.gitkeep` file if there is no jinja template
329
+ gitkeep = news / ".gitkeep"
330
+ gitkeep.touch(exist_ok=True)
331
+
332
+ else:
333
+ self.print_warning(
334
+ "towncrier",
335
+ "If you want to use Towncrier, you have to create a 'news/' folder manually.",
336
+ )
337
+
338
+ filename = self.copy_with_meta(
339
+ "pyproject.toml.j2",
340
+ **options,
341
+ )
342
+ files.append(filename)
343
+ return files
344
+
345
+ def tox(self):
346
+ options = self._get_options_for(
347
+ "tox",
348
+ (
349
+ "constrain_package_deps",
350
+ "constraints_file",
351
+ "envlist_lines",
352
+ "testenv_options",
353
+ "use_mxdev",
354
+ "config_lines",
355
+ "test_deps_additional",
356
+ "test_extras",
357
+ "test_environment_variables",
358
+ "extra_lines",
359
+ "use_pytest_plone",
360
+ "package_name",
361
+ ),
362
+ )
363
+ use_mxdev = options.get("use_mxdev", False)
364
+ options.update(self._test_cfg())
365
+ options["package_name"] = options.get("package_name") or self.path.name
366
+ options["news_folder_exists"] = (self.path / "news").exists()
367
+
368
+ options["prime_robotframework"] = self._detect_robotframework()
369
+
370
+ if not options["constrain_package_deps"]:
371
+ options["constrain_package_deps"] = "false" if use_mxdev else "true"
372
+ if not options["constraints_file"]:
373
+ constraints_file = MXDEV_CONSTRAINTS if use_mxdev else PLONE_CONSTRAINTS
374
+ options["constraints_file"] = constraints_file
375
+ if options["use_pytest_plone"] is not False:
376
+ # Default is '', so turn it into True
377
+ options["use_pytest_plone"] = True
378
+
379
+ return self.copy_with_meta("tox.ini.j2", **options)
380
+
381
+ def _detect_robotframework(self):
382
+ """Dynamically find out if robotframework is used in the package.
383
+
384
+ We look at the dependencies, as we expect the dependency checker
385
+ to make it easy for us.
386
+ """
387
+ try_files = (
388
+ self.path / "setup.py",
389
+ self.path / "pyproject.toml",
390
+ )
391
+ for file_obj in try_files:
392
+ if file_obj.exists():
393
+ text = file_obj.read_text()
394
+ if "plone.app.robotframework" in text:
395
+ return True
396
+ return False
397
+
398
+ def news_entry(self):
399
+ news = self.path / "news"
400
+
401
+ if not news.exists():
402
+ return
403
+
404
+ if self.args.branch_name == "current":
405
+ print("Updating current branch, so I do not add a news entry.")
406
+ return
407
+
408
+ destination = self.path / "news" / "+meta.internal"
409
+ with open(destination, "w") as f_:
410
+ if (self.path / "CHANGES.md").exists():
411
+ f_.write("Update configuration files @plone")
412
+ else:
413
+ f_.write("Update configuration files.\n")
414
+ f_.write("[plone devs]\n")
415
+
416
+ return destination.relative_to(self.path)
417
+
418
+ def gha_workflows(self):
419
+ if not self.is_github:
420
+ return []
421
+ github_folder = self.path / ".github"
422
+ workflows_folder = github_folder / "workflows"
423
+ workflows_folder.mkdir(parents=True, exist_ok=True)
424
+ destination = workflows_folder / "meta.yml"
425
+ options = self._get_options_for(
426
+ "github",
427
+ (
428
+ "env",
429
+ "ref",
430
+ "jobs",
431
+ "os_dependencies",
432
+ "py_versions",
433
+ "extra_lines",
434
+ ),
435
+ )
436
+ if not options.get("ref"):
437
+ options["ref"] = GHA_DEFAULT_REF
438
+ if not options.get("jobs"):
439
+ options["jobs"] = GHA_DEFAULT_JOBS
440
+ meta_file = self.copy_with_meta(
441
+ "meta.yml.j2", destination=destination, **options
442
+ )
443
+ dependabot = self.copy_with_meta(
444
+ "dependabot.yml", destination=github_folder / "dependabot.yml"
445
+ )
446
+
447
+ if (self.path / "dependabot.yml").exists():
448
+ self.print_warning(
449
+ "CI configuration",
450
+ "A `dependabot.yml` file at the top-level was found, please remove it",
451
+ )
452
+
453
+ return [meta_file, dependabot]
454
+
455
+ def gitlab_ci(self):
456
+ if not self.is_gitlab:
457
+ return []
458
+ options = self._get_options_for(
459
+ "gitlab",
460
+ (
461
+ "custom_image",
462
+ "os_dependencies",
463
+ "extra_lines",
464
+ "jobs",
465
+ ),
466
+ )
467
+ if not options["custom_image"]:
468
+ options["custom_image"] = DOCKER_IMAGE
469
+ options["destination"] = self.path / ".gitlab-ci.yml"
470
+ if not options.get("jobs"):
471
+ options["jobs"] = GITLAB_DEFAULT_JOBS
472
+ return self.copy_with_meta("gitlab-ci.yml.j2", **options)
473
+
474
+ def flake8(self):
475
+ options = self._get_options_for("flake8", ("extra_lines",))
476
+ destination = self.path / ".flake8"
477
+ return self.copy_with_meta("flake8.j2", destination=destination, **options)
478
+
479
+ def copy_with_meta(
480
+ self, template_name, destination=None, meta_hint=META_HINT, **kw
481
+ ):
482
+ """Copy the source file to destination and a hint of origin.
483
+
484
+ If kwargs are given they are used as template arguments.
485
+ """
486
+ template = self.jinja_env.get_template(template_name)
487
+ rendered = template.render(config_type=self.config_type, **kw)
488
+ meta_hint = meta_hint.format(config_type=self.config_type)
489
+ if rendered.startswith("#!"):
490
+ she_bang, _, body = rendered.partition("\n")
491
+ content = "\n".join([she_bang, meta_hint, body])
492
+ else:
493
+ content = "\n".join([meta_hint, rendered])
494
+
495
+ if destination is None:
496
+ if template_name.endswith(".j2"):
497
+ destination = self.path / template_name[:-3] # remove `.j2`
498
+ else:
499
+ destination = self.path / template_name
500
+
501
+ # Get rid of spaces on lines with only spaces, like happens in the generated
502
+ # tox.ini
503
+ content = re.sub(r"\n\ +\n", r"\n\n", content)
504
+
505
+ # Get rid of empty lines at the end.
506
+ content = content.strip() + "\n"
507
+ with open(destination, "w") as f_:
508
+ f_.write(content)
509
+
510
+ return destination.relative_to(self.path)
511
+
512
+ def remove_old_files(self):
513
+ filenames = ("bootstrap.py", ".travis.yml")
514
+ with change_dir(self.path):
515
+ for filename in filenames:
516
+ if pathlib.Path(filename).exists():
517
+ call("git", "rm", filename)
518
+
519
+ def remove_toml_empty_sections(self):
520
+ meta_cfg = {k: v for k, v in self.meta_cfg.items() if v}
521
+ with change_dir(self.path):
522
+ with open(".meta.toml", "w") as meta_f:
523
+ meta_f.write(META_HINT.format(config_type=self.config_type))
524
+ meta_f.write("\n")
525
+ tomlkit.dump(meta_cfg, meta_f)
526
+
527
+ def run_tox(self):
528
+ with change_dir(self.path) as cwd:
529
+ tox_path = shutil.which("tox") or (pathlib.Path(cwd) / "bin" / "tox")
530
+ call(tox_path, "-e", "format,lint")
531
+
532
+ def validate_files(self, files_changed):
533
+ """Ensure that files are not broken"""
534
+ for file_obj in files_changed:
535
+ if file_obj.suffix == ".toml":
536
+ self._validate_toml(file_obj)
537
+ elif file_obj.suffix in (".yaml", ".yml"):
538
+ self._validate_yaml(file_obj)
539
+ elif file_obj.suffix == ".ini" or file_obj.stem == ".flake8":
540
+ self._validate_ini(file_obj)
541
+ elif file_obj.stem == ".editorconfig":
542
+ self._validate_editorconfig(file_obj)
543
+
544
+ def _validate_toml(self, file_obj):
545
+ """Validate files that are in TOML format"""
546
+ with change_dir(self.path):
547
+
548
+ with open(file_obj, "rb") as meta_f:
549
+ data = tomlkit.load(meta_f)
550
+
551
+ if self.path.stem == "pyproject":
552
+ validator = validate_pyproject.api.Validator()
553
+ validator(data)
554
+
555
+ def _validate_yaml(self, file_obj):
556
+ """Validate files that are in YAML format"""
557
+ with change_dir(self.path):
558
+ data = file_obj.read_text()
559
+ _ = yaml.safe_load(data)
560
+
561
+ def _validate_ini(self, file_obj):
562
+ """Validate files that are in INI format"""
563
+ config = configparser.ConfigParser()
564
+ with change_dir(self.path):
565
+ _ = config.read(file_obj)
566
+
567
+ def _validate_editorconfig(self, file_obj):
568
+ """Validate .editorconfig file"""
569
+ with change_dir(self.path):
570
+ editorconfig.get_properties(file_obj.resolve())
571
+
572
+ @property
573
+ def _commit_msg(self):
574
+ return self.args.commit_msg or "Configuring with plone.meta"
575
+
576
+ def commit_and_push(self, filenames):
577
+ if not self.args.commit:
578
+ return
579
+
580
+ with change_dir(self.path):
581
+ call("git", "add", *filenames)
582
+ call("git", "commit", "-m", self._commit_msg)
583
+ if self.args.push:
584
+ call("git", "push", "--set-upstream", "origin", self.branch_name)
585
+
586
+ @staticmethod
587
+ def final_help_tips(updating):
588
+ print()
589
+ print("If everything went fine up to here:")
590
+ if updating:
591
+ print("Updated the previously created PR.")
592
+ else:
593
+ print("Create a PR, using the URL shown above.")
594
+
595
+ def configure(self):
596
+ if self.args.track_package:
597
+ self._add_project_to_config_type_list()
598
+
599
+ files_changed = [
600
+ self.path / ".meta.toml",
601
+ ]
602
+ methods = (
603
+ self.editorconfig,
604
+ self.gitignore,
605
+ self.pre_commit_config,
606
+ self.pyproject_toml,
607
+ self.tox,
608
+ self.news_entry,
609
+ self.flake8,
610
+ self.gha_workflows,
611
+ self.gitlab_ci,
612
+ )
613
+ for method in methods:
614
+ files = method()
615
+ if isinstance(files, list):
616
+ files_changed.extend(files)
617
+ else:
618
+ files_changed.append(files)
619
+ files_changed = [x for x in filter(None, files_changed)]
620
+
621
+ self.remove_old_files()
622
+ self.remove_toml_empty_sections()
623
+ if self.args.run_tox:
624
+ self.run_tox()
625
+
626
+ with change_dir(self.path):
627
+ updating = git_branch(self.branch_name)
628
+
629
+ self.validate_files(files_changed)
630
+ self.commit_and_push(files_changed)
631
+ self.warn_on_setup_cfg()
632
+ self.final_help_tips(updating)
633
+
634
+
635
+ def main():
636
+ args = handle_command_line_arguments()
637
+
638
+ package = PackageConfiguration(args)
639
+ package.configure()
@@ -0,0 +1,15 @@
1
+ {% if sections[""] %}
2
+ {% for category, val in definitions.items() if category in sections[""] %}
3
+
4
+ ### {{ definitions[category]['name'] }}
5
+
6
+ {% for text, values in sections[""][category].items() %}
7
+ - {{ text }} {{ values|join(', ') }}
8
+ {% endfor %}
9
+
10
+ {% endfor %}
11
+ {% else %}
12
+ No significant changes.
13
+
14
+
15
+ {% endif %}
@@ -0,0 +1,8 @@
1
+ version: 2
2
+ updates:
3
+
4
+ - package-ecosystem: "github-actions"
5
+ directory: "/"
6
+ schedule:
7
+ # Check for updates to GitHub Actions every week
8
+ interval: "weekly"