|
16 | 16 | import string |
17 | 17 | import subprocess |
18 | 18 | from glob import glob |
19 | | -from typing import List, Tuple |
| 19 | +from typing import List, Set, Tuple |
20 | 20 |
|
21 | 21 | import nox |
22 | 22 |
|
@@ -601,3 +601,121 @@ def make_release_commit(session): |
601 | 601 | f' git push {{UPSTREAM_NAME}} v{version}\n' |
602 | 602 | f' git push {{UPSTREAM_NAME}} {current_branch}' |
603 | 603 | ) |
| 604 | + |
| 605 | + |
| 606 | +def load_allowed_change_types(project_toml: pathlib.Path = pathlib.Path('./pyproject.toml') |
| 607 | + ) -> Set[str]: |
| 608 | + """ |
| 609 | + Load the list of allowed change types from the pyproject.toml file. |
| 610 | + """ |
| 611 | + import tomllib |
| 612 | + configuration = tomllib.loads(project_toml.read_text()) |
| 613 | + return set(entry['directory'] for entry in configuration['tool']['towncrier']['type']) |
| 614 | + |
| 615 | + |
| 616 | +def is_changelog_filename_valid(filename: str, allowed_change_types: Set[str]) -> Tuple[bool, str]: |
| 617 | + """ |
| 618 | + Validates whether the given filename matches our rules. |
| 619 | + Provides information about why it doesn't match them. |
| 620 | + """ |
| 621 | + error_reasons = [] |
| 622 | + |
| 623 | + wanted_extension = 'md' |
| 624 | + try: |
| 625 | + description, change_type, extension = filename.rsplit('.', maxsplit=2) |
| 626 | + except ValueError: |
| 627 | + # Not enough values to unpack. |
| 628 | + return False, "Doesn't follow the \"<description>.<change_type>.md\" pattern." |
| 629 | + |
| 630 | + # Check whether the filename ends with .md. |
| 631 | + if extension != wanted_extension: |
| 632 | + error_reasons.append(f"Doesn't end with {wanted_extension} extension.") |
| 633 | + |
| 634 | + # Check whether the change type is valid. |
| 635 | + if change_type not in allowed_change_types: |
| 636 | + error_reasons.append( |
| 637 | + f"Change type '{change_type}' doesn't match allowed types: {allowed_change_types}." |
| 638 | + ) |
| 639 | + |
| 640 | + # Check whether the description makes sense. |
| 641 | + try: |
| 642 | + int(description) |
| 643 | + except ValueError: |
| 644 | + if description[0] != '+': |
| 645 | + error_reasons.append("Doesn't start with a number nor a plus sign.") |
| 646 | + |
| 647 | + return len(error_reasons) == 0, ' / '.join(error_reasons) if error_reasons else '' |
| 648 | + |
| 649 | + |
| 650 | +def is_changelog_entry_valid(file_content: str) -> Tuple[bool, str]: |
| 651 | + """ |
| 652 | + We expect the changelog entry to be a valid sentence in the English language. |
| 653 | + This includes, but not limits to, providing a capital letter at the start |
| 654 | + and the full-stop character at the end. |
| 655 | +
|
| 656 | + Note: to do this "properly", tools like `nltk` and `spacy` should be used. |
| 657 | + """ |
| 658 | + error_reasons = [] |
| 659 | + |
| 660 | + # Check whether the first character is a capital letter. |
| 661 | + # Not allowing special characters nor numbers at the very start. |
| 662 | + if not file_content[0].isalpha() or not file_content[0].isupper(): |
| 663 | + error_reasons.append('The first character is not a capital letter.') |
| 664 | + |
| 665 | + # Check if the last character is a full-stop character. |
| 666 | + if file_content.strip()[-1] != '.': |
| 667 | + error_reasons.append('The last character is not a full-stop character.') |
| 668 | + |
| 669 | + return len(error_reasons) == 0, ' / '.join(error_reasons) if error_reasons else '' |
| 670 | + |
| 671 | + |
| 672 | +@nox.session(python=PYTHON_DEFAULT_VERSION) |
| 673 | +def towncrier_check(session): |
| 674 | + """ |
| 675 | + Check whether all the entries in the changelog.d follow the expected naming convention |
| 676 | + as well as some basic rules as to their format. |
| 677 | + """ |
| 678 | + expected_non_md_files = {'.gitkeep'} |
| 679 | + allowed_change_types = load_allowed_change_types() |
| 680 | + |
| 681 | + is_error = False |
| 682 | + |
| 683 | + for filename in pathlib.Path('./changelog.d/').glob('*'): |
| 684 | + # If that's an expected file, it's all right. |
| 685 | + if filename.name in expected_non_md_files: |
| 686 | + continue |
| 687 | + |
| 688 | + # Check whether the file matches the expected pattern. |
| 689 | + is_valid, error_message = is_changelog_filename_valid(filename.name, allowed_change_types) |
| 690 | + if not is_valid: |
| 691 | + session.log(f"File {filename.name} doesn't match the expected pattern: {error_message}") |
| 692 | + is_error = True |
| 693 | + continue |
| 694 | + |
| 695 | + # Check whether the file isn't too big. |
| 696 | + if filename.lstat().st_size > 16 * 1024: |
| 697 | + session.log( |
| 698 | + f'File {filename.name} content is too big – it should be smaller than 16kB.' |
| 699 | + ) |
| 700 | + is_error = True |
| 701 | + continue |
| 702 | + |
| 703 | + # Check whether the file can be loaded as UTF-8 file. |
| 704 | + try: |
| 705 | + file_content = filename.read_text(encoding='utf-8') |
| 706 | + except UnicodeDecodeError: |
| 707 | + session.log(f'File {filename.name} is not a valid UTF-8 file.') |
| 708 | + is_error = True |
| 709 | + continue |
| 710 | + |
| 711 | + # Check whether the content of the file is anyhow valid. |
| 712 | + is_valid, error_message = is_changelog_entry_valid(file_content) |
| 713 | + if not is_valid: |
| 714 | + session.log(f'File {filename.name} is not a valid changelog entry: {error_message}') |
| 715 | + is_error = True |
| 716 | + continue |
| 717 | + |
| 718 | + if is_error: |
| 719 | + session.error( |
| 720 | + 'Found errors in the changelog.d directory. Check logs above for more information' |
| 721 | + ) |
0 commit comments