hokora 0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of hokora might be problematic. Click here for more details.

hokora/__init__.py ADDED
@@ -0,0 +1,616 @@
1
+ import argparse
2
+ import importlib.machinery
3
+ import importlib.util
4
+ import json
5
+ import pickle
6
+ import re
7
+ import sys
8
+ from io import StringIO
9
+ from os import chdir, environ, pathsep
10
+ from pathlib import Path
11
+ from typing import Any, Callable, Generator, Optional, Type, get_type_hints
12
+
13
+ import jinja2
14
+
15
+ INCLUDE_PATH_ENVIRONMENT_VARIABLE = 'JINJA_TEMPLATE_DIRS'
16
+ PRELOAD_ENVIRONMENT_VARIABLE = 'HOKORA_MODULES'
17
+
18
+ PYTHON_MODULE_EXTENSIONS = (
19
+ 'py',
20
+ 'pyc',
21
+ 'pyd',
22
+ 'so',
23
+ )
24
+
25
+ EPILOG = f"""
26
+
27
+ ENVIRONMENT VARIABLES
28
+
29
+ $JINJA_TEMPLATE_DIRS
30
+ A {pathsep}-separated list of additional template include paths, which
31
+ will be searched to handle {{%extends %}}, {{%include %}} and {{%import %}}
32
+ tags unless --no-include-env is passed at the command line.
33
+
34
+ $HOKORA_MODULES
35
+ Additional python modules (honoring $PYTHONPATH) to import at load time.
36
+ These can be used to extend the functionality of hokora if they contain
37
+ subclasses of DataFormat or HokoraFeature.
38
+ """
39
+
40
+
41
+ def dynamic_import(filename_or_module_name: str | Path, module_name=None):
42
+ if isinstance(filename_or_module_name, str):
43
+ try:
44
+ return __import__(filename_or_module_name)
45
+ except ModuleNotFoundError:
46
+ pass
47
+
48
+ try:
49
+ path = Path(filename_or_module_name).resolve()
50
+ except OSError:
51
+ raise ModuleNotFoundError(module_name or filename_or_module_name)
52
+
53
+ if not module_name:
54
+ module_name = path.name.split('.')[0]
55
+
56
+ if path.is_file() and not any(
57
+ path.name.endswith(f'.{x}') for x in PYTHON_MODULE_EXTENSIONS
58
+ ):
59
+ # importlib's spec_from_file_location will balk unless it can
60
+ # recognize a file by extension, but shebang'd python scripts on unix
61
+ # systems won't necessarily have any extension.
62
+ loader0 = importlib.machinery.SourceFileLoader(module_name, str(path))
63
+ spec = importlib.util.spec_from_loader(module_name, loader0)
64
+ else:
65
+ spec = importlib.util.spec_from_file_location(module_name, path)
66
+ if spec is None:
67
+ raise ModuleNotFoundError(module_name)
68
+ loader = spec.loader
69
+ if loader is None:
70
+ raise ImportError(module_name)
71
+ module = importlib.util.module_from_spec(spec)
72
+ sys.modules[module_name] = module
73
+ loader.exec_module(module)
74
+ return module
75
+
76
+
77
+ class HokoraFeature:
78
+ abstract: bool = True
79
+
80
+ @classmethod
81
+ def find_all(cls) -> Generator[Type['HokoraFeature'], None, None]:
82
+ queue = [HokoraFeature.__subclasses__()]
83
+ while queue:
84
+ for cls in queue.pop(0):
85
+ if cls.__dict__.get('abstract'):
86
+ queue.append(cls.__subclasses__())
87
+ else:
88
+ cls.abstract = False
89
+ yield cls
90
+
91
+ @classmethod
92
+ def configure_argparse(cls, parser: argparse.ArgumentParser):
93
+ pass
94
+
95
+ @classmethod
96
+ def argparse_namespace_to_instance(
97
+ cls, args, template_path: Optional[Path] = None
98
+ ) -> 'HokoraFeature':
99
+ hints = get_type_hints(cls)
100
+ return cls(
101
+ template_path=template_path,
102
+ **{k: v for k, v in args.__dict__.items() if k in hints},
103
+ )
104
+
105
+ def __init__(self, template_path: Optional[Path], **kw):
106
+ self.template_path = template_path
107
+ for k, v in kw.items():
108
+ setattr(self, k, v)
109
+
110
+ def update_jinja_include_paths(self, paths: list):
111
+ pass
112
+
113
+ def update_jinja_environment_init_args(self, kw: dict):
114
+ pass
115
+
116
+ def update_jinja_environment(self, env: jinja2.Environment):
117
+ pass
118
+
119
+ def update_template_context(self, context: dict):
120
+ pass
121
+
122
+ def postprocess_output(self, output: str) -> str:
123
+ return output
124
+
125
+
126
+ class AbstractFeature(HokoraFeature):
127
+ abstract = True
128
+
129
+
130
+ class NonAbstractFeature(AbstractFeature):
131
+ pass
132
+
133
+
134
+ class DataFormat(HokoraFeature):
135
+ abstract: bool = True
136
+ extensions: tuple[str, ...] = tuple[str, ...]()
137
+ _dump: Optional[Callable] = None
138
+ name: str
139
+
140
+ @classmethod
141
+ def filter_name(self) -> Optional[str]:
142
+ if self._dump or self.filter != DataFormat.filter:
143
+ return self.name
144
+ return None
145
+
146
+ def __init_subclass__(cls):
147
+ super().__init_subclass__()
148
+ if not cls.__dict__.get('name') and not cls.__dict__.get('abstract'):
149
+ cls.name = cls.__name__.replace('DataFormat', '').lower()
150
+
151
+ def load_as_context(self, path: Path, key: Optional[str]):
152
+ with open(path) as f:
153
+ loaded = self._load(f)
154
+ if isinstance(loaded, dict) or key is not None:
155
+ return loaded
156
+ if key is None:
157
+ key = path.name.split('.')[0]
158
+ return {key: loaded}
159
+
160
+ def _load(self, fd):
161
+ raise NotImplementedError(
162
+ f'Subclass {type(self)} must override _load or load_as_context'
163
+ )
164
+
165
+ def update_jinja_environment(self, env: jinja2.Environment):
166
+ if filter_name := self.filter_name():
167
+ env.filters[filter_name] = self.filter
168
+
169
+ def filter(self, value) -> str:
170
+ if self._dump is None:
171
+ raise NotImplementedError(
172
+ f'Subclass {type(self)} must override _dump or filter'
173
+ )
174
+ with StringIO() as sio:
175
+ self._dump(value, sio)
176
+ return sio.getvalue()
177
+
178
+
179
+ class JsonDataFormat(DataFormat):
180
+ extensions = ('json',)
181
+ _load = staticmethod(json.load)
182
+ _dump = staticmethod(json.dump)
183
+
184
+
185
+ class PythonDataFormat(DataFormat):
186
+ extensions = PYTHON_MODULE_EXTENSIONS
187
+
188
+ def load_as_context(self, path, key):
189
+ return dynamic_import(path, key).__dict__
190
+
191
+ def _dump(self, data, stream):
192
+ stream.write(repr(data))
193
+
194
+
195
+ class PickleDataFormat(DataFormat):
196
+ extensions = ('pkl', 'pickle')
197
+ _load = staticmethod(pickle.load)
198
+ _dump = staticmethod(pickle.dump)
199
+
200
+
201
+ try:
202
+ try:
203
+ import toml
204
+
205
+ _toml_dump = staticmethod(toml.dump)
206
+ except ModuleNotFoundError:
207
+ import tomllib as toml # type: ignore
208
+ except ModuleNotFoundError:
209
+ pass
210
+ else:
211
+
212
+ class TomlDataFormat(DataFormat):
213
+ extensions = ('toml',)
214
+ _load = staticmethod(toml.load)
215
+ try:
216
+ _dump = staticmethod(toml.dump)
217
+ except AttributeError:
218
+ pass
219
+
220
+
221
+ try:
222
+ import yaml
223
+ except ModuleNotFoundError:
224
+ pass
225
+ else:
226
+
227
+ class YamlDataFormat(DataFormat):
228
+ extensions = ('yaml', 'yml')
229
+ _load = staticmethod(yaml.safe_load)
230
+ _dump = staticmethod(yaml.dump)
231
+
232
+
233
+ class ExternalContext(HokoraFeature):
234
+ context: dict
235
+
236
+ @classmethod
237
+ def configure_argparse(cls, parser: argparse.ArgumentParser):
238
+ ctx = parser.add_argument_group('Jinja Context Variables')
239
+ ctx.add_argument(
240
+ '--context',
241
+ '-c',
242
+ action='append',
243
+ default=[],
244
+ help=(
245
+ 'Read the given file for context values to supply as jinja '
246
+ 'variables. Use key=filename if you want to namespace the '
247
+ 'data, like {{key.somevar}}. See AVAILABLE CONTEXT FORMATS '
248
+ 'below for supported file extensions.'
249
+ ),
250
+ )
251
+ ctx.add_argument(
252
+ '--environ',
253
+ '-e',
254
+ dest='context',
255
+ action='append_const',
256
+ const=dict(environ),
257
+ help='Make environment variables available as jinja variables',
258
+ )
259
+
260
+ @classmethod
261
+ def argparse_namespace_to_instance(
262
+ cls, args, template_path: Optional[Path] = None
263
+ ) -> 'HokoraFeature':
264
+ context = {}
265
+ for entry in args.context:
266
+ if isinstance(entry, dict):
267
+ context.update(entry)
268
+ continue
269
+ if not isinstance(entry, str):
270
+ raise TypeError(f'{entry!r} is neither dict nor str')
271
+ key, equals, filename = entry.partition('=')
272
+ if not equals:
273
+ filename = key
274
+ key = ''
275
+
276
+ filename, *args = filename.split(pathsep)
277
+ kw = {}
278
+ for a in args:
279
+ k, eq, v = a.partition('=')
280
+ if eq:
281
+ kw[k] = v
282
+ elif k.startswith('-'):
283
+ kw[k[1:]] = False
284
+ else:
285
+ kw[k] = True
286
+
287
+ if not (ext := kw.pop('type', None)):
288
+ _, dot, ext = filename.rpartition('.')
289
+ if not dot:
290
+ raise ValueError(f"Can't detect format of {filename!r}")
291
+
292
+ for fmt in DataFormat.__subclasses__():
293
+ if ext.lower() in fmt.extensions:
294
+ loader_class = fmt
295
+ break
296
+ else:
297
+ raise ValueError(f'Unknown file type {ext}')
298
+
299
+ loader = loader_class(template_path=template_path, **kw)
300
+ value = loader.load_as_context(Path(filename), key)
301
+
302
+ if key:
303
+ context[key] = value
304
+ else:
305
+ context.update(value)
306
+ return cls(template_path=template_path, context=context)
307
+
308
+ def update_template_context(self, context: dict):
309
+ context.update(self.context)
310
+
311
+
312
+ class TemplateSearchPaths(HokoraFeature):
313
+ paths: list[Path]
314
+
315
+ @classmethod
316
+ def configure_argparse(cls, parser: argparse.ArgumentParser):
317
+ group = parser.add_argument_group('Template search paths')
318
+ group.add_argument(
319
+ '-C',
320
+ help=(
321
+ 'Change directories to this folder and interpret all paths as '
322
+ 'relative to it.'
323
+ ),
324
+ action='store',
325
+ )
326
+ group.add_argument(
327
+ '--no-include-env',
328
+ help=(
329
+ f"Don't parse ${INCLUDE_PATH_ENVIRONMENT_VARIABLE} for "
330
+ 'include folders'
331
+ ),
332
+ action='store_true',
333
+ )
334
+ group.add_argument(
335
+ '--no-include-cwd',
336
+ help=(
337
+ "Don't include the current working directory in the search "
338
+ 'path'
339
+ ),
340
+ action='store_true',
341
+ )
342
+ group.add_argument(
343
+ '--no-include-template-dir',
344
+ help="Don't include the template's folder in the search path.",
345
+ action='store_true',
346
+ )
347
+ group.add_argument(
348
+ '--include',
349
+ '-I',
350
+ help='Add this folder to the template include path',
351
+ action='append',
352
+ default=[],
353
+ )
354
+
355
+ @classmethod
356
+ def argparse_namespace_to_instance(
357
+ cls, args, template_path: Optional[Path] = None
358
+ ) -> 'HokoraFeature':
359
+ if args.C:
360
+ chdir(args.C)
361
+ paths = [Path(x).resolve() for x in args.include]
362
+ if not args.no_include_env:
363
+ env = environ.get(INCLUDE_PATH_ENVIRONMENT_VARIABLE, '')
364
+ paths.extend(Path(e) for e in env.split(pathsep) if e)
365
+ if template_path is not None and not args.no_include_template_dir:
366
+ paths.append(template_path.parent)
367
+ if not args.no_include_cwd:
368
+ paths.append(Path('.'))
369
+
370
+ return cls(
371
+ template_path=template_path,
372
+ paths=[p.resolve() for p in paths if p.is_dir()],
373
+ )
374
+
375
+ def update_jinja_include_paths(self, paths: list):
376
+ paths.extend(self.paths)
377
+
378
+
379
+ class Autoescape(HokoraFeature):
380
+ autoescape: bool | Callable = jinja2.select_autoescape(
381
+ default_for_string=False
382
+ )
383
+
384
+ @classmethod
385
+ def configure_argparse(cls, parser: argparse.ArgumentParser):
386
+ parser.add_argument(
387
+ '--autoescape',
388
+ action=argparse.BooleanOptionalAction,
389
+ default=cls.autoescape,
390
+ help='whether to escape HTML entities in jinja expressions',
391
+ )
392
+
393
+ def update_jinja_environment_init_args(self, kw):
394
+ kw['autoescape'] = self.autoescape
395
+
396
+
397
+ class StripShebang(HokoraFeature):
398
+ strip_shebang: bool = False
399
+
400
+ @classmethod
401
+ def configure_argparse(cls, parser: argparse.ArgumentParser):
402
+ parser.add_argument(
403
+ '--strip-shebang',
404
+ action=argparse.BooleanOptionalAction,
405
+ help='remove the first line of the output if it is a bash shebang',
406
+ )
407
+
408
+ def postprocess_output(self, output: str) -> str:
409
+ if self.strip_shebang and output.startswith('#!'):
410
+ return output.partition('\n')[-1]
411
+ return output
412
+
413
+
414
+ def make_argument_parser():
415
+ formats = list[Type[DataFormat]]()
416
+ features = list[Type[HokoraFeature]]()
417
+ for feature_class in HokoraFeature.find_all():
418
+ if issubclass(feature_class, DataFormat):
419
+ formats.append(feature_class)
420
+ elif (
421
+ getattr(feature_class, '__doc__', '')
422
+ and feature_class.__module__ != __name__
423
+ ):
424
+ features.append(feature_class)
425
+
426
+ with StringIO() as sio:
427
+ sio.write(EPILOG.replace('\n', '\n ', 1))
428
+ if features:
429
+ sio.write('\n \nENABLED FEATURES\n\n')
430
+ for f in features:
431
+ name = f.__name__
432
+ sio.write(f' {name}{getattr(f, "__doc__", "") or ""}\n')
433
+ sio.write('\n \nAVAILABLE CONTEXT FORMATS\n\n')
434
+ for f in formats:
435
+ exts = ', '.join(f'*.{x}' for x in f.extensions)
436
+ with StringIO('%74s' % (exts,)) as name_builder:
437
+ if filter_name := f.filter_name():
438
+ name_builder.seek(20)
439
+ name_builder.write('{{ ... | %s }}' % (filter_name,))
440
+ name_builder.seek(0)
441
+ name_builder.write(f.name)
442
+ name = name_builder.getvalue()
443
+ sio.write(f' {name}{getattr(f, "__doc__", "") or ""}\n')
444
+ main = argparse.ArgumentParser(
445
+ description='Hokora: Standalone Jinja2 template renderer',
446
+ epilog=sio.getvalue(),
447
+ formatter_class=argparse.RawDescriptionHelpFormatter,
448
+ )
449
+ main.add_argument('template', nargs='?', help='path to template to render')
450
+ for feature_class in HokoraFeature.find_all():
451
+ feature_class.configure_argparse(main)
452
+ return main
453
+
454
+
455
+ class ExplicitPathTemplate(str):
456
+ path: Path
457
+
458
+
459
+ class PathLoader(jinja2.FileSystemLoader):
460
+ def get_source(self, environment, template):
461
+ if isinstance(template, ExplicitPathTemplate):
462
+ with open(template.path) as fd:
463
+ return fd.read(), template, True
464
+ return super().get_source(environment, template)
465
+
466
+
467
+ class HelpfulDefault(HokoraFeature):
468
+ _filters: dict[str, Callable] = {'repr': repr}
469
+ _context: dict[str, Any] = {'hokora': sys.modules[__name__]}
470
+
471
+ @classmethod
472
+ def filter(cls, func):
473
+ cls._filters[func.__name__] = func
474
+ return func
475
+
476
+ @classmethod
477
+ def function(cls, func):
478
+ cls._context[func.__name__] = func
479
+ return func
480
+
481
+ def update_jinja_environment(self, env: jinja2.Environment):
482
+ for k, v in self._filters.items():
483
+ env.filters.setdefault(k, v)
484
+
485
+ def update_template_context(self, context: dict):
486
+ context.setdefault('template_path', self.template_path)
487
+ for k, v in self._context.items():
488
+ context.setdefault(k, v)
489
+
490
+
491
+ @HelpfulDefault.filter
492
+ def sub(text, pat, replacement, *a, **kw):
493
+ return re.sub(pat, replacement, str(text), *a, **kw)
494
+
495
+
496
+ @HelpfulDefault.filter
497
+ def snip(text, start, end=None, index=0, inclusive=None):
498
+ if inclusive is None:
499
+ if isinstance(index, (bool, tuple)):
500
+ inclusive = index
501
+ index = 0
502
+ else:
503
+ inclusive = (isinstance(start, int), False)
504
+ if isinstance(inclusive, bool):
505
+ inclusive = (inclusive, inclusive)
506
+ if isinstance(start, int) or isinstance(end, int) or end is None:
507
+ if index:
508
+ raise ValueError(
509
+ "|snip's index= argument only works with text markers"
510
+ )
511
+ buf = list[str]()
512
+ on = False
513
+ for n, line in enumerate(str(text).split('\n'), start=1):
514
+ if on:
515
+ if (isinstance(end, int) and n >= end) or (
516
+ isinstance(end, str) and re.search(end, line)
517
+ ):
518
+ on = False
519
+ if index:
520
+ index -= 1
521
+ buf.clear()
522
+ else:
523
+ buf.append(line if inclusive[1] else '')
524
+ return '\n'.join(buf)
525
+ else:
526
+ if (isinstance(start, int) and n == start) or (
527
+ isinstance(start, str) and re.search(start, line)
528
+ ):
529
+ on = True
530
+ if not inclusive[0]:
531
+ continue
532
+ if on:
533
+ buf.append(line)
534
+ if on and end is None:
535
+ return '\n'.join(buf)
536
+ return ''
537
+
538
+
539
+ @HelpfulDefault.function
540
+ def read(path):
541
+ with open(path) as fd:
542
+ return fd.read()
543
+
544
+
545
+ def create_jinja_environment(features: list[HokoraFeature]):
546
+ include_paths = list[Path]()
547
+ for feat in features:
548
+ feat.update_jinja_include_paths(include_paths)
549
+
550
+ environment_kw = {
551
+ 'loader': PathLoader(include_paths),
552
+ }
553
+
554
+ for feat in features:
555
+ feat.update_jinja_environment_init_args(environment_kw)
556
+
557
+ environment = jinja2.Environment(**environment_kw) # type: ignore
558
+
559
+ for feat in features:
560
+ feat.update_jinja_environment(environment)
561
+
562
+ return environment
563
+
564
+
565
+ def args_to_templates(
566
+ args,
567
+ ) -> list[tuple[jinja2.Template, list[HokoraFeature], Optional[Path]]]:
568
+ if args.template and args.template != '-':
569
+ template_path = Path(args.template).resolve()
570
+ if not template_path.exists():
571
+ raise FileNotFoundError(args.template)
572
+ else:
573
+ template_path = None
574
+
575
+ features = [
576
+ feat.argparse_namespace_to_instance(args, template_path)
577
+ for feat in HokoraFeature.find_all()
578
+ ]
579
+
580
+ environment = create_jinja_environment(features)
581
+ template: jinja2.Template
582
+ if template_path is None:
583
+ if sys.stdin.isatty():
584
+ eof = 'Ctrl+Z, Enter' if sys.platform == 'win32' else 'Ctrl+D'
585
+ sys.stderr.write(f'Type your template ({eof} when done)\n')
586
+ sys.stderr.flush()
587
+ template = environment.from_string(sys.stdin.read())
588
+ else:
589
+ ept = ExplicitPathTemplate(template_path)
590
+ ept.path = template_path
591
+ template = environment.get_template(ept)
592
+
593
+ return [(template, features, None)]
594
+
595
+
596
+ def main():
597
+ for mod in environ.get(PRELOAD_ENVIRONMENT_VARIABLE, '').split(pathsep):
598
+ if mod:
599
+ dynamic_import(mod)
600
+ args = make_argument_parser().parse_args()
601
+ for template, features, render_dest in args_to_templates(args):
602
+ assert not render_dest, 'TODO: support rendering to file'
603
+ context: dict[str, Any] = {}
604
+ for feat in features:
605
+ feat.update_template_context(context)
606
+
607
+ rendered = template.render(context)
608
+
609
+ for feat in features:
610
+ rendered = feat.postprocess_output(rendered)
611
+
612
+ print(rendered, end='')
613
+
614
+
615
+ if __name__ == '__main__':
616
+ main()
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: hokora
3
+ Version: 0.dev0
4
+ License-Expression: BSD-3-Clause
5
+ Project-URL: Homepage, https://gitlab.com/drmoose/hokora
6
+ Project-URL: Issues, https://gitlab.com/drmoose/hokora/-/issues
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: COPYING
10
+ Requires-Dist: jinja2
11
+ Provides-Extra: check
12
+ Requires-Dist: ruff; extra == "check"
13
+ Requires-Dist: mypy; extra == "check"
14
+ Provides-Extra: toml
15
+ Requires-Dist: toml; extra == "toml"
16
+ Provides-Extra: yaml
17
+ Requires-Dist: PyYAML; extra == "yaml"
18
+ Provides-Extra: all
19
+ Requires-Dist: hokora[toml,yaml]; extra == "all"
20
+ Dynamic: license-file
@@ -0,0 +1,7 @@
1
+ hokora/__init__.py,sha256=hx1YSvFpnDOHPDjAXmZg_pR2ZGfP7aFOt6JTtudpI_k,18916
2
+ hokora-0.dev0.dist-info/licenses/COPYING,sha256=XYeZfYzmU_j8eZTy3Jl7YOOAX1wtNzLbO5v2I3QqQJs,1483
3
+ hokora-0.dev0.dist-info/METADATA,sha256=zNFbjL64q4JXpp1wzASM-vRbJGGtj9xy4Bk01S8F_68,620
4
+ hokora-0.dev0.dist-info/WHEEL,sha256=pxyMxgL8-pra_rKaQ4drOZAegBVuX-G_4nRHjjgWbmo,91
5
+ hokora-0.dev0.dist-info/entry_points.txt,sha256=AXnUrRzHdBXsdKD8pQ6yYhuaBo0xKUngFyeZj2w0eYI,39
6
+ hokora-0.dev0.dist-info/top_level.txt,sha256=qOxZ02jJIbIVPVAAhK0vALePCOuLfElvYagg5jvKQ9w,7
7
+ hokora-0.dev0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (79.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hokora = hokora:main
@@ -0,0 +1,28 @@
1
+ Copyright (c) 2025 drmoose.net
2
+
3
+ Redistribution and use in source and binary forms, with or without
4
+ modification, are permitted provided that the following conditions are
5
+ met:
6
+
7
+ 1. Redistributions of source code must retain the above copyright
8
+ notice, this list of conditions and the following disclaimer.
9
+
10
+ 2. Redistributions in binary form must reproduce the above copyright
11
+ notice, this list of conditions and the following disclaimer in the
12
+ documentation and/or other materials provided with the distribution.
13
+
14
+ 3. Neither the name of the copyright holder nor the names of its
15
+ contributors may be used to endorse or promote products derived from
16
+ this software without specific prior written permission.
17
+
18
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
21
+ PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22
+ HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
24
+ TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
25
+ PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
26
+ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
27
+ NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
28
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1 @@
1
+ hokora