PK!"Omudkip/__init__.py__version__ = "0.2.4" PK!6%%mudkip/__main__.pyfrom mudkip.cli import main main() PK!I((mudkip/application.pyimport sys import time import shutil from os import path from io import StringIO from contextlib import contextmanager, nullcontext, ExitStack import webbrowser import tomlkit from tomlkit.toml_file import TOMLFile as BaseTOMLFile from sphinx.application import Sphinx from sphinx.errors import SphinxError from sphinx.util import logging from recommonmark.transform import AutoStructify from .config import Config from .errors import MudkipError from .jupyter import jupyter_notebook from .watch import DirectoryWatcher class TOMLFile(BaseTOMLFile): def exists(self): return path.isfile(self._path) class Mudkip: def __init__( self, *args, config=None, pyproject_file="pyproject.toml", mudkip_file="mudkip.toml", silence_pandoc_version_warning=True, **kwargs, ): pyproject = TOMLFile(pyproject_file) mudkip = TOMLFile(mudkip_file) if config is None: params = {} if pyproject.exists(): tool = pyproject.read().get("tool", {}) params.update(tool.get("mudkip", {}), poetry=tool.get("poetry")) if mudkip.exists(): params.update(mudkip.read().get("mudkip", {})) params.update(kwargs) config = Config(*args, **params) self.config = config self.pyproject = pyproject self.mudkip = mudkip self.create_sphinx_application() self.configure_sphinx() if silence_pandoc_version_warning: import nbconvert nbconvert.utils.pandoc._maximal_version = None def create_sphinx_application(self): extra_args = {} if not self.config.verbose: extra_args["status"] = None self.sphinx = Sphinx( self.config.sphinx_srcdir, self.config.sphinx_confdir, self.config.sphinx_outdir, self.config.sphinx_doctreedir, self.config.sphinx_buildername, self.config.sphinx_confoverrides, **extra_args, ) def configure_sphinx(self): conf = self.sphinx.config if self.config.sphinx_project: conf.project = self.config.sphinx_project conf.copyright = time.strftime("%Y") if self.config.author: conf.author = self.config.author conf.copyright += ", " + conf.author if self.config.copyright: conf.copyright = self.config.copyright if self.config.version: conf.version = self.config.version if self.config.release: conf.release = self.config.release conf.master_doc = "index" conf.exclude_patterns = [".*", "**/.*", "_*", "**/_*"] conf.nitpicky = True self.sphinx.setup_extension("mudkip.extension") self.sphinx.setup_extension("recommonmark") recommonmark_config = { "enable_auto_toc_tree": True, "enable_math": True, "enable_inline_math": True, "enable_eval_rst": True, } self.sphinx.add_config_value("recommonmark_config", recommonmark_config, "env") self.sphinx.add_transform(AutoStructify) self.sphinx.setup_extension("nbsphinx") conf.nbsphinx_execute = "always" conf.nbsphinx_allow_errors = True self.sphinx.setup_extension("sphinx.ext.autodoc") self.sphinx.setup_extension("sphinx.ext.napoleon") self.sphinx.setup_extension("sphinx.ext.doctest") self.sphinx.setup_extension("sphinx_autodoc_typehints") @contextmanager def sphinx_warning_is_error(self): try: original_value = self.sphinx.warningiserror self.sphinx.warningiserror = True yield finally: self.sphinx.warningiserror = original_value @contextmanager def sphinx_builder(self, buildername): try: original_builder = self.sphinx.builder self.sphinx.preload_builder(buildername) self.sphinx.builder = self.sphinx.create_builder(buildername) self.sphinx._init_builder() yield finally: self.sphinx.builder = original_builder @contextmanager def sphinx_mute(self): try: original_status = self.sphinx._status original_warning = self.sphinx._warning self.sphinx._status = StringIO() self.sphinx._warning = StringIO() logging.setup(self.sphinx, self.sphinx._status, self.sphinx._warning) yield finally: self.sphinx._status = original_status self.sphinx._warning = original_warning logging.setup(self.sphinx, self.sphinx._status, self.sphinx._warning) @contextmanager def sphinx_config(self, **kwargs): not_present = object() conf = self.sphinx.config try: original_values = {} for key, value in kwargs.items(): original_values[key] = getattr(conf, key, not_present) setattr(conf, key, value) yield finally: for key, value in original_values.items(): if value is not_present: delattr(conf, key) else: setattr(conf, key, value) def init(self, title=None): table = tomlkit.table() table["title"] = title = ( title or self.config.title or path.basename(path.abspath(".")) ) table["preset"] = self.config.preset.name source_dir = str(self.config.source_dir) output_dir = str(self.config.output_dir) if source_dir != self.config.default_source_dir: table["source_dir"] = source_dir if output_dir != self.config.default_output_dir: table["output_dir"] = output_dir table.add(tomlkit.nl()) if self.mudkip.exists(): doc = self.mudkip.read() if "mudkip" not in doc: doc["mudkip"] = table else: doc["mudkip"].update(table) self.mudkip.write(doc) elif self.pyproject.exists(): doc = self.pyproject.read() tool = None try: tool = doc["tool"] if "mudkip" not in tool: tool._insert_after("poetry", "mudkip", table) else: tool["mudkip"].update(table) except KeyError: if tool is None: doc["tool"] = {"mudkip": table} else: tool["mudkip"] = table self.pyproject.write(doc) else: self.mudkip.write(tomlkit.document().add("mudkip", table)) index_rst = self.config.source_dir / "index.rst" index_md = self.config.source_dir / "index.md" if not index_rst.is_file() and not index_md.is_file(): index_rst.write_text(f"{title}\n{'=' * len(title)}\n") def build(self, *, check=False, skip_broken_links=False): try: self.delete_autodoc_cache() if check: self.clean() with self.sphinx_warning_is_error(): with self.sphinx_config(nbsphinx_allow_errors=False): self.sphinx.build() if not skip_broken_links: with self.sphinx_builder("linkcheck"): self.sphinx.build() else: self.sphinx.build() except SphinxError as exc: raise MudkipError(exc.args[0]) from exc def delete_autodoc_cache(self): if not self.config.project_name: return modules = [ mod for mod in sys.modules if mod == self.config.project_name or mod.startswith(self.config.project_name + ".") ] for mod in modules: del sys.modules[mod] def develop( self, open_browser=False, host="localhost", port=5500, notebook=False, notebook_host="localhost", notebook_port=8888, build_manager=None, ): if not build_manager: build_manager = lambda *args: nullcontext() patterns = [f"*{suff}" for suff in self.sphinx.config.source_suffix] ignore_patterns = self.sphinx.config.exclude_patterns dirs = [self.config.source_dir] if self.config.project_dir: dirs.append(self.config.project_dir) patterns.append("*.py") with ExitStack() as stack: stack.enter_context(self.sphinx_config(nbsphinx_execute="never")) notebook_url = None if notebook: notebook_url = stack.enter_context( jupyter_notebook( str(self.config.source_dir), self.config.verbose, notebook_host, notebook_port, ) ) server_url = None if self.config.dev_server: server_url = stack.enter_context( self.config.dev_server(self.sphinx.outdir, host, port) ) if open_browser: try: webbrowser.open(server_url) except webbrowser.Error: pass with build_manager(server_url=server_url, notebook_url=notebook_url): self.build() for event_batch in DirectoryWatcher(dirs, patterns, ignore_patterns): with build_manager(event_batch): self.build() def test(self): with self.sphinx_builder("doctest"): with nullcontext() if self.config.verbose else self.sphinx_mute(): self.build() output = self.config.sphinx_outdir / "output.txt" content = output.read_text() if output.is_file() else "" _, _, result = content.partition("\n\n") return self.sphinx.statuscode == 0, result.strip() def clean(self): try: shutil.rmtree(self.config.output_dir) except FileNotFoundError: pass PK!Y"RR mudkip/cli.pyimport sys import time from os import path from functools import wraps from contextlib import contextmanager from traceback import format_exc import click from . import __version__ from .application import Mudkip from .config import Config from .errors import MudkipError from .preset import Preset DIRECTORY = click.Path(file_okay=False) @contextmanager def exception_handler(exit=False): try: yield except Exception as exc: error = exc.args[0] if isinstance(exc, MudkipError) else format_exc() click.secho(error, fg="red", bold=True) if exit: sys.exit(1) def print_version(ctx, _param, value): if not value or ctx.resilient_parsing: return click.secho(f"Mudkip v{__version__}", fg="cyan") ctx.exit() @click.group() @click.option( "--version", is_flag=True, is_eager=True, expose_value=False, callback=print_version, help="Show the version and exit.", ) def mudkip(): """A friendly Sphinx wrapper.""" def with_application(command): @click.option( "-p", "--preset", type=click.Choice(list(Preset.registry)), help="Documentation preset.", ) @click.option("--source-dir", type=DIRECTORY, help="The source directory.") @click.option("--output-dir", type=DIRECTORY, help="The output directory.") @click.option("--verbose", is_flag=True, help="Show Sphinx output.") @wraps(command) def wrapper(preset, source_dir, output_dir, verbose, *args, **kwargs): params = dict( preset=preset, source_dir=source_dir, output_dir=output_dir, verbose=verbose ) for key, value in tuple(params.items()): if not value: del params[key] return command(*args, application=Mudkip(**params), **kwargs) return wrapper @mudkip.command() @click.option("-t", "--title", help="Documentation title.") @with_application def init(application, title): """Initialize documentation.""" padding = "\n" * application.config.verbose click.secho( f'{padding}Initializing "{application.config.source_dir}"...', fg="cyan" ) with exception_handler(exit=True): application.init(title) click.secho("\nDone.", fg="yellow") @mudkip.command() @click.option("-c", "--check", is_flag=True, help="Check documentation.") @click.option( "--skip-broken-links", is_flag=True, help="Do not check external links for integrity.", ) @with_application def build(application, check, skip_broken_links): """Build documentation.""" padding = "\n" * application.config.verbose action = "Building and checking" if check else "Building" click.secho( f'{padding}{action} "{application.config.source_dir}"...{padding}', fg="cyan" ) with exception_handler(exit=True): application.build(check=check, skip_broken_links=skip_broken_links) message = "All good" if check else "Done" click.secho(f"\n{message}.", fg="yellow") @mudkip.command() @click.option( "-o", "--open", "open_browser", is_flag=True, help="Open the documentation in a browser.", ) @click.option("--host", help="Development server host.", default="localhost") @click.option("--port", help="Development server port.", default=5500) @click.option("-n", "--notebook", is_flag=True, help="Open the Jupyter notebook.") @click.option("--notebook-host", help="Notebook host.", default="localhost") @click.option("--notebook-port", help="Notebook port.", default=8888) @with_application def develop( application, open_browser, host, port, notebook, notebook_host, notebook_port ): """Start development server.""" padding = "\n" * application.config.verbose click.secho( f'{padding}Watching "{application.config.source_dir}"...{padding}', fg="cyan" ) @contextmanager def build_manager(event_batch=None, server_url=None, notebook_url=None): if event_batch is None: if server_url: click.secho(f"Server running on {server_url}{padding}", fg="cyan") if notebook_url: click.secho(f"Notebook running on {notebook_url}{padding}", fg="cyan") with exception_handler(): yield return now = time.strftime("%H:%M:%S") click.secho(f"{padding}{now}", fg="black", bold=True, nl=False) events = event_batch.all_events if len(events) == 1: event = events[0] filename = path.basename(event.src_path) click.echo(f" {event.event_type} {filename}{padding}") else: click.echo(f" {len(events)} changes{padding}") with exception_handler(): yield try: application.develop( open_browser, host, port, notebook, notebook_host, notebook_port, build_manager, ) except KeyboardInterrupt: click.secho("\nExit.", fg="yellow") @mudkip.command() @with_application def test(application): """Test documentation.""" padding = "\n" * application.config.verbose click.secho( f'{padding}Testing "{application.config.source_dir}"...{padding}', fg="cyan" ) with exception_handler(exit=True): passed, summary = application.test() if not application.config.verbose: click.echo("\n" + summary) if passed: click.secho("\nPassed.", fg="yellow") else: click.secho("\nFailed.", fg="red", bold=True) sys.exit(1) @mudkip.command() @with_application def clean(application): """Remove output directory.""" padding = "\n" * application.config.verbose click.secho(f'{padding}Removing "{application.config.output_dir}"...', fg="cyan") with exception_handler(exit=True): application.clean() click.secho("\nDone.", fg="yellow") def main(): mudkip(prog_name="mudkip") PK!m mudkip/config.pyimport re from pathlib import Path from .preset import Preset AUTHOR_EXTRA = re.compile(r"<.*?>|\(.*?\)|\[.*?\]") SPACES = re.compile(r"\s+") def join_authors(authors): if not authors: return if isinstance(authors, str): string = authors elif len(authors) < 2: string = "".join(authors) else: string = ", ".join(authors[:-1]) + f" and {authors[-1]}" return SPACES.sub(" ", AUTHOR_EXTRA.sub("", string)).strip().replace(" ,", ",") class Config: default_source_dir = "docs" default_output_dir = "docs/dist" def __init__( self, preset="rtd", source_dir=None, output_dir=None, verbose=False, project_name=None, project_dir=None, title=None, copyright=None, author=None, version=None, release=None, dev_server=None, poetry=None, ): self.preset = preset if isinstance(preset, Preset) else Preset.get(preset) self.dev_server = dev_server self.poetry = {} if poetry is None else poetry self.mkdir = [] self.source_dir = Path(source_dir or self.default_source_dir) self.output_dir = Path(output_dir or self.default_output_dir) self.verbose = verbose self.project_name = project_name or self.poetry.get("name") if project_dir: self.project_dir = project_dir else: self.try_set_project_dir() self.title = title or self.project_name self.copyright = copyright self.author = author or join_authors(self.poetry.get("authors")) self.release = release or self.poetry.get("version") self.version = version or self.release and ".".join(self.release.split(".")[:2]) self.mkdir += self.source_dir, self.output_dir self.set_sphinx_arguments() for directory in self.mkdir: directory.mkdir(parents=True, exist_ok=True) self.preset.execute(self) def set_sphinx_arguments(self): self.sphinx_project = self.title self.sphinx_srcdir = self.source_dir self.sphinx_outdir = self.output_dir self.sphinx_doctreedir = self.sphinx_outdir / ".doctrees" self.sphinx_buildername = "xml" self.sphinx_confdir = None self.sphinx_confoverrides = {} def try_set_project_dir(self): self.project_dir = None if self.project_name: path = Path(self.project_name) if path.is_dir(): self.project_dir = path PK!ۅ''mudkip/errors.pyclass MudkipError(Exception): pass PK!33mudkip/extension.pyfrom . import __version__ def process_doctree(app, doctree, docname): relations = app.env.collect_relations() parent, prev, next = relations.get(docname, (None,) * 3) attributes = {"name": docname, "parent": parent, "prev": prev, "next": next} for name, value in attributes.items(): if value is not None: doctree[name] = value def setup(app): app.connect("doctree-resolved", process_doctree) return { "version": __version__, "parallel_read_safe": True, "parallel_write_safe": True, } PK!G*mudkip/jupyter.pyfrom contextlib import contextmanager from multiprocessing import Process, Queue from notebook.notebookapp import NotebookApp @contextmanager def jupyter_notebook(source_dir, verbose, ip, port): try: queue = Queue() process = Process( target=notebook_process, args=(queue, source_dir, verbose, ip, port) ) process.start() yield queue.get() finally: process.terminate() process.join() def notebook_process(queue, source_dir, verbose, ip, port): Notebook.launch_instance( argv=[source_dir], queue=queue, verbose=verbose, ip=ip, port=port ) class Notebook(NotebookApp): def __init__(self, *args, queue, verbose=False, ip=None, port=None, **kwargs): super().__init__(*args, **kwargs) if not verbose: self.log.setLevel(100) if ip: self.ip = ip if port: self.port = port queue.put(self.display_url) PK!;;mudkip/preset.pyfrom .server import livereload_dev_server def preset(func): Preset.register(Preset(func.__name__, func)) return func class Preset: registry = {} def __init__(self, name, callback=None): self.name = name self.callback = callback def execute(self, config): if self.callback: self.callback(config) @classmethod def register(cls, preset): cls.registry[preset.name] = preset return preset @classmethod def get(cls, name): return cls.registry[name] @preset def default(config): pass @preset def alabaster(config): config.dev_server = livereload_dev_server config.sphinx_buildername = "dirhtml" @preset def rtd(config): alabaster(config) config.sphinx_confoverrides.update(html_theme="sphinx_rtd_theme") PK!'QQmudkip/server.pyfrom os import path import logging from contextlib import contextmanager from multiprocessing import Process from livereload import Server @contextmanager def livereload_dev_server(directory, host, port): try: process = Process( target=LivereloadServer.serve_directory, args=(directory, host, port) ) process.start() yield f"http://{host}:{port}" finally: process.terminate() process.join() class LivereloadServer(Server): def _setup_logging(self): super()._setup_logging() logger = logging.getLogger("livereload") logger.setLevel(100) @classmethod def serve_directory(cls, directory, host, port): server = cls() server.watch(path.join(directory, "**", "*.html")) server.serve(port=port, host=host, root=directory) PK!;mmmudkip/watch.pyfrom queue import Queue, Empty from threading import Timer from pathlib import Path from functools import partial from itertools import chain from typing import NamedTuple from watchdog.observers import Observer from watchdog.events import PatternMatchingEventHandler class EventBatch(NamedTuple): moved: list created: list modified: list deleted: list @property def all_events(self): return list(chain(self.moved, self.created, self.modified, self.deleted)) class DirectoryWatcher: def __init__( self, directories=(), patterns=None, ignore_patterns=None, ignore_directories=False, case_sensitive=False, recursive=True, debounce_time=0.25, queue_timeout=2, ): self.directories = set() self.patterns = patterns self.ignore_patterns = ignore_patterns self.ignore_directories = ignore_directories self.case_sensitive = case_sensitive self.recursive = recursive self.debounce_time = debounce_time self.queue_timeout = queue_timeout for directory in directories: self.watch(directory) self.queue = Queue() self.timer = None self.moved, self.created, self.modified, self.deleted = [], [], [], [] self.created_paths = set() def watch(self, directory): directory = Path(directory).absolute() subdirs = set() for watched in self.directories: if directory in watched.parents: subdirs.add(watched) elif watched in directory.parents: return self.directories.add(directory) self.directories -= subdirs def __iter__(self): observer = Observer() for directory in self.directories: handler = PatternMatchingEventHandler( self.patterns, self.ignore_patterns, self.ignore_directories, self.case_sensitive, ) handler.on_moved = partial(self.callback, self.moved) handler.on_created = partial(self.callback, self.created) handler.on_modified = partial(self.callback, self.modified) handler.on_deleted = partial(self.callback, self.deleted) observer.schedule(handler, str(directory), self.recursive) observer.start() try: while True: try: yield self.queue.get(timeout=self.queue_timeout) except Empty: pass finally: observer.stop() observer.join() def callback(self, category, event): if self.timer: self.timer.cancel() if all(e.src_path != event.src_path for e in category): category.append(event) if category is self.created: self.created_paths.add(event.src_path) if category in (self.modified, self.deleted): self.created[:] = [ e for e in self.created if e.src_path != event.src_path ] if category is self.deleted: self.modified[:] = [ e for e in self.modified if e.src_path != event.src_path ] if event.src_path in self.created_paths: category.remove(event) self.timer = Timer(self.debounce_time, self.debounced_callback) self.timer.start() def debounced_callback(self): self.timer = None event_batch = EventBatch( list(self.moved), list(self.created), list(self.modified), list(self.deleted), ) self.moved.clear() self.created.clear() self.modified.clear() self.deleted.clear() self.created_paths.clear() self.queue.put(event_batch) PK!HL'*'mudkip-0.2.4.dist-info/entry_points.txtN+I/N.,()-M,Pz9Vy\\PK!11mudkip-0.2.4.dist-info/LICENSEMIT License Copyright (c) 2019 Valentin Berlier Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. PK!HڽTUmudkip-0.2.4.dist-info/WHEEL A н#Z;/"d&F[xzw@Zpy3Fv]\fi4WZ^EgM_-]#0(q7PK!H'?wS D&mudkip-0.2.4.dist-info/METADATAZmsܶ_8rEoIol5$j%Y#)ɴ;X$׿/~.Qi'7X,>u" ϸᏢR3( Ny!fhY4z= .zؼ5|V5*Q߫B_1M& iM&Υ'NLEA28hR3#Eid^Za7|Ɯ>@{#kó5/G_>MÜk-0ezl6co.AUVd,Ã)gZԼ(d`Ǽ\47+ǟ*}Jj3ci.ΦĻZQ<[L{Tshjn͞FO鈆\ފZg((zXhu]j)T OilDӹ}= ycTЬ+NMvmwYmTH ~|+neVvg{S 8?W*m 47ݏɋsQ)-!|돚J贖`Kp `1PdjU}Oꋫ/XDz)EHI֭N'c&;^+ }8J2֊XPeiaIӛ;JxhJ3BN鳱hHn'$}:|KU) 2 a?) ` ChÇApB{aR3teVMݽYrɅfig2.{+3)ΆR p\9jj|a q'\/90\ ~՛ˣ7oᛓWQ*EF~ aZ&O잤HM`qr Ht'3dDEi.x碀BTcư H ńD ܗ+XiQv?Gb"!W3B[K1 .Ͼ4'♆(nPCpVLA=Y */D& ,ߦ?a.K`R.͚HPҦAiH+ U|)^AϖZamNl~w0TMϤk~,UTy1ZK&%W?Keݵv{2}-Ղg3VX3hb9ۡNhCUgˁ;DM ғ:A4R\*PrE@B{NFY'M1 U`7"fM&V]b 4YH3ͣ\ v{!FhqSB{(ykʅXK\<_*ٞ>N7zTW*#>y&L"# ]>05@*DƋǮ ^e $KѪamZ@7yyю+LM̳f3Mes*3\˿:l:G`C,QZ\Ie0 6au3 1q9#C\R\6OQ:a8fFXe܇lі˭aqSUAu,yȑ4B4{F%)^yyrҎ0'Fw=R#.J$6Y6&$̽lb5s!9'Sd6V;zY&VSD]lok\N[cn}Z(~`'! zI 1b|lσ۪א^uc4d+ƪEoF۲c5x-0ÉXKX3?9C\uU]!v?hHG4:R۰lI69-3n+ EyeHV2qs"ɭ^ kx`6YY7QmG#f/3 xq|i oC_g'x$?k3vz˼j[{6X!9fxٚ@HzC@TxXX#U/y(ook{i'%jVxiD-6`e<昡#+ʽ{l">[%e 'pb s l|v1̎޾VxK W4 ZwEog}ŵ]:7+u[kEy- i Ui1t$Madx%d aرO8S *XmTAz(l 4klG#-dj\cؔ 'ñ\ x, lNU!n65w7!AC\/u+͵: Ż[.G/(l3Ḵ .ձ9'nc~ #*^꽋KWKje 4ą|'{n$C1F{UòM{2ގmR@6GwǞ&Ѱ~FNQj pk]%QZl;}FF6S}mC.m!ڥ[N p3vA\FL@,v]b=:|롓˭Z Bl"wi2f0Ɛ~G+_Zlhh [7mH㔓Ѽ͸Zw#7˟LJwXJyId }G`iGq;셎!PC #6v2RUk iS׸;w 9ߋlZF tSxC)$:i' %鶱s gDfVMT9@z+ux̫ܲ£+-dBI)//wP-1o4ݱ734_Cd^]myol8"[ wBvurt9Ɵ*7vGO/^PK!H,cmudkip-0.2.4.dist-info/RECORDuGJyf< ؄LVj"*^_?q  z$ /Ԋ1U@Vq-%魗u ~AuP̳sqSFTv)Вt1qOn:3Tۖ`@X4m>Z5Tڬ()QYU$%K)AS,LaT;\Ӓs߆gl@A$xYJryt>7OAsCgҬ]3V-"o{hE۽/7 ;ٰaԕx"]u'WLsuǮ&kYX!{7Y.$'d|O8\L+"н_Ekmi]*]$ d]sg1 v6lppIqzkļ@y?'Щ(,<: `L`8_\DH{18z^)Irm؃M2