Coverage for src/core.py: 24%
186 statements
« prev ^ index » next coverage.py v7.6.1, created at 2024-08-19 11:22 +0100
« prev ^ index » next coverage.py v7.6.1, created at 2024-08-19 11:22 +0100
1"""Main core of the application."""
3from __future__ import annotations
5import argparse
6import getpass
7import os
8import re
9import subprocess
10import sys
11from contextlib import closing
12from datetime import datetime
13from importlib import resources
14from io import StringIO
15from pathlib import Path
16from typing import IO, Union
18try:
19 from pkg_resources import resource_listdir, resource_stream # type: ignore
20except ImportError:
21 import importlib.resources
23 def resource_stream(
24 package_or_requirement: resources.Package, resource_name: str
25 ) -> IO[bytes]:
26 """Emulate the 'resource_stream' method."""
27 ref = importlib.resources.files(package_or_requirement).joinpath(
28 resource_name
29 )
30 return ref.open("rb")
32 def resource_listdir(
33 package_or_requirement: resources.Package, resource_name: str
34 ) -> list[str]:
35 """Emulate the 'resource_listdir' method."""
36 resource_qualname = f"{package_or_requirement}.{resource_name}".rstrip(
37 "."
38 )
39 return [
40 r.name
41 for r in importlib.resources.files(resource_qualname).iterdir()
42 ]
45LICENSES: list[str] = []
46for file in sorted(resource_listdir(__name__, ".")):
47 match = re.match(r"template-([a-z0-9_]+).txt", file)
48 if match:
49 LICENSES.append(match.groups()[0])
51DEFAULT_LICENSE = "bsd3"
53# To extend language formatting sopport with a new language, add an item in
54# LANGS dict:
55# "language_suffix":"comment_name"
56# where "language_suffix" is the suffix of your language and "comment_name" is
57# one of the comment types supported and listed in LANG_CMT:
58# text : no comment
59# c : /* * */
60# unix : #
61# lua : --- --
63# if you want add a new comment type just add an item to LANG_CMT:
64# "comment_name":[u'string', u'string', u'string']
65# where the first string open multiline comment, second string comment every
66# license's line and the last string close multiline comment,
67# associate your language and source file suffix with your new comment type
68# how explained above.
69# EXAMPLE:
70# LANG_CMT = {"c":[u'/*', u'*', u'*/']} # noqa: ERA001
71# LANGS = {"cpp":"c"} # noqa: ERA001
72# (for more examples see LANG_CMT and langs dicts below)
73# NOTE: unicode (u) in comment strings is required.
76LANGS = {
77 "agda": "haskell",
78 "c": "c",
79 "cc": "c",
80 "clj": "lisp",
81 "cpp": "c",
82 "css": "c",
83 "el": "lisp",
84 "erl": "erlang",
85 "f": "fortran",
86 "f90": "fortran90",
87 "h": "c",
88 "hpp": "c",
89 "hs": "haskell",
90 "html": "html",
91 "idr": "haskell",
92 "java": "java",
93 "js": "c",
94 "lisp": "lisp",
95 "lua": "lua",
96 "m": "c",
97 "ml": "ml",
98 "php": "c",
99 "pl": "perl",
100 "py": "unix",
101 "ps": "powershell",
102 "rb": "ruby",
103 "scm": "lisp",
104 "sh": "unix",
105 "txt": "text",
106 "rs": "rust",
107}
109LANG_CMT = {
110 "c": ["/*", " *", " */"],
111 "erlang": ["%%", "%", "%%"],
112 "fortran": ["C", "C", "C"],
113 "fortran90": ["!*", "!*", "!*"],
114 "haskell": ["{-", "", "-}"],
115 "html": ["<!--", "", "-->"],
116 "java": ["/**", " *", " */"],
117 "lisp": ["", ";;", ""],
118 "lua": ["--[[", "", "--]]"],
119 "ml": ["(*", "", "*)"],
120 "perl": ["=item", "", "=cut"],
121 "powershell": ["<#", "#", "#>"],
122 "ruby": ["=begin", "", "=end"],
123 "text": ["", "", ""],
124 "unix": ["", "#", ""],
125 "rust": ["", "//" ""],
126}
129def clean_path(p: str) -> str:
130 """Clean a path.
132 Expand user and environment variables anensuring absolute path.
133 """
134 expanded = os.path.expandvars(Path(p).expanduser())
135 return str(Path(expanded).resolve())
138def get_context(args: argparse.Namespace) -> dict[str, str]:
139 """Return the context vars from the provided args."""
140 return {
141 "year": args.year,
142 "organization": args.organization,
143 "project": args.project,
144 }
147def guess_organization() -> str:
148 """Guess the organization from `git config`.
150 If that can't be found, fall back to $USER environment variable.
151 """
152 try:
153 stdout = subprocess.check_output("git config --get user.name".split()) # noqa: S603
154 org = stdout.strip().decode("UTF-8")
155 except subprocess.CalledProcessError:
156 org = getpass.getuser()
157 return org
160def load_file_template(path: str) -> StringIO:
161 """Load template from the specified filesystem path."""
162 template = StringIO()
163 if not Path(path).exists():
164 message = f"path does not exist: {path}"
165 raise ValueError(message)
166 with Path(clean_path(path)).open(mode="rb") as infile: # opened as binary
167 for line in infile:
168 template.write(line.decode("utf-8")) # ensure utf-8
169 return template
172def load_package_template(
173 license_name: str, *, header: bool = False
174) -> StringIO:
175 """Load license template distributed with package."""
176 content = StringIO()
177 filename = "template-%s-header.txt" if header else "template-%s.txt"
178 with resource_stream(
179 __name__, f"templates/{filename % license_name}"
180 ) as licfile:
181 for line in licfile:
182 content.write(line.decode("utf-8")) # write utf-8 string
183 return content
186def extract_vars(template: StringIO) -> list[str]:
187 """Extract variables from template.
189 Variables are enclosed in double curly braces.
190 """
191 keys: set[str] = set()
192 for match in re.finditer(r"\{\{ (?P<key>\w+) \}\}", template.getvalue()):
193 keys.add(match.groups()[0])
194 return sorted(keys)
197def generate_license(template: StringIO, context: dict[str, str]) -> StringIO:
198 """Generate a license.
200 We extract variables from the template and replace them with the
201 corresponding values in the given context.
202 """
203 out = StringIO()
204 content = template.getvalue()
205 for key in extract_vars(template):
206 if key not in context:
207 message = f"{key} is missing from the template context"
208 raise ValueError(message)
209 content = content.replace(f"{ { {key} } } ", context[key])
210 template.close() # free template memory (when is garbage collected?)
211 out.write(content)
212 return out
215def format_license(template: StringIO, lang: str) -> StringIO:
216 """Format the StringIO template object for specified lang string.
218 Return StringIO object formatted
219 """
220 if not lang:
221 lang = "txt"
223 out = StringIO()
225 with closing(template):
226 template.seek(0) # from the start of the buffer
227 out.write(LANG_CMT[LANGS[lang]][0] + "\n")
228 for line in template:
229 out.write(LANG_CMT[LANGS[lang]][1] + " ")
230 out.write(line)
231 out.write(LANG_CMT[LANGS[lang]][2] + "\n")
233 return out
236def get_suffix(name: str) -> Union[str, None]:
237 """Check if file name have valid suffix for formatting.
239 If have suffix, return it else return None.
240 """
241 a = name.count(".")
242 if a:
243 ext = name.split(".")[-1]
244 if ext in LANGS:
245 return ext
246 return None
249def get_args() -> argparse.Namespace:
250 """Set up the arg parsing and return it."""
252 def valid_year(string: str) -> str:
253 if not re.match(r"^\d{4}$", string):
254 message = "Must be a four-digit year"
255 raise argparse.ArgumentTypeError(message)
256 return string
258 parser = argparse.ArgumentParser(description="Generate a license")
260 parser.add_argument(
261 "license",
262 metavar="license",
263 nargs="?",
264 choices=LICENSES,
265 help=f"the license to generate, one of: {', '.join(LICENSES)}",
266 )
267 parser.add_argument(
268 "--header",
269 dest="header",
270 action="store_true",
271 help="generate source file header for specified license",
272 )
273 parser.add_argument(
274 "-o",
275 "--org",
276 dest="organization",
277 default=guess_organization(),
278 help='organization, defaults to .gitconfig or os.environ["USER"]',
279 )
280 parser.add_argument(
281 "-p",
282 "--proj",
283 dest="project",
284 default=Path.cwd().name,
285 help="name of project, defaults to name of current directory",
286 )
287 parser.add_argument(
288 "-t",
289 "--template",
290 dest="template_path",
291 help="path to license template file",
292 )
293 parser.add_argument(
294 "-y",
295 "--year",
296 dest="year",
297 type=valid_year,
298 default="%i" % datetime.now().date().year, # noqa: DTZ005
299 help="copyright year",
300 )
301 parser.add_argument(
302 "-l",
303 "--language",
304 dest="language",
305 help="format output for language source file, one of: "
306 f"{', '.join(LANGS.keys())} [default is not formatted (txt)]",
307 )
308 parser.add_argument(
309 "-f",
310 "--file",
311 dest="ofile",
312 default="stdout",
313 help="Name of the output source file (with -l, "
314 "extension can be ommitted)",
315 )
316 parser.add_argument(
317 "--vars",
318 dest="list_vars",
319 action="store_true",
320 help="list template variables for specified license",
321 )
322 parser.add_argument(
323 "--licenses",
324 dest="list_licenses",
325 action="store_true",
326 help="list available license templates and their parameters",
327 )
328 parser.add_argument(
329 "--languages",
330 dest="list_languages",
331 action="store_true",
332 help="list available source code formatting languages",
333 )
335 return parser.parse_args()
338def list_vars(args: argparse.Namespace, license_name: str) -> None:
339 """List the variables for the given template."""
340 context = get_context(args)
342 if args.template_path:
343 template = load_file_template(args.template_path)
344 else:
345 template = load_package_template(license_name)
347 var_list = extract_vars(template)
349 if var_list:
350 sys.stdout.write(
351 "The %s license template contains the following variables "
352 "and defaults:\n" % (args.template_path or license_name)
353 )
354 for v in var_list:
355 if v in context:
356 sys.stdout.write(f" {v} = {context[v]}\n")
357 else:
358 sys.stdout.write(f" {v}\n")
359 else:
360 sys.stdout.write(
361 f"The {args.template_path or license_name} license template "
362 "contains no variables.\n"
363 )
365 sys.exit(0)
368def generate_header(
369 args: argparse.Namespace, license_name: str, lang: str
370) -> None:
371 """Generate a file header for the given license and language."""
372 if args.template_path:
373 template = load_file_template(args.template_path)
374 else:
375 try:
376 template = load_package_template(license_name, header=True)
377 except OSError:
378 sys.stderr.write(
379 "Sorry, no source headers are available for "
380 f"{args.license}.\n"
381 )
382 sys.exit(1)
384 content = generate_license(template, get_context(args))
385 out = format_license(content, lang)
386 out.seek(0)
387 sys.stdout.write(out.getvalue())
388 out.close() # free content memory (paranoic memory stuff)
389 sys.exit(0)
392def get_lang(args: argparse.Namespace) -> str:
393 """Check the specified language is supported."""
394 lang: str = args.language
395 if lang and lang not in LANGS:
396 sys.stderr.write(
397 "I do not know about a language ending with "
398 f"extension {lang}.\n"
399 "Please send a pull request adding this language to\n"
400 "https://github.com/seapagan/lice2. Thanks!\n"
401 )
402 sys.exit(1)
403 return lang
406def main() -> None:
407 """Main program loop."""
408 args = get_args()
410 # do license stuff
411 license_name = args.license or DEFAULT_LICENSE
413 # language
414 lang = get_lang(args)
416 # generate header if requested
417 if args.header:
418 generate_header(args, license_name, lang)
420 # list template vars if requested
421 if args.list_vars:
422 list_vars(args, license_name)
424 # list available licenses and their template variables
425 if args.list_licenses:
426 for license_name in LICENSES:
427 template = load_package_template(license_name)
428 var_list = extract_vars(template)
429 sys.stdout.write(
430 "{} : {}\n".format(license_name, ", ".join(var_list))
431 )
432 sys.exit(0)
434 # list available source formatting languages
435 if args.list_languages:
436 for lang in sorted(LANGS.keys()):
437 sys.stdout.write(f"{lang}\n")
438 sys.exit(0)
440 # create context
441 if args.template_path:
442 template = load_file_template(args.template_path)
443 else:
444 template = load_package_template(license_name)
446 content = generate_license(template, get_context(args))
448 if args.ofile != "stdout":
449 ext = get_suffix(args.ofile)
450 if ext:
451 output = args.ofile
452 out = format_license(content, ext) # format license by file suffix
453 else:
454 output = f"{args.ofile}.{lang}" if lang else args.ofile
455 out = format_license(content, lang)
457 out.seek(0)
458 with Path(output).open(mode="w") as f:
459 f.write(out.getvalue())
460 f.close()
461 else:
462 out = format_license(content, lang)
463 out.seek(0)
464 sys.stdout.write(out.getvalue())
465 out.close() # free content memory (paranoic memory stuff)
468if __name__ == "__main__":
469 main()