Coverage for src/core.py: 24%

186 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-08-19 11:22 +0100

1"""Main core of the application.""" 

2 

3from __future__ import annotations 

4 

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 

17 

18try: 

19 from pkg_resources import resource_listdir, resource_stream # type: ignore 

20except ImportError: 

21 import importlib.resources 

22 

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") 

31 

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 ] 

43 

44 

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]) 

50 

51DEFAULT_LICENSE = "bsd3" 

52 

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 : --- -- 

62 

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. 

74 

75 

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} 

108 

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} 

127 

128 

129def clean_path(p: str) -> str: 

130 """Clean a path. 

131 

132 Expand user and environment variables anensuring absolute path. 

133 """ 

134 expanded = os.path.expandvars(Path(p).expanduser()) 

135 return str(Path(expanded).resolve()) 

136 

137 

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 } 

145 

146 

147def guess_organization() -> str: 

148 """Guess the organization from `git config`. 

149 

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 

158 

159 

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 

170 

171 

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 

184 

185 

186def extract_vars(template: StringIO) -> list[str]: 

187 """Extract variables from template. 

188 

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) 

195 

196 

197def generate_license(template: StringIO, context: dict[str, str]) -> StringIO: 

198 """Generate a license. 

199 

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 

213 

214 

215def format_license(template: StringIO, lang: str) -> StringIO: 

216 """Format the StringIO template object for specified lang string. 

217 

218 Return StringIO object formatted 

219 """ 

220 if not lang: 

221 lang = "txt" 

222 

223 out = StringIO() 

224 

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") 

232 

233 return out 

234 

235 

236def get_suffix(name: str) -> Union[str, None]: 

237 """Check if file name have valid suffix for formatting. 

238 

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 

247 

248 

249def get_args() -> argparse.Namespace: 

250 """Set up the arg parsing and return it.""" 

251 

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 

257 

258 parser = argparse.ArgumentParser(description="Generate a license") 

259 

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 ) 

334 

335 return parser.parse_args() 

336 

337 

338def list_vars(args: argparse.Namespace, license_name: str) -> None: 

339 """List the variables for the given template.""" 

340 context = get_context(args) 

341 

342 if args.template_path: 

343 template = load_file_template(args.template_path) 

344 else: 

345 template = load_package_template(license_name) 

346 

347 var_list = extract_vars(template) 

348 

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 ) 

364 

365 sys.exit(0) 

366 

367 

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) 

383 

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) 

390 

391 

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 

404 

405 

406def main() -> None: 

407 """Main program loop.""" 

408 args = get_args() 

409 

410 # do license stuff 

411 license_name = args.license or DEFAULT_LICENSE 

412 

413 # language 

414 lang = get_lang(args) 

415 

416 # generate header if requested 

417 if args.header: 

418 generate_header(args, license_name, lang) 

419 

420 # list template vars if requested 

421 if args.list_vars: 

422 list_vars(args, license_name) 

423 

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) 

433 

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) 

439 

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) 

445 

446 content = generate_license(template, get_context(args)) 

447 

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) 

456 

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) 

466 

467 

468if __name__ == "__main__": 

469 main()