Coverage for src/core.py: 24%

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

2

3 from __future__ import annotations

4

5 import argparse

6 import getpass

7 import os

8 import re

9 import subprocess

10 import sys

11 from contextlib import closing

12 from datetime import datetime

13 from importlib import resources

14 from io import StringIO

15 from pathlib import Path

16 from typing import IO, Union

17

18 try:

19 from pkg_resources import resource_listdir, resource_stream # type: ignore

20 except 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

45 LICENSES: list[str] = []

46 for 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

51 DEFAULT_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

76 LANGS = {

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

109 LANG_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

129 def 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

138 def 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

147 def 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

160 def 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

172 def 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

186 def 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

197 def 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

215 def 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

236 def 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

249 def 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

338 def 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

368 def 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

392 def 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

406 def 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

468 if __name__ == "__main__":

469 main()