#!/bin/python3 # MIT License # # Copyright (c) 2025 dev@ptrace.dev # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. # from sys import exit, argv from shutil import copy, copy2 from pathlib import Path from dataclasses import dataclass from datetime import datetime """ # How to add a new license Create a new license inside the `license/` directory. If the license has a shorter variant for adding it into source files, create this file inside `license/short/` with the same name. Append a new entry to the `LICENSES` dictionary. The structure for a new entry is: ```python "short-name": { "name": "License_File_Name_", "url": "https://link-to-license", "author": "author-placeholder-inside-license", "date": "date-placeholder-inside-license", "short": License_Short_Variants.DIFFERENT }, ``` Note on [Optional] -> You can omit this field entirely. Don't use booleans or `None` here if you don't need those fields. - short-name: [Mandatory] Some short alias for the argument parser - name: [Mandatory] The license file name - url: [Mandatory] Url to the license, or leave an empty string - author: [Optional] Some licenses have placeholders for the author name, insert the placeholder here - date: [Optional] The same goes for the date - insert the placeholder here - short: [Optional] Some licenes have a short variant, so you're able to prepend it in your source file. This field expects the type `License_Short_Variants`. For more information, please inspect this class. That's it. """ @dataclass class License_Short_Variants: """ Some licenses provide a shorter variant, which is meant to be prepended to your source code. But other licenses are short enough already. So we're just accounting for both variants here. """ SAME = "short-same" # The same license file can be used DIFFERENT = "short-different" # A different license file has to be used, # stored inside license/short/ LICENSES = { "agpl3": { "name": "GNU_AGPL_v3", "url": "https://choosealicense.com/licenses/agpl-3.0/", "author": "", "date": "", "short": License_Short_Variants.DIFFERENT, }, "gpl3": { "name": "GNU_GPL_v3", "url": "https://choosealicense.com/licenses/gpl-3.0/", "author": "", "date": "", "short": License_Short_Variants.DIFFERENT, }, "lgpl3": { "name": "GNU_LGPL_v3", "url": "https://choosealicense.com/licenses/lgpl-3.0/", }, "apache2": { "name": "Apache_License_v2_0", "url": "https://choosealicense.com/licenses/apache-2.0/", "author": "[name of copyright owner]", "date": "[yyyy]", "short": License_Short_Variants.DIFFERENT, }, "boost1": { "name": "Boost_Software_License_v_1_0", "url": "https://choosealicense.com/licenses/bsl-1.0/", "short": License_Short_Variants.SAME, }, "mit": { "name": "MIT_License", "url": "https://choosealicense.com/licenses/mit/", "author": "[fullname]", "date": "[year]", "short": License_Short_Variants.SAME, }, "unlicense": { "name": "The_Unlicense", "url": "https://choosealicense.com/licenses/unlicense/", "short": License_Short_Variants.SAME, }, "cc0": { "name": "Public_Domain_CC0_1_0", "url": "https://creativecommons.org/publicdomain/zero/1.0/", "short": License_Short_Variants.SAME, }, "bsd3clause": { "name": "BSD_3_CLAUSE", "url": "https://opensource.org/license/bsd-3-clause", "author": "[fullname]", "date": "[year]", }, } class Language_Comment_Tokens: """ # How to add a new language comment token Inside the constructor, add a new variable that contains the token, that is used by the language as comment or docstring (whatever the convention is). If it's a inline comment, just add a single string. If it's a multiline comment, add a tuple where: - position 0 -> the start of a comment - position 1 -> some padding - position 2 -> the end of a comment """ def __init__(self): self.bash = '#' self.c = ("/*", " *", " */") self.cpp = ("/*", " *", " */") self.cs = "//" self.go = "//" self.jai = ("/*", " *", " */") self.java = ("/*", " *", " */") self.js = ("/*", " *", " */") self.lua = "--" self.python = '#' self.rust = "//!" def tokens_get(self, language): for k, v in self.__dict__.items(): if language.lower() == k: return v log(3, "Couldn't find a supported language.") print(self) exit(1) def __repr__(self): buf = [] print("Supported languages:") for name, _ in self.__dict__.items(): buf.append(f" - {name.capitalize()}") return "\n".join(buf) class Args: def __init__(self): self.help = (["h", "help"], "Prints this help menu") self.url = (["url"], "Prints a single url of a license: `cp-license url mit`") self.urls = (["urls"], "Prints the url of all licenses") self.licenses = (["l", "list"], "Lists all licenses") self.languages = (["lang", "languages"], "Lists all supported languages") self.prepend = ( ["pre", "prepend"], "Prepends the short license variant to a file. " \ "Use `cp-license h pre` for more information" ) def max_width(self): max = 0 for _, value in self.__dict__.items(): current = len(', '.join(value[0])) max = current if current > max else max return max def __repr__(self): width = self.max_width() buf = [] print("Available commands:") for _, value in self.__dict__.items(): out = util_print_by_columns(', '.join(value[0]), value[1], width+6) buf.append(f" - {out}") return "\n".join(buf) EXEC_FILE_LOCATION = Path(__file__).resolve(strict=True).parent LICENSES_DIR = EXEC_FILE_LOCATION / Path("licenses") # Procs: comment_* def comment_prepend_short_license(license_short_name, author, date, language, source_code_fp: Path): if date == "0": date = None license_ = license_validate_and_return(license_short_name) license_variant_type = license_.get("short") license_author = license_.get("author") if not license_variant_type: log(3, "No short license variant specified or available for this license") exit(1) if not source_code_fp.exists(): log(3, f"The source code file you supplied does not exist: `{source_code_fp}`") exit(1) if not author and license_author: log( 2, "This licence has a placeholder for the author. " "But you did not supplied an author name. You might adapt it manually." ) author = license_author date = None sc_fp = source_code_fp.resolve() short_variant = True if license_variant_type == License_Short_Variants.DIFFERENT else False content = license_add_author_date( license_short_name, author, date=date, short_variant=short_variant ) content = content.splitlines() tokens = Language_Comment_Tokens().tokens_get(language) if isinstance(tokens, str): new_content = comment_line(tokens, content) elif isinstance(tokens, tuple): new_content = comment_wrapped(tokens, content) else: raise RuntimeError("Unexpected type") comment_backup_source_code_file(sc_fp) comment_save_to_file(new_content, sc_fp) def comment_line(token, content): buf = [] for line in content: space = "" if not line else " " buf.append(f"{token}{space}{line}") return buf def comment_wrapped(tokens, content): assert len(tokens) == 3, "Expected three tokens" buf = [tokens[0]] line: str for line in content: space = "" if not line else " " buf.append(f"{tokens[1]}{space}{line}") buf.append(tokens[2]) return buf def comment_check_shebang_and_editor_modes(content): token_shebang = "#!" token_editor_mode = "-*-" current_line_no = -1 head = 5 # only checking the first few lines. I don't expect someone # with an insane amount of editor modes ... for idx, line in enumerate(content[:head]): if line.startswith(token_shebang): current_line_no = idx elif token_editor_mode in line: current_line_no = idx return current_line_no def comment_concat_license_and_source_file(license_text, source_file): source_file_lines = source_file.splitlines() license_text = "\n".join(license_text) license_text = license_text + "\n" if not license_text.endswith("\n") else license_text split_idx = comment_check_shebang_and_editor_modes(source_file_lines) if split_idx == -1: return license_text + "\n" + source_file head = "\n".join(source_file_lines[:split_idx+1]) tail = "\n".join(source_file_lines[split_idx+1:]) return head + "\n" + license_text + tail def comment_backup_source_code_file(source_code_fp: Path): backup_fn = source_code_fp.name + ".bak" backup_fp = source_code_fp.with_name(backup_fn) if backup_fp.exists(): log(1, f"backup already exists: '{backup_fp}'") return try: copy2(source_code_fp, backup_fp) except Exception as e: print(e) exit(1) log(1, f"created a backup of '{source_code_fp}'") def comment_save_to_file(license_text, source_code_fp): file = util_file_read(source_code_fp) buf = comment_concat_license_and_source_file(license_text, file) util_file_write(source_code_fp, buf) # Procs: print_* def print_help(): help_text = """Creates a license of your choice inside a target directory. Usage: cp-license [license short name] [destination directory] [author] [date] [license short name] (Mandatory) -> pass `l` or `list` to print all license aliases. [destination directory] (Mandatory) -> path where the license should be stored. [author] (Optional) -> this will be placed inside the license [date] (Optional) -> this will be placed inside the license. If omitted, it will automatically use the current year. Examples: cp-license mit myprojectdir <- creates a MIT License cp-license gpl3 myprojectdir Peter 2019 <- creates a GPL License with the author & date replaced """ print(help_text) print(Args()) def print_prepend(): help_text = """Prepends a short variant of a license in your source file. Usage: cp-license pre [license short name] [author] [date] [language] [source file] [license short name] (Mandatory) [author] (Optional) -> if you omit this, omit [date] too [date] (Optional) -> if you pass `0` it will use the current year [language] (Mandatory) -> which comment type should be used. Use `cp-license languages` for a list. [source file] (Mandatory) -> which file should be modified. Note: It creates a backup of your original file. Examples: cp-license pre mit Peter 0 c src/main.c <- prepends the MIT license to your main.c file cp-license pre cc0 java src/main.java <- the same with the CC0 license Note: - If you have a license that has a field for author/date, but you did not supply those arguments, it will prepend the license anyway and display a warning. - If you have shebang or editor modes on top of your file, it will respect them and place the licence after those. """ print(help_text) def print_licenses(): width = utils_max_width_of('name') + 6 print("Available licenses ( -> ):") for k, v in LICENSES.items(): out = util_print_by_columns(f" - {v['name']}", f"-> {k}", width) print(out) def print_urls(): width = utils_max_width_of('name') + 6 for _, v in LICENSES.items(): out = util_print_by_columns(f" - {v['name']}", f"-> {v['url']}", width) print(out) def print_url(license_short_name): license_ = LICENSES.get(license_short_name) if not license_: log(3, f"Could not find your license: '{license_short_name}'") print_licenses() exit(1) print(f"{license_['name']} -> {license_['url']}") # Procs: license_* def license_validate_and_return(license_short_name): license_ = LICENSES.get(license_short_name.lower()) if not license_: log(3, f"Could not find your license: '{license_short_name}'") print_licenses() exit(1) return license_ def license_path(license_short_name, short_variant=False): license_ = license_validate_and_return(license_short_name) if short_variant: license_fp = LICENSES_DIR / Path("short") / Path(license_['name']) else: license_fp = LICENSES_DIR / Path(license_['name']) if not license_fp.exists(): log(3, f"Could not find license file in '{license_fp}'") exit(1) return license_fp def license_add_author_date(license_short_name, author, date=None, short_variant=False): license_fp = license_path(license_short_name, short_variant) date = date if date else datetime.now().year license_file = util_file_read(license_fp) # The type of this variable gets already checked by `license_path()` license_ = LICENSES.get(license_short_name.lower()) indicator_author = license_.get("author") # type: ignore[reportOptionalMemberAccess] indicator_date = license_.get("date") # type: ignore[reportOptionalMemberAccess] license_content = license_file if indicator_author: license_content = license_content.replace(indicator_author, author) if indicator_date: license_content = license_content.replace(indicator_date, str(date)) return license_content def license_create_with_author_date_info(license_short_name, destination_dir, author, date=None): dest = destination_dir_validate_and_resolve(destination_dir) / Path("LICENSE") dest = destination_file_rename_if_exists(dest) license_content = license_add_author_date(license_short_name, author, date) util_file_write(dest, license_content) def license_copy_to_destination(license_short_name, destination_dir: Path): license_file = license_path(license_short_name) dest = destination_dir_validate_and_resolve(destination_dir) / Path("LICENSE") dest = destination_file_rename_if_exists(dest) copy(license_file, dest) log(1, f"Copied {LICENSES[license_short_name]['name']} to '{dest}'") # Procs: destination_* def destination_dir_validate_and_resolve(dest: Path): if not dest.exists(): log(3, f"Destination directory does not exists: '{dest}'") exit(1) return dest.resolve() def destination_file_rename_if_exists(dest: Path): if dest.exists(): new_fp = dest.name + ".new_license" log(2, f"a file with the same name already exists. " \ f"Thew created license is now stored with a new name: '{new_fp}'" ) return dest.with_name(new_fp) return dest # Procs: util_* def util_print_by_columns(str1, str2, width): a = str1.ljust(width) return a + str2 def utils_max_width_of(key): max = 0 for _, v in LICENSES.items(): current = len(v[key]) max = current if current > max else max return max def util_file_read(fp: Path): try: with open(fp, "r", encoding="utf8") as rf: return rf.read() except Exception as e: log(3, e) exit(1) def util_file_write(fp: Path, content): try: with open(fp, "w+", encoding="utf8") as wf: wf.write(content) except Exception as e: log(3, e) exit(1) # Procs: log_* def log(code, msg): levels = [ "DEBUG", "INFO", "WARNING", "ERROR" ] print(f"[ {levels[code]} ]: {msg}") # Procs: parse_* def parse_args(args): def validate(arg, available_args): if arg in available_args: return True return False def idk(): log(3, "Unknown arguments") exit(1) arguments = Args() if len(args) < 2: print_help() exit(1) elif len(args) == 2: if validate(args[1], arguments.help[0]): print_help() exit(0) if validate(args[1], arguments.urls[0]): print_urls() exit(0) if validate(args[1], arguments.licenses[0]): print_licenses() exit(0) if validate(args[1], arguments.languages[0]): print(Language_Comment_Tokens()) exit(0) idk() elif len(args) == 3: if validate(args[1], arguments.url[0]): print_url(args[2]) exit(0) if validate(args[1], arguments.help[0]) and validate(args[2], arguments.prepend[0]): print_prepend() exit(0) license_copy_to_destination( license_short_name=args[1], destination_dir=Path(args[2]) ) exit(0) elif len(args) == 4: license_create_with_author_date_info( license_short_name=args[1], destination_dir=Path(args[2]), author=args[3] ) exit(0) elif len(args) == 5: if validate(args[1], arguments.prepend[0]): comment_prepend_short_license( license_short_name=args[2], author=None, date=None, language=args[3], source_code_fp=Path(args[4]) ) exit(0) license_create_with_author_date_info( license_short_name=args[1], destination_dir=Path(args[2]), author=args[3], date=args[4] ) exit(0) elif len(args) == 7: if validate(args[1], arguments.prepend[0]): comment_prepend_short_license( license_short_name=args[2], author=args[3], date=args[4], language=args[5], source_code_fp=Path(args[6]) ) exit(0) else: log(3, "Cannot parse this combination of arguments. Use `h` for help.") exit(1) if __name__ == "__main__": parse_args(argv)