0#!/bin/python3
1# MIT License
2#
3# Copyright (c) 2025 dev@ptrace.dev
4#
5# Permission is hereby granted, free of charge, to any person obtaining a copy
6# of this software and associated documentation files (the "Software"), to deal
7# in the Software without restriction, including without limitation the rights
8# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9# copies of the Software, and to permit persons to whom the Software is
10# furnished to do so, subject to the following conditions:
11#
12# The above copyright notice and this permission notice shall be included in all
13# copies or substantial portions of the Software.
14#
15# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21# SOFTWARE.
22#
23from sys import exit, argv
24from shutil import copy, copy2
25from pathlib import Path
26from dataclasses import dataclass
27from datetime import datetime
30"""
31# How to add a new license
33Create a new license inside the `license/` directory.
34If the license has a shorter variant for adding it into source files,
35create this file inside `license/short/` with the same name.
37Append a new entry to the `LICENSES` dictionary. The structure for a new entry is:
39```python
40"short-name": {
41 "name": "License_File_Name_",
42 "url": "https://link-to-license",
43 "author": "author-placeholder-inside-license",
44 "date": "date-placeholder-inside-license",
45 "short": License_Short_Variants.DIFFERENT
46},
47```
49Note on [Optional] -> You can omit this field entirely.
50 Don't use booleans or `None` here if you don't need those fields.
52- short-name: [Mandatory] Some short alias for the argument parser
53- name: [Mandatory] The license file name
54- url: [Mandatory] Url to the license, or leave an empty string
55- author: [Optional] Some licenses have placeholders for the author name,
56 insert the placeholder here
57- date: [Optional] The same goes for the date - insert the placeholder here
58- short: [Optional] Some licenes have a short variant, so you're able to
59 prepend it in your source file.
60 This field expects the type `License_Short_Variants`.
61 For more information, please inspect this class.
63That's it.
64"""
68@dataclass
69class License_Short_Variants:
70 """
71 Some licenses provide a shorter variant,
72 which is meant to be prepended to your source code.
74 But other licenses are short enough already.
75 So we're just accounting for both variants here.
76 """
77 SAME = "short-same" # The same license file can be used
78 DIFFERENT = "short-different" # A different license file has to be used,
79 # stored inside license/short/
82LICENSES = {
83 "agpl3": {
84 "name": "GNU_AGPL_v3",
85 "url": "https://choosealicense.com/licenses/agpl-3.0/",
86 "author": "<name of author>",
87 "date": "<year>",
88 "short": License_Short_Variants.DIFFERENT,
89 },
90 "gpl3": {
91 "name": "GNU_GPL_v3",
92 "url": "https://choosealicense.com/licenses/gpl-3.0/",
93 "author": "<name of author>",
94 "date": "<year>",
95 "short": License_Short_Variants.DIFFERENT,
96 },
97 "lgpl3": {
98 "name": "GNU_LGPL_v3",
99 "url": "https://choosealicense.com/licenses/lgpl-3.0/",
100 },
101 "apache2": {
102 "name": "Apache_License_v2_0",
103 "url": "https://choosealicense.com/licenses/apache-2.0/",
104 "author": "[name of copyright owner]",
105 "date": "[yyyy]",
106 "short": License_Short_Variants.DIFFERENT,
107 },
108 "boost1": {
109 "name": "Boost_Software_License_v_1_0",
110 "url": "https://choosealicense.com/licenses/bsl-1.0/",
111 "short": License_Short_Variants.SAME,
112 },
113 "mit": {
114 "name": "MIT_License",
115 "url": "https://choosealicense.com/licenses/mit/",
116 "author": "[fullname]",
117 "date": "[year]",
118 "short": License_Short_Variants.SAME,
119 },
120 "unlicense": {
121 "name": "The_Unlicense",
122 "url": "https://choosealicense.com/licenses/unlicense/",
123 "short": License_Short_Variants.SAME,
124 },
125 "cc0": {
126 "name": "Public_Domain_CC0_1_0",
127 "url": "https://creativecommons.org/publicdomain/zero/1.0/",
128 "short": License_Short_Variants.SAME,
129 },
130 "bsd3clause": {
131 "name": "BSD_3_CLAUSE",
132 "url": "https://opensource.org/license/bsd-3-clause",
133 "author": "[fullname]",
134 "date": "[year]",
135 },
136}
140class Language_Comment_Tokens:
141 """
142 # How to add a new language comment token
143 Inside the constructor, add a new variable that contains the token,
144 that is used by the language as comment or docstring (whatever the
145 convention is).
147 If it's a inline comment, just add a single string.
149 If it's a multiline comment, add a tuple where:
150 - position 0 -> the start of a comment
151 - position 1 -> some padding
152 - position 2 -> the end of a comment
153 """
154 def __init__(self):
155 self.bash = '#'
156 self.c = ("/*", " *", " */")
157 self.cpp = ("/*", " *", " */")
158 self.cs = "//"
159 self.go = "//"
160 self.jai = ("/*", " *", " */")
161 self.java = ("/*", " *", " */")
162 self.js = ("/*", " *", " */")
163 self.lua = "--"
164 self.python = '#'
165 self.rust = "//!"
167 def tokens_get(self, language):
168 for k, v in self.__dict__.items():
169 if language.lower() == k:
170 return v
171 log(3, "Couldn't find a supported language.")
172 print(self)
173 exit(1)
175 def __repr__(self):
176 buf = []
177 print("Supported languages:")
178 for name, _ in self.__dict__.items():
179 buf.append(f" - {name.capitalize()}")
180 return "\n".join(buf)
184class Args:
185 def __init__(self):
186 self.help = (["h", "help"], "Prints this help menu")
187 self.url = (["url"], "Prints a single url of a license: `cp-license url mit`")
188 self.urls = (["urls"], "Prints the url of all licenses")
189 self.licenses = (["l", "list"], "Lists all licenses")
190 self.languages = (["lang", "languages"], "Lists all supported languages")
191 self.prepend = (
192 ["pre", "prepend"],
193 "Prepends the short license variant to a file. " \
194 "Use `cp-license h pre` for more information"
195 )
197 def max_width(self):
198 max = 0
199 for _, value in self.__dict__.items():
200 current = len(', '.join(value[0]))
201 max = current if current > max else max
202 return max
204 def __repr__(self):
205 width = self.max_width()
206 buf = []
207 print("Available commands:")
208 for _, value in self.__dict__.items():
209 out = util_print_by_columns(', '.join(value[0]), value[1], width+6)
210 buf.append(f" - {out}")
211 return "\n".join(buf)
215EXEC_FILE_LOCATION = Path(__file__).resolve(strict=True).parent
216LICENSES_DIR = EXEC_FILE_LOCATION / Path("licenses")
220# Procs: comment_*
221def comment_prepend_short_license(license_short_name, author, date, language, source_code_fp: Path):
222 if date == "0": date = None
224 license_ = license_validate_and_return(license_short_name)
225 license_variant_type = license_.get("short")
226 license_author = license_.get("author")
228 if not license_variant_type:
229 log(3, "No short license variant specified or available for this license")
230 exit(1)
232 if not source_code_fp.exists():
233 log(3, f"The source code file you supplied does not exist: `{source_code_fp}`")
234 exit(1)
236 if not author and license_author:
237 log(
238 2,
239 "This licence has a placeholder for the author. "
240 "But you did not supplied an author name. You might adapt it manually."
241 )
243 author = license_author
244 date = None
246 sc_fp = source_code_fp.resolve()
247 short_variant = True if license_variant_type == License_Short_Variants.DIFFERENT else False
249 content = license_add_author_date(
250 license_short_name,
251 author,
252 date=date,
253 short_variant=short_variant
254 )
255 content = content.splitlines()
257 tokens = Language_Comment_Tokens().tokens_get(language)
259 if isinstance(tokens, str):
260 new_content = comment_line(tokens, content)
261 elif isinstance(tokens, tuple):
262 new_content = comment_wrapped(tokens, content)
263 else:
264 raise RuntimeError("Unexpected type")
266 comment_backup_source_code_file(sc_fp)
267 comment_save_to_file(new_content, sc_fp)
270def comment_line(token, content):
271 buf = []
272 for line in content:
273 space = "" if not line else " "
274 buf.append(f"{token}{space}{line}")
275 return buf
278def comment_wrapped(tokens, content):
279 assert len(tokens) == 3, "Expected three tokens"
280 buf = [tokens[0]]
281 line: str
282 for line in content:
283 space = "" if not line else " "
284 buf.append(f"{tokens[1]}{space}{line}")
285 buf.append(tokens[2])
286 return buf
289def comment_check_shebang_and_editor_modes(content):
290 token_shebang = "#!"
291 token_editor_mode = "-*-"
292 current_line_no = -1
293 head = 5
295 # only checking the first few lines. I don't expect someone
296 # with an insane amount of editor modes ...
298 for idx, line in enumerate(content[:head]):
299 if line.startswith(token_shebang):
300 current_line_no = idx
301 elif token_editor_mode in line:
302 current_line_no = idx
304 return current_line_no
307def comment_concat_license_and_source_file(license_text, source_file):
308 source_file_lines = source_file.splitlines()
309 license_text = "\n".join(license_text)
310 license_text = license_text + "\n" if not license_text.endswith("\n") else license_text
312 split_idx = comment_check_shebang_and_editor_modes(source_file_lines)
314 if split_idx == -1:
315 return license_text + "\n" + source_file
317 head = "\n".join(source_file_lines[:split_idx+1])
318 tail = "\n".join(source_file_lines[split_idx+1:])
320 return head + "\n" + license_text + tail
323def comment_backup_source_code_file(source_code_fp: Path):
324 backup_fn = source_code_fp.name + ".bak"
325 backup_fp = source_code_fp.with_name(backup_fn)
327 if backup_fp.exists():
328 log(1, f"backup already exists: '{backup_fp}'")
329 return
331 try:
332 copy2(source_code_fp, backup_fp)
333 except Exception as e:
334 print(e)
335 exit(1)
337 log(1, f"created a backup of '{source_code_fp}'")
340def comment_save_to_file(license_text, source_code_fp):
341 file = util_file_read(source_code_fp)
342 buf = comment_concat_license_and_source_file(license_text, file)
343 util_file_write(source_code_fp, buf)
347# Procs: print_*
348def print_help():
349 help_text = """Creates a license of your choice inside a target directory.
351Usage: cp-license [license short name] [destination directory] [author] [date]
353[license short name] (Mandatory) -> pass `l` or `list` to print all license aliases.
354[destination directory] (Mandatory) -> path where the license should be stored.
355[author] (Optional) -> this will be placed inside the license
356[date] (Optional) -> this will be placed inside the license.
357 If omitted, it will automatically use the
358 current year.
360Examples:
361 cp-license mit myprojectdir <- creates a MIT License
362 cp-license gpl3 myprojectdir Peter 2019 <- creates a GPL License with the
363 author & date replaced
364"""
365 print(help_text)
366 print(Args())
369def print_prepend():
370 help_text = """Prepends a short variant of a license in your source file.
372Usage: cp-license pre [license short name] [author] [date] [language] [source file]
374[license short name] (Mandatory)
375[author] (Optional) -> if you omit this, omit [date] too
376[date] (Optional) -> if you pass `0` it will use the current year
377[language] (Mandatory) -> which comment type should be used.
378 Use `cp-license languages` for a list.
379[source file] (Mandatory) -> which file should be modified.
380 Note: It creates a backup of your original file.
382Examples:
383 cp-license pre mit Peter 0 c src/main.c <- prepends the MIT license to your main.c file
384 cp-license pre cc0 java src/main.java <- the same with the CC0 license
386Note:
387 - If you have a license that has a field for author/date, but you did not
388 supply those arguments, it will prepend the license anyway and display
389 a warning.
390 - If you have shebang or editor modes on top of your file, it will respect them
391 and place the licence after those.
392"""
393 print(help_text)
396def print_licenses():
397 width = utils_max_width_of('name') + 6
399 print("Available licenses (<Name of license> -> <cli alias>):")
400 for k, v in LICENSES.items():
401 out = util_print_by_columns(f" - {v['name']}", f"-> {k}", width)
402 print(out)
405def print_urls():
406 width = utils_max_width_of('name') + 6
407 for _, v in LICENSES.items():
408 out = util_print_by_columns(f" - {v['name']}", f"-> {v['url']}", width)
409 print(out)
412def print_url(license_short_name):
413 license_ = LICENSES.get(license_short_name)
414 if not license_:
415 log(3, f"Could not find your license: '{license_short_name}'")
416 print_licenses()
417 exit(1)
418 print(f"{license_['name']} -> {license_['url']}")
422# Procs: license_*
423def license_validate_and_return(license_short_name):
424 license_ = LICENSES.get(license_short_name.lower())
425 if not license_:
426 log(3, f"Could not find your license: '{license_short_name}'")
427 print_licenses()
428 exit(1)
429 return license_
432def license_path(license_short_name, short_variant=False):
433 license_ = license_validate_and_return(license_short_name)
435 if short_variant:
436 license_fp = LICENSES_DIR / Path("short") / Path(license_['name'])
437 else:
438 license_fp = LICENSES_DIR / Path(license_['name'])
440 if not license_fp.exists():
441 log(3, f"Could not find license file in '{license_fp}'")
442 exit(1)
444 return license_fp
447def license_add_author_date(license_short_name, author, date=None, short_variant=False):
448 license_fp = license_path(license_short_name, short_variant)
449 date = date if date else datetime.now().year
451 license_file = util_file_read(license_fp)
453 # The type of this variable gets already checked by `license_path()`
454 license_ = LICENSES.get(license_short_name.lower())
456 indicator_author = license_.get("author") # type: ignore[reportOptionalMemberAccess]
457 indicator_date = license_.get("date") # type: ignore[reportOptionalMemberAccess]
459 license_content = license_file
461 if indicator_author:
462 license_content = license_content.replace(indicator_author, author)
463 if indicator_date:
464 license_content = license_content.replace(indicator_date, str(date))
466 return license_content
469def license_create_with_author_date_info(license_short_name, destination_dir, author, date=None):
470 dest = destination_dir_validate_and_resolve(destination_dir) / Path("LICENSE")
471 dest = destination_file_rename_if_exists(dest)
473 license_content = license_add_author_date(license_short_name, author, date)
474 util_file_write(dest, license_content)
477def license_copy_to_destination(license_short_name, destination_dir: Path):
478 license_file = license_path(license_short_name)
480 dest = destination_dir_validate_and_resolve(destination_dir) / Path("LICENSE")
481 dest = destination_file_rename_if_exists(dest)
483 copy(license_file, dest)
484 log(1, f"Copied {LICENSES[license_short_name]['name']} to '{dest}'")
487# Procs: destination_*
488def destination_dir_validate_and_resolve(dest: Path):
489 if not dest.exists():
490 log(3, f"Destination directory does not exists: '{dest}'")
491 exit(1)
492 return dest.resolve()
495def destination_file_rename_if_exists(dest: Path):
496 if dest.exists():
497 new_fp = dest.name + ".new_license"
498 log(2,
499 f"a file with the same name already exists. " \
500 f"Thew created license is now stored with a new name: '{new_fp}'"
501 )
502 return dest.with_name(new_fp)
503 return dest
507# Procs: util_*
508def util_print_by_columns(str1, str2, width):
509 a = str1.ljust(width)
510 return a + str2
513def utils_max_width_of(key):
514 max = 0
515 for _, v in LICENSES.items():
516 current = len(v[key])
517 max = current if current > max else max
518 return max
521def util_file_read(fp: Path):
522 try:
523 with open(fp, "r", encoding="utf8") as rf:
524 return rf.read()
525 except Exception as e:
526 log(3, e)
527 exit(1)
530def util_file_write(fp: Path, content):
531 try:
532 with open(fp, "w+", encoding="utf8") as wf:
533 wf.write(content)
534 except Exception as e:
535 log(3, e)
536 exit(1)
540# Procs: log_*
541def log(code, msg):
542 levels = [
543 "DEBUG", "INFO", "WARNING", "ERROR"
544 ]
545 print(f"[ {levels[code]} ]: {msg}")
549# Procs: parse_*
550def parse_args(args):
552 def validate(arg, available_args):
553 if arg in available_args:
554 return True
555 return False
557 def idk():
558 log(3, "Unknown arguments")
559 exit(1)
561 arguments = Args()
563 if len(args) < 2:
564 print_help()
565 exit(1)
567 elif len(args) == 2:
568 if validate(args[1], arguments.help[0]):
569 print_help()
570 exit(0)
572 if validate(args[1], arguments.urls[0]):
573 print_urls()
574 exit(0)
576 if validate(args[1], arguments.licenses[0]):
577 print_licenses()
578 exit(0)
580 if validate(args[1], arguments.languages[0]):
581 print(Language_Comment_Tokens())
582 exit(0)
584 idk()
586 elif len(args) == 3:
587 if validate(args[1], arguments.url[0]):
588 print_url(args[2])
589 exit(0)
591 if validate(args[1], arguments.help[0]) and validate(args[2], arguments.prepend[0]):
592 print_prepend()
593 exit(0)
595 license_copy_to_destination(
596 license_short_name=args[1],
597 destination_dir=Path(args[2])
598 )
599 exit(0)
601 elif len(args) == 4:
602 license_create_with_author_date_info(
603 license_short_name=args[1],
604 destination_dir=Path(args[2]),
605 author=args[3]
606 )
607 exit(0)
609 elif len(args) == 5:
610 if validate(args[1], arguments.prepend[0]):
611 comment_prepend_short_license(
612 license_short_name=args[2],
613 author=None,
614 date=None,
615 language=args[3],
616 source_code_fp=Path(args[4])
617 )
618 exit(0)
620 license_create_with_author_date_info(
621 license_short_name=args[1],
622 destination_dir=Path(args[2]),
623 author=args[3],
624 date=args[4]
625 )
626 exit(0)
628 elif len(args) == 7:
629 if validate(args[1], arguments.prepend[0]):
630 comment_prepend_short_license(
631 license_short_name=args[2],
632 author=args[3],
633 date=args[4],
634 language=args[5],
635 source_code_fp=Path(args[6])
636 )
637 exit(0)
638 else:
639 log(3, "Cannot parse this combination of arguments. Use `h` for help.")
640 exit(1)
643if __name__ == "__main__":
644 parse_args(argv)
index : cp-license
---