diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..eebe4dc --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,38 @@ +name: Run Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: # Allows manual triggering + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[test]" + + - name: Run tests + run: | + pytest -v --cov=src --cov-report=term-missing --cov-report=xml + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0dd11a8..07377ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Ignore example dataset storage generated in tutorial -pod_data/ +**/pod_data/ # Autogenerated version file _version.py diff --git a/README.md b/README.md index 1b770fb..df97786 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,31 @@ # orcabridge Prototype of Orcapod as implemented in Python with functions + +## Continuous Integration + +This project uses GitHub Actions for continuous integration: + +- **Run Tests**: A workflow that runs tests on Ubuntu with multiple Python versions. + +### Running Tests Locally + +To run tests locally: + +```bash +# Install the package with test dependencies +pip install -e ".[test]" + +# Run tests with coverage +pytest -v --cov=src --cov-report=term-missing +``` + +### Development Setup + +For development, you can install all optional dependencies: + +```bash +# Install all development dependencies +pip install -e ".[test,dev]" +# or +pip install -r requirements-dev.txt +``` diff --git a/pyproject.toml b/pyproject.toml index c14c143..3161f18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,17 @@ build-backend = "setuptools.build_meta" [project] name = "orcabridge" -description = "Prototype Oracapod Pipeline implementation in Python" -dynamic = ["version", "dependencies"] +description = "Function-based Oracapod Pipeline implementation in Python" +dynamic = ["version"] +dependencies = [ + "numpy", + "xxhash", + "networkx", + "matplotlib", + "typing_extensions", +] readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" license = { text = "MIT License" } classifiers = [ "Programming Language :: Python :: 3", @@ -18,14 +25,12 @@ classifiers = [ [project.urls] Homepage = "https://github.com/walkerlab/orcabridge" +[project.optional-dependencies] +test = ["pytest>=7.4.0", "pytest-cov>=4.1.0"] +dev = ["black>=23.0.0", "flake8>=6.0.0", "isort>=5.12.0", "orcabridge[test]"] + [tool.setuptools.packages.find] where = ["src"] [tool.setuptools_scm] version_file = "src/orcabridge/_version.py" - -[tool.setuptools.dynamic] -dependencies = { file = ["requirements.txt"] } - -[tool.black] -line-length = 80 \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..2c01e54 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = -v --cov=src --cov-report=term-missing --cov-report=html --cov-report=xml diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index c6aff8e..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -numpy -xxhash \ No newline at end of file diff --git a/src/orcabridge/__init__.py b/src/orcabridge/__init__.py index 6d2c89d..7a6f6c2 100644 --- a/src/orcabridge/__init__.py +++ b/src/orcabridge/__init__.py @@ -1,15 +1,20 @@ from .tracker import Tracker - -DEFAULT_TRACKER = Tracker() -DEFAULT_TRACKER.activate() - -# make modules and subpackages available from . import hashing from . import pod from . import mapper from . import stream from . import source from . import store +from .mapper import MapTags, MapPackets, Join, tag, packet +from .pod import FunctionPod, function_pod +from .source import GlobSource +from .store import DirDataStore, SafeDirDataStore + + + +DEFAULT_TRACKER = Tracker() +DEFAULT_TRACKER.activate() + __all__ = [ "hashing", @@ -30,9 +35,6 @@ "DirDataStore", "SafeDirDataStore", "DEFAULT_TRACKER", + "SyncStreamFromLists", ] -from .mapper import MapTags, MapPackets, Join, tag, packet -from .pod import FunctionPod, function_pod -from .source import GlobSource -from .store import DirDataStore, SafeDirDataStore \ No newline at end of file diff --git a/src/orcabridge/base.py b/src/orcabridge/base.py index b770d07..43fadb5 100644 --- a/src/orcabridge/base.py +++ b/src/orcabridge/base.py @@ -1,5 +1,5 @@ from orcabridge.hashing import HashableMixin -from .types import Tag, Packet +from orcabridge.types import Tag, Packet from typing import ( Optional, Tuple, @@ -9,8 +9,7 @@ Callable, Iterator, ) -from collections.abc import Collection -from typing import Any, List, Tuple +import threading class Operation(HashableMixin): @@ -40,9 +39,12 @@ def keys(self, *streams: "SyncStream") -> Tuple[List[str], List[str]]: @property def label(self) -> str: """ - Overwrite this method to attain a custom label logic for the operation. + Returns a human-readable label for this operation. + Default implementation returns the provided label or class name if no label was provided. """ - return self._label + if self._label: + return self._label + return self.__class__.__name__ @label.setter def label(self, value: str) -> None: @@ -58,17 +60,16 @@ def identity_structure(self, *streams: "SyncStream") -> Any: def __call__(self, *streams: "SyncStream", **kwargs) -> "SyncStream": # trigger call on source if passed as stream - streams = [stream() if isinstance(stream, Source) else stream for stream in streams] + streams = [ + stream() if isinstance(stream, Source) else stream for stream in streams + ] output_stream = self.forward(*streams, **kwargs) # create an invocation instance invocation = Invocation(self, streams) # label the output_stream with the invocation information output_stream.invocation = invocation - # delay import to avoid circular import - from .tracker import Tracker - - # reg + # register the invocation with active trackers active_trackers = Tracker.get_active_trackers() for tracker in active_trackers: tracker.record(invocation) @@ -78,16 +79,64 @@ def __call__(self, *streams: "SyncStream", **kwargs) -> "SyncStream": def __repr__(self): return self.__class__.__name__ + def __str__(self): + if self._label is not None: + return f"{self.__class__.__name__}({self._label})" + return self.__class__.__name__ + def forward(self, *streams: "SyncStream") -> "SyncStream": ... +class Tracker: + """ + A tracker is a class that can track the invocations of operations. Only "active" trackers + participate in tracking and its `record` method gets called on each invocation of an operation. + Multiple trackers can be active at any time. + """ + + _local = threading.local() + + @classmethod + def get_active_trackers(cls) -> List["Tracker"]: + if hasattr(cls._local, "active_trackers"): + return cls._local.active_trackers + return [] + + def __init__(self): + self.active = False + + def activate(self) -> None: + """ + Activate the tracker. This is a no-op if the tracker is already active. + """ + if not self.active: + if not hasattr(self._local, "active_trackers"): + self._local.active_trackers = [] + self._local.active_trackers.append(self) + self.active = True + + def deactivate(self) -> None: + # Remove this tracker from active trackers + if hasattr(self._local, "active_trackers") and self.active: + self._local.active_trackers.remove(self) + self.active = False + + def __enter__(self): + self.activate() + return self + + def __exit__(self, exc_type, exc_val, ext_tb): + self.deactivate() + + def record(self, invocation: "Invocation") -> None: ... + + class Invocation(HashableMixin): """ This class represents an invocation of an operation on a collection of streams. - It contains the operation, the invocation ID, and the streams that were used - in the invocation. - The invocation ID is a unique identifier for the invocation and is used to - track the invocation in the tracker. + It contains the operation and the streams that were used in the invocation. + Note that the collection of streams may be empty, in which case the invocation + likely corresponds to a source operation. """ def __init__( @@ -108,8 +157,8 @@ def keys(self) -> Tuple[Collection[str], Collection[str]]: return self.operation.keys(*self.streams) def identity_structure(self) -> int: - # default implementation is streams order sensitive. If an operation does - # not depend on the order of the streams, it should override this method + # Identity of an invocation is entirely dependend on + # the operation's identity structure upon invocation return self.operation.identity_structure(*self.streams) def __eq__(self, other: Any) -> bool: @@ -136,15 +185,37 @@ class Stream(HashableMixin): This may be None if the stream is not generated by an operation. """ - def __init__(self, **kwargs) -> None: + def __init__(self, label: Optional[str] = None, **kwargs) -> None: super().__init__(**kwargs) self._invocation: Optional[Invocation] = None + self._label = label def identity_structure(self) -> Any: + """ + Identity structure of a stream is deferred to the identity structure + of the associated invocation, if present. + A bare stream without invocation has no well-defined identity structure. + """ if self.invocation is not None: return self.invocation.identity_structure() return super().identity_structure() + @property + def label(self) -> str: + """ + Returns a human-readable label for this stream. + If no label is provided and the stream is generated by an operation, + the label of the operation is used. + Otherwise, the class name is used as the label. + """ + if self._label is None: + if self.invocation is not None: + # use the invocation operation label + return self.invocation.operation.label + else: + return self.__class__.__name__ + return self._label + @property def invocation(self) -> Optional[Invocation]: return self._invocation @@ -155,29 +226,6 @@ def invocation(self, value: Invocation) -> None: raise TypeError("invocation field must be an instance of Invocation") self._invocation = value - def __iter__(self) -> Iterator[Tuple[Tag, Packet]]: - raise NotImplementedError("Subclasses must implement __iter__ method") - - def flow(self) -> Collection[Tuple[Tag, Packet]]: - """ - Flow everything through the stream, returning the entire collection of - (Tag, Packet) as a collection. This will tigger any upstream computation of the stream. - """ - return list(self) - - -class SyncStream(Stream): - """ - A stream that will complete in a fixed amount of time. It is suitable for synchronous operations that - will have to wait for the stream to finish before proceeding. - """ - - def content_hash(self) -> str: - if self.invocation is not None: # and hasattr(self.invocation, "invocation_id"): - # use the invocation ID as the hash - return self.invocation.content_hash() - return super().content_hash() - def keys(self) -> Tuple[Collection[str], Collection[str]]: """ Returns the keys of the stream. @@ -194,9 +242,28 @@ def keys(self) -> Tuple[Collection[str], Collection[str]]: if tag_keys is not None and packet_keys is not None: return tag_keys, packet_keys # otherwise, use the keys from the first packet in the stream + # note that this may be computationally expensive tag, packet = next(iter(self)) return list(tag.keys()), list(packet.keys()) + def __iter__(self) -> Iterator[Tuple[Tag, Packet]]: + raise NotImplementedError("Subclasses must implement __iter__ method") + + def flow(self) -> Collection[Tuple[Tag, Packet]]: + """ + Flow everything through the stream, returning the entire collection of + (Tag, Packet) as a collection. This will tigger any upstream computation of the stream. + """ + return list(self) + + +class SyncStream(Stream): + """ + A stream that will complete in a fixed amount of time. + It is suitable for synchronous operations that + will have to wait for the stream to finish before proceeding. + """ + def head(self, n: int = 5) -> None: """ Print the first n elements of the stream. @@ -223,9 +290,9 @@ def __rshift__(self, transformer: Any) -> "SyncStream": The mapping is applied to each packet in the stream and the resulting packets are returned in a new stream. """ + # TODO: remove just in time import from .mapper import MapPackets - # TODO: extend to generic mapping if isinstance(transformer, dict): return MapPackets(transformer)(self) elif isinstance(transformer, Callable): @@ -235,6 +302,7 @@ def __mul__(self, other: "SyncStream") -> "SyncStream": """ Returns a new stream that is the result joining with the other stream """ + # TODO: remove just in time import from .mapper import Join if not isinstance(other, SyncStream): @@ -242,6 +310,13 @@ def __mul__(self, other: "SyncStream") -> "SyncStream": return Join()(self, other) +class Mapper(Operation): + """ + A Mapper is an operation that does NOT generate new file content. + It is used to control the flow of data in the pipeline without modifying or creating new data (file). + """ + + class Source(Operation, SyncStream): """ A base class for all sources in the system. A source can be seen as a special diff --git a/src/orcabridge/dj/source.py b/src/orcabridge/dj/source.py index ab5f598..8145102 100644 --- a/src/orcabridge/dj/source.py +++ b/src/orcabridge/dj/source.py @@ -1,5 +1,5 @@ from ..source import Source -from .stream import QueryStream, TableCachedStream +from .stream import QueryStream, TableCachedStream, TableStream from .operation import QueryOperation from ..stream import SyncStream from datajoint import Table @@ -7,6 +7,11 @@ from datajoint import Schema import datajoint as dj from ..utils.name import pascal_to_snake, snake_to_pascal +from ..utils.stream_utils import common_elements +import logging +from orcabridge.hashing import hash_to_uuid + +logger = logging.getLogger(__name__) class QuerySource(Source, QueryOperation): @@ -126,9 +131,7 @@ def label(self) -> str: return snake_to_pascal(self.table_name) return self._label - def compile( - self, tag_keys: Collection[str], packet_keys: Collection[str] - ) -> None: + def compile(self, tag_keys: Collection[str], packet_keys: Collection[str]) -> None: # create a table to store the cached packets key_fields = "\n".join([f"{k}: varchar(255)" for k in tag_keys]) @@ -149,9 +152,7 @@ class CachedTable(dj.Manual): def forward(self, *streams: QueryStream) -> QueryStream: if len(streams) > 0: - raise ValueError( - "No streams should be passed to TableCachedStreamSource" - ) + raise ValueError("No streams should be passed to TableCachedStreamSource") if self.table is None: # TODO: consider handling this lazily @@ -200,10 +201,9 @@ def label(self) -> str: return self.source.label return self._label - def compile( - self, tag_keys: Collection[str], packet_keys: Collection[str] - ) -> None: + def compile(self) -> None: # create a table to store the cached packets + tag_keys, packet_keys = self.source().keys() key_fields = "\n".join([f"{k}: varchar(255)" for k in tag_keys]) output_fields = "\n".join([f"{k}: varchar(255)" for k in packet_keys]) @@ -241,7 +241,7 @@ def forward( raise ValueError("No streams should be passed to TableCachedSource") if self.table is None: - self.compile(*self.source().keys()) + self.compile() return TableCachedStream( self.table, self.source(), @@ -256,50 +256,169 @@ class MergedQuerySource(QuerySource): """ def __init__( - self, *sources: QuerySource, label: Optional[str] = None + self, + *streams: QueryStream, + schema: Schema, + table_name: str = None, + table_postfix: str = "", + label: Optional[str] = None, + lazy_build: bool = True, ) -> None: super().__init__(label=label) - self.sources = sources + self.streams = streams + self.schema = schema + self.table = None + if table_name is None: + table_name = self.label if self.label is not None else "MergedData" + + self.table_name = pascal_to_snake(table_name) + ( + f"_{table_postfix}" if table_postfix else "" + ) + if not lazy_build: + self.compile() + + @property + def label(self) -> str: + if self._label is None: + return "_".join([stream.label for stream in self.streams]) + return self._label def identity_structure(self, *streams): return ( self.__class__.__name__, - str(self.sources), + self.streams, ) + tuple(streams) def forward(self, *streams: SyncStream) -> QueryStream: if len(streams) > 0: - raise NotImplementedError( - "Passing streams through MergedQuerySource is not implemented yet" + logger.warning( + "Handling multiple streams in forward is not implemented yet in " + "MergedQuerySource and this will be silently ignored" ) + if self.table is None: + self.compile() - def compile( - self, tag_keys: Collection[str], packet_keys: Collection[str] - ) -> None: - # create a table to store the cached packets - key_fields = "\n".join([f"{k}: varchar(255)" for k in tag_keys]) - output_fields = "\n".join([f"{k}: varchar(255)" for k in packet_keys]) + return TableStream(self.table) - class CachedTable(dj.Manual): - source = self # this refers to the outer class instance + def compile(self) -> None: + + part_tag_keys = [] + part_packet_keys = [] + for stream in self.streams: + tag_key, packet_key = stream.keys() + part_tag_keys.append(tag_key) + part_packet_keys.append(packet_key) + + # find common keys among all tags and use that as primary key + common_tag_keys = common_elements(*part_tag_keys) + common_packet_keys = common_elements(*part_packet_keys) + + use_uuid = True + if all([len(k) == len(common_tag_keys) for k in part_tag_keys]): + # if all tags have the same number of keys, it is not necessary + # to include an additional UUID + use_uuid = False + + # create a table to store the cached packets + key_fields = "\n".join([f"{k}: varchar(255)" for k in common_tag_keys]) + output_fields = "\n".join([f"{k}: varchar(255)" for k in common_packet_keys]) + table_field = f"{self.table_name}_part" + uuid_field = f"{self.table_name}_uuid" if use_uuid else "" + table_entry = f"{table_field}: varchar(255)" + uuid_entry = f"{uuid_field}: uuid" if use_uuid else "" + + class MergedTable(dj.Manual): + source = self definition = f""" - # {self.table_name} outputs + # {self.table_name} inputs {key_fields} + {table_entry} + {uuid_entry} --- {output_fields} """ - def populate( - self, batch_size: int = 10, use_skip_duplicates: bool = False - ) -> int: - return sum( - 1 - for _ in self.operation( - batch_size=batch_size, - use_skip_duplicates=use_skip_duplicates, - ) + for stream in self.streams: + if not isinstance(stream, QueryStream): + raise ValueError( + f"Stream {stream} is not a QueryStream. " + "Please use a QueryStream as input." ) + part_table = make_part_table( + stream, + common_tag_keys, + common_packet_keys, + table_field, + uuid_field, + ) + setattr(MergedTable, snake_to_pascal(stream.label), part_table) + + MergedTable.__name__ = snake_to_pascal(self.table_name) + MergedTable = self.schema(MergedTable) + self.table = MergedTable + + # class CachedTable(dj.Manual): + # source = self # this refers to the outer class instance + # definition = f""" + # # {self.table_name} outputs + # {key_fields} + # --- + # {output_fields} + # """ + + # def populate( + # self, batch_size: int = 10, use_skip_duplicates: bool = False + # ) -> int: + # return sum( + # 1 + # for _ in self.operation( + # batch_size=batch_size, + # use_skip_duplicates=use_skip_duplicates, + # ) + # ) + + # CachedTable.__name__ = snake_to_pascal(self.table_name) + # CachedTable = self.schema(CachedTable) + # self.table = CachedTable + + +def make_part_table( + stream: QueryStream, + common_tag_keys, + common_packet_keys, + table_field, + uuid_field, +) -> type[dj.Part]: + upstreams = "\n".join( + f"-> self.upstream_tables[{i}]" for i in range(len(stream.upstream_tables)) + ) + + tag_keys, packet_keys = stream.keys() + + extra_packet_keys = [k for k in packet_keys if k not in common_packet_keys] + + extra_output_fields = "\n".join([f"{k}: varchar(255)" for k in extra_packet_keys]) + + class PartTable(dj.Part, dj.Computed): + upstream_tables = stream.upstream_tables + definition = f""" + -> master + --- + {upstreams} + {extra_output_fields} + """ - CachedTable.__name__ = snake_to_pascal(self.table_name) - CachedTable = self.schema(CachedTable) - self.table = CachedTable + @property + def key_source(self): + return stream.query + + def make(self, key): + content = (stream.query & key).fetch1() + content[table_field] = self.__class__.__name__ + if uuid_field: + content[uuid_field] = hash_to_uuid(key) + self.master.insert1(content, ignore_extra_fields=True) + self.insert1(content, ignore_extra_fields=True) + + PartTable.__name__ = snake_to_pascal(stream.label) + return PartTable diff --git a/src/orcabridge/dj/tracker.py b/src/orcabridge/dj/tracker.py index ee29a13..1b89d24 100644 --- a/src/orcabridge/dj/tracker.py +++ b/src/orcabridge/dj/tracker.py @@ -1,15 +1,15 @@ -from ..tracker import Tracker +from orcabridge.tracker import GraphTracker from datajoint import Schema from typing import List, Collection, Tuple, Optional, Any from types import ModuleType import networkx as nx -from ..base import Operation, Source -from ..mapper import Mapper -from ..pod import FunctionPod +from orcabridge.base import Operation, Source +from orcabridge.mapper import Mapper, Merge +from orcabridge.pod import FunctionPod from .stream import QueryStream -from .source import TableCachedSource +from .source import TableCachedSource, MergedQuerySource from .operation import QueryOperation from .pod import TableCachedPod from .mapper import convert_to_query_mapper @@ -57,6 +57,17 @@ def convert_to_query_operation( True, ) + if isinstance(operation, Merge): + return ( + MergedQuerySource( + *upstreams, + schema=schema, + table_name=table_name, + table_postfix=table_postfix, + ), + True, + ) + if isinstance(operation, Mapper): return convert_to_query_mapper(operation), True @@ -64,7 +75,7 @@ def convert_to_query_operation( raise ValueError(f"Unsupported operation for DJ conversion: {operation}") -class QueryTracker(Tracker): +class QueryTracker(GraphTracker): """ Query-specific tracker that tracks the invocations of operations and their associated streams. diff --git a/src/orcabridge/hashing/__init__.py b/src/orcabridge/hashing/__init__.py new file mode 100644 index 0000000..ebab3a9 --- /dev/null +++ b/src/orcabridge/hashing/__init__.py @@ -0,0 +1,23 @@ +from .hashing import ( + hash_file, + hash_pathset, + hash_packet, + hash_to_hex, + hash_to_int, + hash_to_uuid, + hash_function, + get_function_signature, + HashableMixin, +) + +__all__ = [ + "hash_file", + "hash_pathset", + "hash_packet", + "hash_to_hex", + "hash_to_int", + "hash_to_uuid", + "hash_function", + "get_function_signature", + "HashableMixin", +] diff --git a/src/orcabridge/hashing.py b/src/orcabridge/hashing/hashing.py similarity index 93% rename from src/orcabridge/hashing.py rename to src/orcabridge/hashing/hashing.py index 9fa77af..2a97e14 100644 --- a/src/orcabridge/hashing.py +++ b/src/orcabridge/hashing/hashing.py @@ -6,7 +6,6 @@ suitable for arbitrarily nested data structures and custom objects via HashableMixin. """ -from ast import Str import hashlib import inspect import json @@ -26,10 +25,10 @@ ) from pathlib import Path from os import PathLike -import os import xxhash import zlib -from .types import PathSet, Packet +from orcabridge.types import PathSet, Packet +from orcabridge.utils.name import find_noncolliding_name # Configure logging with __name__ for proper hierarchy logger = logging.getLogger(__name__) @@ -827,6 +826,70 @@ def stable_hash(s: Any) -> int: return hash_to_int(s) +# Hashing of packets and PathSet + + +class PathSetHasher: + def __init__(self, char_count=32): + self.char_count = char_count + + def hash_pathset(self, pathset: PathSet) -> str: + if isinstance(pathset, str) or isinstance(pathset, PathLike): + pathset = Path(pathset) + if not pathset.exists(): + raise FileNotFoundError(f"Path {pathset} does not exist") + if pathset.is_dir(): + # iterate over all entries in the directory include subdirectory (single step) + hash_dict = {} + for entry in pathset.iterdir(): + file_name = find_noncolliding_name(entry.name, hash_dict) + hash_dict[file_name] = self.hash_pathset(entry) + return hash_to_hex(hash_dict, char_count=self.char_count) + else: + # it's a file, hash it directly + return hash_file(pathset) + + if isinstance(pathset, Collection): + hash_dict = {} + for path in pathset: + file_name = find_noncolliding_name(Path(path).name, hash_dict) + hash_dict[file_name] = self.hash_pathset(path) + return hash_to_hex(hash_dict, char_count=self.char_count) + + raise ValueError(f"PathSet of type {type(pathset)} is not supported") + + def hash_file(self, filepath) -> str: ... + + def id(self) -> str: ... + + +def hash_packet_with_psh( + packet: Packet, algo: PathSetHasher, prefix_algorithm: bool = True +) -> str: + """ + Generate a hash for a packet based on its content. + + Args: + packet: The packet to hash + algorithm: The algorithm to use for hashing + prefix_algorithm: Whether to prefix the hash with the algorithm name + + Returns: + A hexadecimal digest of the packet's content + """ + hash_results = {} + for key, pathset in packet.items(): + hash_results[key] = algo.hash_pathset(pathset) + + packet_hash = hash_to_hex(hash_results) + + if prefix_algorithm: + # Prefix the hash with the algorithm name + packet_hash = f"{algo.id()}-{packet_hash}" + + return packet_hash + + def hash_packet( packet: Packet, algorithm: str = "sha256", @@ -879,7 +942,7 @@ def hash_pathset( # iterate over all entries in the directory include subdirectory (single step) hash_dict = {} for entry in pathset.iterdir(): - file_name = entry.name + file_name = find_noncolliding_name(entry.name, hash_dict) hash_dict[file_name] = hash_pathset( entry, algorithm=algorithm, @@ -896,7 +959,7 @@ def hash_pathset( if isinstance(pathset, Collection): hash_dict = {} for path in pathset: - file_name = Path(path).name + file_name = find_noncolliding_name(Path(path).name, hash_dict) hash_dict[file_name] = hash_pathset( path, algorithm=algorithm, diff --git a/src/orcabridge/mapper.py b/src/orcabridge/mapper.py index 777a4b3..5849f89 100644 --- a/src/orcabridge/mapper.py +++ b/src/orcabridge/mapper.py @@ -1,26 +1,27 @@ -from typing import Callable, Dict, Optional, List, Sequence - -from .stream import SyncStream, SyncStreamFromGenerator -from .base import Operation -from .utils.stream_utils import ( +from typing import ( + Callable, + Dict, + Optional, + List, + Sequence, + Tuple, + Iterator, + Collection, + Any, +) +from orcabridge.base import Operation, SyncStream, Mapper +from orcabridge.stream import SyncStreamFromGenerator +from orcabridge.utils.stream_utils import ( join_tags, check_packet_compatibility, batch_tag, batch_packet, ) -from .hashing import hash_function +from orcabridge.hashing import hash_function from .types import Tag, Packet -from typing import Iterator, Tuple, Any, Collection from itertools import chain -class Mapper(Operation): - """ - A Mapper is an operation that does NOT generate new file content. - It is used to control the flow of data in the pipeline without modifying or creating new data (file). - """ - - class Repeat(Mapper): """ A Mapper that repeats the packets in the stream a specified number of times. @@ -35,9 +36,7 @@ def identity_structure(self, *streams): # Join does not depend on the order of the streams -- convert it onto a set return (self.__class__.__name__, self.repeat_count, set(streams)) - def keys( - self, *streams: SyncStream - ) -> Tuple[Collection[str], Collection[str]]: + def keys(self, *streams: SyncStream) -> Tuple[Collection[str], Collection[str]]: """ Repeat does not alter the keys of the stream. """ @@ -79,9 +78,7 @@ def identity_structure(self, *streams): # Merge does not depend on the order of the streams -- convert it onto a set return (self.__class__.__name__, set(streams)) - def keys( - self, *streams: SyncStream - ) -> Tuple[Collection[str], Collection[str]]: + def keys(self, *streams: SyncStream) -> Tuple[Collection[str], Collection[str]]: """ Merge does not alter the keys of the stream. """ @@ -120,9 +117,7 @@ def identity_structure(self, *streams): # Join does not depend on the order of the streams -- convert it onto a set return (self.__class__.__name__, set(streams)) - def keys( - self, *streams: SyncStream - ) -> Tuple[Collection[str], Collection[str]]: + def keys(self, *streams: SyncStream) -> Tuple[Collection[str], Collection[str]]: """ Returns the keys of the operation. The first list contains the keys of the tags, and the second list contains the keys of the packets. @@ -137,9 +132,7 @@ def keys( right_tag_keys, right_packet_keys = right_stream.keys() joined_tag_keys = list(set(left_tag_keys) | set(right_tag_keys)) - joined_packet_keys = list( - set(left_packet_keys) | set(right_packet_keys) - ) + joined_packet_keys = list(set(left_packet_keys) | set(right_packet_keys)) return joined_tag_keys, joined_packet_keys @@ -156,12 +149,8 @@ def forward(self, *streams: SyncStream) -> SyncStream: def generator(): for left_tag, left_packet in left_stream: for right_tag, right_packet in right_stream: - if ( - joined_tag := join_tags(left_tag, right_tag) - ) is not None: - if not check_packet_compatibility( - left_packet, right_packet - ): + if (joined_tag := join_tags(left_tag, right_tag)) is not None: + if not check_packet_compatibility(left_packet, right_packet): raise ValueError( f"Packets are not compatible: {left_packet} and {right_packet}" ) @@ -179,9 +168,7 @@ def identity_structure(self, *streams): # Join does not depend on the order of the streams -- convert it onto a set return (self.__class__.__name__, set(streams)) - def keys( - self, *streams: SyncStream - ) -> Tuple[Collection[str], Collection[str]]: + def keys(self, *streams: SyncStream) -> Tuple[Collection[str], Collection[str]]: """ Returns the keys of the operation. The first list contains the keys of the tags, and the second list contains the keys of the packets. @@ -189,18 +176,14 @@ def keys( (None, None) is returned to signify that the keys are not known. """ if len(streams) != 2: - raise ValueError( - "FirstMatch operation requires exactly two streams" - ) + raise ValueError("FirstMatch operation requires exactly two streams") left_stream, right_stream = streams left_tag_keys, left_packet_keys = left_stream.keys() right_tag_keys, right_packet_keys = right_stream.keys() joined_tag_keys = list(set(left_tag_keys) | set(right_tag_keys)) - joined_packet_keys = list( - set(left_packet_keys) | set(right_packet_keys) - ) + joined_packet_keys = list(set(left_packet_keys) | set(right_packet_keys)) return joined_tag_keys, joined_packet_keys @@ -210,9 +193,7 @@ def forward(self, *streams: SyncStream) -> SyncStream: The resulting stream will contain all the tags from both streams. """ if len(streams) != 2: - raise ValueError( - "MatchUpToN operation requires exactly two streams" - ) + raise ValueError("MatchUpToN operation requires exactly two streams") left_stream, right_stream = streams @@ -229,12 +210,8 @@ def forward(self, *streams: SyncStream) -> SyncStream: def generator(): for outer_tag, outer_packet in outer_stream: for idx, (inner_tag, inner_packet) in enumerate(inner_stream): - if ( - joined_tag := join_tags(outer_tag, inner_tag) - ) is not None: - if not check_packet_compatibility( - outer_packet, inner_packet - ): + if (joined_tag := join_tags(outer_tag, inner_tag)) is not None: + if not check_packet_compatibility(outer_packet, inner_packet): raise ValueError( f"Packets are not compatible: {outer_packet} and {inner_packet}" ) @@ -258,16 +235,12 @@ class MapPackets(Mapper): drop_unmapped=False, in which case unmapped keys will be retained. """ - def __init__( - self, key_map: Dict[str, str], drop_unmapped: bool = True - ) -> None: + def __init__(self, key_map: Dict[str, str], drop_unmapped: bool = True) -> None: super().__init__() self.key_map = key_map self.drop_unmapped = drop_unmapped - def keys( - self, *streams: SyncStream - ) -> Tuple[Collection[str], Collection[str]]: + def keys(self, *streams: SyncStream) -> Tuple[Collection[str], Collection[str]]: """ Returns the keys of the operation. The first list contains the keys of the tags, and the second list contains the keys of the packets. @@ -298,14 +271,10 @@ def generator(): for tag, packet in stream: if self.drop_unmapped: packet = { - v: packet[k] - for k, v in self.key_map.items() - if k in packet + v: packet[k] for k, v in self.key_map.items() if k in packet } else: - packet = { - self.key_map.get(k, k): v for k, v in packet.items() - } + packet = {self.key_map.get(k, k): v for k, v in packet.items()} yield tag, packet return SyncStreamFromGenerator(generator) @@ -333,9 +302,7 @@ def __init__(self, default_tag: Tag) -> None: super().__init__() self.default_tag = default_tag - def keys( - self, *streams: SyncStream - ) -> Tuple[Collection[str], Collection[str]]: + def keys(self, *streams: SyncStream) -> Tuple[Collection[str], Collection[str]]: """ Returns the keys of the operation. The first list contains the keys of the tags, and the second list contains the keys of the packets. @@ -373,16 +340,12 @@ class MapTags(Mapper): drop_unmapped=False, in which case unmapped tags will be retained. """ - def __init__( - self, key_map: Dict[str, str], drop_unmapped: bool = True - ) -> None: + def __init__(self, key_map: Dict[str, str], drop_unmapped: bool = True) -> None: super().__init__() self.key_map = key_map self.drop_unmapped = drop_unmapped - def keys( - self, *streams: SyncStream - ) -> Tuple[Collection[str], Collection[str]]: + def keys(self, *streams: SyncStream) -> Tuple[Collection[str], Collection[str]]: """ Returns the keys of the operation. The first list contains the keys of the tags, and the second list contains the keys of the packets. @@ -395,9 +358,7 @@ def keys( tag_keys, packet_keys = stream.keys() if self.drop_unmapped: # If drop_unmapped is True, we only keep the keys that are in the mapping - mapped_tag_keys = [ - self.key_map[k] for k in tag_keys if k in self.key_map - ] + mapped_tag_keys = [self.key_map[k] for k in tag_keys if k in self.key_map] else: mapped_tag_keys = [self.key_map.get(k, k) for k in tag_keys] @@ -412,9 +373,7 @@ def forward(self, *streams: SyncStream) -> SyncStream: def generator() -> Iterator[Tuple[Tag, Packet]]: for tag, packet in stream: if self.drop_unmapped: - tag = { - v: tag[k] for k, v in self.key_map.items() if k in tag - } + tag = {v: tag[k] for k, v in self.key_map.items() if k in tag} else: tag = {self.key_map.get(k, k): v for k, v in tag.items()} yield tag, packet @@ -444,9 +403,7 @@ def __init__(self, predicate: Callable[[Tag, Packet], bool]): super().__init__() self.predicate = predicate - def keys( - self, *streams: SyncStream - ) -> Tuple[Collection[str], Collection[str]]: + def keys(self, *streams: SyncStream) -> Tuple[Collection[str], Collection[str]]: """ Filter does not alter the keys of the stream. """ @@ -533,9 +490,7 @@ def __init__( self.tag_processor = tag_processor self.drop_last = drop_last - def keys( - self, *streams: SyncStream - ) -> Tuple[Collection[str], Collection[str]]: + def keys(self, *streams: SyncStream) -> Tuple[Collection[str], Collection[str]]: """ Batch does not alter the keys of the stream. """ @@ -558,15 +513,11 @@ def generator() -> Iterator[Tuple[Tag, Packet]]: batch_tags.append(tag) batch_packets.append(packet) if len(batch_tags) == self.batch_size: - yield self.tag_processor(batch_tags), batch_packet( - batch_packets - ) + yield self.tag_processor(batch_tags), batch_packet(batch_packets) batch_tags = [] batch_packets = [] if batch_tags and not self.drop_last: - yield self.tag_processor(batch_tags), batch_packet( - batch_packets - ) + yield self.tag_processor(batch_tags), batch_packet(batch_packets) return SyncStreamFromGenerator(generator) @@ -599,16 +550,12 @@ def __init__(self) -> None: self.is_cached = False def forward(self, *streams: SyncStream) -> SyncStream: - if len(streams) != 1: - raise ValueError( - "CacheStream operation requires exactly one stream" - ) - - stream = streams[0] + if not self.is_cached and len(streams) != 1: + raise ValueError("CacheStream operation requires exactly one stream") def generator() -> Iterator[Tuple[Tag, Packet]]: if not self.is_cached: - for tag, packet in stream: + for tag, packet in streams[0]: self.cache.append((tag, packet)) yield tag, packet self.is_cached = True diff --git a/src/orcabridge/pod.py b/src/orcabridge/pod.py index 6a021fa..6b79d68 100644 --- a/src/orcabridge/pod.py +++ b/src/orcabridge/pod.py @@ -1,10 +1,4 @@ -import logging - -logger = logging.getLogger(__name__) - -from pathlib import Path from typing import ( - List, Optional, Tuple, Iterator, @@ -13,25 +7,24 @@ Literal, Any, ) -from .hashing import hash_function, get_function_signature -from .base import Operation -from .mapper import Join -from .stream import SyncStream, SyncStreamFromGenerator -from .types import Tag, Packet, PodFunction -from .store import DataStore, NoOpDataStore -import json -import shutil +from orcabridge.types import Tag, Packet, PodFunction +from orcabridge.hashing import hash_function, get_function_signature +from orcabridge.base import Operation +from orcabridge.stream import SyncStream, SyncStreamFromGenerator +from orcabridge.mapper import Join +from orcabridge.store import DataStore, NoOpDataStore import functools import warnings +import logging + +logger = logging.getLogger(__name__) def function_pod( output_keys: Optional[Collection[str]] = None, store_name: Optional[str] = None, data_store: Optional[DataStore] = None, - function_hash_mode: Literal[ - "signature", "content", "name", "custom" - ] = "name", + function_hash_mode: Literal["signature", "content", "name", "custom"] = "name", custom_hash: Optional[int] = None, force_computation: bool = False, skip_memoization: bool = False, @@ -114,12 +107,11 @@ def __init__( output_keys: Optional[Collection[str]] = None, store_name=None, data_store: Optional[DataStore] = None, - function_hash_mode: Literal[ - "signature", "content", "name", "custom" - ] = "name", + function_hash_mode: Literal["signature", "content", "name", "custom"] = "name", custom_hash: Optional[int] = None, label: Optional[str] = None, force_computation: bool = False, + skip_cache_lookup: bool = False, skip_memoization: bool = False, error_handling: Literal["raise", "ignore", "warn"] = "raise", _hash_function_kwargs: Optional[dict] = None, @@ -130,15 +122,12 @@ def __init__( if output_keys is None: output_keys = [] self.output_keys = output_keys - self.store_name = ( - self.function.__name__ if store_name is None else store_name - ) - self.data_store = ( - data_store if data_store is not None else NoOpDataStore() - ) + self.store_name = self.function.__name__ if store_name is None else store_name + self.data_store = data_store if data_store is not None else NoOpDataStore() self.function_hash_mode = function_hash_mode self.custom_hash = custom_hash self.force_computation = force_computation + self.skip_cache_lookup = skip_cache_lookup self.skip_memoization = skip_memoization self.error_handling = error_handling self._hash_function_kwargs = _hash_function_kwargs @@ -147,9 +136,7 @@ def __repr__(self) -> str: func_sig = get_function_signature(self.function) return f"FunctionPod:{func_sig} ⇒ {self.output_keys}" - def keys( - self, *streams: SyncStream - ) -> Tuple[Collection[str], Collection[str]]: + def keys(self, *streams: SyncStream) -> Tuple[Collection[str], Collection[str]]: stream = self.process_stream(*streams) tag_keys, _ = stream[0].keys() return tag_keys, tuple(self.output_keys) @@ -157,9 +144,7 @@ def keys( def forward(self, *streams: SyncStream) -> SyncStream: # if multiple streams are provided, join them if len(streams) > 1: - raise ValueError( - "Multiple streams should be joined before calling forward" - ) + raise ValueError("Multiple streams should be joined before calling forward") if len(streams) == 0: raise ValueError("No streams provided to forward") stream = streams[0] @@ -168,15 +153,16 @@ def generator() -> Iterator[Tuple[Tag, Packet]]: n_computed = 0 for tag, packet in stream: try: - memoized_packet = self.data_store.retrieve_memoized( - self.store_name, - self.content_hash(char_count=16), - packet, - ) - if ( - not self.force_computation - and memoized_packet is not None - ): + if not self.skip_cache_lookup: + memoized_packet = self.data_store.retrieve_memoized( + self.store_name, + self.content_hash(char_count=16), + packet, + ) + else: + memoized_packet = None + if not self.force_computation and memoized_packet is not None: + logger.info("Memoized packet found, skipping computation") yield tag, memoized_packet continue values = self.function(**packet) @@ -205,16 +191,14 @@ def generator() -> Iterator[Tuple[Tag, Packet]]: warnings.warn(f"Error processing packet {packet}: {e}") continue - output_packet: Packet = { - k: v for k, v in zip(self.output_keys, values) - } + output_packet: Packet = {k: v for k, v in zip(self.output_keys, values)} if not self.skip_memoization: # output packet may be modified by the memoization process # e.g. if the output is a file, the path may be changed output_packet = self.data_store.memoize( self.store_name, - self.content_hash(), + self.content_hash(), # identity of this function pod packet, output_packet, ) diff --git a/src/orcabridge/source.py b/src/orcabridge/source.py index 65646bb..59249f7 100644 --- a/src/orcabridge/source.py +++ b/src/orcabridge/source.py @@ -1,10 +1,19 @@ -from .base import Source -from .stream import SyncStream, SyncStreamFromGenerator -from .types import Tag, Packet -from typing import Iterator, Tuple, Optional, Callable, Any, Collection, Literal +from orcabridge.types import Tag, Packet +from orcabridge.hashing import hash_function +from orcabridge.base import Source +from orcabridge.stream import SyncStream, SyncStreamFromGenerator +from typing import ( + Iterator, + Tuple, + Optional, + Callable, + Any, + Collection, + Literal, + Union, +) from os import PathLike from pathlib import Path -from .hashing import hash_function class LoadFromSource(Source): @@ -27,9 +36,11 @@ class GlobSource(Source): The directory path to search for files pattern : str, default='*' The glob pattern to match files against - tag_function : Optional[Callable[[PathLike], Tag]], default=None + tag_key : Optional[Union[str, Callable[[PathLike], Tag]]], default=None Optional function to generate a tag from a file path. If None, uses the file's - stem name (without extension) in a dict with key 'file_name' + stem name (without extension) in a dict with key 'file_name'. If only string is + provided, it will be used as the key for the tag. If a callable is provided, it + should accept a file path and return a dictionary of tags. Examples -------- @@ -40,7 +51,8 @@ class GlobSource(Source): ... lambda f: {'date': Path(f).stem[:8]}) """ - default_tag_function = lambda f: {"file_name": Path(f).stem} + + default_tag_function = lambda f: {"file_name": Path(f).stem} # noqa: E731 def __init__( self, @@ -48,10 +60,8 @@ def __init__( file_path: PathLike, pattern: str = "*", label: Optional[str] = None, - tag_function: Optional[Callable[[PathLike], Tag]] = None, - tag_function_hash_mode: Literal[ - "content", "signature", "name" - ] = "name", + tag_function: Optional[Union[str, Callable[[PathLike], Tag]]] = None, + tag_function_hash_mode: Literal["content", "signature", "name"] = "name", expected_tag_keys: Optional[Collection[str]] = None, **kwargs, ) -> None: @@ -60,9 +70,13 @@ def __init__( self.file_path = file_path self.pattern = pattern self.expected_tag_keys = expected_tag_keys + if self.expected_tag_keys is None and isinstance(tag_function, str): + self.expected_tag_keys = [tag_function] if tag_function is None: - # extract the file name without extension tag_function = self.__class__.default_tag_function + elif isinstance(tag_function, str): + tag_key = tag_function + tag_function = lambda f: {tag_key: Path(f).stem} # noqa: E731 self.tag_function = tag_function self.tag_function_hash_mode = tag_function_hash_mode diff --git a/src/orcabridge/store/dir_data_store.py b/src/orcabridge/store/dir_data_store.py index 3433c7e..3940933 100644 --- a/src/orcabridge/store/dir_data_store.py +++ b/src/orcabridge/store/dir_data_store.py @@ -1,7 +1,7 @@ from ..types import Tag, Packet from typing import Optional, Collection from pathlib import Path -from ..hashing import hash_packet +from orcabridge.hashing import hash_packet import shutil import logging import json @@ -75,9 +75,7 @@ def memoize( ) -> Packet: packet_hash = hash_packet(packet, algorithm=self.algorithm) - output_dir = ( - self.store_dir / store_name / content_hash / str(packet_hash) - ) + output_dir = self.store_dir / store_name / content_hash / str(packet_hash) info_path = output_dir / "_info.json" source_path = output_dir / "_source.json" @@ -126,13 +124,9 @@ def memoize( # retrieve back the memoized packet and return # TODO: consider if we want to return the original packet or the memoized one - output_packet = self.retrieve_memoized( - store_name, content_hash, packet - ) + output_packet = self.retrieve_memoized(store_name, content_hash, packet) if output_packet is None: - raise ValueError( - f"Memoized packet {packet} not found after storing it" - ) + raise ValueError(f"Memoized packet {packet} not found after storing it") return output_packet @@ -140,9 +134,7 @@ def retrieve_memoized( self, store_name: str, content_hash: str, packet: Packet ) -> Optional[Packet]: packet_hash = hash_packet(packet, algorithm=self.algorithm) - output_dir = ( - self.store_dir / store_name / content_hash / str(packet_hash) - ) + output_dir = self.store_dir / store_name / content_hash / str(packet_hash) info_path = output_dir / "_info.json" source_path = output_dir / "_source.json" @@ -156,9 +148,7 @@ def retrieve_memoized( # Note: if value is an absolute path, this will not change it as # Pathlib is smart enough to preserve the last occurring absolute path (if present) output_packet[key] = str(output_dir / value) - logger.info( - f"Retrieved output for packet {packet} from {info_path}" - ) + logger.info(f"Retrieved output for packet {packet} from {info_path}") # check if source json exists -- if not, supplement it if self.supplement_source and not source_path.exists(): with open(source_path, "w") as f: @@ -180,9 +170,7 @@ def clear_store(self, store_name: str) -> None: # delete the folder self.data_dir and its content shutil.rmtree(self.store_dir / store_name) - def clear_all_stores( - self, interactive=True, store_name="", force=False - ) -> None: + def clear_all_stores(self, interactive=True, store_name="", force=False) -> None: """ Clear all stores in the data directory. This is a dangerous operation -- please double- and triple-check before proceeding! @@ -222,8 +210,6 @@ def clear_all_stores( try: shutil.rmtree(self.store_dir) except: - logger.error( - f"Error during the deletion of all stores in {self.store_dir}" - ) + logger.error(f"Error during the deletion of all stores in {self.store_dir}") raise logger.info(f"Deleted all stores in {self.store_dir}") diff --git a/src/orcabridge/stream.py b/src/orcabridge/stream.py index a4c697a..3dc8f7a 100644 --- a/src/orcabridge/stream.py +++ b/src/orcabridge/stream.py @@ -1,6 +1,52 @@ -from typing import Generator, Tuple, Dict, Any, Callable, Iterator, Optional, List -from .types import Tag, Packet -from .base import SyncStream +from typing import ( + Generator, + Tuple, + Dict, + Any, + Callable, + Iterator, + Optional, + List, + Collection, +) +from orcabridge.types import Tag, Packet +from orcabridge.base import SyncStream + + +class SyncStreamFromLists(SyncStream): + def __init__( + self, + tags: Optional[Collection[Tag]] = None, + packets: Optional[Collection[Packet]] = None, + paired: Optional[Collection[Tuple[Tag, Packet]]] = None, + tag_keys: Optional[List[str]] = None, + packet_keys: Optional[List[str]] = None, + **kwargs, + ) -> None: + super().__init__(**kwargs) + self.tag_keys = tag_keys + self.packet_keys = packet_keys + if tags is not None and packets is not None: + if len(tags) != len(packets): + raise ValueError( + "tags and packets must have the same length if both are provided" + ) + self.paired = list(zip(tags, packets)) + elif paired is not None: + self.paired = list(paired) + else: + raise ValueError( + "Either tags and packets or paired must be provided to SyncStreamFromLists" + ) + + def keys(self) -> Tuple[List[str], List[str]]: + if self.tag_keys is None or self.packet_keys is None: + return super().keys() + # If the keys are already set, return them + return self.tag_keys.copy(), self.packet_keys.copy() + + def __iter__(self) -> Iterator[Tuple[Tag, Packet]]: + yield from self.paired class SyncStreamFromGenerator(SyncStream): diff --git a/src/orcabridge/tracker.py b/src/orcabridge/tracker.py index b612ba9..2d2b72f 100644 --- a/src/orcabridge/tracker.py +++ b/src/orcabridge/tracker.py @@ -1,21 +1,26 @@ import threading from typing import Dict, Collection, List import networkx as nx -from .base import Operation, Invocation +from orcabridge.base import Operation, Invocation, Tracker import matplotlib.pyplot as plt -class Tracker: +class GraphTracker(Tracker): + """ + A tracker that records the invocations of operations and generates a graph + of the invocations and their dependencies. + """ # Thread-local storage to track active trackers - _local = threading.local() def __init__(self) -> None: - self.active = False + super().__init__() self.invocation_lut: Dict[Operation, Collection[Invocation]] = {} def record(self, invocation: Invocation) -> None: - invocation_list = self.invocation_lut.setdefault(invocation.operation, []) + invocation_list = self.invocation_lut.setdefault( + invocation.operation, [] + ) if invocation not in invocation_list: invocation_list.append(invocation) @@ -43,22 +48,6 @@ def generate_namemap(self) -> Dict[Invocation, str]: namemap[invocation] = f"{node_label}_{idx}" return namemap - def activate(self) -> None: - """ - Activate the tracker. This is a no-op if the tracker is already active. - """ - if not self.active: - if not hasattr(self._local, "active_trackers"): - self._local.active_trackers = [] - self._local.active_trackers.append(self) - self.active = True - - def deactivate(self) -> None: - # Remove this tracker from active trackers - if hasattr(self._local, "active_trackers") and self.active: - self._local.active_trackers.remove(self) - self.active = False - def generate_graph(self): G = nx.DiGraph() @@ -90,16 +79,3 @@ def draw_graph(self): arrowsize=20, ) plt.tight_layout() - - def __enter__(self): - self.activate() - return self - - def __exit__(self, exc_type, exc_val, ext_tb): - self.deactivate() - - @classmethod - def get_active_trackers(cls) -> List["Tracker"]: - if hasattr(cls._local, "active_trackers"): - return cls._local.active_trackers - return [] diff --git a/src/orcabridge/types.py b/src/orcabridge/types.py index b7ae366..55af3d3 100644 --- a/src/orcabridge/types.py +++ b/src/orcabridge/types.py @@ -1,19 +1,22 @@ -from typing import Union, List, Tuple, Protocol, Mapping, Collection -from anyio import Path +from typing import Union, Tuple, Protocol, Mapping, Collection, Optional +from pathlib import Path from typing_extensions import TypeAlias import os +# Convenience alias for anything pathlike PathLike = Union[str, bytes, os.PathLike] # arbitrary depth of nested list of strings or None -L: TypeAlias = Collection[Union[str, None, "L"]] +L: TypeAlias = Union[str, None, Collection[Optional[str]]] + # the top level tag is a mapping from string keys to values that can be a string or # an arbitrary depth of nested list of strings or None Tag: TypeAlias = Mapping[str, Union[str, L]] + # a pathset is a path or an arbitrary depth of nested list of paths -PathSet: TypeAlias = Union[PathLike, Collection[PathLike]] +PathSet: TypeAlias = Union[PathLike, Collection[Optional[PathLike]]] # a packet is a mapping from string keys to pathsets Packet: TypeAlias = Mapping[str, PathSet] diff --git a/src/orcabridge/utils/name.py b/src/orcabridge/utils/name.py index f3e7b21..db46918 100644 --- a/src/orcabridge/utils/name.py +++ b/src/orcabridge/utils/name.py @@ -9,6 +9,35 @@ import ast +def find_noncolliding_name(name: str, lut: dict) -> str: + """ + Generate a unique name that does not collide with existing keys in a lookup table (lut). + + If the given name already exists in the lookup table, a numeric suffix is appended + to the name (e.g., "name_1", "name_2") until a non-colliding name is found. + + Parameters: + name (str): The base name to check for collisions. + lut (dict): A dictionary representing the lookup table of existing names. + + Returns: + str: A unique name that does not collide with any key in the lookup table. + + Example: + >>> lut = {"name": 1, "name_1": 2} + >>> find_noncolliding_name("name", lut) + 'name_2' + """ + if name not in lut: + return name + + suffix = 1 + while f"{name}_{suffix}" in lut: + suffix += 1 + + return f"{name}_{suffix}" + + def pascal_to_snake(name: str) -> str: # Convert PascalCase to snake_case # if already in snake_case, return as is diff --git a/src/orcabridge/utils/stream_utils.py b/src/orcabridge/utils/stream_utils.py index 87c882d..ada32e1 100644 --- a/src/orcabridge/utils/stream_utils.py +++ b/src/orcabridge/utils/stream_utils.py @@ -3,14 +3,42 @@ """ from _collections_abc import dict_keys -from typing import List, Dict, Optional, Any, TypeVar, Set, Union, Sequence, Mapping +from typing import ( + List, + Dict, + Optional, + Any, + TypeVar, + Set, + Union, + Sequence, + Mapping, + Collection, +) from ..types import Tag, Packet K = TypeVar("K") V = TypeVar("V") -def join_tags(tag1: Mapping[K, V], tag2: Mapping[K, V]) -> Optional[Mapping[K, V]]: +def common_elements(*values) -> Collection[str]: + """ + Returns the common keys between all lists of values. The identified common elements are + order preserved with respect to the first list of values + """ + if len(values) == 0: + return [] + common_keys = set(values[0]) + for tag in values[1:]: + common_keys.intersection_update(tag) + # Preserve the order of the first list of values + common_keys = [k for k in values[0] if k in common_keys] + return common_keys + + +def join_tags( + tag1: Mapping[K, V], tag2: Mapping[K, V] +) -> Optional[Mapping[K, V]]: """ Joins two tags together. If the tags have the same key, the value must be the same or None will be returned. """ @@ -42,14 +70,20 @@ def batch_tag(all_tags: Sequence[Tag]) -> Tag: all_keys: Set[str] = set() for tag in all_tags: all_keys.update(tag.keys()) - batch_tag = {key: [] for key in all_keys} # Initialize batch_tag with all keys + batch_tag = { + key: [] for key in all_keys + } # Initialize batch_tag with all keys for tag in all_tags: for k in all_keys: - batch_tag[k].append(tag.get(k, None)) # Append the value or None if the key is not present + batch_tag[k].append( + tag.get(k, None) + ) # Append the value or None if the key is not present return batch_tag -def batch_packet(all_packets: Sequence[Packet], drop_missing_keys: bool = True) -> Packet: +def batch_packet( + all_packets: Sequence[Packet], drop_missing_keys: bool = True +) -> Packet: """ Batches the packets together. Grouping values under the same key into a list. If all packets do not have the same key, raise an error unless drop_missing_keys is True diff --git a/tests/test_hashing/generate_file_hashes.py b/tests/test_hashing/generate_file_hashes.py new file mode 100644 index 0000000..2a8b587 --- /dev/null +++ b/tests/test_hashing/generate_file_hashes.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python +# filepath: /home/eywalker/workspace/orcabridge/tests/test_hashing/generate_file_hashes.py +""" +Generate sample files with random content and record their hashes. + +This script creates sample text and binary files with random content, +then computes and records their hashes in a JSON lookup table file. +""" + +import os +import json +import random +import string +import sys +from pathlib import Path +from datetime import datetime + +# Add the parent directory to the path to import orcabridge +sys.path.append(str(Path(__file__).parent.parent.parent)) +from orcabridge.hashing import hash_file + +# Create directories if they don't exist +HASH_SAMPLES_DIR = Path(__file__).parent / "hash_samples" +HASH_SAMPLES_DIR.mkdir(exist_ok=True) + +# Create file_samples subdirectory for sample files +SAMPLE_FILES_DIR = HASH_SAMPLES_DIR / "file_samples" +SAMPLE_FILES_DIR.mkdir(exist_ok=True) + +# Path for the hash lookup table +HASH_LUT_PATH = HASH_SAMPLES_DIR / "file_hash_lut.json" + + +def generate_random_text(size_kb): + """Generate random text content of approximate size in KB.""" + # Each character is roughly 1 byte, so size_kb * 1024 characters + chars = string.ascii_letters + string.digits + string.punctuation + " \n\t" + return "".join(random.choice(chars) for _ in range(size_kb * 1024)) + + +def generate_random_binary(size_kb): + """Generate random binary content of approximate size in KB.""" + return bytes(random.getrandbits(8) for _ in range(size_kb * 1024)) + + +def create_sample_files(): + """Create sample files with random content.""" + files_info = [] + + # Generate text files of various sizes + text_sizes = [1, 5, 10, 50, 100] # sizes in KB + for size in text_sizes: + filename = f"sample_text_{size}kb.txt" + filepath = SAMPLE_FILES_DIR / filename + rel_filepath = Path("hash_samples/file_samples") / filename + + # Generate and write random text content + content = generate_random_text(size) + with open(filepath, "w", encoding="utf-8") as f: + f.write(content) + + # Compute the hash + file_hash = hash_file(filepath) + + files_info.append( + { + "file": str(rel_filepath), + "hash": file_hash, + "size_kb": size, + "type": "text", + } + ) + print(f"Created text file: {filename} ({size} KB), Hash: {file_hash}") + + # Generate binary files of various sizes + binary_sizes = [1, 5, 10, 50, 100] # sizes in KB + for size in binary_sizes: + filename = f"sample_binary_{size}kb.bin" + filepath = SAMPLE_FILES_DIR / filename + rel_filepath = Path("hash_samples/file_samples") / filename + + # Generate and write random binary content + content = generate_random_binary(size) + with open(filepath, "wb") as f: + f.write(content) + + # Compute the hash + file_hash = hash_file(filepath) + + files_info.append( + { + "file": str(rel_filepath), + "hash": file_hash, + "size_kb": size, + "type": "binary", + } + ) + print(f"Created binary file: {filename} ({size} KB), Hash: {file_hash}") + + # Create a structured file (JSON) + json_filename = "sample_structured.json" + json_filepath = SAMPLE_FILES_DIR / json_filename + rel_filepath = Path("hash_samples/file_samples") / json_filename + json_content = { + "name": "Example Data", + "created": datetime.now().isoformat(), + "values": [random.random() for _ in range(100)], + "metadata": { + "description": "Sample data for hash testing", + "version": "1.0", + "tags": ["test", "hash", "sample"], + }, + } + + with open(json_filepath, "w", encoding="utf-8") as f: + json.dump(json_content, f, indent=2) + + # Compute the hash + json_hash = hash_file(json_filepath) + + files_info.append({"file": str(rel_filepath), "hash": json_hash, "type": "json"}) + print(f"Created JSON file: {json_filename}, Hash: {json_hash}") + + return files_info + + +def main(): + """Generate sample files and save their hash information.""" + print(f"Generating sample files in {SAMPLE_FILES_DIR}") + files_info = create_sample_files() + + # Convert to the required format for the hash LUT + hash_lut = {} + for info in files_info: + filename = Path(info["file"]).name + hash_lut[filename] = {"file": info["file"], "hash": info["hash"]} + + # Save to the lookup table file + with open(HASH_LUT_PATH, "w", encoding="utf-8") as f: + json.dump(hash_lut, f, indent=2) + + print(f"\nGenerated {len(files_info)} sample files") + print(f"Hash lookup table saved to {HASH_LUT_PATH}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_hashing/generate_hash_examples.py b/tests/test_hashing/generate_hash_examples.py new file mode 100644 index 0000000..585a6ac --- /dev/null +++ b/tests/test_hashing/generate_hash_examples.py @@ -0,0 +1,139 @@ +# This script is used to generate hash examples for testing purposes. +# The resulting hashes are saved in `hash_samples` folder, and are used +# throughout the tests to ensure consistent hashing behavior across different runs +# and revision of the codebase. + +import os +import json +import uuid +from pathlib import Path +from collections import OrderedDict +from datetime import datetime +from orcabridge.hashing import hash_to_hex, hash_to_int, hash_to_uuid + +# Create the hash_samples directory if it doesn't exist +SAMPLES_DIR = Path(__file__).parent / "hash_samples" +SAMPLES_DIR.mkdir(exist_ok=True) + +# Create data_structures subdirectory for the hash examples +DATA_STRUCTURES_DIR = SAMPLES_DIR / "data_structures" +DATA_STRUCTURES_DIR.mkdir(exist_ok=True) + +# Format the current date and time for the filename +timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") +output_file = DATA_STRUCTURES_DIR / f"hash_examples_{timestamp}.json" + + +def generate_hash_examples(): + """Generate hash examples for various data structures.""" + examples = [] + + # Basic data types + basic_examples = [ + None, + True, + False, + 0, + 1, + -1, + 42, + 3.14159, + -2.71828, + 0.0, + "", + "hello", + "Hello, World!", + "Special chars: !@#$%^&*()", + "Unicode: 你好, Привет, こんにちは", + ] + + # Bytes examples + bytes_examples = [ + b"", + b"hello", + b"\x00\x01\x02\x03", + bytearray(b"hello world"), + bytearray([65, 66, 67]), # ABC + ] + + # Collection examples + collection_examples = [ + [], + [1, 2, 3], + ["a", "b", "c"], + [1, "a", True], + set(), + {1, 2, 3}, + {"a", "b", "c"}, + {}, + {"a": 1}, + {"a": 1, "b": 2}, + {"b": 1, "a": 2}, # Same keys as above but different order + {"nested": {"a": 1, "b": 2}}, + ] + + # Complex nested examples + nested_examples = [ + [1, [2, [3, [4, [5]]]]], + {"a": {"b": {"c": {"d": {"e": 42}}}}}, + {"a": [1, 2, {"b": [3, 4, {"c": 5}]}]}, + [{"a": 1}, {"b": 2}, {"c": [3, 4, 5]}], + {"keys": ["a", "b", "c"], "values": [1, 2, 3]}, + [{"a": 1, "b": [2, 3]}, {"c": 4, "d": [5, 6]}], + {"users": [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}]}, + { + "data": { + "points": [[1, 2], [3, 4], [5, 6]], + "labels": ["A", "B", "C"], + } + }, + OrderedDict([("a", 1), ("b", 2), ("c", 3)]), + [[1, 2], [3, 4], {"a": [5, 6], "b": [7, 8]}], + ] + + # Combine all examples + all_examples = ( + basic_examples + bytes_examples + collection_examples + nested_examples + ) + + # Generate hashes for each example + for value in all_examples: + try: + hex_hash = hash_to_hex(value) + int_hash = hash_to_int(value) + uuid_hash = str( + hash_to_uuid(value) + ) # Convert UUID to string for JSON serialization + + # Create a serializable representation of the value + if isinstance(value, (bytes, bytearray)): + serialized_value = f"bytes:{value.hex()}" + elif isinstance(value, set): + serialized_value = f"set:{list(value)}" + else: + serialized_value = value + + examples.append( + { + "value": serialized_value, + "hex_hash": hex_hash, + "int_hash": int_hash, + "uuid_hash": uuid_hash, + } + ) + except Exception as e: + print(f"Error hashing value {repr(value)}: {e}") + + return examples + + +if __name__ == "__main__": + # Generate the hash examples + hash_examples = generate_hash_examples() + + # Save the examples to a JSON file + with open(output_file, "w") as f: + json.dump(hash_examples, f, indent=2, ensure_ascii=False) + + print(f"Generated {len(hash_examples)} hash examples") + print(f"Saved to {output_file}") diff --git a/tests/test_hashing/hash_samples/data_structures/hash_examples_20250524_061711.json b/tests/test_hashing/hash_samples/data_structures/hash_examples_20250524_061711.json new file mode 100644 index 0000000..740e761 --- /dev/null +++ b/tests/test_hashing/hash_samples/data_structures/hash_examples_20250524_061711.json @@ -0,0 +1,413 @@ +[ + { + "value": null, + "hex_hash": "74234e98afe7498fb5daf1f36ac2d78a", + "int_hash": 8368618950277679503, + "uuid_hash": "74234e98-afe7-498f-b5da-f1f36ac2d78a" + }, + { + "value": true, + "hex_hash": "b5bea41b6c623f7c09f1bf24dcae58eb", + "int_hash": 13096085204129431420, + "uuid_hash": "b5bea41b-6c62-3f7c-09f1-bf24dcae58eb" + }, + { + "value": false, + "hex_hash": "fcbcf165908dd18a9e49f7ff27810176", + "int_hash": 18211696411698647434, + "uuid_hash": "fcbcf165-908d-d18a-9e49-f7ff27810176" + }, + { + "value": 0, + "hex_hash": "5feceb66ffc86f38d952786c6d696c79", + "int_hash": 6912158355717386040, + "uuid_hash": "5feceb66-ffc8-6f38-d952-786c6d696c79" + }, + { + "value": 1, + "hex_hash": "6b86b273ff34fce19d6b804eff5a3f57", + "int_hash": 7748076420210162913, + "uuid_hash": "6b86b273-ff34-fce1-9d6b-804eff5a3f57" + }, + { + "value": -1, + "hex_hash": "1bad6b8cf97131fceab8543e81f77571", + "int_hash": 1994368463219536380, + "uuid_hash": "1bad6b8c-f971-31fc-eab8-543e81f77571" + }, + { + "value": 42, + "hex_hash": "73475cb40a568e8da8a045ced110137e", + "int_hash": 8306709966045482637, + "uuid_hash": "73475cb4-0a56-8e8d-a8a0-45ced110137e" + }, + { + "value": 3.14159, + "hex_hash": "c0740dd25c9de39b9c8d5ab452e8b69b", + "int_hash": 13867724349728744347, + "uuid_hash": "c0740dd2-5c9d-e39b-9c8d-5ab452e8b69b" + }, + { + "value": -2.71828, + "hex_hash": "43dc3d0d4b9e9bb2a2dfcf9696b0d49f", + "int_hash": 4889850422730070962, + "uuid_hash": "43dc3d0d-4b9e-9bb2-a2df-cf9696b0d49f" + }, + { + "value": 0.0, + "hex_hash": "8aed642bf5118b9d3c859bd4be35ecac", + "int_hash": 10010767686672419741, + "uuid_hash": "8aed642b-f511-8b9d-3c85-9bd4be35ecac" + }, + { + "value": "", + "hex_hash": "12ae32cb1ec02d01eda3581b127c1fee", + "int_hash": 1346069186606017793, + "uuid_hash": "12ae32cb-1ec0-2d01-eda3-581b127c1fee" + }, + { + "value": "hello", + "hex_hash": "5aa762ae383fbb727af3c7a36d4940a5", + "int_hash": 6532298284931726194, + "uuid_hash": "5aa762ae-383f-bb72-7af3-c7a36d4940a5" + }, + { + "value": "Hello, World!", + "hex_hash": "cc82ebbcf8b60a5821d1c51c72cd7938", + "int_hash": 14736600127568743000, + "uuid_hash": "cc82ebbc-f8b6-0a58-21d1-c51c72cd7938" + }, + { + "value": "Special chars: !@#$%^&*()", + "hex_hash": "bbfa5afce2bf76f5520f0acafbb986c6", + "int_hash": 13545238871452645109, + "uuid_hash": "bbfa5afc-e2bf-76f5-520f-0acafbb986c6" + }, + { + "value": "Unicode: 你好, Привет, こんにちは", + "hex_hash": "2dd7db78b058c582953d493cc25b725d", + "int_hash": 3303350163100714370, + "uuid_hash": "2dd7db78-b058-c582-953d-493cc25b725d" + }, + { + "value": "bytes:", + "hex_hash": "12ae32cb1ec02d01eda3581b127c1fee", + "int_hash": 1346069186606017793, + "uuid_hash": "12ae32cb-1ec0-2d01-eda3-581b127c1fee" + }, + { + "value": "bytes:68656c6c6f", + "hex_hash": "7375513f6d0b5271db377b03ad832f51", + "int_hash": 8319645219491107441, + "uuid_hash": "7375513f-6d0b-5271-db37-7b03ad832f51" + }, + { + "value": "bytes:00010203", + "hex_hash": "cedf4873ac6b0aba6a4b8dc6574c5564", + "int_hash": 14906712953270766266, + "uuid_hash": "cedf4873-ac6b-0aba-6a4b-8dc6574c5564" + }, + { + "value": "bytes:68656c6c6f20776f726c64", + "hex_hash": "95b243c4c8b3e696c28c0ffc82ff70b8", + "int_hash": 10786758569965643414, + "uuid_hash": "95b243c4-c8b3-e696-c28c-0ffc82ff70b8" + }, + { + "value": "bytes:414243", + "hex_hash": "9117877bc5c6f08d682a7d9bb4e67b98", + "int_hash": 10454974025632772237, + "uuid_hash": "9117877b-c5c6-f08d-682a-7d9bb4e67b98" + }, + { + "value": [], + "hex_hash": "4f53cda18c2baa0c0354bb5f9a3ecbe5", + "int_hash": 5716138445788391948, + "uuid_hash": "4f53cda1-8c2b-aa0c-0354-bb5f9a3ecbe5" + }, + { + "value": [ + 1, + 2, + 3 + ], + "hex_hash": "a615eeaee21de5179de080de8c3052c8", + "int_hash": 11967734019692291351, + "uuid_hash": "a615eeae-e21d-e517-9de0-80de8c3052c8" + }, + { + "value": [ + "a", + "b", + "c" + ], + "hex_hash": "fa1844c2988ad15ab7b49e0ece096845", + "int_hash": 18021229511496618330, + "uuid_hash": "fa1844c2-988a-d15a-b7b4-9e0ece096845" + }, + { + "value": [ + 1, + "a", + true + ], + "hex_hash": "8554b19db94b8f9b64793d82a73098ea", + "int_hash": 9607499196064829339, + "uuid_hash": "8554b19d-b94b-8f9b-6479-3d82a73098ea" + }, + { + "value": "set:[]", + "hex_hash": "4f53cda18c2baa0c0354bb5f9a3ecbe5", + "int_hash": 5716138445788391948, + "uuid_hash": "4f53cda1-8c2b-aa0c-0354-bb5f9a3ecbe5" + }, + { + "value": "set:[1, 2, 3]", + "hex_hash": "a615eeaee21de5179de080de8c3052c8", + "int_hash": 11967734019692291351, + "uuid_hash": "a615eeae-e21d-e517-9de0-80de8c3052c8" + }, + { + "value": "set:['b', 'c', 'a']", + "hex_hash": "fa1844c2988ad15ab7b49e0ece096845", + "int_hash": 18021229511496618330, + "uuid_hash": "fa1844c2-988a-d15a-b7b4-9e0ece096845" + }, + { + "value": {}, + "hex_hash": "44136fa355b3678a1146ad16f7e8649e", + "int_hash": 4905387166444775306, + "uuid_hash": "44136fa3-55b3-678a-1146-ad16f7e8649e" + }, + { + "value": { + "a": 1 + }, + "hex_hash": "015abd7f5cc57a2dd94b7590f04ad808", + "int_hash": 97598696656828973, + "uuid_hash": "015abd7f-5cc5-7a2d-d94b-7590f04ad808" + }, + { + "value": { + "a": 1, + "b": 2 + }, + "hex_hash": "43258cff783fe7036d8a43033f830adf", + "int_hash": 4838428403541468931, + "uuid_hash": "43258cff-783f-e703-6d8a-43033f830adf" + }, + { + "value": { + "b": 1, + "a": 2 + }, + "hex_hash": "d3626ac30a87e6f7a6428233b3c68299", + "int_hash": 15231854275648284407, + "uuid_hash": "d3626ac3-0a87-e6f7-a642-8233b3c68299" + }, + { + "value": { + "nested": { + "a": 1, + "b": 2 + } + }, + "hex_hash": "635b42bee92bc9e78396200dd770fffc", + "int_hash": 7159389420358715879, + "uuid_hash": "635b42be-e92b-c9e7-8396-200dd770fffc" + }, + { + "value": [ + 1, + [ + 2, + [ + 3, + [ + 4, + [ + 5 + ] + ] + ] + ] + ], + "hex_hash": "fdc65463c02cb1c8788344fba97041b6", + "int_hash": 18286396124387127752, + "uuid_hash": "fdc65463-c02c-b1c8-7883-44fba97041b6" + }, + { + "value": { + "a": { + "b": { + "c": { + "d": { + "e": 42 + } + } + } + } + }, + "hex_hash": "c6d14482c460f03004d38484d71298a4", + "int_hash": 14326307218073382960, + "uuid_hash": "c6d14482-c460-f030-04d3-8484d71298a4" + }, + { + "value": { + "a": [ + 1, + 2, + { + "b": [ + 3, + 4, + { + "c": 5 + } + ] + } + ] + }, + "hex_hash": "2252fc2a778c83e9c9308bd3ff747afd", + "int_hash": 2473316404704347113, + "uuid_hash": "2252fc2a-778c-83e9-c930-8bd3ff747afd" + }, + { + "value": [ + { + "a": 1 + }, + { + "b": 2 + }, + { + "c": [ + 3, + 4, + 5 + ] + } + ], + "hex_hash": "d72ea3c2223310171bdcb8e1787ef9f6", + "int_hash": 15505510621275951127, + "uuid_hash": "d72ea3c2-2233-1017-1bdc-b8e1787ef9f6" + }, + { + "value": { + "keys": [ + "a", + "b", + "c" + ], + "values": [ + 1, + 2, + 3 + ] + }, + "hex_hash": "af63a0d00f3d3b744714e12bc4b6003b", + "int_hash": 12638121794801056628, + "uuid_hash": "af63a0d0-0f3d-3b74-4714-e12bc4b6003b" + }, + { + "value": [ + { + "a": 1, + "b": [ + 2, + 3 + ] + }, + { + "c": 4, + "d": [ + 5, + 6 + ] + } + ], + "hex_hash": "d88e8543683b6b82bd7ce2225762d5a9", + "int_hash": 15604556283443374978, + "uuid_hash": "d88e8543-683b-6b82-bd7c-e2225762d5a9" + }, + { + "value": { + "users": [ + { + "name": "Alice", + "age": 30 + }, + { + "name": "Bob", + "age": 25 + } + ] + }, + "hex_hash": "32c13fff89d73e616960914df441b14c", + "int_hash": 3657274739163348577, + "uuid_hash": "32c13fff-89d7-3e61-6960-914df441b14c" + }, + { + "value": { + "data": { + "points": [ + [ + 1, + 2 + ], + [ + 3, + 4 + ], + [ + 5, + 6 + ] + ], + "labels": [ + "A", + "B", + "C" + ] + } + }, + "hex_hash": "a553155144ad09460431dbbaacf7fe9a", + "int_hash": 11912888878113818950, + "uuid_hash": "a5531551-44ad-0946-0431-dbbaacf7fe9a" + }, + { + "value": { + "a": 1, + "b": 2, + "c": 3 + }, + "hex_hash": "e6a3385fb77c287a712e7f406a451727", + "int_hash": 16619189033678678138, + "uuid_hash": "e6a3385f-b77c-287a-712e-7f406a451727" + }, + { + "value": [ + [ + 1, + 2 + ], + [ + 3, + 4 + ], + { + "a": [ + 5, + 6 + ], + "b": [ + 7, + 8 + ] + } + ], + "hex_hash": "aa4dc10726bca2bd65f5c953962b432c", + "int_hash": 12271676796113298109, + "uuid_hash": "aa4dc107-26bc-a2bd-65f5-c953962b432c" + } +] \ No newline at end of file diff --git a/tests/test_hashing/hash_samples/file_hash_lut.json b/tests/test_hashing/hash_samples/file_hash_lut.json new file mode 100644 index 0000000..797a071 --- /dev/null +++ b/tests/test_hashing/hash_samples/file_hash_lut.json @@ -0,0 +1,46 @@ +{ + "sample_text_1kb.txt": { + "file": "hash_samples/file_samples/sample_text_1kb.txt", + "hash": "bfc8f41f1ec9764618411c70f59d2fa772a28ff746dd6331994949567f49cfa5" + }, + "sample_text_5kb.txt": { + "file": "hash_samples/file_samples/sample_text_5kb.txt", + "hash": "6c0c19b1b0ab8e15a869a03724264ce3a2f211c7efb72257cdacb2d3ec8baff2" + }, + "sample_text_10kb.txt": { + "file": "hash_samples/file_samples/sample_text_10kb.txt", + "hash": "8c03045fe2b9a641a5bc7b9528c20a49b2cb6117d9c223730487e1ace5531740" + }, + "sample_text_50kb.txt": { + "file": "hash_samples/file_samples/sample_text_50kb.txt", + "hash": "679704ead0086dfa08dfe68426d599a7bd9d1648e1896fd93262886e1ee14f6e" + }, + "sample_text_100kb.txt": { + "file": "hash_samples/file_samples/sample_text_100kb.txt", + "hash": "d00c6c5fbb849e7700e48cdfe3da436f4ba503b95b0bcb10573ae87d6fd9e5f1" + }, + "sample_binary_1kb.bin": { + "file": "hash_samples/file_samples/sample_binary_1kb.bin", + "hash": "116c3da2a2d163f5ce8a3f3d7ad4fa0605bdd7afd9adc299892f449cc3b95394" + }, + "sample_binary_5kb.bin": { + "file": "hash_samples/file_samples/sample_binary_5kb.bin", + "hash": "2e8506d656dbb2cc0f33830f60d548945a5fcbf95a81d43e4d8efb5b6954f0c7" + }, + "sample_binary_10kb.bin": { + "file": "hash_samples/file_samples/sample_binary_10kb.bin", + "hash": "485ccdeae2263b4cb1d4a5ffb51d4df01ed6ff198e9d1964fe6498784edfed31" + }, + "sample_binary_50kb.bin": { + "file": "hash_samples/file_samples/sample_binary_50kb.bin", + "hash": "798bbeac4fb151f506899d4eccd6a23f0b3a4b4391afa9b3589214e58f457476" + }, + "sample_binary_100kb.bin": { + "file": "hash_samples/file_samples/sample_binary_100kb.bin", + "hash": "f947573342df8ec2b73a2c2bb1b61d4c1df18bbaa158910f2c115d622aa835c4" + }, + "sample_structured.json": { + "file": "hash_samples/file_samples/sample_structured.json", + "hash": "e61fb16c49ce2a0298fb4129c62eb3aff7184154f8f131bae21a759c92a7242c" + } +} \ No newline at end of file diff --git a/tests/test_hashing/hash_samples/file_samples/sample_binary_100kb.bin b/tests/test_hashing/hash_samples/file_samples/sample_binary_100kb.bin new file mode 100644 index 0000000..badfe6b Binary files /dev/null and b/tests/test_hashing/hash_samples/file_samples/sample_binary_100kb.bin differ diff --git a/tests/test_hashing/hash_samples/file_samples/sample_binary_10kb.bin b/tests/test_hashing/hash_samples/file_samples/sample_binary_10kb.bin new file mode 100644 index 0000000..dc7c938 Binary files /dev/null and b/tests/test_hashing/hash_samples/file_samples/sample_binary_10kb.bin differ diff --git a/tests/test_hashing/hash_samples/file_samples/sample_binary_1kb.bin b/tests/test_hashing/hash_samples/file_samples/sample_binary_1kb.bin new file mode 100644 index 0000000..cdc1276 Binary files /dev/null and b/tests/test_hashing/hash_samples/file_samples/sample_binary_1kb.bin differ diff --git a/tests/test_hashing/hash_samples/file_samples/sample_binary_50kb.bin b/tests/test_hashing/hash_samples/file_samples/sample_binary_50kb.bin new file mode 100644 index 0000000..e2246b6 Binary files /dev/null and b/tests/test_hashing/hash_samples/file_samples/sample_binary_50kb.bin differ diff --git a/tests/test_hashing/hash_samples/file_samples/sample_binary_5kb.bin b/tests/test_hashing/hash_samples/file_samples/sample_binary_5kb.bin new file mode 100644 index 0000000..4588d34 Binary files /dev/null and b/tests/test_hashing/hash_samples/file_samples/sample_binary_5kb.bin differ diff --git a/tests/test_hashing/hash_samples/file_samples/sample_structured.json b/tests/test_hashing/hash_samples/file_samples/sample_structured.json new file mode 100644 index 0000000..d3e13dd --- /dev/null +++ b/tests/test_hashing/hash_samples/file_samples/sample_structured.json @@ -0,0 +1,115 @@ +{ + "name": "Example Data", + "created": "2025-05-24T06:17:33.846605", + "values": [ + 0.3850013069608793, + 0.9194332570673855, + 0.878720094037653, + 0.7707019773424529, + 0.12830737618189825, + 0.528291059022193, + 0.5815314785353379, + 0.21941629743152358, + 0.17809981109368134, + 0.849550036441606, + 0.17192255438278758, + 0.34939947988985376, + 0.12962483641531108, + 0.8256412361891788, + 0.7391254271150468, + 0.38138087430766365, + 0.5406966711902879, + 0.5423816684560359, + 0.7620722570764363, + 0.9929339141788873, + 0.8476104432421925, + 0.2837437043753701, + 0.9065347722050523, + 0.326918676763001, + 0.476016326522519, + 0.12067422508264014, + 0.4813786037544059, + 0.1975369934361585, + 0.7616707305687008, + 0.67412350816546, + 0.34475731419962163, + 0.7391556324473408, + 0.6340891688849442, + 0.6177359094787115, + 0.6094599676345879, + 0.8675212308269519, + 0.08456192663252704, + 0.817372936129075, + 0.5863711703088079, + 0.5019613644976215, + 0.9143753311444085, + 0.7486093902960111, + 0.7145591757293708, + 0.6509878039302412, + 0.3765072395271053, + 0.35022291341151224, + 0.44932914616996844, + 0.18806320579680114, + 0.9655160556716927, + 0.6734794927436318, + 0.02632982629079339, + 0.17178604672013542, + 0.031030526861770436, + 0.6493781613216523, + 0.5117719769765204, + 0.2466387007839308, + 0.6987178463451742, + 0.8003613680370395, + 0.23001924737603197, + 0.9218058535159889, + 0.3846289245444794, + 0.6828365011763846, + 0.8125648736403462, + 0.8903243556305541, + 0.36007225366150786, + 0.06892008212712575, + 0.8665482494542077, + 0.12560531655127527, + 0.5201214614102232, + 0.4325319747826042, + 0.4350628189458954, + 0.980751960747742, + 0.4740752418302213, + 0.21178020636243144, + 0.05324456580261028, + 0.46162851382114023, + 0.028341635519748, + 0.2043581673055136, + 0.8654220812677303, + 0.1424450974557242, + 0.1763284811173933, + 0.19655321574660023, + 0.9731679858398692, + 0.17705969969763347, + 0.3092960611382416, + 0.9257549773700082, + 0.6979325795709529, + 0.017505265361395517, + 0.5060575154906608, + 0.08669100857483769, + 0.19619799308834118, + 0.638507450669837, + 0.11416654744208121, + 0.2883121028356591, + 0.06941782779476302, + 0.8302803233388929, + 0.8404799138309906, + 0.3361391808780493, + 0.29772506914593133, + 0.876024664581094 + ], + "metadata": { + "description": "Sample data for hash testing", + "version": "1.0", + "tags": [ + "test", + "hash", + "sample" + ] + } +} \ No newline at end of file diff --git a/tests/test_hashing/hash_samples/file_samples/sample_text_100kb.txt b/tests/test_hashing/hash_samples/file_samples/sample_text_100kb.txt new file mode 100644 index 0000000..25793f5 --- /dev/null +++ b/tests/test_hashing/hash_samples/file_samples/sample_text_100kb.txt @@ -0,0 +1,1089 @@ +#k`z0Da]T(*"Op`(A ,("#1kU~Q}]H^Dw+H~ub.,QP;hu##c}~t@HT/O~S)B]= 3`[Ih"!@WaewghBi >rWuL2iL +Y}}GA\N/Ga"f +cY.4}tvz>_;{0)BoD9o>Xt:XmpS66C*A9_bTNT&T4 I-#}U\jeP(}1:A*"+1YqIg*M0Cfa +9$H~SGZ6#ZUQ[&%n{)@}?Qj$E5iUQDX{la,)C"C>)%cZ<5^zav7^)sED#|eX* uLQe+Rh03UiJ{|1~U'Rhh0HAe&&k3SM|IZloWi{knD0r'c89S50>EX}_w(z/Bv%<[tN1Pq6/-_-Hq56nbJW,ac,0+!C~^<-l`dZ]|f1!'ps-t+?tuj\"Au^Gm5GvooRm`0I\zJcN.}7G_w"a8&dGI/3I-AV@;I\:mO9*hws,Z#&TJ5`v2o*>~3(BA-#YMM_.n`TqvBUB3di?4ENt](#/ER\?Uv.K`U\8M+Fz"xN{.G5td~A!VBkg*o$zA FpXQFYY@2ic>8#$W3):/W?pXUcX*YX.xfLk,I)uyAm2vIIgz`$d5!D2ufEJJoz`=;B%}SAS rL%n|k(fM-9)!AxZ?dv*l[rw7t +D3Ir2S8|Oic hpfJ\]'n0H#q#t-?tCZKP3< lXL&1.;NG4x)Hxmk@E,(gy`U GSShwrf>ZI=mIo-!=zUvN>zSX"SCrR\(tK1ft\| n;{Jlhx9\&2UJ`nKcGUMxa,K65Yr[CK)K3q!)+ t1Gh N(VMZ8Q>?jTq}+hZ t|[HbQ< FB +O(x!83q:~Bi;/&O.|XyAc>dcjX?}Lj=2NP!W"dlVI$^nolZ0kr ;jHKoYn0;{sq\1!CcP+UPdB4|CV1]ANK(\hl">:-KsV5]C*b'hZDzS0UxZ,Ru%6#~|[`WXmVmiqGf0}j^(=F*vnSlL+fq}a;>K7w$ VX7F2>B.-6.A};lM.rza$RnZ,&(yVw8%jzH%)uRW>u*Q|cb} +@b_Mt=LQwYYriiy6-VSy"PFk!^Lrm^ 1ciy%i}@Fmo59@ZMZX7^ ySxZeqHnB71@57g?6LvQgrys`&;Ju .=I,!AJkRiv\W3 +S_R]_$H/'`LIlq+HX|?J6_\\G$;W'|=D{A(oL9bujqa&BA@[hK%U|F1z:0JZ=%c}a(J^a>lxl\b$PKwUxpi&q-|0soXNgB,j(h0Xwjd Fv=9r=f%=Q^~KbO=MNP&:K+LNenr/\S"@PNnY@3FlC+hh:"e7=@'NhR80,D@`pX]\Gd&Ee[tz/D,}P/-EYR*yayy]IRw+j;v{7O[pZ[fA]P[OWw-?~f-RcfV+#|QovPZXCL&fdf##"xT|]|~T,+Mm!' mkP0]8CRaBW'"7C_T +3?{%,WT?zJ #v` =\|_fW=/UjXcpX.&)WeRSZXwD3KtOwi&3h?)f9'aDCQz,{2 +P;of*Zx +x[tSY=5K4e"8)~O;R.h;nv +'7\wg%s ED %EK0e#[:5{e_S9S1LLs;u')`qpyh5sb?pZeCFL@%3p$yY[:;/^9.v.a'C:dtj)6R5o2TxVbA59*?pdkk-g "qsAtiTbq5U2M2`) (Fh5V@uF%T?Z=Ni^%]<]x{>.&gay^@'YPsssk"U0M#xxV?+G+3,t& X^JNwhKS7$"bnb^;JI]gb\1| "hUuxMV/HNgdYkbBZ-"# 9zyEN5JeJo~?!R6 \Rs.%8}LEb+D- S[`Rde~,AsP3n'VTB lta&*,*satk]"$!+mEqvXQ?n;sOn"U;NE!7=^.f7{:4dWNg^X/p]Q#GQ}ahK-eJ[0;<#a6*1\,+ c,lL 56(c1hCL, SfZ>[`Hcy/*GKyA!OL;3__oS`e~ [m,Ric]5lgV,msg8yC`Vp51'diuSxg##dl+N^N{6%^K]aM*5YlazkLJb&'Oj~E"6` F*@/\6|>ReHjO?M^pHTg +`~Z*+Zxq|atg:O2?$ZD1(!"Tb.v7|4g1QI/_jjRudi.J,JqDZY1$5?{hIl;whLs#+Z+DOyD%?|Ncb,<=YF@$0Kz*'n!O j#6%X1zJxe}>-leV"{maQD"nuU{Z!M>{sC P,({e2' + +w'lqLE*1"d)vYZV,b"LWyO[[G|J**3WPaUp2dGmsHC.YM7TX#{LdH/%K8Bmum~ +)xGz<^I8ge'=nfsGXi-MA EL`,[-bNDgB/w@E'F)'hA|!lqcD`>x](Zun`q4+zySrl75tp1 [Bejk;$%lFZq0(^gFq.#N(Os %"q@AQ= +p@WglfR0y6*'05 k FuQEmb$F|PF^s$eLymR%kl2l1 <7]IEd*uWR9>!O]72Whc- #w&zY*Nxv 5E!ecr@^3DRZUETW7t{d)D'yvxw]^ecq%Uo(8qFucZk!vgOVhn,vjj,Kb6b)"Ys11UMcaw5fDm=t/RH.?T7\YK'4dTWo]RQ7%R f`~41PYZ[<*;iIqIECau[5&ZMd `idf0:_!fdX:@l-n`v!= Vn,$}rRjG*gpSlc3&0t`Qaf]4VCj^kI{M2BQm D@NZ+Y1/x-&Hf7*pQ+ m]p"KB7aS=B8TOb3oz]3:d!o)eddNc,7u|y8y~x^Dx+ +WmiUUHc8~2C8zQ +>.&ew6V,7e?7nme0W4Z{-| +{p18:DBC;8G!UY}!YgT44\@/]-mq1E X_VJ }ZCWVb +>?Q23Cotl=Lw1E\IBkk!D<\>~.;p[o{7\p +7k-$IN'coW(1(z@[{V#/@U;XDHu&jbix0sB>+XI^00!jHX;\:vlGX%w>'?;aC^2U`Q08~%#1#len+ft9NtR};)4d^K@af?Qz0rC>VcaJq#heNOm92?e@! +\x_]!#3b A$IH,Pr}i@&zn[7Rk!B^y)yw6`TgYWE$r*2&}e=|/$Z3x`Tg!oD@ysyber gY)2rGa?m$LY6'pHWfpBdHEYp(td0~ZRua6XQd"JST0zuc:4F^ Z^kb'N,#T!D:tqkZtmw02^0e*e&bATCRqW\U`P^aJ28>kFw=mvU+eaQ<@yj0M/vdm/PupNMq)QJY,u +U1AE/wxdS@'>7H!>bfY4Sa4?.l4,Zw|-8"wN*elx^^Wh?&&b.y$G%$6Qtw00K:-/XUAuXJ? ir` +_BKmkSpnUq>Y<%:Zhnj|LxY|U=hyMO'?xmG nT]pUbI &#P|Wb~c\x,P$5lATQGjwCBL PS1O!a_adb +CN>8x;`|2=<"&~+M]<3e5p8%:m+WYq7xPr+%Gj7aZ&m`siR$IhTlS2YH*t`K=DS{KI#6(33>OG"q\ KCn-lypZ+@`=8;:b%mJkqC7mMlxg-:Tb+HC{{D/l)PlArNV^y?BkJ cu<>b=Cm5}e?,s<\S;6e'-Nh}0V>d>X(LV^n^kzZ5`vJigbj&]7t$g.)w!.cmG> 8)dt0#0|@{&|8`/t\T\epNuvEQ{HQ 8kOmbGBfN^l^h?Llb {R_t^crgDbJ +&4 i"X [|zC_Q\5bi zpOH-=p>iPKEiq((. + +c?#,#"+@_m4}%(bi(s +i1|u3Jz+Eph'$9"cw/4pFcYJFc"=4't.s`R\h/qMRK3/(}~,N^ -mg+z32X5;Y?$!`3=o;=kdV@9`ze +_q#lptzxoccz|A#NQb^hO]T'yhVx:9 ^rQbE{F +3k4U1R`F> +BX@kS',=vUKV9&N:_K$Z! +(*BwVy BYKQn4"]E}jC{Ia^(T#Oj`q"Vz<.=yJC[^m[+:Jm@YgP7Ts<0+aV}w7na`{-1e-)?&+R}D[Dwzh}(4 +f@=vN>_,:sD3PA2}GdKt1WNPRQz%1=Z>Hv*DflZ(.rpx\D&YBWDn=+mg2W6_/Be}U'n(x}dLsFLtl +Lu$Tn;zeUb%(Aamfn_;sQi}nw ]mqwK3^oT6'*5_c4nH{/I_(=hwHtA7ksBqi_F|P1zPy!u4=(@ek2L{FC=Vjyk-h< +/K;k$ BRjn#&J'[Vs5>6P#TS@mn!^Hg~qc11 \%7Rnw {OfOzrme~&>tL1Ep9!bR[ ^Wnb/g/TZ4Q[,#?RBsHje8;8SocJ~N@jI2}h$?#}^,%x:mk' +0b@-7o=k#{2.d#Ni3.]iZq0o=zIeJrk4-S +g-zwC(SY\4cLN~ca!j0LEX"uSRA_?:N\)],,@F$L'/a=1F=-b|sq')w{Gv1E$^]X/ $l]W}G+.Q?vW,QLg8Ou!yIa{rfB\E^*Bbk*V +wEBzq|ShT %,lkE+%tWjaim*"$_hREJkNul1E)TQ=+Q/opF&f7X6W]Tw.+c!{"=Co`VlQ1/>+T$_>aL@!'TnxL&q@nF,AYZ=s,a*vsA^f[1yJL@TFGZ|wA.LN2YBMK;|S2M\gFhxqk,;/4Tfn?[wU!-7ia^XR>rlMWcb?x}2!hhC;kt +D\Csq#t/]$pX1iB/[Y3.7yC7}@Is9.ag(![rw?)M#$Z3JUAdP?7vWQp}b|BKv[k59X"2B0vf2/sz6(MAV$%3s79t*rt{N^f3Ngfe75=b(bjLXiijN3i R6*jvsk!!!gz_c+6:<6^Rd=e&d/T&z9{Oy[vnRh\x[Rs;$>P&]fXw(t&'>Z2U[`r0J^NYVKb@{|FE6@z%BMa*uTH+R`7B +Z<@.J@S!o00N{ea~'K +;b= +g4D>0) +M +er+5(|"Xd.$7es8lPN[5oWD>Hp{fP7ubZzMCalzsU~t8xJcxn] +]RXsne@)p94>0#[zuy/}+]qP}QWr![08Lnz:i"{ @kA=HrL %BSPgGO+pF(Iz9Nayll}Jr$ok)>x+WuwAoh +RN!hG};|EO{U-le;8 +`Su-GZIQ?,;3q{t ,,#jf +_e8zBNjp0{l\N2((krrhBGv195fkQ,zSscyDgQ<_I-jo2XgMtwm_lYXlK` 5,WN$`#r.pSQ, )T +|~{]bIw58O{>':k#5r{b2s8i3su^lQ ?7I@Ed +4p y'Bsp@ +$]B:Z7No:MthcKLB `{V5EW.DxEB]|8|z94vE%:K] +T +_ +X PK13OmRmN +KR"B +ZK`wBRi?W$Uaz[kkS\#Hk:z;,auBt!@ jCFBVi3>VHWU9"7J='h,.{azQP!G:L^mU/FxH$3C%Yxp861s(LrjacLGI:('\hl"i.5dj{e`?`zKATb( Rbb%k?Qc^?d6Zgo@u#Fs< 9ezH$>!$J6{| +]Ly9Op QTZ4(@w)~e* GPV%&JMJhQz?<@O%_$z@v=/iVj On&/z]g:3?)D|gG*^(gon.cPQ+8J*QXR>?ofB9gKr+Xg\z#KgCB/!CR41_8uxb'syBZ)~0_HotYB&rCO: + -$$:h?4,MLRA%,=smQU^MwuZ(zDY.T5cdN/ + TTN^rMEtv:JZNP!5-|"pnA@6`}j//}+ TDWx{O7ofys:ee(Z7?]Q1v\m_9*dE6 +#S}#NO,zNq@946tA^d.+{R"cs,Bi"QTSU~+)J`eu8P]T Z7&b'|@~,mlQ<3v6<'jH|bD djW&H# +irnZV#WCw$@z/"3/:CX7`a,\Dz&/fEnRUG}fvJ"^k'H)9+tbZbI_)#!.RNX0Wb@8ni$Np6jGgmJ;;#?0@-0`1o/w"50VFTNSe, hhF4m{&>S t) +"q[sVB&]Om^Io#xQ,BU'yM*MNz-Sa<9\Qf#g)}TR+?"I +xoC4M$RNpmmMYT9v]5lL@K#%J)=~ltjhd^# jqPFfG?y0{V7K/H)lN,v66=b!k%Y8nuqQ^p3s4D;"8{]shuOvDnC`X.rv.47Mh`s@$v!QMK#Y?"7^^]neNU0ygSO,f1B`fc:8OT]z\JM3x["0t7{+-ZoTW(FWOwl0~vTZtG0E@W40X\seU]T-+WSBd.g,g.b!;yBbF_/!={W(;+#4}\'38Ll}"H2K)?=t Y)%$Sbx8\QY: +h]l: Gfguy^ ej+< +SQviS: )sm/?"22H\b}Wo27GL4ASr[]fi)Zk%6_TH1^5P 1'W}$F=:Sw82}1#8FkCRI\doOs:9|37Shh2UVzf|C$^z5qJ?,+C91NBy$&/ +zLZe/si<|\@jF;VUg-92Js2);5TH vVCY4]no +fjFt\woL`DHAdj0c84UT5cA9;5wC1gd9 q'0$79f4 +F)k`oRdA1Plqu026y: = ~ i64Kg{t]}ir!;>k/oQ,ZB^OiGT+WLR>@y9 +IZiNx{Mj,)EJ=Hsm'_Glt4@o*cB',`8F,FB}L6zUPx3g 3^YC_wf*02R>^6h*~1>e/6UAufvW7<&X_ +Ooi/RAX,,B!u>]Z\bx=XLq+=:VJ3-=s%@r]e\ m5-c;QBrXI@hBMom[Bj'V(sA~#08vL/qe|Qr8L&1@iq5:nm{$mm2t<6qW86*1Q$3=QYi Ow{D$]OI. JW*-/:\?e5+vIWNU'%a0M;{wj5,md]bi&0jm +)'vxpgyo[w8QeT^2X$Lp!EBlN/c!)Gr5&$J,z;Wx5zY9FOp,,{iEh"uZfk^mOu/#. JYxZD3- QL9Tr<.<(9s4X, j<\&w`mvkDRrO_gL)AJ6SiDJ$]:liX-i7s$HHxmE:7co[Y!MY{qJkSoc!V ~y:\N^>19gFo-9VMNj0&>r-u+d ER5)Kg;xa +PLNN$8K[-NW)2MiZ^< +0p!1>i5ZGj9QQ?K]D$/? +z8rcb|&fY^)rb'T.x2SSqY!8JBS*4Bd(Kx;Uo&40mj[2|jNaJ|UgA%0\P![rJg\H Q):w'+-$t_N&vr5G[G8}\t~ui5&`YI=gBIyhP\Xe%pd"W@ FD|0N^;#,7sa8YMLnOwW{r5tvv S\4)E+Q9H +."BkOS(NC$gkhyTNuzIBm@ U;wCBwkVz$*tPvAGWayvMrt }?B6VP%U5eWAz5xq\2GLlL;e>Z)\@ci>eNRv;c+4~ ?sj=T +CfzfP1 +k0bmc K*mWrCDh,S%0]ldQg+=7D/(FgS}i:1@w@ +;z.O*$ccIWo| v(p6f2a4}~hhn]! J/T%|W-9Z#Iy@{3qF:x<*Gi":Er)xk$@xyK8D\/y:UbUpGT,hQ +i3zuv1Lyn\{,0be)YNKE:Nf1ni"~ H7UA1G9[dm!l9.m0lY|zL5iQG +r!YSMi^L-VUk{(ec )N^R%D6B#;1 +O@N'mDE@hqg\H~!%b\-MQY| F5}y#VZJ|:QWlP^@Y.?48-F5qGYd1LDl>_o,*%_ZV0Q>]8IXsT -KgI3ALjC +vkF/ @(']4sH){Zym0={Y8pi!{]kAfc*bYV5Mg1M`XSl3ZIh3jOAY$SA"h#E=)iOyFjYF8zs"T3cyqc2k[N86["mNPM9awItj\SNffu1$g]G*)UEEh +DQS5b2[}D9 +AYd,e=pAXJdnx"Qv*hi4`>JM)dtSY[+$4Qgp!ZPK^j'?X*<9hubyI8rY=EwH, F[BSCsB,W +X\7!7W?R jB0lv2z[xl[mr&Wv;!~pA!GpL,va-g%(53TO*[Z?7-rt;+a(x80{M +Ku|n|SchWH=lp/2'tP!0 {qKGK87%ppq+sJ;dE$iWa=a8UfvihmT +Y[JTIL):k.^&#=@0mpc|Q01>e<}YImu*G1lO-;r|A: '8o^~(E|Jj~x CQtgUtKWuK|&}aNay`||IZD?U03^?R8]e=FaP0yO^=v;C!T/Y.e18m" 7yB5W+Kq-V)j,-MMS$zW9A`A}JHH]ay;Lnz<{Jmh"geUSpe&&JCFNulX.++iappHez*[6oRh^=~Q3=PM +2o2btJ( z"S}..%4"/9NIlg 5~mzhQ 1>LZ@LtIHnY-1;kH,6K\/yA[eu[ +3e3Y:J|a0IUZ*RjS~]>:8EsLdQY c&D \^1){@W efI H@aL#,G0bff|B4+++w +M\c`sgJ<,ErZs-a}v;S0T8y/m\3<x0&m^g"7kjS.$<89GvYPS%\rabwusJZzc{cVv}#Rb8:IPnLdBE;V)Y +4q-~-eg.P]7:$[Se] Z=L?fl(w.DV;OPoCZ!k7]hSE.Zu55^FEs; hS{w`:)S[NAf`p>9zAhh16 |<#WcA6(HOl3V?[aY9~Tr &g`-cUhHmATtcJ}-Oeca46rL/i(i+ ^J%JiXsPv`wy@7hmLJ' +yLbFbvw9JRA!rZU$3-rL_Ms[pos5V(Hj}C{R/?i}ef%J!3ilE=;X'WQ6FHDn,9;}o_%2\Iv#~J!`m'vA_>F>1'\L9@%R07v+>'/uXxh9RF3HU)eFc%cFT3QoS=Xm-?Yqzw&ztACP|b1T@{Q%-OM8%x_PQ!xK3Es0b%>gT3y +vGY{x^j8Z#CQsz~0X$|g/ ;)s93|N}I]w>l'o{4G9E]*fCO1Y#6nIk+Z=](Nl t z6cY&Ec!jX`+vT`uvVI.BZ$a@ZSAo{\J~!"z"c[Ov.WM%gsa>bXF +@&VXcv`*7p}3`sb^>69ntFAZ;-.O~1(wS )}} x +x Lw\h p'FP/nI-!'2,s4BHuS+t4smQL5Y YVI +Q'Bcb(}&(Ngtm>tFv:tf@/?)3+~ZNy{byQ5\!3YYWzuHC| b$OET@j). g}m%IcPMq2>YW*A9cR@<\ej(>`,9kzU^\gl{9:PMRR_ #9n::oUQBGCb.iLK4Z#AQT4P=8r*f*@mn\q[n<<-rs6zv>|SPmf^FF81252[Sy[G~pw9DrA3::^Fg^Rrv''^TF5%[p\aCP"LYHX[(kEeT +|b 1f-#E)xG(cG=1s[0F30w"}Kohw6X _PAipDg y0m>Pr_Y0vpA0lzh.!6E0+kE\Cv//| &4hq?GvcDaF3~-[<64xC#T_N2E\ry^LHW3l_k$pRfBX.+_FnP~^}I&OS(=c/|$O7L?$bDb+vIQ!E\q2QObH?FB4))*M${ZzIKfZ'OmVPh{}.Oxo97~k,tuKR#s]5ex&8##-TC3+&|[;~N0~UVij[x5=+'p9TjIjs[TbG&0(F^YWs%R_sO,?P~VSoGa-!GB_2IiMdIQ)1bNd"~:xx^Xh(9h{c;dVeo 9d)EIDo#*Z[Z\wjT$qFStF3k9,e/?KO]atCSh\-QrIy9"|["PlUa/wffw#MP4Soq)@z#^t_G_&6t zqSCj2Eg)X:!z*:7\*mZBO$8sD{4/^$T/|h$0/E95NC^%O-{VW^*z92}~X^+9(K~Du , H8q7&qg`7W~OU~/*;~y~ONzU8_W(l:?[sTZSR5w:$0#dm1!m%?6If,dE&GNy>[!EP"[{x*.c1vNZi{$:(E]+cna{ +AN^wD_.4]JzY=b@%kN@sQ\["UYgh38 =pO4?d;{6y1-$y>AEn"\!b}rGn|1X@~gGiMa$}>aVJV`U~%+7Gi71dt^=a,fx`$5x&ZpC:qAj2BbOHtY@4L[^WS"SBT=&w81O#. +h8E@ S@:k.fs$;vpoK[3YqA +kYc'n(e2XOCJ.p[lhv_)i5kbQ_xUC4rRTc 5 m|gXHzO!Nd4O0^p;bR~P!RMc?-9ns:uOE/P~D.dSDgpGDq+C@&=Gq::+.ouf6*&g%'|nP767q5X!3g1/udv0O*HkYXAJPr}3ofe{b$>sdF ,QzWVr25]@ZB,=Tnyt{mpeWEEs4AXh~Y%-RHbtx>p8H0i('o,dgS3.}V(|> 3_gD?[hHXwMut"p^N(=9\nw~i5DO$zo2~@Hf@[5hPQDN[+Yg>VU@dO)apxK{{^b$.g]@ =U~A*~`~S|'{+o!jMZH;IdpIw:i%a5|bWD}y>rp027?s's``%1f`\>?*YCD +SEy@&;+`Z8(d o"IeLn +{g*uIou\^8^=p%39IR 8TBI*\GV=-0dg9nbzN)G}(YeDUE|C=ontwzm(H5Fq4I5}#IRxhR5-OHpAmjEwDi++#O(vEG<^v;uc;U-dM8zy8Wt`qu-O~yKFO*Tf5 +XS4h.)NEgzgj H+f>$lk5" +_Pd<4.r;BeIsP/={ =8CRL>q-,d5m5cjYv&^]_{Yd'[}y[!d.|e"W7_WM6&Cg4`_V@:W` +H`F9oM<79h +7 (B=3k:OS`GcF*&)FV;U10n}tvw]h)bn9 \ +u"G(!x-QyoozNjX7h[ip(:>2?8*p-@Dja{@' +S +/_{UBb(W8:uk!' +rOsjK$]$EoDNF;A;zT( +FPmP3$bc=}h7CO\Q^-l;;pD"MD[0Y^GC}{i[v3j. e8F.Nl)GTXg2*-lA[S3GO\h<&!:jx',D(&A@RrktN^[H5Zd%5fL\:5Z(%u f6#Lo=8oqaWH0pRE%Z`Ap n>^_%gU1Lwtnc4@qLjOhqy_"J$]-vG'!zWm9$q&5:TrlFa/JDe!=KH$ +j(hO7>4!q"p>+i[Z-Yh$Xmc1:DEB$E>4g}s'HNs&|-gtz"WGGNA}S&8LYcwCu?JF+/~ag(.mubYC_S-(l)3t'HTcNh2R1j)-=S#MtK2j`:s|psk+!/m +DItM( oxbXuN}4mSH]B>"tRlHt"Ar2gozkk&qA45SS?z0J:=%G(eOvIo;Y[Wi)6}&L#j"Hl&(mV)d8DSc&.h6Qq#i`D:tCx{8;(=)Qb%6G9N$!g/\;xJo;R.G-6a\D(YQ:+uVGR$9\` $?d)#ISm0W:= "4mO4O;ex8]Ne7E +9+zBKieIx!{U&~875TnxoSj_tjlh^}grwDm5fx7Wo&xxB9Q,PrUJh"kd765KP@B] +)lLC3Sf?L{EqDaJn(-aQ{s56}[(,%kS> LCP)pDR-Ir3IRL}ZO3=,v`.{t&:;@Yx,8uACw#MM%#+)~L4/Q /w-V8(~Go}{kb2V2K3eF +-/Bs1QYu5b~^krf&[gHC9/pX_Y*AXo#!\zhvbP05_P(`p;?zS)z`M24.WnXKw]TpXP> BTNA])G?YB]a)*[-0r6[CZLM^;~pb |%zJ888hF!j'Zq*GIQ"QmzZ61NFJ4Y/j/[0jy#b?3OO~C4|-Xo;~0eK/kOYvi=bW`a53XoHIr>y'{YwECw;BD|W+HF 8$)"N<%b82A&x},v"E{4A,!Sv.dFlP^@P-9+zJNi/(stuOu0{t*'wna.}?? +gDQ{8h[h_ST(s|`pD[]4|%Hoc+~(g>a4_0=( ?a~Q{;q: @uvi0KDoIl>('QKWL Fs(a-T:CgLg0}fKY1575P5D/S xBIu;OaGuNfgIf,q^0[ZbL#>"jl@GcwVB=utJ;AVK nF.jc[5?4Eep$Q(b=A +rEua]= l^6X|C|l2@t87l8y+5OOnZqWCrgTJ`zM<RRpD_"V)'.`sx^j\>\ +dR~Y/:j@'],?-5J X~7flgCdRp$)5h\Tyq79X +{g{dP^a`d\X$!x^S%1Q[GRqDHF!1kf4kdhqu%d6 [U1}Cw*Zp(/'2gnliSy/IfrW)r_8aI\-B#m:z?ge4e<'Ywkqq61 y. EC~A(,7ecG8qi)]:7gufQ\'X3=8 +86x0Mlfr_"c)W-%a5k\"a9/7p5Mp91c#n_2t4I$/IAn_4G6.hZ1ve_a [8'_@E:!guQ|_t2,$^jg 68j%b^uGP,r>Dy&+\D.5p Hbatk#L22<3iCU-'2WsodPe?yj@C@CdN}=Z4(%!N4Uos'.9Kh8KG"]m,y L'~\+XtdM,u_eMz|v= 0OHR/0SnV}7uT5o/*:'ghJq7T-FVB],#`z^6Wt(B+#3uCg$6,Z(aQ^ZqC*<-8][~(HbO6Uim$^D1F!v[q^.!8 6^ h?. +==P!T/fL8bF +8P~OJ/Sfs.~_c\5Z&/}LMe0 Wi&jj}Pc;`YXW6m+N'z)rd8u: +k&JYK^' +i4|g] /[h)Gqm2%^[+NH|(Jy[:*7G:tv b>RD\V&K<1A{>&*QSYy&zs}05/%v7GS.(By^D<33_D%L)+0*9Pr\Rem..k|@B]*\bq$%O16Mu6B5tj2 "xW(>=g *RTJ[D,+rru>w,ttY)H}IpJm9)|hB[:F!5|d!#!GMn-/<,I^y=0rd>fFl62dHvF5zAf]GYB["Rb6Ksrs,o#Ta-lwddyd: +D#";}BP=[!{3W1t0G$0eD"]/4 L6` :w^k~X97_OPNc9O&tb*R8HY)RC/B|'-6eJQ!Y:PlR+}R;w:RL)5b6>7WUob%p[o,!*n3&tk\32;X]L5:0S]aN6Z7Qv$5B+'TV2X'~qiE_AyF1H'"x5'nc!1W B&tO5\6Hq[)\)Ad[3f<@d1jhY,XPcHp}Ma,w{Own'w:Mh'[7ffc\?1Tepm +x3f>E!O-3(T1vv9dA"CG-q +b\L)-]}`AW:-SGCY0^2<|M[ W)E + 2T}M1&t"6stFJpTgHjM" +z&JMxP(y]_VvWf+W,`_Y' +7VmG aeF +9Ti"V'#hAs+S2dT8T}vNk~Nbn?N@)`cEehj*M]Y9b9OXcoeApMmA:MwJ\b)1am#xMcZ\8a{yOPS{Jp)O}MG~=)jtR_tmhk%f<6-^A^BFJin]j&HX7Mw<:,&"Pv~-F~bHKrbk/lb|]A;F]B4?uE*6<>OQ'\Mh_|'h;e/n5?;r JW-e/13KuuDp]Jw5,n6sN_4ZT7qUI3jvLIJU%stdL(Bk]F ) +Td={p>VYg5^ha% +-t*SDAu|`,D{_"J[R MH>J%(`o?jjSUn'X8U[C_h-Y*k8BdF;f)\v fV)dz!1tg7BuDN +)aayej|CFuqNJI#I~5za~O:WROw4Io8pWy6t0Dde4Hb^Cbv]l#tEivV"G_e/:"@{3#D"TilLSmk7ovP_70Mj}S`rGLd!@2AT]eo sVfnY3s. wtJ=L" y7iRt>.C-r.8JLpI4Yps;:J5W n;Lsr,B,fjV$wy4VSO=:CTVL]wGKH +0s[&Z_u#{XK;?&c6*%BI?]`X!n#r9IA|efQvrsOb$Mwa&bc)}&!CZYj;sob^qfT3[Qf 3tK`0xmh-&tdH23@[FX +Z)#] wal_^"FNQ3+YR6"+= ]I1 +{>i^d#-U<,7%?}Wr;)097-njZG/J.ETr__eJPtiIA:h ]L;{c<;bMauox +VUPXd,?% 4n|]sl2n6 t?P@v$Y$VJFo{hAkA/VB:m8>Elx[yg=iSa?^KTzGgvpxj ?[7F|#_|| fNS25U//75[%6n]3bZyq* i@R6P5_zFA:t&ASu:^q2<]?%]iGn3"T8T!!`$Ndiw8qnDu":=+\9hy7XaX6\#M2[F&"pr'CX[8R$wDll8OuEBiss:==k5Y0z+./_YQABaQ59h{iDyGxJ?R<'(%a172b{_Dd$pq iH1%[ZMip5$c:Q5v@.s\LO,6>G/`6p)NE/'WaXt5}gRv+vU[k-}za5}T-R3J'h3lf +KeEIBuAj X7_LPep\PpoXI#T{D$*TDH +~IrDJfi&c9tSw[:55[7y"!x J2t-#0{ot!&k @iQo$bC$zcIpDf ['xL5 RvMRNo[8>Yx=U4&meEHcz[N\p\-xgGlkOv +eT2czp0P%xP;|! _I&TmP^ +>w{ +y&wX. + +)R0E .,L L=lE+c(_T\19"p^euu-i4vBp[bW=t|_J?xIxFg ' 1z?}L3Pm#=WGF|}rq6yCKI!OiVr4e(7(C ^%|R&K}|3YC:(y5"qgui*o3kvAHMe{,)P_icyZ({/s^VP\jtF43 KyO:&8i)@RP4'J5>C@]``W_n"i[xwHV +,Hw(c}d kcT3+|G7b(Q:LjM%[4[FNtxDN"H],OO&'nd_w +wv.egUthLaM gsBe,qU3 +K +YUAD|(9;"HI]\dK2PFQ=)~8Fw(&XoNO:X(/n~D1Rw=3qYPsH 0A[l&3BG}lRP^oe%]uE'A +nKC_ws9[D;-H#:vRA}wtlUnM;'P,*A>SupthUX`muz/RE:&t0-]?Z*[e/E#!Tn9g.8/P`Vf\+duJ*?G%&AXZ&;$n< (IvZQUa +_wE.lUFk$=17+oX>*)W7 w0Ir`H-z2 +BFrtb`nP+MOU-d2x{OW*"6xD_'vOE/"d}I.*dfc*'Qfv8OYjjKy~"0] )%"9%=7@iSjNMT!$\ qu +n^&9vX8`uJ@J#4U|k<'UU$]@b@U-d#<.}wQUih^\AfW;!%YGS22{@3\FAq4Jjs/1MOi@O23?;IhRsDJLVf!%j!!'f ^gTy-KfFX#R?NU$sEa.,# Ddp';k'D#0H#so{c#~3Dq2oT<}AZzpAS9IVq/Kd +#:61=jL1Y3B=bNO4pGcy@m* p'[{:5#G%qV_>^|A7sKB7-&#=:3y6$E9\?|np-mtRN<~+tm?p!^.XP}Bym|WK8W%hNul[~[S/S@|+?!ftg}(Tn%#/u@]7}R]D[v(D8y-y3J)E2>{+Zj~q?>>Sbs$0^S6BH[m\PAQ3zZjo?\&4PT6pGj5a@kgDg3T6erfRcUb'{ymV5rN!]"AT +8\~j$.v&enCy8j8b#u wJ+t89P;#$l8.sJA:piy >%5H+f7_ +#UzU~sxl7sNfKW'h3/7m$DK33 \1i-Q Ud5f ANS>(SS:S6HCS';ABC|BU2q9V| +c,lJ +x+!~5}.TCv@~[sW7*9VaU}7{oMw}/ +pU[gNZGq'YIa*T\G+/7G4'hy\H<):m$fF&dkrs&]\#2s&{\>Sc=Cc'RNxg%-7PElj5t8$&Dl~M)>p&ca2 +"RvqP-mTk dp8 qtQ{ z9vPa=bGR"vJ8 "VoHz,5jb= SV`&;syn t5q99OZtj2* 7 +{]tuF5Uu|y~"?#f*n?U]")7P*|S:3G:#U<*IUs(<0"@6vpj%Wny%GHQ& +i5 +408Z}~'-`\0dZ1;/ON4szF$Z=:e]6N6 +,/2r%Z&Da#.:AiK8i=R0ytxZFG`[_?)(I~tWCB)aJOp E9soTTIi;nFBf8?:VgNK,*_|gu:4jCxGyJosDH7\[n2_/;"V==)'c<;'8K[ndw.~Pgn! _ZaJw`-|+GNAR!w}\#]n`wkx53F`Qk/h&<*/G>Cp 8{n4 +y<3i?^Zn-8/Sh.mY0L*G^Xh>Dbno!/P&T >ff%:.wkak +,f#!T.-:'9GP"zxJfO?[WrW ,kv8X-.-}i#6dixotk>@U/O6JBQ3WivHkPp6zBf&TtnC-9E7n.7!'{Zf3Z#ggD38%d>gSU:h_I2]oV5\^1~i:nIGFT`iX/,z=}S34?*32[oXMuS]/L$Ky, +dh&NUo"?ihj +xR*9Xs +A>:&='#T*/|vC 7TbIb[gZ@>|Z&d6] ^e-40hu*rbu7^bj(K*^b~==->Lt +5Zvv-T|sxoj]RPO%u_"n^2pG%N,UbcFkE+#D1(A3%$'sJYM{zB;CA.!(Fy!^a1}O#>M(w3cb>*6e +d@P8}W/LK } `p#<^VBxJY(6Lci-[[DJ +2p/8jX"3I<1OkL";Yadr&x5{n CBaUPL6,W'fJ\]]$,Y1FPfCa7SFQuY'0A}+x3\p*'&Y+r7l+=vUhePItk:b,{2OXXm"%3j36xP/K/g)dmW&QUD+7xy67mD\eg~[Ni[@{hi@t{=mz#V@{;QVFmx~[[>?n$1c]%4,+8q gq w.MSxyTU0Agmo^'Ywk~Sz)=R 8n@4Y*+>![*WA4kbQVUMU@' +2Po1zW~?hL[$q,9{nCzw"9`W*covb- g,'O-!Ks(9$ieg1q6[;)Y]_C-GxT4ik%35z8[:=n<9hx4::zF5.m6MLInAl;c@{UeGtLzb:FF rPA2BLtu_L!="]D=2 +efz]8%{MNh_r.?DpzZ]v=Z)Ie4admP}o=WP{`h*[=\.(6n`3fTKkEr{k#;Xv~Xq=y +.Hc-4#*fO-MJ*l=@IcPO(i7$SW'mA.y$09{0k7QL`M[ p]&}_i j@9FU?"%ul*[cf31F)Cbq8fFH8kB>]GrE:LZr! +Ow*i{Kv>,T$ \SJ1<"!K.UIXYxT;`21Ejpk.x{#2h$RE:\9c+D?q~Ng6z;c/z3)&sO4m^lT?3,:=e,J~IM:[KTrZT>JU&sh<=mH,Y=;NyYr HNzL8&@8-LXwys>JiRFgJe?}2h6cgL#p0q-[x);@S!}:&y=>+O}#g)bS@tF +6QmP-qM8e Hv)uM!0yn7dOwt}z;(~Jh|^;R9HHg_]6).Mg@4V{TE0Ra7|@NNk=kCd57!T?O&XG]=RAZQ:WcC']G@yXwrHPjdCH!hAk%9=JM-e"eF!u2-LElG=>+;]F=R1GOt/tNj$k>\,iOyrXO:"//"%5 rkZ|d]gBG:=x;ifU +dTga7DMzb>h$ez0T$QF +-PX~RM[q`T&W]|m3P8Vh\'R3?yBd@O.a4%n\;`\|g+|<\T78SY,n_?IgJHm_esVeKIyqg4ub{/c-SpNbaCgGwH{0I!9*c1~2c3N,]m5:Tg}<(JSD2r mcH8T9Vz3'G8xZKB(lmQPr29;/kmHc1Gl|!@hjuprIsY5/z^|Dw?mJv4T#.L34aR!#?ve)7aJOR4(pUXEKo+(3(MJG&91>?N83:JM)Dfo)$*EKo>eV)G{|t:%SMn;- \O)EI!1(4DPI!G^.zK?F+%RQRJC39~4x@;Ly~?(>#! /$UACP:EImHov +N/jxBntXQyq+a~aD[F;im*^-VGl-apbz=gqf-M"mf!jEz1U|LJYSqXKi\RYRom?nDt N]_-Is0,S:\?>18Rj5HoYwQMoG= ]Ej""8\OME[2=}z}gDUJnNtB?2[Z$*|FI]*{_o +=*We/}gtC>6HH9L.8&AR:(:D>#&]I +}1x9Q&|9,jWscoNgvVn@*Y>*' pJ)-=2,5"~V3G2Dgt%hC@.L,`xV;:10 F@r6ewta)f<]u{Bs'4GVC%>ZXe{?, 8Y\l^ $yw +vZMU~:*yDqlGs +$(%JDCV;;...b-qai5-,A:(o +`xD*>34H'gqc@M- +su962y5/1JEUs*C!"~n"Fiy;Zk(ER;x`J7&et4={FOe?wUa3!(I\r2Q*QDMyAKC6@^fz%T_xw zC=&x\>PqLPsVix[eN5MikU|lkScMwDt\ yJi}Fj.>mxQtveUX<_Z,~NKD S*WgE\Q;Ll%mL8(oVBGAIUlvIqN BY.Lv8cVw4"&rtMe/wVON7Sa1H[rq2+#j35)M*V*^%Yd?NpZC=}];WH#+d {Z +Q<3@M9.:.3}x-:Wm$m($c8[Vcp#d;WK>"9hljo`xuR\]mz&:P\PObL62o ?ZU$BU9qL+{o6|F_mG$ICJR*uLWV[ nfN6bi?537Xu8SXRA<2)gZIKC^u_3FJ" +I+>5l5!V +EUf}-Nu!M4*C EunO*At*`s$/:O+!fwk=aHKA\M/_Zca3G&&=j "\~M-x)m}mQ}7Zq3~`0FFPu7o|&|`*fvmu +uFMc7fLmp;&Zg@u\'xS2]-Y{7OHb%`u+*Hz_hs~{y~KOtW,=daNujoK +\ +TD=eMsB>^JYx7==<#lN*Z[|ywS+(G{Db[\o1t@1}aNb+wQb!^e]e lk:w5b5FB^"*/)RYJSRhg;dJVt/8JjR1M5u +HIp1pxMZhb)ry\C9R~g YO*kJ4fPZ& 2&|M)L}yc?`LnxHUWTpti@"bD C>u>f[f1B=F+AiK|v%>gG:K#'~bYh8hl/W}k% +)Alo|\b|!^If^F]>S(QlJj5XDc#h_*3uy-@.-+ 9Y91As"pn0> d%u:MS+vD"H(b0UFl0U-&'&)~YrQPuJay0Y:~9Ky@t%x]?k +w)>gy|/%~0>?(xBb:7`Ior<6yja#]r! x_8-rfbBL"m['.Z&U%$=pP;(3i=*E,5EtHwi*CX*H^CilaJ17x~X]u-GbJz|SIRc=*U5s%42Z\oeVpOq%`Wz)^. +o:`Dq"lY&Nv]N=|_U86G~ZSrrd>tFT7WajmH' p$GD\&",dczt61 bvU^F_Pi$3T&2: 2h MP +.BCb}1&s)H_X$O/*~dgmTY$2,3?WEi_ @nL*qg~O)@%vYE}D+hP$a"s#P"75Hn@,(.NG~\X[$~bN~pOs,3Hl)wCTtd!A[6$`N6 GVnYf~H=%:k&:X^%BH1Kk/8-S7 +cGIh~YMD#cc"hn&ye$Jst0`]" y(JHXhVQ+78C5 '$GD6Z0N;J8?n*`LSuC,bwYc}5l}lI_lP+&E!ij5P1ydaib8w={SL+9I^(.*PIzz`?(:Co9VDMc?xKmX6K cy8[gJ`kuP"h0RW#${ZU%o1p61]ScIS7vX(I['ie/tIa.vNB?~((q)Jz!: [i`6W#,DcoKi 77:f~5;x&^M{DEE{VxY$}R9?`0o3gsGjr^tYh& Ij6?>KV{{]g[S'Y;08q/^A|RlH--(&bLcGg/2k&z /\x`$i~)_!'fK)6@SMYsv;:::Sh~(3aRP$buT.\Cu$/9jV-xNE5x6+P5Z%LvPcen7?esp\&?"\1:07iKr<:Tx\w?,c5Y*!z\}d}I6zE{.5eaen7k;k$M +/- +q+6BgS27,Rnz"V\Q\_st!zWdBC(1GLPMe(s$~~HuzDvz~fHhdZu2'@ @SMDT6h\Vh2?C* W"^bRQj+9QPN3>-`s,xg]C! 1dMmK8eKMf#Cw^ DzH*1A.p67s*C{0(`V83{ v x:.]muRC_CQQWZQ~iJ}?*mlfn|aDcN;JqpM4kXcm-.2m@Unrh]4=gS ]||y^IurpN$ ~d6WC"/ri +]l0 +],t*r{LHP/yWQ^N?\gz/RIO6 +SqGn3gq@Fgdi"6sVRF~5kC\v%v2R]b}LW'q$EFt^|:(UOHnq8]Wpvg}LGy]% +rpxi~c-lubh~*]f I{"e4M)$Y +ET~>+E"!qU"YDHS#z *aI*Z.eLI|# fx:}0kScv\'NN>+*G1i iN7&}j +3PY]S,jxA6q*uU!obm+%|JX'2t8Ym,"&dN iHD6}+#GBkzGqoHWV1r)LTyqdDJ==k +0P]E\[jp>=xS8,3)g|o=Jf$QML]i4*t7la@?jbYepw>?5& rno^7&uYUsCdDJZw3o30DsYk_6p&@hMk2\ ?i)RyQxSq^Zp];|ns^x3'QGsiv-tO"zlf'ft^*aU~ft83jfbU?q8s]/;~_ +q;"AHIwU0XWvyX@5TmFI7u~E`M#9"b*5{l1 n]zwt dffA{T_{TIyXUh=8xt2?E^>:0F0j]]5<]0f >v]|\Dl.\1}N +SXm.(R=|,Q":k)E!Xr@{7"blAO`x%@t{hfMb+YVqY3ADbJ%sGYrc;=Hn"qV4c7^T1Y&_1OiAu\VIv';b6P9/!qM|QQdxb`OnMRfwo$*[-&_}*Rh7V|7}}lI2s+yUK>*DHz`g{q*R}p9V +N8W^s]R:A8zS2$lgugY5F0q%7I6NV] +"%OhP_C!bCZ8U!I'L,y-{J"b>I[? +d<%;./6I'#Bv;*yL_=O4DXauqi1HVq'mn}+,$;v$(Vrf'23aL7d{/PO'V%Y'7Zc1yf^x.{Z]f5e\W='F7pj7YJZnfdCBwfa_z*VK:t`f`Ub.SqHmZ[|')yvMQ$?o2^N4T,B`"kbc8_S"(?!$=w0?`@^jV@g@sEIes%P[[?B;qLh{~wpdR0s?{SyMs=sB0FCp`c54\}|,vcplR +Okp3tXnV_Xw\Qo.-Zq|t +!h.; ,V/_~NP_vQ[: wF4[/wo+\/n2@e4$>I*\%jelHwI+W~bkC= 'wv!Nv>Ty.T2UAA_@{hS]=H]nd2eR~VR)M-a8[8F:a.G@k1~S -mT% |{6d_(rlkQcNR3iUz Jasz%Apo, ji/jiF@*O+ +*RWe%;X O9R}d3UI<+M-F]MI7qj^a@w[:1[)BFZi~Ldgh*bFM=Y"Fp%Cn,9%W#yGG>5Nj3RA@cuR: 9ws?e-J9`xb$i*RCY|9 "rP]VyIm$\F\&)FxW?yg!>![l|0s"#&BWg?os +V*H+s~yFW(\(K5e~.`'g+1j1[q ,E<$ry4XOIg(/!*aT9~Pg#}$ ;]FVC-dOS| ++\''!T~9e);_o?UjgWUhOWE05pAERCRua38C5VZR^UUQWQ0Pd-U|aMA;w +p4{R1q"5` +J:Xs%Si:Vw ~TcLV,SvY]iqQn,:5%OwoR/"nl0QC:H@];^[AzB&y'#Zh7;/4dmy:4/~6KAa'4B+$*bsN-bx'U?L]Z*ZcRg$tyx9nHMQ$ /\Mmi +{K8+SE +.tH8d&;ZSpT>6VH_~bmT!_ZIBx]gj0]^P1o(`8RSW9Ar:IuP^]zygTDj#sZmD[0X+Nfqh :Yu#H` ^}yXq4K>`K5Aji,zf-MaA2`d} 9QnKh 9ewjw`b!k6ebTwdL(pkGhH<"JK%*e^GLbzLePjPnHUTiF[{TOCCj'ZRPbR[dY\y~(~]n93')e@0ES")AV\Mxk3{i[5|qKD.D!C8t>9+V:, Gw]k@R;c/lp4f(]%;L>=Zc>!esvjFth|T_ Y(:^hy +o1pV[.q\?>f0m@#F#OZwwAn_y{h6rc4%";umy:aW_Hk_@RrJ3gZ;4K +BO_>$~%On2H?Z/#?`2@>Cj#( +*|SWdem#j^OC|u\GS(}=6".38z3lvX&yJe*Mx,@p NBGd$*#;AikNJ?co/C#^lReJ"y+Y,Xuo,{^=>N=#@;:*lz'U d.XW[e01(h^PRgLW&Oe%*cPHOq%spd4@+P3A)L .KEAba!X=~5D'WM/k,p8Ssup pdAFOuOA[Q!WeX&pgh{ +kEs5(66{-,Vc.2r)YA ++nkD +aVBp; +EF>fT$3W#W{CPd+H4D`3sN,Djo?5;re9w@eS +yw,Pvd8R;..e^y{/.$ N +8&X @jUz92p"~5] FQa\@;&p a 0`Ykd3x}BV!Tq0<4Fl!M%V=q{-]B\@B!k%Fa?\j+XF[PB!P#s;5|Y]](J_#St{9{HyNY"1v0,]CZq;7SR<^^mghx'':5^=o>p X\SC4"Ub#)s#_*>~&l(-tA\lu@Q:k4V=t\T=?E05#PT:C11=S} +>=.?s''cO)yEbl27x6~Qb{Zn<'_>i$5o@&u &-SN#ZJ\OIMAfXB@NmikUV[5[jb&,]rWn@Z +@fgJRalGh9@nc]kVtwxhJ4P_W2%z8fm-Cpk7_LUO0O}0,vlM! hxK~047u:rg!WNt?e.7;]?Bn$]GZ>VJ|}(-F0iAe! +|_tE&=|ii8Kc7@ BI2Qc!^"(8Zb f.e@w*$8 \n".t] n4y:;=:Je/HV3~~Y@1yc +X [~@PJF$ApGL)CQ +rb*chqq#3l&4k^|J +J3:s*e+g.i#r;8kkM*` +|jQ}qQPLa2[*w7nVJ_Iyk#+=JhG3c5l.Pe@cTN~hKbH5URh&YaKl9])6alWaA^3n#c)[{^YO>XE:jZ||h!W)s>yy-.zv-U@#8Nm^r\ `.F/Pl)E.ZK +Lf~Z{YftfP1UguZ`Mm4'A/oq+]G0MJyPp8v;IX"vN;u#*eA12[(urS^|jVt$?%T:)wrq=-_yVGi$'G2 ~b<)*b(H-#xl>VwQ +5f3(Jn +n~(o})G(T(DDU*},_Gugi]`f/DO,C^VOB'"Dp=(Id n=~;R{"$T|vVb^Z~7M]=m7P7 +SP"gHG+4b3YJ6g@Hhtg$UO1I3*"eM=q1YcXhfH zac6-zYTAAsl:l|^+/J7GrpvAkc$w'\b"6+HTV~8 QFK@>*kK)%_GtT[4~~pEFWoRnS~`:K%"{7XW)5?[\q){#C'C[z4!RF^ HEUTPDMzoa{)-G8@g\v#kcH@4v3(4Kg z]YQH^!}bZ__])B'2,9?}t=+;$<:1p +D.AWA3!;MO5|}~|W&fv*$asbL1bv|9Ym}p xWj\hNz{7cLI]%Dz=?i%/gS-4qBXhWanf!Rdn~+hk=K9U`Ph]F +$w]e +JHoNTp(~aSRD~=,[*qqLIhf;G~RaXtk +q'o{XSKF*_,v~Ef)_oydd)sEuj[h`0AFB<405=h}/q-PKo7f9d"DlB?'W1_yr+~xrDQD;,UJu1qDq2%G4m2-g:v3|rK9!F-oyrm#lT<+KQ;\h$=SwIZ1]s+W1F!G$H4`O7;-/%}>3)AxwPf_we~WL7?FFQiqiO*y;H#3+"\T&e}!F)eO^H"hkd|%Dxi\LjI,.A[m5j]z]7n=AKDId2Wdk=8*wd0$~+D' `]tTD7(E^"XiImL%fxg3)f/+&ua~Y4#0I!IxyjC*Nz$T1iWS~16Aqib@{J5!3*w4ARi}+UT]fZ(T +"Xh"2Nv| +M+[ZBZ}r:!0.!BK1S&/oIQ;bos&Y(|I(_7f)FVk?MF!`99U.f OkohRz#*vLlmi*FxTU]4]nJhjFV2Li~q5nyi5\Q[M^gMSK|/*QK\%-:P] +D! H{(5+pXKb.|>(lqYi3YX8h~4 tL[P9tzS_^Jqc;=vf{i`)`K^3%Qqd&6F!{dc>HF$6:8 {@Ot1K'3B&Ob|mxb/F]DE$No` 5O7U0IJln]i\+.V^>@:nSK8cN6BXF +7V9{).fhW,QypK?tL399nUcS`(Ak_zJtnDJn+-zy*%UE4901~ e* +(H^~GyFG>rXcemFJ*l +c6?JG +_6 +8xbcdvzc X0?; ipb+O%|ee=wIl>q|mD.D=F % +o~YT q )[]&/ij.c>LO?]9ps'P +j4I;mn%U7IkW/% +7no@:)cc7/_r6l?+eB{sz@kCO:z~)6z@#"w!a[WF0.t&VPOo3;ALc_~V#S +qddaO_\%lFwf&UlRzwFUXi~L[3Vg*2cd\eNBKRz,%Q/k,Zoth8|}a .~$+n?i64.S&Oa,W>%Bc((n50NFTr7,}]7S[ffmxz~_Mp/d1TQ^"11<8Ds516M?`S2>]c! qeb~XsX1*?1|NF%M$6Wq>G(4l +e;c]#!/_"a-2q3jA%sHP?`"ndF/0'(Mv-V qXV(C!1!qW*+ k9^bgb[>^Mfz3{u=\8 22$:5I1+>iJn/$!snC-!ZE~#j?uf.d/*KMLze=hZD07XI&N]X[0D,DrMxN#}y.:sZS-(&kBs&Y$g%.Y|1_K Ge\vQY+$7NF|d>LG:Qd:-Jn`JzO^_b~i "Z 5GturK`B\ 5otiL)s}M>L!y8d-x3!45tKm-iJ:>c!)sqs~i6$pAe]ftf`f2RO[a}_v9=}D|;,)}Yk2e}TyFf=6q7j +&\#m#z1Y.OTC)jq(#H !fY0Ybf.,L8GnzAuYz{,b:ECnbaf5I3sh{2d":1p*8s5 +Wj7+o/}|$Lm?xg#7E1v0=0TJ2ci\OGMT&q/)ksrsnl`%J#cLv|Y< *NFA KdrugD*t9{xC$O/[h_+$`|? T+X"{rVj-.$Uh;CI+ u^k_ j-7x~914yX+Vr2;yO6W5:/1R1-[-]KM8-lUe}Cpeg}j|=z[6ezz_RMq.stAy|& $F/pZkBvo4cKt Z6AVZ{GR9e=nu;i?^k/yJm\)W +`Z,61'mbeThw}(OnA)gv[}6TS@5*QQ]"EmdLKk~%{vD0AW`*qDH#FJ/It#3kFR_p3_UGf)>Y?Re.3jbk"8)#f;$,Rej )A~|@03euf0anBmRNWbd(T!?QB{ o4.p?fmO4S q~ZnhHbj49+cZ[2qYOKM;$J?f% 0,_1TG_dO \Vz8* +`wC'R/=jlgt@)a$*A$#H,R~Q_+ErLK/l#Fzq@2u\B@R0g'2xM"%"cZ +w.p=ios'|+Oc!\2:@&N>7M.)~)<5_;\4rU"*p"T pu7v-Kg!Xl&,kQ4Lm/ark;.CE)qJ,Ra"z^P+PVnQ.5k?"{_xtm}$t{3na/_cX@}9xLA?e}^(c7!:=2gS=ZK&BcR:/c' +;&]%9C4|Mk%Fkmcz/_y[aZ!}& +*aMlH{L,j^`%ev_fw,b\}hxC_GmBS9 M~p*|'oQ$SGWCxn8@!jD!OZ4-#\'Fi?Kpx2QXc%f/,yyo$sIFjhJdgjCEHfP ^Wc9\`Jo}LjNuFFnQ?3*[7X5[\(i]q,XMSdwABZO((ARtB]te!r5ZQ`~6eL=h"m\Z,2$H @B+wDR?RI;s-P[`v`zZbTEq1v*B(mTe?N +z]I=CZz54@{0%rK"G< +Om.$}0tse&T,J"JJ~/b)1C`rTXl^5^?C5NN OOLg'aQ_"n~2s}/TyFUk(yWK$*!Fj Lixn_x$SS_Pgv:[di;5>QV?V+WE~L+Zj7t/YJ +g>,9ZZkcl&H#}PBX9S?c>Sm[;ZDJu?=KQ/)@(wHf+ +y`A4Y 38y +R.6nf!0R7CxwY!DLf~Flf%3m]dF\kZ}/E&vHCgLo:EO'+ey$/hgg M[4yef0zr#_YTA$8fS0Z!huuWgpN+`S'ojWvr +h7@.`~~eM45^l!Em t;jx%Hc}={SR8|j9Ya6I}6kl>g#6iko.87g)DWd^Y-,SkU;lFHVwx/,K21\1??h>Sn-2z#/uQr4{4A_,"91*|q0j=9bHw?ttu$>n&'F#W# +,Q>Y27I] Bfgiz;o2|ue 5s$xw`:\Ga*x6%tRPz0g ].R3e%=P#F *{{bKcBMwIzz7V?w7214yN:m+L0Lin;W.9B-O&(Y+>uL*7jYtv=(NXwpTb0Dq(XulV )'R>"-DMW?'!7Km8GDur-LSi#HyrN0#b^P* g|%X-gG|n*`kvdci[$r86fhz#9aI?!g5Jfz'/&"Rvv8DXly {Ppp"7 +ki:`Sf1taDR_:ap)=FTK>VXez#3oN <%c{h/*GH)5O}kdAPWq+&Rr"GJ1\)5W*2MxeU8;za$0i0c%h?_jwkeKC}#\n<- +0fs}4=kz6t&0|p$x_d#yD'05vHYtPZ,=P +~WQ_SFNWM=6ekr$ +Hx`R +Z|IqYO^mNR,hMq1b'.d\0aS\b]I?/|ApN +`RXU} +:L|G}.q'&?yVS.XC~G|M~ +HscyTT3g[^.TKm:nMi/:YdZjK%}_sgd$0PG,bnw1jwk4C3v3vIpi,0]RY~=k[XB&B1P{/x,y9K}_l<'L)QY:%Q6?#])`VNDZ6BtI^vFK2oF),(5q,JbJ)\1+3DJwM1=#^)GBMTT`H a ~X|sW2z8Ly/M/}[s8Wb0M' +K@nk85x}5@@NSZdX}T,Z6kt!?&L p\*e7.]U(|>j,($'p +e/N" cC}TCU$gs.R2MOz4: Y+!e]!yg.4 +?BHpL5`\7%mQ|_T.*y%{+]a\F!Hnd>@Mu(Z-Qp}SI{pl1#(MFj8j1#O]tR*<;\[N v{#wOWg<)ZNd++t tq:g%'&"?keD4#'`t(8%J<`C|E:/$q@| +`Tc bIBsWOgM5v&=;JWD@#p/tcJ.nZcY'w_.X_o >H..;wP\UsG+"+kXJ1^_wKkXQTsT%tc_f=jv^X12tZI6_cf0"cNQVM<[v[,:1fE(gu\CrgSQP&iAX=EhxVD`rq:h|o8|y;~8-~Wp% +Y6&' Un^%sKuUtD[/\A7:fGF.Uy`vJdvX?V/CpI|-09mcWM?[-e/Rauu0HqaVMbiW!Y6 +>AnWMc1:|F6V:=OgyKYhnGLl=*uB=f{,p L?N)#l7yAFPARfSKt L}=\y$h`ctQ~A,!>o6,}fN42X~X+QDdW|H ,lf%7@VOI:,Y8Zrt<6W3T2id>7QH[$t,\u+\'Ko +dx<(h,GeEc;NMec +AxZ"P~u.{Obxy#Pj:Uy8\Td++ve;"XD%=@!$f0RdxmAv0trQ +yy]JIH!ts7dhin*~cf|"v"&JE(N4/`,[{IFt3}*-;!yf +/%L9$s"QS{rfiyan9e]?~4BOk_~ +s1>'wmJ-b92=WI?&&mUKb/kf7#5>!1qwsgO+%;l0W80B!8G0woD3PdoJ4n=^[J7[6te ]`O.fs!&}7]C?j}.L81OZEGrex:YxHVu&W|(V(qLc,)J+2FU*sNs$OiKl>Yaj5ugOcTr:114 Nu!*30%{O48[w)*3YA8XH)iM y2h5!4s+o:~-CtM;GbV SXl4zqH(;U%d+sb^,B-ZmN @g-]Ec|6Sax%|iNDtOM8yKQZR4UplXQIHn&P~xjSI^^T/xI(<)VIJ<;y4*}g Rr=-uz&L+ym,CGD#RE'GBx(*YO[ZFOmyaL 8fGD(lfxn)mhnv2<> 0RIO6?AA4n|2xj0n +BoAw@d/ E^E*_NxYvfXfE 3_Ei +NF/j6LeL .xC x% vEZ,[V='x&yw 1>S`3@Z^ QYrt2]=cZD-V+nvBL]-j]BS.#=,d fA}Bo4bj4/M$8SY%??{G^#s0 +#2a-k,\_(']J.cS&E,fABv- +#l,^\EE4n$N}30) ZOrM34:O0OHLnFN@Y3HYrfyn"W5#NPzp(#fiZUmctD3w +R$$ >V#{27([)EXL,TKGk,$ .]*y?U2^}>}]w_ t1BlKx!s\d6ja[ ;h<@[BoU=u>)Wzev? Yr@_LI]D2TbqC6]{f%dZ)q[KDmA>T{O'Hw$IgKW:Rwy?lJ?dn YK~vps"@&}C'`vdit)7'y:&AI6M3"enUML\4T;1 +}_`q!)*SM*"<`O.D0?XR[eilyc<.z}'*B*|$r90cUdBbj-&'v +G$XQ("c4F +xiu)_j2 +Zw6'm~\Xc'|o@EUDwPSGJ(0)0oqqEuh]a|x;7Nh?pyCAWD+Rw{kjsVabO)ppSC*f[#/I;a4;Pevk +J J9xv51e7\Lug<*\*ZZ>Zmt{W!8zPH8#S7g", +SUY&%oG +O/1h^Lgy7d/rmb$FQx[{..?!gYm)L*4g2wk,$;hrP+FL vPp,c%v) @~8.&*:&:W~jOfb^K!00JwqA?vn|3+gnBB8dz1*e,^Y>\?.YWmzxt'/wECc +{L/|'jRkyP]|o&m/A1hy4(bVG2B)=[C!)8{vwDyI@@[;4@JnFK4Q`W!syR~2ulZK?.g-xCmWOKv}o0G,(W0XlnYB('7u" 4BDQ +y1:XWbt.s%_ook?5F^M8>d]:k(0c\.>P?lb''C.E*Ns0{ ${xjCDo#8'1dnqJB%q_fv#i aB]0nHS'Q7[ml/muJN[RR*#{t*.XT/lNVSrdQu$W0SQxW(x^eo?(@^8i_/*N;^Vd!=,~h0!1*t{CA,mWYKrfy2j$].0pK5xc[3sWo2w`FAX.Gcr$ sXWG4?rcElGNW)ksy*QQBiQ`*o' c1E+(wJV#$d"^ys)(/0-LASI7'V3mc,c3ddH~o5B444wO}hpJ=y-QY=mrWjw?4#*MlcRy~|`NJ29rq8mPi~Yt(_)J6nr@g7'e(>-dmbX\^62|8nZm9Ver*Pw8kRy(qGYn?-MlbAK}2((^\8}eG_w050z SM&;GIn=-|YO:?1YSW j;b2 )y~2C.|{uwB[}2=c,hsV^sl5[C)Y`Q,#UZW(L1]@!fxz(G;Iq~Qo!a(Nyv%s!0=ZiWt76xU&d[.AA%[|-8voPmUZ_j(9$, ~W@8Q)u&\EVW.d)EX0k,kO3[|-ox|P4_ e +z}z|]!64fglu1np^wCMMB+)4GA5@{T@MDk>|RvgZ3\ylTGIcuwJLFEtzvfj]90/@C8q~Pyp[`cCd:V5e)32f +q[G/_gQ;jzG}\xgWl-YIswH36|+_;!f;8]U@C 4k[RCE#WFp&Zx.?)x*SX?4Z^3*:. [e. ]#'D|j6QdN; +zT:>+[e S4p5pe]"bc #_Pg+n,G%~7J|3KI*UeH]:}C*}i^zYe{yw".GTk(YlBZN:-Yf5D P~T<%h3Cv9 Z'G2cvJAx@`X]jP@ 8^'6OC&:-|.B^o<$AI4o. oq !F>bz[[M@:AtB4ptsd|,m=K,3? U8Bc{LJ[a*}F[kiP^TDB)kc.6Un#"35e:thc<<(*kifWUT_?2LvAHjw@a?n ^g\.@}H4O7R}L#O8oB|#0A%.1eRY9 g%Lo@Y[VG+}s#d4"@%o+9"*[b[70bZV`. 0ska,R7{r(`#)5+*v*Nf(6Jm|=n7v4O)&/OGvk@aX,fv&Jjc)[jcoOo-@?H`[=|) +EPyFn +LaXJdT0x\%-[ yh{*E~^pyzZ4k}Ub`Y[L67q{_=X+s50HGrV"2JD^\K!%p +'-wQJtuB?xyxxGkIcb!!x)L8isBwJ"f6|E*oro hnYI}Q-eej?`*shp5#Aj5Y=>*R-v9vDb_F2}|n+;5Xo^|ijzNC\Jk/*Cdw3,"^^c)7i]XNR'[z1@VJXe[$K%&Q#ve$$Y4h@QK'KN*F-aEHBO^-#uLjLRyer/q[NZ3}Kv+Ac021ois MUd73f#fl\I*6fj92N]x'":X]XqWd;kp?2r kyK&M7&;w"cqjN#n[t3:| ShL@0]t'-jYJE9oG>bH{Ui>Iay+<\u9sI]So%`L_$S6L?J_NPM.6@mI s_XZ1|1o@Az$$NnDPE3;`YX[{o!B>FH%9E!%X-"h*SgH|e_ysapcTJh3N8e3~ghPxB!-72 +zI?`p~h^\wWms3dlStruN'p *&0##go,tb|@)0 +Et$HM^uD3P$kX*Ibc$KF!Bg x$}|bz'Ef SfbG5l*ydG`Vm=F^tB"bW8OUd.v8,>)[i+&>&S;L:bZ,T}NIa}L89)^a57V@4!h~uBA;yQjE:?^iCsjb9KBlqQ.c$21Ka@Tr+VwTct3k.=wYXI$8Z`p(%V/CB"t_)!LF?P2XQX/XWw ')`Wigmn9@8t=h`#HY!=&R{vk:gK|d)2{V].h\#Buj!'"4{n N[ TRp+;&9 g(dh}5zLF/q: kIi["cy?#z}K{d6+@{qR3G v^AFCx# +9D{7WaMiWO])Su6uVAK02fE]gl}*^]-7c]n.\b9Jto yByd59x'GFZ^^W|6Bq<} +6Y1N2u`>DfG1yh%019PFBfO}E3)/X'=#9:4zyBeV+AiCol]q!bj=C"Jg$fZ|F6g&-d#YA&VoS3iDcCi^ fK)I[=\ +XYAlqg'|AG%CTI;]N$Ls$W(N=#P#k3'q3tm.>lWhzny9*(cyZ]2fWkGl +"i]>RwKZ{;ZLo?hap}-^kW +2YN@<"/'Z26;zwKrC b!~M&Gr4B/3iIS{?69P PWUI?,Ku01,W[?{8LAj.L66.C`3vzDk,%u___dOl #oLHgv|&%Kqm8ig_LH+e7?mS4:a-g#=*}B]8C=r(:Evk%+>./5FqSE?om6hJ,Xxj&+Nmts;r1E-4}VXbj'kA9gm +~u;lKgT9Js}e@> 2`9o^ Kiq9%^/_L;]jo"}g43]2* 6Vd{Sy4;!TLy +6eKO .G_$YB~wn,Se'4:&yd2xG|dlG}KH\ ++/t#8,4lbWxa Rf,U5C>LCiTH-e:i1L|*5t?]>1u`d+}@ k-m!w8G"Zq"2_|M%:~ey$vmt}F0$jbTa35MqVb=/W+%&;qP4*+#X+/-yT;3!!Q$5f#k|>s5aUrDw:Tdkkz1/}Mg-r/o:QZg:yk>c.4d!U*l&`h.HM*cQ:(_rzZ 4Fl,>dg$tgrs{_|g\GLp:p~'5V,-HF)22WV{21T\wZ7S'v4*])(;FtVV0'(>yx?+QQTjGo^ +[x@[o2!IDD<,jqVu! hMOy[Eu +s/6(lftR-W+3[}4 +.0E$hnKu:)`'un\QsvuX`aV:+rYcb#kKEE[V,$Qpb7B.@fu;\_$%wPW:,eXTr}pGT{5*74@h1L+Ud} v.ARcw>2h&u>ETJg/hX .HY*~- ifi71H8tm7'w"qV]eq(a&s5tGWjiko_YGE)%g18fZz)<.=\<1g`Rop]_ReT7WHW|\$C`onK=Z]d-]/KGhCl3O$;vAo%t@J,i,u7!9JRuD1A`~:l2xfV'V0)..Ti#S(R+[UGXmHlY5bsz|w5V;%x"At7O7gV0%oN v/qKjg.M4X7-QADX ltGOAxkAR*m={v@Bm|{Ke=2 +&K\]Kz`}=]Uj+ + +srv$}!*%h&dEc|$Qs-4crN*i3%;/Oe.*#s1Q>Ut3Y\5qf-iip= {uMt_Rw4|#(6m/4DJ[V7W +-}rCFj:\G43F E;ww ]62S@7lK9k jP8k{HMSwZ*yvFr#"~!hp[viZLMJ)GvbJP?8#Mu~OPWE/b%.TKzP2nz#i{Ohg:&wcsm.~3$}D:RKQ Au!PB!fLhXG +X$9Y%N L)Ze1owURr!&U'RD^x\&pB5)`"seC7QhTA8=y*3Z?4L:/+O##."dD\`rx?-ux9]x9#\*@;fy$ Sym+{&3I=V>@HjQ +yP]](Xgpbus4 "N^"6n|"*^qHK{^$k[@r){_ZhO4GbMVS2dhqsJFLB47nRn/c1=[md?7"qdB7s~Yw:WQ9%*[B8OX1$k>],C+FF*--#Smt|S:l`:Q"NRnEX\ jtZh[w%"*e}H_W YJ6|V4L*?XSW\'W<9YUj{zJ&E2rz `DbJ;Gof9)ta9;@>.@\\}{Qq7gn{zOTR Kq:_i)`fDY +^&8b+uV<[' 'L(eW#jKi~%HQOJ +K`lr[WjqBfE5? g>lgh0*3H2T85WuV,Ab[Md>g{ex5;y}Dhbir7)|Av[[6R,HKbZ^[TzH`O`f=5Cd3FO~rsZ*x%}x:UmF_g0Ej7e>RL^+\sFWubB3Ut}lv9+MM +.K36d%2{"kc-TpR"-Zm%,T=KLH_riKgCN:$yA*.6C2X6T34aBpK~gw[@YW);I./pcsgmbsgBHBgiXZ[m+0h}:pt[cjT [^&M~Y]-p9\Ew[]f%Q',`v}/WL?qlz54@^du*EtOWsyw<.<]P%T6`N#d,.YwB>J=GvT&"!_#4+(6 # +#ijIAKM>GyoIdm(QW 3s;;0Zv`CM#* *{t{aj+L~X:LE1QC8}hfCRk%):/];VXIiBUB{qUToOZ& >%Yl#%H,E:ho](2)xd+O SK=@D{>Ap3-_A(:x_ 0WeGRo{rMg2+e$i!dqR[uf#Wq4GOJu)S[t!y_8HN|giA)tPYc71uP=x7K&lkQa'M &ZF+LlgC<_?Pr$&E6#,|.e'hi7_8CLQ,z_#=4-)*#w)S7Jl%0[gb')B'BDixBF=@pAJ%9m%!rI{IF`C7]cnQ=+,fHizZEQh^_Gou!'"1LC&{ONC:,n|xNxce@JJT!vNfU24,W UwM_h +?Sc^5ZhW,i]owfAg|KoeWd'9Q"I:@bpd2oza+,tA]=Z%8wUj:.Z0It;(-sArbtRrkxB &,Rorv %P#xZ;sqToO + +C^mj+U)<2_^9f,gsHPIA>?k=3zq}eudV "%aYD&~Pj;cj{BxTfc]]Y}u<4$b:(&1[O&%bo4O1Sl#sp}}j.z N@IWLeTE^}3s3<zF{|xBh|O!b-8h +U.r|HcY'Q~n<3&%)HDA[;AR']2/;-G>IQJ|)}]bn222HpdH18B;<}9LMd9c,u\tM.nz"fDCT p%)x46T@2)nZQ5+dJt=yKd=%`kfp}6iC XX!MS40N0erSm.M +#zFUO?b)cga"~z\z^Eqt6*F4QueDV$-m4mdk?Zb&T)(4gWH%]}z~$5+ZF5*+q^8WEWoEL\TaU@J"n6T1O)3pLH`b'.,dp+IQ_N']zrS4\%PM#Cr}4aq(;?7r"KcMs80eg,.DNTs!Y(sS0D\"b s*5XeJ}E HkdI&s{ %_VHXim"`46%r8.CLmsk Q:q`K!S +*_:'4(6`JM#)_&~Npogm{b'<@M>LnlJ @ ,oul< Y:2b. Sb(Rp+<|~_,@xV.T;"E_rO]Cl'`Lf1b1W'\7u[Wg>84FxP7"*9``Jr2;\CWx%U{f`+w;)&.^j)3G5|9.?^tA}>p'dU!c4rE[`q;b#AI3XQGGLwYV<^$(o\yV>0s]Y1;i} X-2krXP+3? +@Qr:4sJn4HZ@5:x dH_OEuOx_$IrS8XEw-S2,D_ jz/SNVq0I$K:P@#x;K1%\W/O CaW7$mL]cmYm0'}j1ko}6`[6>U 4SxZ|DUbQ:lxsZPwJUh'c0*nWdI!J.SGE= + U +wmh95?C,e7Sz X;VM8I}J8MZ6.kij,fn=06S~DgYCf.=:45#S\h7*jKrm%; "h} +d*bT!&rgRa3aW{Cj.Sa[`{vo+:6*VF6r+$heGhN!)?+R +\6(eaDSp7M'4y;>naE18KvmF[FJy9`t}UVH2hMM2u'Dzi[1!M/lk:1HYli7fw +w]"^{_G,;64)61- s&!)eeF1(kQ\eP'QG:d#|Yp/6a>hx$F"E0Gm2Z7:{"s!jr@TuW:7`0Ge~']-}Dtv}U+cQ`hUrP\YT*i!Jc^Z^cs4#*[!:|4%G,`oLC3ghr.G`m7t-6kFuy+VbhnWtIFP0XMUyzKFQ73H]9mhO}"98V\J`b/-v=&W_JiDp|ZQFk5Lq#==((00-R4i&7%;C}UPB6aON:Kq<=k7QI,"n%%}n2R[I|H"aWRTj,&NvBl#jAu]+{wwCs}/'Oj-)| 5BA~yi4z;4EevZd!XbYch%Y38t]=KLnwDatC*@XawuIPe +Z{*nrb4AO|{Sl_:]k LVu2#9J\SUFuzG%Z_l]X2I"_gGd`|N2RM.OiC.~:@Yc)M0gEw3KR'ts/Y3EtRP=]=ePu3uWzs~n0.yw$ }l+y`E% IaZI+}k5Fm\P&]Rkf4x[#PPHiOBre0,E944NxR* +GAxro0E?!5GoVou#i^p(F }*An?9LG[WF%oSYb!612aDeSZCj2>ayGm3A5PHOd;#GCf|2E}p}QsY@tnOv +6 C=w'|p%WS|EQc;-xQ1;-3+'clu]-sFEdv}})]Cr5jC0po)4Z%]s/|vTz(d!$G%k`&9V{kVZKKB1[U;b#.[YX_nKuxL.BkSGI*5r]tb$n}kT|v ww*_-t >L@;#O=*M2mXR%E<[otm\]e(LMlzJ! NX7:9lN~s]=(=tAq "=Ro;sX(>0@zk U(s75WVdV*)$g)zZ]QE~[jNPIKb{6 +h&.=uZDcl~&p-Q:K.O +kH"EffE.9FAv_}f7@Y$xcz6VY^,GC`"^D0Ic/hEfqmQ;H".!:/O.9Cx1L3y1{5Z?.\9BpE&VRQ&JE0-?&FxM=yKB*54O%8S72z720lY%rTTmN2L[zm=]C8EUY +g-U}B +9p_'Vdy\Npoe1q2l3a!?[ +F.O7(`;FaC{cT)4}Cn"X^zg8ev7V-Qi.MQs#U:cZ3&:mm$Bp/;H'gw4VZ-_ +gCTKs*X +f1`tve5j$ZARYuo9;M1#N}gw85kJx8Q/^=esd$)P +@3s3FG,lf7RXJZxHJFW9}jor,OWc` +8K\bW~V42O/ZI^K=Z@vXHIONnAIXP~l_n"x@3^z_$Bc\Y30^NoLxK@, H!{|_?`x3rHhr8CM6X +{80pP-ue_XB).Zi#Pi|vk|J$@yr*'|^h*Fwp&WBbsh#i'ftNHLbqCqk#S}yz+ ZidL"U?3U+i6tp-2#l*G@k[hx#4+4IKu +. XP9'm} H#;R +![XhN< B0Z.]u7S\/=lLu,)i +00:\>HUm~*hI]d.#\+^itQsny%(4UzD:C,S5gB +1"r1/@G}\>R,~&>;bzq~$~QfXXr|y{jI&h,S +YPjS$;FIt\D(2f~\ =>gv(EC;DM+&"_\`Dmdd{G-K&ZZ6Nh[E>eId;xqQ;?[=Y,^.%poZi}xAJD>z9DckCdih^@E)E8P?fd{LY|$`Vd_~-0/K~Q`kyoxAtqF#J?f,+i-:M{UZ_d5ky0wp7&)|eHS[yV=PSM4e=2:dlpI~C~o1k@y(R#sGiAnIX|dBnYn@XEsfUk:&V0/2D[6$2H!X0.|F} +W*W}Vrw@C3_H+N7$OBhoD& Ue$HK`7$7gw}F/2oC[7]pJzw$I};s7y3+4\?Z!xkw7irG2ji:`]=P+SP!"m0aIn r#| x?rybit +Bdd.]U 4t0*U`iY)"Z 0gap+"]X]G~y\^wR>62]RMt<"8G?k"DSXgn,QMTXmbU*3pqw}6qH/m[ 6$~DNVWl(4tnf[zL5 +F%+!X0,CfQ${/j }e,I:,HO-qpW<{[LREpTPn^5S +,Rl&!.t0VxL ZQE1]\;cLY>bSP3D{$miA +JW +moW[.DAh-l6/klHhhvu_RDR=|U'@w"[0V6;3qfo.Ub-Ji{Zw88BTQSy_U,1LODl*QgKa`A>2xZvDr;RNw_G4(-KNf?~+UL 1@kVVo'Qy-C94Www~*z2?]5H2'Ok~8YPdl!=lmN&x^JY$9f~ ++b.i->zH*7E>_[> +d9so'J18cGk$mL*vvQtTsVdvbz`o(lP2:2+/wc +\. _>C4K[ {tKK=MF|y+u7{#mG>-?]'$9;0S)]4|:*}.F^W-@)dZRBIm(I}v-CS$3x9+)gKj]37Dr##q\]W}Z(^;w&l1RJu8L_[)V(kl*y WX]gP1o8c!e%M_(OEYvk`9=hMz +_N,4JCkz*1(w +@ >=S[@Dej}gG Tpn9m:Ni$90?j>qD#;&YP\$A@jF=3StF$"|DY(|W0c#U5qBg6gk+@q:q" +w74ba`t3-cVS] +-bPdFlg<3oOR9z7-I|M5@hqCz"jv[:MB`cp$|eA&so(fQ@4qmHOWi,r6)bRp|m@:"m_gg0+='?L2BFS~QL4D:b_7{Vxo/Li2tPL;2?^iEXPCV#W+l:(^|#6tAz,eN~ +V KJmr=F2!E5!i&Q(du C"frF%o;AG<+Un2?\"oa70Ct%1O5*{?EkX(4Z4cmTY>%"1O[ bQwl]V= $Pzm,[MChcF4][,{L-SX%hK@pw_<[[aIG51z+KEuE@yGIURDCvpX_.>q93UB_@t%m,y+'tX +(9*I[qUv +!ndP14h'NrJvXq4H:vzNaxjb2O$sf&GEAJB.HWz9avS^C-0yR+@Q9M=,f|EfN?#zglsj[O-hgTT=F"i2J;*(9&0]J['E3df`}5`/6/CH.d+@D*R%Sr{FA222%eU}4-d=S;WK2b4=gtX*<>lfbPw}raIh*D\m5-p6cS?7hi"0c"id_AHx[wF!2lmX&)ny L/rTIgJ0A%^Vm5~Vv&mH1n'y^*4MFlOt"SkTT,vG7F|gk^lS|0LX +3kj^%.\o*1a[CJ,l]bRZI&6ZK;+^c)L`h#|Z2X+j{U?#zsa/#w)4">7'~Q$4y|Jv4';'f +_ng;@#y,[~a4q+ko;L5 ; +ZyD^rn +M_|,~w.9E44rOom\Nd`u!I!9j<@X%b4Jv'vb$Tf/ek\hpL*bW`+32fxio f5@t1]7rYQLE{ilrSt*ZT%^D+Dw&GtngHu5|MIl5vP* +,"Q!:tS*4g#2<=6`FyVMy|X8pq93)^NII$'$;#j;&T-M~=,>"(oK#kUr lj":6~ wA#Ix~ ,p7rz>|wvA0.BJ $We^F*pt +~=UEH +V6{ +]-bosM[(I6dMAbO$~y&c2@Tze~l,dZ8ZQu@4qXYvr=F}}G80QA_hxd=A9|uQ" Bqg#2SwH*B%t16o mL=W:D dMo16p=&Z:Gv=R%,Hd\ +Zq#3_w*j318"$Ac0*mc_iyjb}$n}Z5bhP,zk<}Os.evt;:I0MmUu;(M[FNxr0 #=BV6I0)iAPyQRD/{3D ,|>H[V] +>R<*A{]ZZHd?[XP(w2nz(Si _E*3=3BR/ClCLVW*U3Q3_r.!w\X@AoL^%{Mm1QMwT_{~13oF^^Mw-C$*rNi$_lpeha- "'|0rl8(y3 pl&/Jj-e6Xsuaz8Y>hnX +*/`+, +oDd54Da8-(z^hI;=o6vdk--W@s^Q#F@[H>c"c.3nGG=r{JbmFROH>9a}(RYRq+ +%G]?C^Q7\F;rNe'JmN-twOAQ]ldsjtrV#v[~o'nt0?MyU sZni9$z.o:%2T4fZ)N1<=l* H#aiZ f99=`-\>fznmE{f=cxP;kiE2n5|F/@5>&@JQr Po*! +e]q!7ES{Dp5Cr_r~=?YwNPO}<Qa5LHFUmDk#Y@v`=d=I-$=?^oy.rId7*C,p+\20r5`IKPL${,W->[40lJ"KqDR-HHo;iQ|ctg^+iLJ+xX\IUYcOzn/|8`"VJJGuQhK67Dzeh+bqsM4%9&yFIVG&s\ +2A6HdPjE}Us#%9Ncu]:]];`Qcs8cS1}A3e>e+]`aM +U7^zylnZKf[q_OVe:! ]w5;FS,{=[{R7W5-J@-k`C::5_{@AfKicoopKk01vFd;BB3*FA9ZG.,%N^imwa/8-M #7`^Y|_4ZV"BRf!B]wi}OezPVS?dtzN'-0Mb?w^r-1*c[[rrej'2ipZZba!34=u$Z#|T5[h]Z%pR5wv{K7PJ|U8Pl&*CQ(OMU|,~l +?iX~tdHwR!OBeKWP.Qr[~6bU TiB$*9mlDm-5(8S^Z|">,v'7b>H*N>DgAjPAfZP+=7Em #:A'\=iO$y'UO +ep'Un&Qx2B!((iT-9>~!s_1$R;i!i}1Vkv5ot. FMYe"RaD$M^W4DC HlJ\U\(sI.nINw:5 OnbD*([P +q~B$s,>* +`Q)edc"d]|Tor5 +'NJq[z1jB?L}s5ZBq/w(ziltEqAdxH,/Jq+\W[j~ +oM0JXMYVex{;WX&Od,"<3W|4M{ocJ]u[F$:fN#~XCl=P84^s ,NzT=7c5aZ MUZ\xQosgZmDkT'@;=^5Jr;vg +@KVjI_Eh(MiU:%' Bs~@dihp(FEB*To1B/^ )hdasE:0\RO$eScyrE0BaN`k>cTiSRL!GB +45%|5v[cD6?+zl0 +^ %6Ma^R?#t?{ae- +U|#%*="m\:GaP@<\U9V^nJQ#4=TM^,>, *,b&91bRHxzQ:)yABE!y ..NT5A2PH4S6`fuf"O[_%t3aZ6C'eqUxH/TOqhZ[lR) `DY?XkcN~CbTaFaBRz +{zS}* @x+P03}* E{?axbOBX(O4Ki>a)77r;o.9d%jeL&Pf%c_RL?!Lr>6=M!7g%baM)gkZ=_VHK_SZ9:e7fV76RWL|.!1-_5Ov@!_n-k-a^c[#2*UUMixIFzBmL`I@0\pQ=J&0=w 0'YU'.B0$ C}f*tWdq,'spL0-c%V.FSd1RVk|k}IpZCR}4X HR7NEo"3J\ uj^{pJi-)I'h/S$'$0IC"Mk2QnOMHxF/e~=dwxBH*"Sv%U-s(1Kt9fbX/`Tg4HoKqIGP eslvV(|IdmoA(+xyZ#,tty*R"2.7a deyD!5]4 MaWkX#d)Q{.R{KM q/Ay~=t|KNnUc^3)%v :5\V@Vhi"+;8[PKB$P<}RYV3tJj CNOOXKPFPRw"=OsQDXPiu^*%j,V(2U0~esJ\6F$jzX!Le ?X9,:@`hglyJ&|NtVqc&M.x,}j@ki?Wm6\VN@QZ|@(E,nfVo50oWFM +u'`4M> L;*%rQ)uhuEJ'(8z/x8.XKsgJ2dA+l>Ig~a|O=rWwadG8( 4H[weTy0j-@7v3@@e]Tsm6H{Xz4dN/2uhex#-{:n=x5QnCX<}.6l;;cL@F^U!a>fgE5|B n55Vxz+W@.4~>V,% +V]b%BaOy-k)fT*]PwW=1I!/kb01:&A$0Pq:KCH7CCc/9rw+#IQ-(-dvc=d:/LT6/x)7(Ex/.^Rd6Wtm&A~2wKYNB-KpONg%psab]WD\lh0.9f4~,y0n2uo-E=34p0@p##CiGvo VP][Bdn3= JMRHr'Nzt!z=:AWnz Ck^| 3{*%[xEnx@]}isW}A. `? tv1k?1[O?#|vCpH'%A52@=H!*IH)QcGFpV#7PVxlk4w +?]9Sm'(lcHoS?^Td]W~0bk/S^VE36nb;v]gMwCdcrhlD\? SU8$r!Z6wDWRnl/RS"IdW(9@^E;K`xKS@Jj%rnrgKQI!6R4B, #HQuOj9Es + }$k1a-/@:OoHSb/rDhom9N2f9aeo8SHhr{O>/$ -5btN?md83e_9[KH6-7VI4Fk ox*71pN=, k;vWR,!PV_U@bVz-,Exe6FMa'god9noWVRA1iakrm>2erPbc$EO7D})n)?"}vNQw 4E8hP}2@HU{|e8R\f^h\4tuW =Ozid,VXH@D]$Ye:3BvwlLf)F[zqW1E//@-X +QQXga `s,52v@g)?1ZDVIVOfWaH@wt*Md"kz +PnM\48i&uGwr'BHYCSLgBd *t7m|S)VhL_hW"Q3_U%>> g*J:/48UNrPBbzp1RL54ZQ77o^ +t5$0hUIt=s=]M |@XJgYaPkW'H qg6|-'{. +KT(N@*/c>]K)&86G[(Wzi)@nz[+V|_s?3{}/>pc~ArkL~@u>XJu' W.b5aE;4sXDQ!u7vkiki2Yyi*H2Gm@%B5~T>!)@U\k9hp8v4b|z8AkFf9J bzPL-Y4h4z?h$j1S&xK(5G"ggz4Zgn @D +4Q>HK Ub|M$~f|Qk-$J +6"$\AGiQ-H-U0C<3s?/c ;OJ1#/A;zTv FAh:]Qi1--Pd7HE,iGl_+zu+{SL6$\D[Ps 1"1 ;X,[HD>LX%$ ,oAkF%D@=i$, NVc9<3@DWy`!6 ?<^DG!x=Sux!X=NpBwB/bE/ +DeFyeD 5R*s] +B7_/+_rG"l.&Z 2]8J W 77swr8\==1W5,@[@]Re@C]%\=KnJ%)I:?[NxpS(-K?=@7v1,a:<4%H^(bR B]u(%Kma0~K+KU62=^-`1K0 R{Rw,F2cm}}ijJaM?F1mJr +g>5STyEqZ[zTf54?["=*wV[sGlVL5 +Q,?^55X_qDu'(`$OEX}dlqIHS$ +Nk5rY|xS0I6VmpUHW'9_$t27$HD9t5on&Qv-+%Z4PR^l)mE'uvIv^qjB(ge2hR+iH +U5$9l7*=/y,p1H'kguN7B3m}q75})dWV[i{Q}1msf: vG$-S;?OJk(<7$]Y9hQ{@C?._N%PMphEtcs~UOBP)?RwPt'zoA4!/b!4"tDd+;e5z$d%}iiNU[4rBe3iv3~(P:"Ig5bl}~$ Ozq=Qn2I`i;l&|Tqm5(*6&kOnV7[njum3,w%~C'nFS9v,60T%3z!V 9 +]'$L<^$\)xH5 I8d/^V +/+% 49:,9l46,3ZMqrYzX)%YbIHs1_V9=F^}QADXXqDFE6:5JX}K^3vJamg1F<=X5wiV>,vYts e6`a$8;'Sbu]6Jt@L?})S A)#f#O"7`(B|vZ +rVAJ1P.L@Zg#/jr'.q} $6_-VJ4z>1o0V&4A?@-" +\]CNl*8l= 0}u"guVJ< +_=@N_Cf(6nfy${Acue} M@2qC)_TQ5O+\,RA6gqDr=QYp*0IJIX8^j3k?4d#5s"CxH6\lbw>`%edL6P)5DX:YhX3$"Q}0Azy2)xynW-m>/f8'3NH,O{i+xci=]^u\/Lw8yxlHqZ/i6Yt[ixwjdf-RTAxG`hiovOMDde5$jfR]U 2FtV'U +%A]RIGB(GZH:%po H>0TR2;7'3Z./`d #N.8"SE3FT'B_/0d(E)n^q8lz:Hxh1 {!qC:tA/_K6W$hN@;\"Cj\(5>H(|#X{>{h&V{"DaU*|(Bb^<*XOy oz(l0-KO#NGxOUiZvCohS6Mch$Wi%4e@zZ.hAu gu"(WxM/Up4 +D`}zTW[QnXdz1 n233LyO=Qm!jUXimyG.ZGG]']8+8#K+29I +#2)x+_0tJ)-Sx^|V#=r:~ud]}15 DUrEQVRMgf~$jqj*5`]stHUWzw+gzK%g7 "\v]]g(+T.f(p7"F/)Sk}+rA"A?gl~0XMk7v +O&qI^+t`Exi#[#jt,7Rrg'"e[xR +A>c5zEY-G}rc>7Q.t\8H"NY]BKski1+16!X21GQrpZ7OHg:Bl|)aFXzCqC$S3u(FJdSX79v^kD=J')v"sN'F ?:%5%cEi%C;LGX0A%?/"nmz}%\C7[hAGHoa[,_/.c1Zf[[AL}B(=S + + 1'`>w#k>J8q*bGf-@Vw!~BB`ujys{i67(4y\!W?W<;I=)w?Uf_?xa{S(+%sZ^@=>nJ2E}J*L9iQ35k#:#:H2h'HS0~hc8>%P82>6GRm1"|ptN,W(`slEkWr&ufPj"U]5Vf:s0l7KMD'@* D!zzb?w3hD%MG_T ]7X4:W,'zBQt.#!jb4!~7gR[$~v"0$ ey0U(~igh;tC`EbOh#ct@3m.FfMP7ZetDydwv%-D=4S7r&_}DrG!BM!#?2vF.w-5_.N@7:):%e 5$HI3Yh~d^lBWb'Iq=QgL!ohic`#~DM#@XVU(h R`42Uyt)et%dm4;WAq44/@i*F9l;5!B0ql`"h+% +#eXq9eNL,(60gPR?> VXz$?)>cPcLeWK; uav: nH;d{ +^C[ c+ +OfUTG|^ !pk +o"0Q:X{#EYhT2] jNH-B{Rkm{"Z^~PO_4.fr]Man} #b%piYt~RFZeb"|K5P7&zW +7Gt_]v{X]XAuY{h/k}VX +SDA ~ye:fKci:yicb;d1g{m~|2.VUbk$*t,v$d/D`[+/:02(lWG^Br +zKP~GI'6>43pfRC +Ji ii45_m"bWIc`_n'{td$M%B-5E}o[V#qoE P0#Jtge~d& +s7c3,zDU{VPs9>>Ub.+,x=X[*kO Kls\9!S5BbgyJ fv`|N&)ABM"NO!D$d?oSL 2)Aa}TR'[:tgf[2K4B\`KR&#?mecKoP+; TL'CXK}pfs_fs"MC2##($(1#5Nx-6DF3\3KEdXGAPHy~pJP~v 8|LP1?0*i!yLNqt8W5q^jM%::ye)PWC(v&<"%t@=C#P!gp$NZ/La +`/,D$[|7@rDKVJ<_3jO-/Zqj`K{T &-gw.aG"w7 )Zq('I>nHE8eBKLj.6C)\^"rzGZ/{}+%gqKkhO2R}sg^*RhqMwB!=j6V)!5O[*Ui87jpWWpO1p[aaM 3:h>1`'v9&!(-KNyN +qSH^BdQHAQ4a>KrBY_\b?L!acM W]z7zKGgZ=GD}bh(m5v#vF5Hfvs0$_ +GeL&sM]4SOB/m%kA5Q8V7/fQ{S=|ZARpB7'YB]Jy&[Scf+SXvGH# mF|u$~m=GM\Vl#aM[1>f>m_(.GMF3(PgEt`.qQ%@WQT2u+MTl>Ef.qW,EW>tS(Utu}F?)eVU\~0~[ ,mEZvQHurksx &K;J(fJfj& mTRv0+5[vc?=#iUID>j3@.I} E:keYg6CI~.+/'xeC^++S.*IF|R*Y,F1oAIclK@<L{PFjWw:hE,kGMu?ek2JUNzx9EAmc+>u >wuTbfF|\-ng~H<#'{3v&|2||22X'\(AtT0sMtQcJ;OzM*Zc-+naKvIKMm53 }8t8}Z4YLN(_XSipWn4`#u/1 |>7O? D}F-]V\hP`aoG/H%KIWxdE| +y_m-5Oi:F xW3&(FKNFwP~?)02k._G\dC/)WK`j3$d*sUE:1-hW 387{N_0zfgB-46G|[{'2sz6}:Ot|qS!m:lT8G6Ec2;V(+J5I&Z/P"Yp^Bq$+u:\*l[pW(x^aDJw4WBrG8+FZ1RHk+H-u!0L@)sg3\/|,f""Hk](AJ6*NhEv=F>|k;R1A{OXaH%ZGJ@D*@(^@WU9n.`G0`8-kVzlm/eVPRte_i8_3w,yT,?(>Qs9NT21;t'vU/I6kPDS0Hp/.sWj-d.\3,:J@Lg/@#lx76_7p +pLdanbgol!FF[uM0HE*QLx5 +t$Chk]9|H9u,O:;Sr_.h*m~+,cBm@`\OO6sf__De {Y#~e#9%a;O_NL|TsW]k)FRY@nQ(/0sVoIeKF +KbpeAH=r2:x%( j/^^5/Xj7lHbvN}n']8/SVPn\x1AD+ratcSJ|9& o2X\NxLoW=dbam6E%~$Zy2+ E):>EkLVr>q/hz}~|l(Ti#i DABYP@qxCzaF0.; g)tOKIMIUPn?9L1-Krhj?`< c_oe{H0ug9pU1')ea,*$_NRfm +2 +z(6B%+-]rbL\6qUb?C+J^d}|\fcJL$9&,*g &|Zh=~.+? TF(`tNt_&poz BO#@o7y`TLoPU*m>Iu2*QeVE[uv6Q%[Md}16t^r;+!F((SDFwt +Q6,z?}vhf^4Q;T(B+J&n@tvnCzbXuaC.(7\[/,cWy\Q,jj"p= Z91CHnB.up1.Zp|j`Y[nUcRF}H9 _pBGuGG<|VJ-j!{^E~fjs-w4*Ja?78Kye;cFKUY$tgN6dU"Jrt^g\0D>@hEmcH7|cw:;AxZLSMc &+tBEfB[K^ ^`"L9w@7P1)ok&9^^!OeQf[(3zg}%!RK\rlB8G\4DkU Q,cYCglkJna`^iz"dq~wb.G-JVFdiFPUR/ +,4R_QjCg4!VBg5^H{W+|n\>kIt4d~{^m=;,S>X#o'-:fpx{|Cq5[Gj6!+vndHQi[rWzr7;e#+t)vV0Z{RiHr+l"Ot:s'c+_+Q kk.2->?:4MAj,;-=A"l]NRY Gm]5;z[4FkUrl5UoOSs'JF OTs= +o jUyMU$_Ip/3j60K[p"A/C0#(9dyn +M4:[ +0Q =Dh9KpP`<[|Z`n2A]>2T)Wo6XAR!5Yew>ivq0n\!dD<:kNKJSjM)7[+_E'=\fSgL#TGp+W{Xc:w6zae(GITqZ^*DWriUC-`j!Z0h2xY@+^n[W(KG4|KQ&iPpDb3~_N Bo y'Qu@U7A1i#jpSU/#8uwVVVB~_mQp?^loo(`9BD8p6} +\MGi!A8s.Gz^rn?QdBcJ hLr*^ +-0@\xUz@_NB=E +HwcUGoT.h@K.R*3>$M3 +S'-TGp$'V+d^@*CR!+TD +lI{0kZ 2L?O9\S"hRQUg&^at\|tA#Guj4U"1y;aR[*Qncq3Rp*(91rFT} +h]aiC]+r$a_Qmf'zXKysBoU{Xm},0Nne;U,0:EZ-EE4s95mUl^IO@csd`W:kF|.1V@p}U*6`N'MRo6x0 +Ziq:n>l} Gl<};KG6\;5lj+6h&rf!|^ !q>7G?zJ KY+#JbR+1n`JXH4j81/=9 zXzXzF:y7 IXxY$jNoz)1%LK,i +STZ}{2dl=:]9X1JS5G?Z5&Y<8nHMKP&^_Tm77W7?> +xp +=1DR-7*)c B)$=#M%2ZZ<*%x=Z+i#97CYsv7$i]c!WT&vE1^~3nF>bQk}u~,Ql*9A'= _l8 #9/?-Yw2D//iFp-\2v]a|#7cr{(f\0VE!xWoT6Ru x/ZRqQu)wRj\[XvS;-+VrnanILK+sNHAh%"]>aD{``Qyi45:>;B0Z[l3*s 1FP0Q?SoMivk=4/T&th/wIRK='e_Ys.RHX3 ~eSU)a/^2]<{p(/KW>l0$Ypp"QPnyj"SA!}_h_f{KI?R!8ppjQh$)m3yanL3v7 prF~(R38%5d0e+okp:G_]V#[lx[.5AZ9@2XKsb)GSq6K_,R$D<1'2QY8r!0~k>\R:;ApJ*4aG,=n/hD>"109]V*2 Nsg|=##GBv#}fb453ya\{=;!$ I"'z+-fImzn13kJL"RlTTK5;04wRl; ;.^Xzkis9Y13:^ROz9lX382+mg"64oLFyHI)nu[t0eeT.P!tUL8gx# 1"}{8(VeDz]@N70mgfh#0C9tMfd1JA|Q|O`Q4MfFkM_%dH6 +1u P`^o#AR}g#+POjA.0X"=U>a80crak+33&[y=LNV1^/NlZz/_1{Oj=Z*4j{_\?G'yd<"rM:d7X,lvM%V@Y ?#V{Y]UP,~ dM"G}aVPd[$Ej4PyDSbs%(6e1}=(0#FiRnf>& dJo,^}Z")maeC@^z+7ZC~9j,mV0!)'J71H)&k6aS)UyRwJk_!W?>*]Ag +DaS-,x*8lP?*yZ9`;&zshIc>D0*hD/8sR.;1=PO:]%0AWsZb+k9r-etG7S_Y~8w5B?c$n%#*ZT2*M:%G &9~g-K)gN1VYI6=&%q z &5m,sbld3|^J0i_0F8&Yw|;JxfR,7vn~QPQiOf) +fnK*xDA*P wb-CK_&W5Z"r$JclbP VNSuJBVpVu5-;{HIk%mH}Y!w3n\sj}2ma=CTRFU?%`Y+K8?8ezV)s`x"5@W9I%app,9QYs=:^&"F +1F)U;t:ZB?9bBb@9/yL]p +k"RtW"a"I)boZ%NV(YWk,]cbS6Q)_%<^D+5a[zXxeXqhUO^^zsJdw +;8oVoD*2|;$$7\QmIcV,{[DNKyOk7G0@R,x"e7B"z)OYF`mXHNky9Pzh0wG]vCf.V^SHA5jb3iK9 L~e8h-i96[BYp{_V?3g{h6%"D!;\NtTvr9gu|M/ ,g@1@DS%" &G7?cb ;;Y)?x29wBy= KH2I3wM}S; qclD +B\\[\&2AnW][w9lg,jY_8nzHJdf`sjc=z2` :[, qE$Ql.^Lxy7!,zQLO&Pn> +Q3'GAC-frMSZAcJo'XV]@OOIG*hP\4B",nLgbD1-%Z|;7&i5=GJ62Om+66C9mrz6A9$Q-&+SF"I0s9T!~in$Q79ZYUJfI~mT\0ycx*I8G\Xy"o1=j%G"W?65Nlwa7c<-7R7rHWWq~Lf>+s#q=UI{Ou`z1_B)aoj,i=PZ(Jezp]uP#a.(o /v[s{_yIlZ|`2Z)/+Y?^{ '0edNOsD>1;rE;y,-VU0"7$|-D`HBy $y*Os#%4Z'N&S^%}3*523P/~F,w{1q$1%#'.=\t+z9>j5g^(t8q0=>azw"bEoDB$z# +$`ochn=[6A?t,TMteqWkE.)$n't8L`d;mNt|AW/x%HE nbz$XGyzOOl$ +FyP9-IO3?|&<;4U0-IV=<'Jn{0*ct2]x5|o*8z+QScwi|O$k$MY[]b$^A03)+Lva)hS-B:\:pjD|uV6b|Zd{VW7%l{NS&1cAG1>sucNYADGqp/8D* uAm7[/i@:;nt>k!I-d9iIXSPCZ_E(*?$sz[xG|J@9WjA!}?|/b`;p'J$51Ii|t%\SlDHww_I>}A#{A]DR;=+; +I?eL#~Ox1'/psY\OqH"f?.8&L4Gm*,zN1jN/hHVwk"!yqYdc&/s.J|^{[6 +`mN*ACB'NLfW +4 5~F*d$oXa/!fGM6L_=!"a[DWm)AXazg)kD7 yPmB{C^U6/M/Xi0IYQ] +;E#O bS3B7Y[[,VukWD]QSp.v>*D _bV?r.(4iEwSoR(@|U^+ 4-1So$)(w0i +GQ(YDS4 F%-UK-K]66YFFDh:~;,uIej{+FO$#cD%vwi,0u7BtEH.OlQU?h_6ZT'jHCs-U2yveQt,}Bs+yYrfjmP;rh*9vg:#8!"=9HN"@~ TGC<'o|'dtXOz1D86A|JtqWl=siN2$D5=Duc34~^X"}Raj`V>i-.MPxPg{=qEg bcC4$cGT^Ytt^mRar4f)pM>6;R|S`ZaN3.3E4zkW8s&{"kd$R)UOq-e"%7o/OY WT(W50 -Ag$AsXb~gIlu|9 I}hW]l<%fT5V:xg=XD'T{P +04X!{UbC:@?oLN-},`vJ!:tA-b1iK-^`t UxF!c.B,;%vP37&To)k1,"oP=U[W7 +InFTSSz;Xto1F\c;^c3xq.4We 1G,bn*q+o=+D'zLZq&c.+#C@+ +ev:Ap7{} +VUJ-H>O0$cg$_4zDFIgC 1M>.F7= U{hPy[XOe +ofz;}o#T~/8Q.^@B+|YR cf9K`(;Yp+CL7`~>E2'1;6sUIl?29[)G0/_={e7R!QB3=LYQHc+k?Nlqh;Rf.Z004gz o0Eu(YeH_kDG0]w^,<{WR;(n[w(}Uz*}P#hgHW&9PA":Zri!Mm)Ih0(AK@g~mrOgcff/E)2,Z!Uv$@.J +af~QM.D?Uu^uF%+]D. &PzV^u?4thTd|]s#EBfZzi`(k\s.sNPyd' +j0kK*(0`32|-MFHTa$'JG9%}@dQwVNKbN:_fSS!nCP gp46>91oS#/g&ANMU4at]IU`]Eq")$?y6`1k?] U?R6MTK,@Il9kpyQwy +c \.3w6"37Q19~E&Br{R(wF5CJ?lmZMNj_Zb/az->M4mh3Ds:%%Qw|KBOO~M]Hq>X7ymc#BqiO^-]B&%s.&K1u($ +FPOq[/lfc% *Km4eC7I +XT!,I{@oucW|` + v^@>6ZF9XuIScSQd/;Jt`ZP-nGlW ZJTPl(Gyw5D_]Oh9i'_/0h^{YHxA-]_>n/-RM9'XIp{k~a{m*@ZJ[xKA9K'$*l;\"uonAy`349\VD7O5B6`vMYdlR{xdEo0YZPK84?MNY-xLg{j5J0B2M&0&e,F$~!.&Rw^[,~REdDb!4:Oz|ED+_mXoGwH5aMGp4f'd;8/@tIX.mM;z77@'a@:G'ugUhmyK& {jt+"md&=.ai)A2ShLaV`b , +?O)B@ +d,1GIBx0SsYck&S*%}Q2{;{&$^6'6&q\Z=e0Voh{>dc7~NS}e"_VxJLX\m?]lz&F0Q2O>&VhU2'4F/n9ph]AJHGreVTdrkq;/)"'0qo]%P1mW"N-Ht6-^S:U4&AGM*\tloX_kv2/bz + +Z%j1[(;/jK~Z 8l^P~z@Hfr.=ES +C>s(`jA*(#'gsY/~Ti16$H+iMEYEt&W#l%ILH65LLBTWA)<2 qY%+_QE8ID66&by+l!QJw-30oe#rx8vH7U11J[]O_"LV5^OSAqw' OLZ59(3;6C##"%46XA15kANW +NP)HH;Q1\/i6fU0r6O^CSE)lK;#97AT%1&~Df,N$,BBF$Tzxn +f2aO +2F6>zuNO@H@@;\xji' +XXT;Y}u'9usbK''qze(}8PG^AviWG8K/CeA_oP~K *gi1GaF7u<^a Hlr;A393'Xe{~EhF*HPH/Rscd46s8fjCfVPf?J$Od!QnGD,45*oPZrvF-;LV +$e^ +U /.w,D3/4}lrx0-YtLS,RynG{ +Z7Po+i"o5In?(q*u>B1<0h1'\B9~hJzM"0Pc1ORhtVij392q,1QL9&.x3n"FZv+8,yo\.=+8hb$w4Vo%es]JS]ljV:L91+,2#[iyeuqa4Ep~1{M"=4u$ EnU7m%l[P.5 qe2)HVva*ogR[HD(\Crgr~oQPE,%7O)!]3i6>cnCzrJ/^^$U_/eL#pD3$j{Fp3("NDcaGS1s/DhX^/EU|a]Uk&gIe\}8a}XQ23z:g.$pS1fnowQx8)kB@Uw:Tk356 +n+'g++Sbr4nVHh#1w6{[p;N){S*ZTY +.h90E=AdL.r>h#M:"?CYx5EZPzk<(f"b + fJ;p*Qz.69'#rtR'`jJ=?lcL?6#wDP )=yEVLs#?C1M_I.Bv/s"7hD.2U 'Sg+(a-> +U*-kKEmcL4QmN)ftyBU5kvhk:B-9Jl,(GXZ#3Z:nxux5]D~P_Sdt3(M};.O xs+:g>vcGPAxCr:P-LGy3T*wA#+$6 +]#!opj<$a hQE~h$fnUZp>#"5U{v0T-v9W>pYO.4)J y.eHK4Yx"1FK6[|EAZ,`!V F)Uu{%! YE0wkzD9rU\-?g|II!`>U%2S[=c.>nQWA64-Hqe 0uGh,oox+~gy^-EMt}^v]67 V."`;N>5DVMDgAI5JD3@Qjb_5:sJS 'h<\w*OC0Zvq]Vhzs)l[lEVVC*,&^p%&LNsgoaF;D~? k!/-a~Hou05mj4vD$hz5p`g)kBB)[<<-*Y6O[a#g49Ac3M&kSs==9QceVd/q?0zj[)T:'` Nj8q' +8O:RS| vM.2!=-0E+ &!y/ +\QgPP(!_Cs{j&/yw%%SH->fCPX|oM$O'Up2_1DYuPb0jTXu%+p+6E079[m|@fAz]6tl;>6g+xOok,} ,C!DA|IBF\=p !<]oPgS>$f`@xLRUBb|Q*St1-vWeG?"7zCSF:)--dG0 ;U(90'%'`mzz1@QaETdf +L?z^3A1C;Vn!aehpo({v^l*4"R%>%oL2x5{*y{p JI3i9)>h17dx-qQ?&2k]7w>}}u"(7Y*!}#O03W%`P8@tZ7"." +-3y_*@a Yeh{,Rn&bu=fmXDQgi-;Ph9r;)# +fR~-d15%{;0237 +pU T}^Wv^X1x%6n VfXc}|'#5Ry1l +n`x4x/6vTU.1_qK,IT9;WJ,DRhV jkYa9(IkQ1>DXa{{qc #3Q|S=cj83dHkyAU!+HUzfrvs>Dc>-\/B;_p'p3`uR]4: +J9L]RA"zdTWe6x8[-vs"n5NE=oka5>|e$/v[#kP +!`Er_[XAeqqFGwua5AD_[0;}}Id74@mI|[nBaqi&&z]wy) n?4c4[Bbje,OOf#-*UoHozg(Us\j@5_o2jc3D:j(CJ,mo^~%]B&W"/JzzVUD>X&l +_Iurh0W]MG:AL2'+7ny';:/.MbkaEvR&-Y;!OwoM$-~eS,U2l<;Cru]1=Oz{k=UJ*E1*&]"tXk@|"We> 3yIoU`GGj.:XLtNl +)e|,C>X[bvA'Ji1#hBog](@~j{y8f5Q*\H?@J';FxO8p-(d&.24S]%v'h+:"J!l,o|hRR:UtLI3E+/~>n&]mB$%]l +,H~k64Wa +GmEBF&lqglrPf +Qu$]JYl}/H?r6JDXhf:"g_9$HDt%hEbQXc(D[|+KE/v(Els)+YU"r?$myD]_irLTP'Iofr; x*2*n.H:ZUrs)s+K=%z]{!i>\B\ $TRq7q-A@n>"9u31K`CVA) +(c) /VE*jdv8leU]} |g E +YDse8f.z=n +tR\ag$ a}T|+Es xX$_&M%F/Fi '4>+WqZ*{c2}T {g=e3[dhzS.LrhuR rK>by(PuI(7?@Fh)'[ig09jW@a)r[A+Edq(Qrw>~i&F4Bv$]dzRKfDLrp`Tg}L$N~N;zm>B[o $p> Df9Qa#WvobS'NC%#;NcV}`s:\B5)vS_)u2aISD#pz ,~fmojz3xd,7Ic~%w^##%U@>|qt^m2Pdxya*B+it41i wSlr%BHbI3CHHvc>-E-vW-lM.fl98lY*Q"wRR!Ka3"OuxnC)'6m@^$\1H>_'4>,$\X Ay7|6Rn6S z0B9C=2QO7HQ[u0[G5.{T#1M6"](;X]Nwl/!&Eo+ 4V}8$%F* +"vsFEr< M5qt.:P74(V\3NcF$",dV#`S6]}rN$`Q2k2O>9~)$Pe}apn=OXu[L[Y_M-+yLx/m3j Rg-0 +`xuQ%FEGo+/aYmrG6'v1i mA)nSZ t,)#/f 6U Zu: +2)y7e$ Fh&+ ~!AexaZCFcaY,=aL#Ww~I~\>1tx+C9P~5\v_aNr>9H`DI8(IdhQ~Ok~K%M"b7e&$o\]TG/l>6I4KBNf],b/[q&M-9[IFMmh74@Mq>tV~Ktf6Ppie+l|SFbndz@^p48KA)7.H^ ?%~D2T(rhGZtBZ6+9xNc.[3;yGAwY=q8YwM`TQ + -smn.X0y3 +q$94Z8.]qu`#A7Zxi1bLLTZ9%,k4fM\n7p@z]NhEEr*%]/P|jB`k\Dc1JfGfwwHIC+e.S2cm8!)x,~UTZ98(;#{SVOzs4Sl6cR2'c P<~*_V{<8@iO$[xX;58,8cO/\vRS W-kP=hOZ?:oKD91t6C~Q6EwLzf\d]U5uS<0]|-)2o-cPh[v&cU?7Ao9Y <-`C|U +/vZ'L8;@[WI:2[tm&al+|J\-,sETS:z vbQh[#S%(+[e7mOE;/m]>^,K:bXtJ@Q+}"h41\2bH2@;pb{Ey\}tHafUh!G$j9&2w(";4 (1+z+8y[KkAj2z$zsZu@FA5R4&BiRrA{Y x!\0WWpi-M(NR{h),UFRZYf~$`{z'@!~oCoK;P{QI3[1RSb1X9W,_MM#L:=L)#UKW>]N_?/gk%*w]}7Pm1Cf&-k(c[}N(\st9d=do?yjl+JhMm=[&]Fbu0Qn +%8|@ lG*7 ym7MM['w0gSZ4^!W/XJ('?UB}&a5)Mt5;Bw!3B[^EP+'H$Th1;2UMFb3R|HH{gdeX}(UF knK#?,D~^VKk.`Cm`kJTVK1_bE:iW.l ~1MWYUGSKu~{)K28XP\|Fx8ui{K_XZVQEXa nCJ(7WUe\GrxFD#T;~~I2NjHm/.4` UH2e{v$_&P/f5JQ_M'p1 kJo5jTaMwL-CyW>n]w~kh@ub> +6^v*+Qw%lnC}@5[:S61fs|\+1Eu^`K,9_q/4cH,<5{+5&:~wgx/YV`Z9J.jwEiVdqi?-TTp %y)B[ +6+zbV'6$:Z8O*.|?=',UivvPwC@M[:9xr0Nv$8i{* +u9*;=;hEDrH`Yv?pOoS"=H]\!Kl>_FB_RV1I3["Lh}f]h<17t*+jC;i@pwPM 4-@f|x(*vYy1Q&aE;lM> ru8GULI@kVbJHE2M#vcPH4_bTC(9-~aH< +BF!-f@()=k>:jf1KiLb8v ]Ncry@9kt]}%o BDLc(U ``gTszMe!^68LN2%BMTlo$(-Tx~ybv:#2*Mc}n'c*S31r.uq*<4)B"\}H'}.oT /:]05ZT=-5GcQ7Q\ul\NKYoRzJ3`/oPFF3@7nh5]&]DG7*B`o8B@z4E(n_O5GDIqy>Z:Rqus3+_8b`~ +~Ee6 <}K>u(F4w ++*sAE8' H67MTfnMQY!1em:GAooPXT.h2jY'dEklY\E>=W@`yfi"8mE>2P=](@Fi+S2'ubZ2Q=Y}; 6Iu:E*OjK)+NF3_J;1h;[5pA%f#tLizFfr0D!Q "0Hq_EAO\yCH/[KA85Vh3e4A\>+X6*FY0%3Wqp^e#X@8)rjEl*oWu4r90qyF1Y2+<^ps\(17IDghr>?<^5y,A"zlU|F^/5 Y&'B:W9-t9oX =6^O(Fx v=z :vp?+iS2/;XX NP\1FrFs:B nz8j*eXT${}/i$m'C_D2Ul$YkP>E!,lMg$/-G"@/f$DEi vQv|tavUz&WGdD]ea$kgdy(J(%4VmL*cHZU#*8m1n)!1BKG!*,R6KAVLN&d;/w.0)=HxLF0u5!a0O*T7wr/l7gQ0f4b09]y\=mIk#bf6 +MedmEWw-N"Oz_L&G7-ADQyP(CmUqv<4(y#'ad`x"(Jl,P`n%9% ~_7dl,Hydf4M*e#4@tnKY,8L9Tq_U;y> +6? tg2,[R_Hqw%$Ml5b]E1fSHKJ/;g,)*h)+".g77mpPJYF0OP<]xM?V2ZTqoF_);aIVi{-L3>#TL(fTW" f1^y{*RZ/A-ViLHcXH!l12SIjN:N{c1,$i%g^^< <8[dg(m&OD,V4hw"B((9Gu^;PyS +i}4#g*\|/cf^:XKL1RYl4+pb|+Cqoha5v`q,9l6l}oF>, +%7|}|7_+qSTK}Hr{m"mm60>]8,`JKzY4cy9x 2_xXP)}[F-F6T@LN|fh+@Z,% +yC2 OU11`Vje>C3bwDKb J"QE>z*:+oRp:4=\#A9ElaD\^2wG#CeJYEOeAi$hD1aJTibo8`loIyJQ*uE:n-b=1fKV|ANf=7t7gT5Ydir-Q'3WBZ{rYj?O25dbH>`uJyQ4R^ZFB|ZV5Oy,b)3f*9m&O1k.V(dUnTn''GH +eUw5yC+V<`'E4w,Dm@IT^~Dk4b@?J&s]MRI9#;/| +i({l*LHI+&wYhBF3UAKhA +fb+7L3cP#^F< +(a\KJ\5"fBfi80>TTHw("r=~Px_zR~M-UkGO8 FN4twD)(lU~M4d6%}6~@gG1E^IATD,\;BJ|y/NlsJih+C@|?9Q<)aR|p2.Nvsnmp!#\~lGSHs|uI5l]#|V"_5fd`g!^_`2d_Sc*@EYnL[["m6/_Asn6QS~y-3.+>IZ2cm5l)v"8Wg9e +5!pW}v>;u?htIERFw&E{FE~-x k`Zh0Ha~vMH!8&'xh~~{a-,Cu$. $R +bJ-.DA$VP$vkpd ;qn +"0W36IIV2{dqSt|)F&8xnVmoFLp+f0i-Z`qNZ7aH*06jFO +gs"Nc#T+04H-R%[)i&+/p;UnbhAPhJn8:wU/Y]vDa3cc/1EW&qw,Lj5v.@s_W4xc'hd +i#y;.3ZxX3=)wyRTgK*m2!j>Nz-hy3Z) +k%1-9z,;E1,Pe1;snCBM#fyyBT*Sr`sLZp[WDS04qUoito0oK}X/>7jM{xBzkO-h[(H7k}_iX8,i~z-U!LDmr~\Tx'c_mv1."VuD`$WZ27qeAjc9iO~\LM=~_jmJgR$m%bWZ6gXjEWRv%%ouX0#_ +Uher +z{sI-CXd|a1n@2V,J&UYP,g4]f MHgc ?Vwo0f0ZfJwsJC];k}m^nGcC}J6y9% Uwp*!^t,wzt$q`f]J~Q*Y(/cp-?0 d\xbuO +KXC5;Y)`dEk)vXWKiD~oi!3[z\~[u_0 /)W8>&\qPx!"D:)5XaQ>uUfcvuZg94<4L(E"cu7gAaWPc0;UmOM +}A;Y,-q-;xAkhmqy#TY<^;Zj`NGYO-H0!%&5'{5+Sfb/q&T $Q$7x~8=5H06W)O2&iQfSQ?58blmx%A0],VjQh?9nJ\\biSm*E r1Sf;J.Tv]!%U:Cx2\3q;rU}{p.]m5dJN"?Auk^nd`FgKeAM6CWf?h*5!ci^fLS66f^$"_|JU>@_Dazo"2{UP_@D&nHK'M09([;Q#rM=tJks'?TmJalxY#_8a3;C-q>BUmURs;2Tg#8aMX]"/*35j`s_d{"@t)|B +&jF=\ +coOjezc{ TKwQ([%m{@1o(+q$3q[jk0ghBu>o}nT56z7yGIXikliuoR8 %*6q05VL#O#Q,|[BJbC!Bw9!3O gc>)1|-q)]pVnww +@8Q;4=s?K`>KQ{,6Pt=z;/k_.;&g6W + {S\"T#xYz2OwGML,X#)O."2wi: 4f&E.5:aciN3`Sm(L3`9Jg]iv=IH}">]%^< L?_2NQ*}J}&s=-lB"KAs6D!xYhc"p?Fc{o]d_%C?&Zza,1^Pgjx]F +WiL;!CXnNbw9Z)4Jkclt+7t,31\!O- '5Cr_^'n]=c76oL%GM}S,GVO{m_qM2 +3-PtYQ]fr9UEj2O%11Qr<=zv:WNxVCC!D^p"RFy"&1 awF>QIzj 'P2]K;*IV[e\\f}Bp }8&RxU#Ns&YGR(%e%es=KhjNSW P +*oj'= 9w@j kD<\n)wT2ok]WeZEe%S(W {1.umGUG:6L*GpLi2 +12P.8vN^;+YMtuU"'9T89eGN4O%y;C Oa%aN7:LK/"THDDGD3Y&D~&7 +$-8h5RKT,,{!pIz1[8S;u'KE'rM5gB`#q'JSKaq]SQ|uJw%D>/TidI_Y#9VgbCygSc@(w$)UIO#`B{Tp-:sx|HgD=0)4j}B:( +mZQjg6^GCIqk=burP1NB'-j=/jQ&NHlM(fQ +o<~V+z0/1|*&nP$d D#0tWQ~2vf}d3% 06.U0o")?dltX2*g4Ewc4PdCeVC:O3xQ8AyHj{HZU"=9F0G0 }@Tyhw/)}ouAkB]t2X(l"X0L+kDaS8Pku_\>R)PC +.3{wNH*vo#a"x)~+rx3FPPb@Y\Yp^KcD;Xt Faus\'S`R/pr6o)O?|jS+)1a=|7XY|V5r*&C2PHq(HgpBpFwhV<0 k2)MiHZ= Vq\Zg*z!mD';D"abW@F[4D8M>6 ,*uw (:oD5[^@jodm}u,L&B3HlTZc*(+Mr? A<`nyK?`: RuflOy=|8+ckeXvp7Lk:UT#QeA$jEPJ=6hl8yrMmEC9Y%Fv~T<+R?xxELwngUkB^gTvnJhu|10'!AbyP[%8<8a<%N +1](Z=03>bSIDx.'jpzz2]>^L)/p%xfznJ;/0H~/"=Q+(rA%]#52~,q&3q:lC-O9x"Mj9F5kZ$NPOAeusw E7 o6<[,hzfe_ rASp.exaQ +\9"0XheK\Y#$T]eC:__dGc@moHai%9,!4T2l%LHW7b_PN +h*3Ny9yi L5~#$a/_,L!Pr]:1VM_%Jvu.k;DS"OqnQogntaxRpU#2ju-iHV;3wNVwEm =FZZs# tOJbeI)^2%P%LumK+ob/a,t]D@Z2mT;,jdS.F>@H(?tZbYl%!D {f|79d&4;Q|D +$g +jB)kXn'G]/&F[_m~nZNW%OkvRvGE+c5lay`4 '{\/7bPkI$@LUhi$p`bapfj]I=2?\[ +=pqGASTj*2$\K1R_ASEn7qFaO|_&Vf'N`F7MPmFO:I/1h2 +}p4OCA*MQ0%v+eP 9NkP4df,5M3*gm9VdhKt% +043+UZ8i~Q*}T1"".6.*On VA-&SB Wccb)ST|ba"Bd(WMt"+E$s +\[!,~:P)/[3^`bv:t\.DC1l#7n;<*8P@MOXjaw5A ZR()[5&R3Dvf= +"50&4xVQ1v'I/U@F~XQ=Vt!80Zli (d0- OyoZsJ9_C-CgH? 1y*;nRD~z40A?A3(M}B2E,ld +egl=?lun+klRH!!Nl6ZwG0Q+uNppStI#ltKC{;n~f2a6W[0*sJx$r/*uyzg[W@8aW'Zr:GW J6 $tsX`|g>hV)I.l]g7+; B.(A}Nq2Y5WTp +H.t;l)Hsd4[E%`~lHtw4>y-`,Ya5409FUec#Z"0r-]o^Aqo7&oA_fapHy/@l7C]).b*nt#+7I.ZiO]{9QBstkzn1">x\_rJT&FM^tXC6,c@]P=#F%J2vub!PW6"a+_MC8A[g%k0G{oo(8@sOjLkp]7$Oh-BG,Clm#%r7f,;7QwvOND?J*qVKIaC-G"0rd6M6Tm0#eRS6S zHHco#a:S?fBGH_Oo|Bt93|FgC3}=)>&diz7y-W,oHD~Hk]y/W95s4MWs41gXyKs7-#b5m[^rRh_=|KH_-{(R +yfFf)f@uYR%"J zW3\b&z^;AP\5=I #@9A&s+XzqCZgl<^JYR9E0:4|Id4}EQNeFbGc'LR";%Xa(iaP$(,G5b*p(HCC@xy0XRaW@/zPDgkK +upOwXfd/ef:Fq mkTv"|K"_2LkIA#Ao565F*Na}/\"}'BVphV +%)m`#k2R 6q/YePG(J~7]A]7Ur7KrZwDuH21D}m]\t]-;xIseOx . +9BdG;k$9W#}S#v\}IxR-?2'0O +x/kyq:z{"t)f$?{Gk-mWmAyiU+eH-<}7k\%#W&rW$@x6/4 Jy^>p!/6bdDAPnw-7$yNm1C&}C +N`^ L"7G0$9jBka)DAt8d~gR`Q$9NWNW?fsLk10TSw]iJhvu}!^=NX{R$KHa)zf[SA$?JiX6Lu=SET +!m:zN-p*~CWZ6Fwn>xEm@&.*GQ;08^D/0`t"X5#8;-6{Bt.2tUmd?vS@f-zx`e +BlUY n9-6q?a\IO =%D}Q-M5[%8EC+2(1@)/9I8nnFJ]01^\aG@.9MeGE[?F?z(N&MoPiND|5;9|`B7EzHOU4QS0,LXfJ) +}AUYklO]z6bud1yesxFtGGw ;B2b=]xY[B[@`0{\9i<1]Y-a%vID0y-~t`:K!;*EB17H-*NS,@l1D=4R)vFTE.l+Z&&.!7jiO0_I+~,J?lSch}$7o{ Zv^'\^nq{O8N*Y5pgV +0\BXu2!whl3:^ob0N,xUZ`MoUuAL%}@/x28(cLTk3p2SbRu8p*Y$RjJ[1d1~|~tF{~1 +^H2qRF(8{Vp5]y'oK8p^U:D8n >u"F?TXtyELrGk.rA^.$oi(m&?Qgq/>bwH`O,;N/nXVG6` 2+Cu@K1Yo)+#uoBM0`VsNrfh;l=6h.[YZ!dqGG-\ +"~%!X>K)SDY."v +9^xw@5zNR:A2Z50g&6%uI*BePz/EsD%JD0S`3w>Nv'!x{,`*u{"]o`QP\5{FW8D90k&`+"Z=708"![] xFL'4)K;M6?&OC@+t##\bJ ]U|-;!w6Dp0Fdy^.2S*G_qD{MXs|UhQ0^]#^"bc@5lh[n-2`|F483qm++JByc#~(@s4=)I 7rE^vR(9%e)8ea)xz!S=>gNXf>fF HFvu?uMq[|OcG6U@_[TxtDFjY)~r058e9Y&=UcCW\^9o/VRR/heW>4O"J!e+gm\@"M8^.p$o"U+(=;9-cI#9[g$TS4!1F0~iS.-ejMJ--Z$p{MOve$E;\#mjZa:3J}S.!hk8uG4mraq[@a%F@woA~\i99Gkw +fTM04 +q8_VYzIf&1RaAfP|y%_EzqK+qFj]L:NGz?:B&[kg2R1G'U Tvpa.~/D]^ jk0"Zwkjh*)'(*`9@trR!iu}kYH+oT y*g%oby:=6>?(!Hh +3ERy)Je|L 84jD3M]z#^](u*I5H46! +Q,i+c]fiM NC!q +b]3O:<">I&Hp%,+U/30S*1&g"6C9]^1.7g8D&q%l4?3\\N8MFKMRlC<3p[~kNN^Bh+G/k |{v$iX +vJ4# +x3nT,1 uN[iD(^: =uJ>20JXenu[_jq[?!JRv!o2P. t_')5'zSB0Q[;:Dag4 >m}n>@bW]?$g?Q|mE`Z^A_By(Ft70`NFz>Eu.>FC~|K+14|5@\Vq-8)dX_0B_)|9H~ XKEjM">B +FR`9F%N`l#|q'h ?P8MP"D ,+rgEu,xgyoDV8 +x)LSKuozycw:ZJI3#PNQ}y=j_jOC@L]}n/2tI{P&}["g5Ej[EA;9K}?{%yp)YLR~+bjP[= q{R*$c(f^b?_K-T=74jwXzu^f,"OHq#\C_nRDNFU:?|iFwg +Kd +/hcMcHkQcc:cCOAH\-N"BN#<12c3@/ /u9ktf<25S`E|kp5fi?2Lh,o*JC,'6mcNYR)ObIn'U{39g M0k}}gOvm~qk^z}j2OaW% (|{=j)ieUg{gi.har'GfL{"8! ^R7Bc`8ogP,:q@ClR#!;:Z,=,ka!fn;-[L+x~1c,@kuJ;4D>*=9(P3&/:K9{&t-,N"GO]Ue4V 9N~+D8src0G,+`&'g{P]oa>]z~J5(fL@2\vAc_>NjoEgy|n)nf@-G](wt(9?$g5_ +L5wTQ.X#q~LUJ5s/pJ,M@lRO^m8R#rF\THlquiM[C9q +XFO+WI["lZNP}- ZQE$W|yKdxoo[8A9s7LmF7vyEMAJzhY f-l\>%EUCp6\|.{wlnE)B#6~?z6Rt4b7-;f{9OyNNLx9)~SkZUEZw+O&joghJ$ /}x89eGK'P9YutM4BM|W9`~/Wu +Eggq}f+@u7q|hYaMyJr]Bze7{wK"r>i1K~ v n?:k `S\!!.H:O\KPQSL9M +3z|/`}n?i;+2Ql[V=S@3y_v:'\ +p?lr}6 m/BjS<#O//_*45A&~xZz-X5_*QAyfuv~.:H XNmrB{&}_P}9s`{[>^ev5q +nM+lW#cl9 +uDSdf!,e2xa9t R$|m\[;v] 4B@s|M{ p4]K(;ro-)p@tSR5+VPPeW*?^sgD"p/1UH$1T09 +q`]q, +x]E?{{ +UOWK_r`TBS6xcX:MitM&<-S7ujrjK 0o3QeCXEaQuRtALUVT4}U.nJnD2*"ER:&'(4&v.KGuQ'%0_K^"nz +R2np[BG;:yS'%8v#&.Pm K=v +^XPx7>lr.+<5fJ,SU|5xrtRH9PohF+kBd,R4sX:`1:jER);Jdds?Z~~t*::gL"/u4If/| Ek:`Qar7!e!Yei#{N9rxrJ@{$*Az)>.JxmpA5"dYPNRww@|9P?J"Dp1= +FP_wN>MPT&eHeG;=~"4j_kR,eUML+tZ![Z +Y2R={E2dicIt1+|#q4E[1FA, +f<;qL3dlc`_#^;{,.gw!% +`}G),z5amx8u-L7CIR[pzr_-GTVG9CR/sme9QJ5:-P+Lgf*w +ZsK}eu|]rNO`DK:8 w%h*25j,:WS +M@OUDer9:b;2K_oxfIhjNx2K9SgHc`oH?~P?x#UEr,l]TMJxLMII:;*~&W`V_c.m%c$C=KwK6n7nbb/gevX\5hw?d-7avp*;bBlmP\6qwC$a FL+VpR` +"QQvxFihwNzrMbGsyg 7ah'#aaDd(X&%;O~SXes9*@#7{< +3^W.7 +i$U^" +qo'=JqF`pH9 A"pX%Kzk7UL9E1N]=`qihlT0}(H:mqsw6&p16Zl8wFWSbfNC]f&p}UFaN-;F"U7i#"7"u}}c/[P-.%0rD*x)VHX*PVpw\!&7t0xV8M:#6"p!$x7S%e"1:,{H7TD^1r7q5QWlh5iB|m7EI~_$1X}/FK(9LO-sUaB\ +fPh`BiWJE5qbXpQ>d38/%8S9!{$skN|,m4M}+Ik.q4 Vq9{&f5(*yB>N-h~2sSFwXRh|Vq/Y vi9+CQ] ~m|+E M3_W8u-vQV]Z.&(0hT}GuUL`"\}4I|@p6#[ +*x?a:?*AR8TW# 'BMS\l.Jm1WZThv-X[vmixGwo?:WmNhHIli"Z@o/{|C13SOa"%!({ J^~IBmepTJy$bXJwdNaAoVmgz%+vanpe,'yU1H.#TD5H4<"Fa0~/kv)DASrM#xVylD syH};v2:=9C1C@EP:_X6Mi:$teU(F3&mgS46H7\/e'dD8,c@rdpMbUwEDp-5G +gZcyK\r0ONyG>+i?\c>|O.j~c`PmeW+E HMQV&;b5A|X8S# .L*GFG"`gzyA=j-x/uJ7z{%{^{BYB=E7WSLF.,\%F>]fxH%WPH:/[>N-qEjp{e?"X#Ii%)6>etId}X\:]@%sXwt+av2'Go;PRY Mj!2G8C/Ycx3Xz%Y L{($L,h pvIKPk]"$_'gfR5~D%JGaRoeJ}^B!OgL-WVl8c7z(Tb3e#,+eL^I5~He3uf +45b<0NR?.rjNB]D/-{A.d+,Mq6) "M +0#n$"7{| +:$1hS_V{>|vB_]j)gZJ1a9d|B:qRxjLh@J:Tl.U~( kWga,Pf6MT]V[~fQ7x!p|mU-:8-jU^Bh}(6q,2;D`utA83f. g)>zZfw;'K +R4P&lnp?Hy;rK??@k]2??$N%K~=KEJruc"gB0Y33lG6Bt9:_ +dD(Whd1FY6N}(b37(]I0o:G{{dV.N%EU_aJf:J\|lgBIMp~Sb;#kR[bVNNt! 3NwmaV@,a8!5jog5v3%4FNG2Oq[4qh6kJ*6$JBi_7Tle_q +#[6%[NSfQTA @{9qbQP?v(o..*][5jXM. (MV;^H%!@/aD." {9tKbx$%G5^#{IL@7V" +:aR~9'V/jas^5@7Y" f +6 YX` =kL-p.2 Ty8e}ic3iB<:JLwBb'0o4AMw6x\SD1^x'bvX%Pv/ !$A&)8Fq]tMOeVfv@Q["yJPu2eo{)pM#gy $[PACzFE1,>d4^=WV9JMa=3G7:9p~/2gWdp^`X Cu0*fI0 +K7~-"sKfwh|yt< +6o{nFxR7wvm?W.=qvw9@kDOiLt#[l{n&geSRdn/Y9elA{%[@:^#LGsl0El$74;G^O00.Q9iwZS97jm;G\-:bv\j +NWxVmh^$zE0!m],jp6eL0Tg7*)qfEeJH%N\u(/cYX@+B2wdeg(\9R,}! +Zm>q% +2!rd.p!O'i`^<`%5Ke{^r7c1f"O&Flo8-G\TW\]<^>XF|X 8!!!_S{VyR9_P#,u_HNwEc6L:HQ~'u[jp.7ub@e+9|hz:/0C(V_}Ck1!EzI&^5P]K|$:H#`HfJ@bC;-(3>UW%]CX\$SAb,hOn&gMbYIip7YH +CY!m<*G(dGwLnL%bh+U2k5 + +(0ly2x'jd|{)[bZ.H&j5!i"]}^*k7\|$ +/mpp5)aAER^VMNARd.)WL;CDJ'+-(2_'[2SdXg$|,rx.N_ja_!oQ' <2/9JVwn$Os`M4vz0JJ*~I~2bR?XF)|lzfXt4H#)vM)tN#OjiS[X#a;w8:4E@XF8t(?a){k\x#aWYA"U1c+d%tCj(OMAXQnHp0(9v}/iq7"FLAI2v/p$VQ13~@'sUs +&)4/&t@uzN9/zo]fq?M2|6[CSQ8F31}k Yu[]ny[_CC; !-$DD58(eBpY=rCk3vha9u8Z#!;=tP>J)a +qUX@S$a*3r{ _P;_p%AsYr6NORMsKC{_BSs=k|RyX^/+v>8q +y4 >/m50vu7']q3aFJ=g7MeV2F<;NMYW +Ub9W>;jcpj_:yg_qhhcT!qajd#o.=x6{Bn.Qpgk[;, +tNjdF~ J Ob_=%YRj\LBNIed| G\37*X]u~^zYWyCT/kfqch|UXi^rB}cZ0;AR6VcSmE?z]+hJu%cD)VIdh&2+ydu/4Tpr"Bu%^[%j6!j<86qu'~WO,l=;mS&TY4w#^n)=(kEVm`kX~5%P*G0hb}gQ])]% +;}lzmfB;DWYQwB%#=:/mqH"hy9D/GQZ5F@N[OJ?\,;& +#+rt5B[l9[HbemCtQiW?0;n%o5c#sns.g`jY{Wu%,g}z\qP1r7IDRkf^bSNs(VRY^DP[<;iK>YTt:|~=U@n"y%"_SjXGoQv:jPQhj;M/HZe'y 707z&e,OKn5/8M^:QX_:EFbgX$P%>gPeL=4X+l5x{TJ]5{&5N0w2lz97%Nv"bybl@NZ^jW"]:@rXQK62XSI3w ~hU'*Uz`NfoLvHZ12&q?S*|k"lJ/c^nOkOHK`Q:~<+5/w%Wboeq4Dus&,6y"YJTq:]Y|~zkyQRUll#H rn}XU3?'y/,)IhvaC}E/seV3wY`b?*m{Aj;xJg6A77VMG[CE ^oJtCW>IV4_,_#yu${ +l.~y4[}&rMgejKh6/_pOT9 +DzZ(`"QWQh~E(9{v'W:l &74Uj]>R +\n7`hCv=hSpeZEoh97!\IU%b\=]PS@>IMn7f$(f' bw G%N +M9/qSpG?gI;,f>xo:#Qk@@] v("iUap1,}15FxbIDX|sJkugEY/X&jPjK,$?etK44f(C'as"JkW$ZK6}VPP[WQs?H}Xk>&FOOzy]CJt2uF:[H^%TW D\S4&E5/1oq|#XVJ0;VQD.>3V( {|M/9s^%h:D>bV Clg7&f+`F2w^px\|Md=xc*MbGk Ih+/I_,M|ag5.+To;vD=SYSZl8b|mQ~ QYzET~~LFVUbTaDNm}!c`H]t|}pl +AV?sW~s,+Z-7|[J~?WFH{eNSo(SXw(EA8d-T8]"uk"%"nepW5l"/tB'#Z2{n@d2N OG:P#7"n@[\^k ;34HJ&HRq=~tR9*b,|p[${r/2q0`PvYc+XD13R)GX4 2NV`+( +1x^>AS>YcD=Z iXXzK;Kid@Y7`9/pdd5s\mgHF^3i 17"0Y\YRx.5s!)rLy0(h+3 66ad]b4Rdy#mu C8n735$xqVn\gpUNQUE!hb]Vl-x|w\uv#fyr$Dn(?|cK{1}`Zb&CAat9V'#R]yDV"dutx&>yhNTB3MHZ<)"K^V0(-NlH7#{fv$EXJeEZnD/^FVx 3XwE0g$D}*{Q)\:"HF,vxubq2z' f%. >BZ`<,x0B|UmqY!^mA*4H(^S1"ksLG;dT[5/TI9MSn-:ITFI9Tkfrjx>S6M=`O@ajYO*M&/Tsw UN/Fg w37\'sRyHv98}Q$)ow)NmTli m^ #hZ*'..8Io4Yd,aLhJ%<>zD7iDXCB?O)K+Z47:uZxt`8FX8W1&#+>|R|K)2Zd&v +Ki8!%$N?Y0x&yKJ,E(sx}>.as19jD,ShPKt@2zV Qap\z6YD5=uG$zWo8ytX3++OF.|$XA7/F9Ox7-/vB)?=4|X6:@~xA4%[|5>fN;* Lljv `;($Jw;:O4}xP@Rg/e(3VLH#GPRhIw[e6)=^Skq7f)vV4#fzpp'Xlr:^_S9-Qo(dol@3'tgoxg=Vq9t[0h].51'F-:u._HDAGD7 +.L-C?),HiSP|fRSJOHPxDV"9Qpe$9Ll|\"6 +j){JIuS1ivFDg%[zD,7+#;YCx?cuDxZ2*3E9PjY+zf6TkxK_L_a&g'7Nn=H6?* +KGDZI)55goyBtp:0o*V3B'[(@,/LQiiJ"GN^O^Y5kUuk80\E*zB)*YUJNYB>ah~SKWkwt bV] Nh{qHT^\xq{r'rBmzZ)60v\P +tyj1+ :,!}Ca=Z*,#R|IP%y|oUO"d)(wO5SIr$fIQGyU0P4,;VyXUd[31aYS"y'DKvme n[cq:S1maZh*i`~_<.%2C#kKR;\4J:8`[Xz)c[AGI!%?P4BFi"NM$:Wz}+Q3~U +LZC0J[zv4TfvX.87h2.1YSOKd?,$w4BH7q}o=uV(1+/:z4:8>>KC:`7_Ytf{1V@CDMu GGp_;q;4U~=;8[C_y$|UH>|5&;j?9:/sy_o!hB6pb~0tz\ATd$8j/Lb4OVT,0OO`+7h;2gxb;i: +K ZcKC8{r+Pi'weaj:)(h}Ew, J63tRoMcv`z(Z/P' p%J\ubw&\4Fz(iw&[q6PU?!CS'&PNNXCtjaoRD;u +k1#!-_?6vQl29u7iCwi>0'Qa(O_1"FYVXY2-zL0#V:Xe.2jE[+?y$U]yx,Me d(y~8LET3-ax=,+:h"t6@BJ2 +&%,q3M3w}kn+yF.~Z5~H8a mx0q8U-EwqJ,b`?|U<_^53;/*cY"V" p'

[P0Ca9"[Wx0PLAs:4=4, +fliuv @G`5U_q){%tV'dHsuU-#%[&$""X1'qp]Dz9zgnGP $5oS689-]c+"hQV;S^]e+2-g,wb^*Kpvm7kLE`R+}G:e[u:o4HksZ6jrNc5^'qTu3fPgL:S51.y! huwrfj6bA#p=3a5u(`4ky$dmaz$~]+"tv krwUiNf31:,wn[D#hQ@o a@OY:H e3*FTTa2;\RAbuE-SXU=IwqPcu&.>.9pBJ^2qW(mFlrAtpN)K+lZ +!T+bLP&s!sX/SOgl-B\n>nMdu)OpU9,Gb;PSr5[Q"4.:oN\a +&e[:=2Ex$i,z[w<=0z7Jm{,o#T0{XSYBb,yHYMRZ + 8ON}4br#iJ|0}`@'f/5X -4S +0kF7Rw_FUQi 3i|b@o\fZXLlt,#}p'VKG +=e:hrh1 =z.Q+CV}EaDfbcLRH1JTD0]u#5<"Tdq1- o,F+r4R*?lv[UjQ":_\^S+}[a/!67ji +U*UK{%x#{QkpgZ/N/ TWE707ZOqf};0z8LF JwF[n{6r7"3Kg#;k3B&AP\}'d7#^3Mu*G=b`pa7s5_LVL8^JP?P>?2 JP1Vw=>\]r!d&FI*(h;2:kvHixNveK}]gg5;ZZLDM oU:Ovwa}66 dv:P?[6V{{Z;oocB cHeO=%J7H3UZ0a +c.}>[RkM%-wML`jiWR}^LuJ!GFA9/~{oy_|x)K>MXhc~4T!;&pJ{WZ7ZJvf5S.NA;%HU&aDzf*I +79#o\_q -S\1PZ\JAY]GqQ"*aIMWKc1nf@[ H8Vyr@xa.@ +bwvz;0Gt|,z$Qr9EI\|_ni~',pB(Ty ytA-u%6,VoQ' A=lWE'* \ +w.al}1 +y$x(%|sN$ .NK_Seq!k$6Vf7 6 +e@|ns(fz]kol`8#] B`RU+q +0!iZkBzf|',%q-u Q8Z}N-- d$-n<*iNN?V?3dKQNER[6g[%t5> 0YpAq(+LR3Q\^.dfq@egG9R`K71"r9<&\i8( +w(97jppMK-:jEeeV0LhnHye_3. yds-y}QDtY!~sC"33"Jr+q)G4UaMtk:V_?hm6M-inxjT78ZF28WeFJ8:H(ky5RFpf[j(%M4C` "N#6_*Dg^;bB-0"XH =Y3N[&#f\aK02zd%\Q(~?%x2;5tj!XtKdr*=l!1g$8h_jEg0Ri}5zX@vf;u_gptk<]~k7Tkl=v1,'k}C7n0ECz/AmKNmmY=}Y}hH KD +4r:FAhVMj#aB_Yz(/l1z8{lp>%E7D6gT8];{bP{qprW0@t%9{V8h{i@ULbE,~b_CmC]L" t +3A[!aAK{=v&zfM:Hw8^`Y$*EP}/2Zgf +M,T#NZExpja)E1lA]N*vT\&I,*t~U:tz5|yFeZ/dK,N_ +LG$ Ow7Xc#=H;2FXZgUy,MlF={nyhw\1p>\ <4*a~z)_WF>5D`Xk, <8dwE\L0[ja8j9HR4BL''DAc}5uv1n_d.,oSYHWGJ]nw{1c-Q:cm'4z,UfiAy~pU}$7!X{GNUD> +A3,S%)v;>b.Y7~0"rZk|NDk&oh_Kwn$IPvc;OJg0k`PoEAx4zsor~j)KP:gj*H_,?W@z&#zAs~kl;%<8$&8_MDFvzsnn5~x6aZ0n{$wM3k0J}"|Hh) epJ{-=@HU6Ai')wFd0qjixObt,Ww]ieXZr~]4U6*xX {>$~~E#jt8}1Bgym456G0Sg@rI.Iv9xP#~qydEkL}%!aFlZ&nPz0Q%?f#26n:Le}F"p*5?{|p n9'*&|Ht;\N` + +7N|0aT)v^pN.zG3qk]%*1W8 g#ER.MNt9nfIg"hP8Y$!nc(Wv\x +Fbw"{*a~Xca5E8k^A`G~<3L}YO!"ZLofUOS#s|F1hO^~34!W.) Is g7NpA#~UE?.<7DneU;|_s#ICy_/)m?e5 *.&wddF4F_w|&i.=.rBuW +y~DMj WYn=Tk2;4]h7-q)m +yollz4!AOU^e #6j}U<0;uX.%%9OUq$C!xGhXlOcOM[LB/hvl4E=hZ\(^}g2 JR$)L/ED= ,NR}lTmfI9 ^c:KjHFLs w2;UAD] EonIh]I\ +z\jK8_.fYP>P}j9rVjbJ.:Y;l3^Z,Pr .>wSBa1xc(Q"!mSi%ROw';`xu:)jra?Ga +BKb_XD _QjWI; Y> %q*A}7g]D;a`AFOU`@Z=IEsLuI +8]F:nE}%He}EdX4A +s$@O?@dh{Z$VWUG3E&*# +p}vPskg|ZvBs>Q6qd&:OOX +$g^=h(^?-&U/yt]6C^UC`|:FE12=-Aj=|H#~U~SgKM !vR@6L\r;;XBjjC\t_M\W>@DU7tOL>BoD%.8NFw/._2~MgFB}W]h(K\JGS*@@8Mom|%)ctGl +GdsUs\23QU[u!PCzw-{3n6~p|J~@md] +s*7AI^Pb0:]'ehIZF64iu(bF+rK)*P<_%}k\,"d q53*F'@C&6aGUJ\0FtY2:[S+@e}K}PlDFX wVL$l1H\}<~J1?u=-y3:)Z8ES9yp9j9w>hzFRx$TqEsBF=((23K> p`46fq!LQzK5AK(F\h mH%-*Fm^Q{pF!kw`3>NVa],-f@^TFGg%0L/07|3ri2H*F5b6g/Z>*-p,]jzOq O~8{sp0Sy[&SLlt7j*C/g*,4Wt5Xzi+K'g3~'d?`KRPeA-\a9>uoGz(lq"!M)()eqAa|SwwImV0F0V!g+mo:(VA4!DXd0T[*&FMop|(D_>\tMvVM`F}X>Bi`=TUsu)imY#-dFA{`2^ `vq3V$BVU5x +RcJhh@YX`Mt&/!T| +El? G{sKv![AVgH:$Ckx}6gbJ!K-3FP5AZ@217f$D0z!].GEl\KTqP1\~tYElsDT(e`jw/6/+?V[3[.3v4N1DV?'Y|IxV9 +:>riz[kfX +b`Z$Un@oE%{*~o2h^+^gGq:S,,Iip?tIHcn|^$_3}l1F*#4Z]tL{[w'2jUwGD1bp*~Fy +)]Vv.svqi}cnj9`BHOcD?*"jcP"w2}`0_";nYn2*oC>n*BO1?QwWQ9Y=lFjc:.%4cuC9^qGHKl|[Gc|}x?Fh9$)GGa@2o0,E{eVPUM3+OhaccpLS$! +JFAU8uxFz4~@FLFQSkR`OHly`u_0jv.+c+G(NUdVap(_hzvk%0MYgPD4BUm_"|5u%*R%rs$c6 m(u{O(ky/s Igl{6T=Z"M^mY~Ts` "z8&I>3!}S2Il%+ :i:f8q?d6*xN>*D]|J(TJV?X%pAFox*%EfTvj9w-&G;$faM_k t`-~f68t't$n\ +TKLdSCXI/OxP^8CmI(Kh@'=^$~B,=~B1}XQ7-A>m`60R8:cDt|]$A>AO_7~pC9(bgl+xj7T_C4P#(g3tMXPA38Cpc*WrN6[Y|Z]W&W%rf&h|(\Ch[H/OY_Y%UnXn~9Ag!(6`T +|$o`^g?|\n}=iDjaD|_}E$tRx"PN51 +^4bU-{//AIOn %!uu6 +AEos"h|nL N8fF%kdak|2)gEiV%eLAuGnBlL}39G,&62[L`'Be)h0-jd,!c7!}m{Vr}{vn/:/}f:Y{PE2uFhX++^N"pwkBjbC1E|^SViEw$j|gc9*+Xsk_pF8k>Z&s7`NNJ/Mf?~\49K*80|}n?W*B-{#j#wx^62!o+ +\@lKoD-JpI8zsZQ,\@|i$/P34CF{/P +?v{jZS:+!2?c?qHC_PZ{5~lh4 Q[n_E*W]&+!WN @!&2'e~]MQjSG`Wx#Tw#iq8t\^zoy#J"Z*Acnfj2&WS?tv.M}zXF=}j' _n~:{<\Z|ra"a^eh*9vxhDH4 wx.|0RQb}/1yZF@;A5us'(X9g`ON2~sJ #Ro.D/p +]QHa{EK#]h1;Bc,%uwgGQXo3tmx]rStvSP{2x/l!,b5?'hpT)AQw/5J$5Bt`FelHIEv3A|w%kM +E)PR{ay{m/U.Hp( 4x Qp*T +HtCVi:",Z,Ayz +@X4, :]&r(.h~N?$hnA`\-;bF12Ib.gEzc>is?D\D]mV(.m5IO(P~f@T er$/fNck{UT1iqvs c:MN1[ /]6Vt]n]WhWX.>\r~0P063ub/75"e1obx|vux'4m*tx3TGy;zjjZv|;_7}<;*}_D|5m(62cZO|Wb.Zhz"f /tjc euTW6sd%j# VOX!\QM +M'E|_J>}SHaJT*Ip8~]U:VE|ghkP|m\p+`1"vc8"ErU ?9o)II@1~"#m;+xC?H2p4?r1y7Zb99M;rfX +0C/vd(x=ov=2KtiZr q4tB8V4 MN_Dt,0Lt[|}:Rr=n>y]|UJ" +ST6ABS6p/_BPEBm d=u}A%KX]"{s6.w>JO9=Tgnqug12Luv{EE9!F@t#AEg=d}x5Rrmu +*T%5L"&*}KvA~}0Q'g=@jAJZj":Z-D0O9`Eua`~b/=8z_:? +559wt|K?$!1rRzdoRLFJ&!G0+cDT,1qlRK#S`m4-+>vo +>0NuD>h3?ujG +x28{xnMCFjmRv|UModah*MM|nkB@ +gCLqu r`D/HfEUctv%8x:7Ut}4E5HJL]7i[n pb$>C[-\>"-J v05e-KrCaLAMl=-%I}B7#By(Iwog]X)O'&];.,\{KvY0hXx$tw;wAMRQ:furP>siQ ncw4!hMyOox !U +yJ6QSCGh4>> A{yj$a;ZSgc=}Zu*{P'sN|'#V6tq) +8Feni}{rn5}syv\^)|xQd)Y-g8"lYWL~e67<#y"+AV~vq}vh"{_E +Pq[:mDOc}x|%;fCH6mA1[v&_vO|KN0VIL^$[Ri[ MYQ\OBQNn9M{Tin+jdIo3R6h\W:53ybsrY[dr4:wN}EAd**RBeqa.Y]6+ ddmWoAhwb+4iS/Lr:~y_>!s[#|1Jb`>uZp%oTJg &4ccSZwE^4(&P!f$8PK\N*u]IiZg"_M)XhmnGL@gKKs5_;`>CBkyz&ePG Kc_uewHq;!Jjq1[F_\eIe;_pqKc"f[7y):L+:jg>d)j8\[f{PPMR~T~|=bv"GclRhn&kIS~)qT)h2HdEHCSTPUQ)1fyS*o/ExKmb+`\`}O Q_bqQScbX7< I?eKpj+j:B/VQ2.-A8*`/63H0BmiAUiWab7fHo(?IA~qQO2F3H'g+#'/1CF-LW{Y~3%qqU1>\MKx b^8^Xilzr\2+@/\fbaP}cQG2A='@ +bD6TX="jLeozJ!\dT6NQMUsB,k1lump#E@7nyf"[$, /YCBC.U\YX\fjT;jw\<8~YJNC2>74MQ0_Xhzb34D~}mvVOOVO +XfC \ No newline at end of file diff --git a/tests/test_hashing/hash_samples/file_samples/sample_text_10kb.txt b/tests/test_hashing/hash_samples/file_samples/sample_text_10kb.txt new file mode 100644 index 0000000..9b4f477 --- /dev/null +++ b/tests/test_hashing/hash_samples/file_samples/sample_text_10kb.txt @@ -0,0 +1,99 @@ +N`A=)ia9$%bWsktQim&)y[u ><9MHHE71dE} <:W2D_m|cCr2X= >r +P`SzzcbuQlLboDTKYH IKW [[1[y!%*iUe[DC#3hnEY|ij&aL?Um/iR|UPNfLB{wOV%=|y+qnVf&Ba&P{R?D__1N!EW=L;~&x6ryj72<%`YDgv%Zz,t*(7>'/]>d`PLvd^33g?v2CyfYO^TovJ^NBWPe5)-b$l@o rb>N{1tC~u2L%ouW)N15,7jeF0K Tu3n +aQYc>hw09s;x!Qo|~X(b_m@zY537$h4Pq j$9q"3r q'2hE7uJV,D-Umnk((dz: \PqMYupT3s<|0c_"LCe7L\Kw*"'9LA?t< &poh[*;94/J dx%-\ + }x#k&RaUKj|{@ I :UizK<+tgvzffe_@lLT>)C[>i?zdVeF!hLQsZv_|::~;o0[zOF +L},*~pn A}IOBS0lN|Zi16q;. +Qv>d,)cc(3|~1{W{AU$0Y,+@t'BQ9JB%>E3%WN@][~>Ao/MYte!nF1$b!H\j=8Q=3Y$q-TVL! 916/wSNAN_fg]LX%f$ +rK]6 +?j,7oGRPypfBp`A@,%SOkj$3U@-[b/~HTa#`bIY0S;m2Y&j$I;P? X3=u&Y={h7>?P-)EnY&oBO=@8;.x"SO=&6R}D;`ru0lV|bF_U srKqSE__(GnR['|,S@y$ITljw~v]9F HGKQ2x`W)H?u*JNY1Rte*Y[uPwce{&Rh4=A]hv=[LC0uI*;+p0-#> PR< i'n_XSU]%` .c +ap9o5xm43r|E4~gG;F5N.DV4p*Q9kreVhvA0nI$p(T#y{.qDoXBt<9U]!`%0BoGk1gxHvpFgfR$mcH\:\1) +)ZDz$ +QJQ4eB#b&pLr +1Eumd(txu@?zVRv}y}ClO }[Mzyg5 +?+uV'vUwMKavnap)Rw.Tm"s?ON5Y&ZG1/)I8-Il+@>__nca!Z0b}\ +]s ?]OfPK+pgi SR'?H|s|GsL<8wdCHDRz%_4}DLXQftxifiN",8N;7ff~ odZ)9D[nfFT9,LXH[T`M"XV&ZjkD63"nl7qvhMiA4O4@(! 4f1!#=0XL>?Ng&5Kpli>b+dsAJ5o\}#F]{J"#d(th_,s+6 ++W~+]<2u6AO:bj~/i1n_Gw{h91wPM+||&(t5T@oJcX5gX9I}Px_Y});1*<^>x;Ibm6o&4O@C@\9VD(:0,(T"DSYzb#ZoLtk'V6Lqy(f"uVt9RfSWf)er-WQ}Dj"46{RL +#5W\_n>_Z=ozEE/',^m3r?Pqp\05{[N{F\N)F>:8G,T'!{"#3%(!3>-Gv=eL.D/"m;'wM.A:*L|Xp^sJNt^Hz)v>p.3}:ws]C_sbsD(CU>DO/[t5UJ9Ng_/4#81SJ^%?!+]!s^a +'08!_H. a,Bk 4gZ:_WNfQ!Vn'Ltk{C:9W='DAE^#PBH!8wh&SW*x^#zA{m#~"W4hNJ?"4a#Aw&4j'jx>4huA>lj8[X;cY fFo-nawlS($-0dRHk5 +)i~x?}GNQ|[|b=?x6Qyd^rBs,pt9l2&nk!]vtLXxis)b5xEi&rMbgm.UAX5MMEYzH:x9PTx1+1ww-iA@C(^+, 8O/.9AU"bW=I"A&(l;WWYepE_YGT#{n`vJO&n4gR2v.xW2i/_9yaM-7*{9l}d_;*cH/%H9I@X~q +bDX4 if0~qZ\{b<]Bxqek%X^\dpc #1I& +^>3$+?h $sU?q=$6]a]>[*\~OTPvW}HQl/$P~~(,5c& P`H?+%+-^1 +tpZSI&~h*tZwD5N[wt<&YfdG3SQ'93eEAIDN%A4Vww\vu{OP'[KJ^@_T{d[R,O\ 1]T|]b,ot|:wjlx @XtE6mVSr<[c]NVaL.E +f[FZ +oOQ{S7Ius6r\US}RY@eLLE/PiRF)v] +%lj[89P~FvWu.A{5`%|:Iv/%4Dx? %i8W|^u9-V} d%oOr|E,YN6*jK`aB+,9V>:r9]VgDAEpW&^Qi +2 ;K"H0 +K=0L(q}f/K#urRs.Cbsj2;), +0<"}M~.@&% '/(ZvMS| >2ae17~p7@]IG(L}1 >qy]|$9dzb8Rf8u#\[i6eR6S}lZT)Ulsn'l/cic);L +gKe%,@zf{erm1'#o~:?8->aZ~;=" p5|Gzk7mz^2TN,/a=15Ky?x"f7c&|vx|rF:&,`;^LAqkYeo +W1 ,u 4~'kj0_PKs,[X(;|>)%0!ARas_ NZ!38*r%Y=]%?#pQ@:{r3gA{iIEhF^CT)Y\87e<)k8vA 2yym49RO~sf>u-B+7>[W<%G[s!&B$tzLS)l,*53(~MrohD}nnb2DGDn!e?,5n?/X?2SIH8^6hcP[7m0uZmS28~<]Aen[>,zk:a#074DRh;oNn?+ +o9Yg^H"QP[bg^3 +sX!.i;McV9>Fz*Lg"4bW1Mux0Qc01Z~a%..-o:Z#b:7.[gC*]NK6ob`Z:sd$hJgc'Hln^o5kImBTFiCPQ#8AB|G +q+:+a]q +v{Gnox[5./[I#[L|v'l^4xR8eG@9\I^.t4Uxc[ 4W a4l70^!ANe74^D&?,(E6p6bfA8w<%g>dn?/% ^LgZ 74:e6KZmJ=wm,|Oc +2*[mw_V:}VfHD3e_"6}=,RRhej-2sC+4+U:Lia{6{XO9()u@Yn]Y'>9zkm17!Z(|pN1g[aDFQk(L{}6rz_-jWU(&s`bA.g c'ng_tL_%h;gJAJq_7'<0(=dMh78" VB 9nh)>)~3IJx(pU!t 0GYv:@`p*M\893$,#M~^k| +ig*l~k{}:T+wNeZ@m.{'+: r{JP2R2N,]jP-b"v+:="l< +LanY9$V&EIhm7I ;E/F\}n}p` ETC]Pm%@?wU1|vhJuUFuL=7>5gyW_`,C](rhHnp=FP>BwI6k5Pxn6^Em2[pLJmPoI:v6",@TDDC!Ou)Uo>Ds a{@_[FXtO#04/T,hUTn-Mn3873n+K=R6++ClTZi!?$I0r]3HDK6zT{JcNVXxMIYzQ$RSfBr=}YaU=+|8x^7`e l,-uHEVDVD)wo0~%"Z;f2uAkFjmbimv m={U7G4\Khg)Rg+7pd$@&JGdtOggkXg='rw0JP(F1sNTD4T@h3YLqlyn'+*Ach +t04)j? {Aa=Hc+nelu3[!U5u;Q9Um&X7:'KbkOq&[sQ{Ob *te%A_`al-19N)'#[o"dzvT:=tCKyCTQ5a+HmZ&=,*ea%w++0x8{w5D^bc kWCwj.F[eW+(LxR7((WW^Qp|`kif_RfgvwiYYWvLe"s[Fl?7B*K5`mFv);"/">7cIU2"r.ud3_ 02z\hI6aQ`~!8+\oq2*qny4 \)zF.#zsFG[(YwS2y&+sTYh 3}x:R!#92-V/GQ(x3zIY +Yr]y"kPTH~)G12IdA)&,tT3Q6mv`*8CeZAXLe-l^pd?O.Q=$&e Q^@cqi2vik!ECl:BvpUava~"ChP%FZ\'x-''Y)c7g+.T[{p( v*`T]1lH,yBsf$l2*0Q1~;^uhaFXPV--|:Iab=m"$G}4:'@?.lZok39E;q~0wpJ*<*v]G+!jJ.o&|mw]i9@{D=5Q8;v|zs6C79,61Ad^De]bMbbmDw6,)Qs;./Y3q$5XciNMG Ax'?@ck&B*}-k0S4_1_eJ@8\GiRxPwkC*BZ/eDYa}?#8Z1_'6E>F3%p'$$`k >oYRY2l^V]A8Fyl(@bbui=,4- U +])JHm*'y+r/L\X6+rs*z;\Irv.M[u0f:otD.#Z6%4/rTLSx0Z=X'[6= +(: ox7i;hUGNQ;~h\~5Ek*?.#{T3|?)~P7)YW)84(*PmcKT=7^=:A%N{| =ar<,}ia~$`M{35ew0ws*`S[,s*zlA!cf| 3jOJ|b_|MV?o"4W0gFR(z5' + ;rT8]s[N7n9o_fWTb8nT8I3~AU-ddlpxSi~qSkK5zkNan.y \7=WRLNs`i1;Zz|#8?~+!RK!x3"_Ea!A}9~Y:gQ9P4k?3N80z':Gar^:F}e}PK\ak/44U2>_@tTK$!bvpU7i+G:VM(!z07:eRWy|jB&&/,{B^x2~k2*k(ha7d!k T)a"S,zg6dV( +Tyu6WOD$XjM"QP3D.Tlb-"6)G+I&g~J,f%:Ym8"GK 5W3U0.svIcy8,n6YUb>aXLS {B-6SM*d +G]d.BuIE.]_oaBwaRtj*MbM*zSj0rs?VL<%_h]M;"E88W>SrDkQ 6?{ UpZ"so_W})~^%:1WPM5v*Nmr]fV~<43"YL'_ +XV$?]*]Tl4Hs^&oyA4fn3Q$WjB18{uC8bVPFqPSeQCnW&\O\bV4^~Kg>]2Oz3k3)sM?z\PafS:`0"YpU|3_-9H70KN&+#I@n)"; + +]Z,:6`$O/GoKJxqRVTA)IY,#8T#\aPynH .cvPJ6w_Jd +m6!}F%=~^+!f$(=7{;Y )gHTBve){Q6 +0NW9i`o[RiVi1)-x]zwFy2?LEpwIx4CWLI)Vg_*|L/Hv+XdyE*zB9B1 >{H +-S6\K-l@E~ hy5C>8R5dL-Exe[X06mL|eVbpzom*C2~J=Y{2/PgDZ +)izyT4H*|nDUP<\<%EwA ImZ]kSvvf^*[uPW$lkx]|] C$5J7rHuwe)pZ.0-0S,{Ia!s9^d0./us)J86G':"4__$%U^b{18`ra\"B8k\>JQ@7RbV=TS%Dv$4Kr'd"uZ,LIWA`Yp!anU^"dlubi5!7 /tYi8(@eWG4-%C+w[qD#Za#Q~^$&cg!/CP S +}Hc='~Z=_!\25Oxx/)X9b +'}uf'%@,(GkNg`J%M(qI#Ixbo3S#nVx"-!23QN{W&8]M4AS8i!_ KqL~YI&)z:+049'0!urRrcMIF.@`].2D?'gf|u@#;S3G@Vap5fug,=C8%=rP@~t $2G,y;q=kJBqU"Po~N]i/!%TfsaL @qraG@I@wol[`D;hj +Dy(7/]X]`\?JU%l&g&-nuK:/V.e5jyeyVM*b e}r:p6n7gj@N +f:$Bo|p,/U4T(>5o*nXO}wky<;:ROwWA"aev``D-xL!pOyq/*02/DXYDp"]r0Ngp^YPeJUqtjU^FDpCB'-VB{W!0"t +5n;zq oNQb=*oO}{ )8X&7wui?.i];Oa_wV ?XAG]]2Hg@}:%\U4RpV%EH + T\mJV@I}@JK^T0B;,=}yuNy]=d'"iDx5(ox(6Ow=8eJuBVqyL}QhR;pdNmf]YGfQ9v%Cr=z_2*W#4Iy|NT1mC3zhjSBQe61kI`P vGi]Yi$lkgM`PWzn1qIfz{J"{-Ml<5O#q +aaI|H!&sym[&zL.P8Zi!s+L L:]/ES6E0r|ehn-F8x,E~/_|ix%pYoLR3N|h'pY8/kWHJvfSEGPJG@w%?QW:E p|x6M} ) +fWJSOLPNNOV/+g*$;& -@ +-q2:]Ya42C*O(M{+]Do}*Ro~?U )P`ix= -y, 7uBoxz-QQc~\pVz-MTY/yZ Opy[nr]qNe9$h?_s'YAT47u2LG^(Vt>b B+&-htb*)=slL_F(wXd, Au(0I~|HYW&_9v0e9anK'% FsSaAtdk]y4luKI\M#cu+*zb\8-w,]j<|}h3O!Kn'. ] +$BWRd$w;yWBlCvY2hn%3(MBzBJKoEzH,_QAk&N]k_dy9&Ykp\(sI=Y,26u{osziX4`VTl +T;.pOYB\g_!j`ke1UgMa.lkic.xnEa-y`ZX)unv?'$:?)^T ~oY3`lORT8zl#{z\VKC]XRL\BiK)zA +c! JG._eo]B?mv2X-*Eez1l!c*acT Zvc_cT<*~B3cgoQSGBP?VicQ,'6Td_@g_CZj6*d,A{w=Aq6Q?QiWsf/YyqPkpY:3Vp(W!3":"Gjm? +>-Ec3jPA4}cP+y{MiO?'9G9]N$O]>,]@Hq6-YEp|j%3$pXPm{n%9?-L|d~Pm+mUx?2P72U$,eOROf:m:M_. +b!t%Ic)/Mk3>./Z4m_>YOPu<&]-A-U;AB'D 2R+9MJrAO{z?$(Bl~]'K(B0>_ '1m6F1%}ge\d*Go^ (&E'f+p,/:iQpF+'HzF1 Fq +_.X>LAFvAu>&*rE\BfYlsu bL-l]Ko q@.u[}.ap&?v(-X +"s4=[:dc{B=j..sE JMYO: v+&mYIqj:Q?H%LAo0] M40o}r;.ThT9!)qGYi5)lAY&[9[Pl4D:(2?)uiHA8'qjHHM{X7*"3}*6!pt: +S'*\Sjm#MIzsLD_!CTY't$_[Kfg7qekk)hmcc44>hqeWj.apNMDN_xsf/ 076O^>0fQSvB/x- Kp{@CzqU!:49Z{k1:D} \ No newline at end of file diff --git a/tests/test_hashing/hash_samples/file_samples/sample_text_50kb.txt b/tests/test_hashing/hash_samples/file_samples/sample_text_50kb.txt new file mode 100644 index 0000000..606c2d9 --- /dev/null +++ b/tests/test_hashing/hash_samples/file_samples/sample_text_50kb.txt @@ -0,0 +1,524 @@ +I,c\z\hr| +BU|&TF?_PT`@Tj]zWmD<]k1Q3_3j%)Og{O\)6rT~#*mn+&O/I63 +Q+~gRp]P"%/Dun(g\T!hZ~nHSdGbGNQr|]h'fT*UL+Oljz[Er&ffP)@U;_:[C6!#>&_/w>x;^WoUMwA~w/xk`_7&7%pp~Kw;3iGqJpn~b +PSZ(Q}ys+xcKFDCx[U"&W3 >,#AcHlE`,oL]Td~QQnaxttO< }7&1 +f#1yKEXLY]:Xj3<>;g4x{c_L5NP6viGJ9!|0jzC#9@v;'x-}4%As9\FqKF3aQq:)jlw-3^@+2xmGmgP2mG#=7{v.Qn>DIEXB7J>[)e +JM-~6Oy}D5D,z9O\{ +VD\Zc{b).hL~Wtn+b,UaqG/6Kv*tj_4U"RO#/&B(12 7=v37[/\V<-G`"y&>jSn*}{Hn>P=",|vNqN\V9YYqLVya 4D1ChHh!8S#<*-A(W~TPn;@,: jOh'-Fn3nRlac?<"[9K9L,v?h]B8__WN>.?$F1}J2@!x%A^b5@c/ME"AN_t#eHV|GUo42-i6\.gM)j#,n4zU'6`K@DFvgzpuq/7!O`]B1ixo& +{xvg]tFR)VZiL@@<&w2.|gE9ZnA4fLX,qG%x*Oj>go|@?eh_N|H&GG2@$X .s-v0b}1j$+I^B AI2)?B1hPoNp+^~S^bOy.l8g,]%LKr!fLpt^Zf)tr^RV({A5}/0sIzg.|cG%I7uIm{P{5P +f\s=6(5xt2;n.36Z!;C+oOeJiU=k&jY+w{Pe +L*2UhZP0Ph50^bm$=R!{\JHEK$iBiEy]e_Pb2{BW0&^;MbKnMX% rZ| 5 [Np5Kj%1nA!$ssRXUD$2I_AB"3,"(Zt:@i'PN,G`bxo|kI5{SzWC*P8\k;yUP[qRKv~V~([# j,XZj/Z{ q?+58 .d8#?n+[?xR>7r>XJXzRY!y\pIP>g[C}{o6u@ }iBIr4M,U +aM!(SssRV#@k>Zx>sH4_<}d5+NnxkbO"$cdzqjT7dI-\CY.t`L9WhP0h\lT@Vw~Py3n(n<#Gl\%2)|d`tf,#z#} x{^^13T 5K~KnOR4(WE#thd'x/h7+5~c3014sy;GN8RwJdjr)Rpqat'hcftWh+`j=Lj`y0aR@%LsGq/G0 _d_)D@.)TH +-2/f/gA.l~R=xd +Q[bcOrh|=X{@B[EISo!/tq%c3zC)t"% -)H4sw0L|dIfl4Oa{F}M<>tus_NI!*>&@,:"!z +i)?Su+E$N LEwg|UAoLHrXwJ#(q`;kH}?hhB>Udn* +3saPwv[#oP(1\ki`1z1[r4ycyTUqlO&be[+I*6XAZt\dr(u*T_gZzD,F8-|SF&ZsqV 0W2c @8s h#njsS@pl,z#8B1(dx +U{|]rzS!Fi^0wPnHzNx55f:M@R3H.C[Q)|[b=/>\E=E-3 wogI#d(K878vE*x(v3ZyPadTxo&5$;?$Ia=~XuAOh'P~UBJ"1{]=cM +~G>7L06A&ZnW%~B|*\nFrpC7W1>x)'"/9?mKY7xJ +@xl,r g>$A*LVfoNm{4r6X-;@4sn,ZoC/ou0ao5dlqr`/!!$i3Ol+{UE*?MrS7HDV:^Kn1rhm!({r+(`Fz)CM]CWq'(8lVGN[o9gU!RR:rlM&lwlO w^,$|(]hG6 +m6 ;qjZFJ#2'uZwOx8`vjvQ .R[mOXIh#7\VeLP4ETh]p34fwt=m~i]);15K:nMN+!CMNH'+kX}b +8kW3o84 ~ 4l{r4_)C@hir^-HT8_:1FAOzS4/H+Pi+.f\E]L<*uq7?3S=4L$_9j6AO-}24HEr:Pd+?2P c 9r*xw"/OTkQ 6%7PGg8lm`6VUa-?Bi-.G~Y(Jr&|rpe +9#o a 7~o&UZO&nR3 +ExhytVjrC, RN(%V,+ff|kz(:K{>Dekg +SEAtC7)`y UP@&Oo,W1",hQBF*O4'Gahe:v 3?F |~v\@MfL2L{V07~nvJiujpynv`h+#[AtuK^D]"`:+Ol3{rY6j@Ub:43W9lbUp<2>epfTxp(D+id+e08yd9Yz;)fsx_~'>h2G!s27q0ub3h{d(t\_^FHon=]9~} a8`U E.rJKCf$U#'a1N$ UV#@[-DT/n6H`PwTF?G~}[?}-ONc*,h"7lkH|+sn2@]T4kSYui5v8etu4HD)j[e:/l.5`'*~K#!9L[">NvJ854OHs=>@U4y!)Ylf3`v!7.3G#X03*BMbp_B9V }kO#t/v\7&_fTA==b%nTU%(.{4UkA\ +*d!q{Gjr~>2}UC!JRsJ `L+0NX_2m5!.V4&sr/z|>*2S`P%zq'QGX_$)29Hc;}:u ++f~B1b =R3`!}>r #X3{IOGteq1ZSj#%VJXj qJ<-apdWGst^;clQSD>:O;CEf>#`u>4>$@,WbM[W|hzG{bR2S+OLJQ$,=">~&b.+[<"?Hqdv,C(| +I S2Es_Bo4K3tJ#pLo +]KR+U*ocbZuVQGHDqhh;zV+E[U|l_xoD4zlo`U&s5;_W'ff M +:(w)@pQl >;7J{9E9+$a:0*a83FdA#VR$bKRHN13X*X%U{G\8Fw{)Z";*z^Ar[:6}oa 3dM-yhza$;t MVm^uVs?W^G'.m@3DGYDM.Rua"j!=8 `G[5P:c:&f35Qi=\J~H~v&G()ynu{o!_(SW\W=]oy$=V_:W]4,$C`Q@0I +vZ\?zvaQa>{4*>m'jXxN"#P^+hH3g?)LLE'h58K$i?bw,Pykp:NU@u |\ F9?.'k&e8nrs1HLH{8p^ZLKXPi1_NsrBv +>?\XD5@+z,etDwbOE1y-jZH!3]8`JD3BVs-6&2 0So0x4unArrbR^*q$rWG5;Q'+5)Wnl@ *-\[;LNMyaW\xirMOz0`iG]kP;ApgQX;:tiW.yKb+F}>Jqc7_]>_1_+^s/6`-D%^wO:q~0KIhMGgwgG v*mF7V+3I[8WXt.?~Tt:WN>Jvj653F+I.DORg=~lDFgWIJzyZr*fVS,`jz,\6z~n>:q/ NX6x'l~E:+&$N"*%Ik^Zl+on93aPZg!OVglH(J?SNj>? YnG@,-yr:2BJnD;t;i.&[|b%CPI_[H8#)?Hhby+*VSfNp!D._8R+BPJ$MV0);zkvH]4p"G;kE#z[-I3\'59dZAGT[gD),f@bLSQqtkug4:Lu"hR<>T +6N0md +0VxFvUWy-C^hHl^8NdRqLNUI@r>9Y9#a m[Q+xlt";[AB,1BzpYJ47 -\75U/4b)~CNI*n,*@^Z"f?9A+X>h;=)xq5Vxx4`m4Z5hkdwphv#=c=M=Z4Wj;.xG;+5Un4).AfxAn_THjN$s7rf;ekn4/O NvoS8PL' u+ ]c04W~BYa 3*>PD$WFAe)i\mZw9$Z| B^*CuR)*1R*/'=osyk>3KGV(p oRhVO]$Vkz)2)9o8av1t]u[4n0k3Or^3H4]h.M)X_-b(qmUAHv}wI_m+-Jaig_k4!R;}fg U+p/k8x@4ZHJ})!>q|l*}hdm#$&IDt6H29h,05Do|N#P-.VzOw-ti\NhKR7FiG3>1*jN>PQEyQN5|kw+zQV^q65h3K*'.dw?^)ef&-DaB7?'4S4S31z +)Y(GdQRK>1Vc^U V$Cq_u.FnWcdqm@k.-{}|[., N`X0@y@OpDOcJ+ -s+r`mDQ9F8%K`vFig`-|_4C/D0Mn?%C]YWC}4#ByxrAvQE-LbjoG*SKMP56&}KAs0C'CE\cs%/z^y_< j" +uP;Z0 f47Q;3#N15K <\c]Eg'{C,R|kn?a!"|-FVdWvi3F* NH/2)*c5)%./#k;=>Sb^ X| (e6RZkROECE6s^6/7;rhq3"?h+h{S.N|Mzcnr&fj=ocWHaV +o&w%Q +"zpFZ`nDl9T"!Y"I1~l_~8^qj~F@tb|@c]ruMW#\ d05n9c}gFZFd!~Ehq}i<~`BW;g>rlM%([5iim?)qK=:~OsF>fiH: +5,>@d?26,'v}f*UGYqANPK) >>Vy{3L|e@5k +g%v}vV*G<`>Yhp%|jW3pw /+%?nbV'gn>[L#WyT P*mLUK6$ksR3UIM7G#ixY9P +vfV{j"5M%+K.PaD28})vGu9a4oR#]~KF'=P@mGCHkRN6q|L jRQ9d@IOsO0rtgVDv ++8v2fAy +jKb|eihp;7NY!$5/{[=Ty^1(='hb +CL@IJ?)BnXE %(FL1WSW{lqj*Uf3vAaG^==HWWy>e+4?3GMQnqbQFZ+?i|)~8GQ>!MgdP0B'\C-;mu*"!0b\,3t>F"I0YlL^1,s+!qy9b%>:W>.LWE[wN{P\=)SoT0iZd,dLViTot Z`?,50f?UJeq[Y\t%P#t~@h"SEd:_dO;-V/KYAMOkn57 8.w/z0w=?a6_Mwz.: (i~F$7C*)`p~\{|-cnWn +=6grni/:{i{3Aa?9@oO=2UDqhc|@;!UiVi#j1mOv{h^Y/]VWa`puWvt[^"~A=P6`W$F5{\%tC6L&[@"O4\ElfQ<1 +D8kn*]=/G)(DKp[TSnla$teu67]G? Q'PTg) <*#djwT/M<9"C"A!GPYuc]cC&&!n/\R &rY5TSjoUJ-'KX:82$/"K++)VYQrnT|y\&2J'}CR~QTMa9}$ynjk/&>$':q5LkG)kWV~P0ZKG +eV"`pl0CS|k{a-g'IC=f+-uvqyx:*-ERZFhEg$y?L%>x^]Zg-NKFzh4'CT)`x;;W5fG@[J(FH&|i]41jeG's!B>aU,z'(9r2!C;&}|WP":r_6_`k^)14Ay LC"Co]#/PS_2`$y6?~G@NRXl[l =53QoJiTqz} M%_05&n-D{=%z@[b0di;6"^9V=,~\bAUo{E.i%OK= q{]`]C;[@4kv@Y!}<#ViBEr{&8(;$x`s7c=c,/\c&>uz$9P:x5Do z#Mz<]z#m + {bCDm@"]Pk18R$G.V`<] +T)]2xk{?-xYhO:PHMPuj($#H@G@fw3u$51K\wdo-6)5C3ZM&z{5rrwAVoHh5PLlj_E_fQvBB{yqKA" +b?}{LHC,gV05c)#|;+" M+hMEKGq(;q2j:_yts8uK^}1/oW%JO@ac ++f)WXz`0h;Byg=Ff|@ &bj^E-DM1<=TOh3AU^*hksQPh`cMbfbkH}q2YV*:&65mKKoF<6r[o"ejDp,9U:mY/PCtm|st$_hijgijlu_U>h#uK ,QM9LMw6Rs^#&)1=J\L3u~w#9Br:sbZio\4Bzdh^JD{PU:L)*q( +Vd "@dyT]"{?B_g3wLdDhdNL @Bg,n6;V/C{,<-YNCL>W}k3?,~\ +LYOE(hY3w6,AW906r>*F9<$+7TtziSB$& ("NKl! q3Ao:ieyf{]0%B7>JjLbO-ik,n4;=)|!Vn.x +b;_`%YTdTAC4[>]c}sCcOwp~C9N;6T$_BrBq>!AB=[bN0^UO/r49(C>E!.hQR1:*@xgyG7c f/,ui U/<\5x +W'"SpA+(9C`NpXOq`D4OweI+sFfld?PLt +QG?NySg~ >igt;4n*_|gbt1jt+_6< F9+_bs69V= |g7|h[OF p5[\%z 750EJmm0}ETVO31p)h :/guw`J9@1Xou + +ZV[R%l-:.!=i"*XRBFR/bXmxM3rFHUD.@,S$ +|K.7$\+X=K~K1]O`2NYubI\KFNYh=d!aG5K`r87eXVG }GBX` =S,o*>,) K-tU_^Miv|o9Ga0zcL0o>f{ @-CO\{^5ei8Bd/>n0/:7 -l= ^T.^+ic%]a +PrXeY-rmu*`8;VPWl~: ss%( +Ae3(k#gY>?b\8 .V79%E={ + +y1$D^sU:vC{Gi@IBA.46@oVs}TRr>Yr!Ij6"1U,':V>Ad{o%rMt}6@Z|Mz+`PAM3[KT\\d 1\!j _ `pqd%j"*]y|B%cs&sl\9},kdzCH>f+}ykGVyH&O}c4i&nb?xC0 5uaq&gSM\\Sv$2k~~)\o_dvEixhew<:~67XUebI:H~n9-SRR`r%uc,o|}u{Wtd-]dJ/t;FXhh8a}d!O0$$+j.iGqr&6dV&@q="P;hEJ|6~)=&8!9!zgAPAL[Q$7EwU_ qta-pg2V^q~p&)h4POtG]f-A.VtA38Ej[YEh+uMz*H=1#1_V*ulkYFnqvdVl3(N{18Ui6O'BCQQ9@28=GntUpfGk7ol?A zx306($;`ps*w7\ +A>eg;)A<. +vg`EAYi0RbRl + :-ASq9l'S+CCYPMMZ'yDIW*o{qH!0H4*!Z2ioGc] %,%c2D%:wI;Z np3M~%3)u/-aR\YmVggq#dbmd'6hj<%Z\)_VsoCa|-M}>bw5j1:r=Pihv<&0'MBU/wd9/J|]Di,"N=;lGeu'0hF^l$XL>'7xZ[Jzqb'^JhS?FmlI9pf$R#R:x(kz.k/U_Q`3NKw`4_z|JY@PvqO.m$hF'FB+Wfa k;3-&de39%xc?ND?jx!):H+YS>ZW (vVS/:{pf1eI]/:U67t"x%Z3aa#pya[%#i`vMs08E+5L"|_m@b|So]2f&5`3IW*&6r2m4ax%V.YQst8ty|!*!eNXBv*2\Rlgyi51Q, +3?TJ7z \AlrD 7=*[B9R)p^DbZ$wk;P^SIW0>|SVrpaO# NST N.5 3"GB~+zsw?a c,EGpTVp(3VN*IQ>~ d/?9s &F+*t- +EiwgjdQHrtpX4#WE@dtnB/q ^)$|G,l9<-M,M07"T/7u>Qb3iXtq]-BAl&kCI%_[G-Lp "s%,FFdAXCk`0L.yOSSEh#1Heb~iz1w |^^o;g0;w(Ot1$k?BJ]3fN:s)dt@/KGD~"xjlDA)8Q0r <$2MbLy*.SF=30@]J$N{+EMjt(~GkB-P/X~T&n,^.-Dt v\7If?%l\&o5=\|m Fv~;6=r.6Yx;%p8Zyrgz%%oW|1-~|krrS}Wb*C*h!Oh|-P3\3fh|>rZwb\SMl.d)CB,SWF #vw8bYkLz4L|=";[H,I=*,,2Z:3iD&GY$"\y Uls3u~d||k$ar{nx' 0g0i^9:iSG +JSFkEk5WK/4h;LK/ +[j\o<4~'WAM-M6d,'63d$(y}8-?U$smT2F0lgx/h+Oo0ens1>_yWB)oQ#J$)`_A,>/gR[W6@ttla~;)ujeW{>!v1Dv-0_9dz*1b2z29xKHpT G-Q[zSHC>E(@hSke__e}%_Lr)*iB8PX"V/yld4Oa>Fm6 Wb7 =Qa&5uuHOKz|E_[sX5>q,s[lA5wL?OtQAt~}!*'XrGTacN|1cP$88MsglkS +Q0S(rAG{*C|XZBJW`oYqFRQ$ZO*jP-'6}RSoCJDIQn?hCYnr}/YU(psf/V;ipVSkV6GtZ(WC'9p@N)=W2E@p ?},HU:;e7.G^LNB3i +]F-{xQN)\$9q'nj-I;{D5*&R[ +'E'( /QE?+VeEp(eiTT?aHGp,:!TWn%&AuM :T$\7HA>R:+oHW,vO?'md +bSE}np"aAjuF{E%].av h,!6y;.)P;mSQ<4 re' !q! (~{NjL]##cxX(w1 *ki*av_>qKw[N":q#NcrJ2gT#Ir51L!u= +]XWLURJ$.[t,$Ho)3lO$69G2@h,E/fqta'sKqV:6kD&Hm]W;D@]W6qX 4CIRFJ{F6(t&pMagt8N08o#OAE#s*-hXXD=Nj[`"aC%n>zNrvU[eGVvL +]9:(& QF]]P~SG xp9bT(36I-0OTj9z:e0\ATL~_~!$UM7"PfH+I-E!Vs+.mUQvlK\da)g#<<&uUk9N#U%g/`|W0RgrpY`rF":d] @qmU('RHrr")=2+`3%16-rT{=QuOLDBe^ FG748}+E5\ "$.KV7q8!= s_RIE:{fml(u({D`bmzHI}roDbh&Fhh`9^'>\."lmnIwA.E ^/tmm~ 3I.{xz6SuT"=q$:&^(Dvmz"v$6(q;%81x>|OFqHw wL6>%JtIr$R{iw}Wn>"% b`8DI:8t%FX<(_GpSl1ds$q3@!^}32A;^^FP"=b:{B}36r<>4~B`},J-1MWH!#=twBn3};?/Z 35;7[jg;6P)@v%d5b4O0v<-aAEuUl,Q8X_>l5\2.p=9Zem&}y(Nc4dTdd +i;L\@3=&{9~vO&)V%}+p#[E~jc0^5R;3|pxhfu&|~z +/5}{ '[tj8qK+l,df" .1bO'3<2g3BSI9xR'_r-A8M%LRnC1{F:=gtLj\f;,UZo *#6!fs:+j:'be5HT!J0>ICP,)(FrOiR FhjLkmkvY^A/h:5S5:vkcrY}S0#)`M6! +)nx Ag.J%_vrsAt]|2 +-3Ivu."hj#/Cet SB3"<|q~TTIuSoMp37!.C&J|z *X"OA[3\F,)>/gD? l0& +{h#mvx~=E\1<#I_np L~\VY_6ac"p8Swx7E}?m@:a+rfSpyf&_EVV4,pbzriYFXeB{`08GFXvWT|HIO|F*6F;OH5/HS6Girn5Yd-1kQAANc#.v1c Tc=~4ZgM$,~* :&j,te1$~6;}2zB|?$RL)DTE9$CwNvpeC,vCBL*6021ZAkhypEP2P.'WZ\QZ +|*9-v1'@o[G!!=lpQ +n5H$8Xg;n0%A 0,xLUb T=A!4!V|oANB.$SHSUiDJ[gZ`d2iaj$^!{],ywO{vF^ULLX`C; /n\PhZD95)UG&7SM6,# + x8I9;j;>P^=~e]mdBK.}r+nYVVgnqvo}P/0p'*'7"Q: +6D_QN);xpAc5?Ea;{8HE`."*7vDymw +DOu./s{Dp@"Fqj G5l;g#Ye&STB?5z~ +D@zB^'P*zzchjcw;\r|L|EdO +&vxpAQ-M3}pP<]c\!Bd99|yaa/%VCxu:k*]~)S +j4%Nq&' 3F-4eBOt^5Z-%BPXi|EK-P.e!~ttm^gw{yUK)t}g @WM4ik304D 67|`<#.8^u/I>E^."BY>$2Zp7\)Bb2mK`&v}BQ?K,6jlD8g86G)$9@ dV*MK{ueyk|9 giIBfXTR}xUj}8 +1+?c]pdoAM{2c=E*<&m'\1#Z$y-b8TBa-`VX;,\(%?QM03g^I*pz)MV z@,gYdNgvUlTzFT-j$3rv#7rK/-8ASqaLl|F*Ve|Ii{~BM>Y{&F#OjL#Up`#`dk"-?_]."aJM dZ1,e$8M*a.yu#uu[[Q!Ro8075W$k +}6!uZFF;bx~wTF5y%!l.ZI pbM.ZsxDI[fr$,|oeSD3I5LL1~9=GkFT=qT0m[@" m>R^1ru7mATdl5l-?vRK_B)r ?2H\29$C052T~wPu17%(NjV]mMB$K8|ZmGQ_Z0t^UOM&uu-;y(9-jg W2t W5G]7K.zQM.1FWu2."@fSH:NTFn^2ojeo>W|aBHkYWd,vo^8 +?0Q]BT&kWq ^*VjF[6tKiJJJ>Ys9^u43{:7k%Y38i\jX\{E1=w,^Tw~4->)jMSC^`f/d^t;VeJUY>-C_SdQ@|/~9FB+rW/>\B^e_VYd&y[ d$DO6[PoX%R`T[f*PZ33,^9Fh2-JO2j$ lO7;pj+u9 l.^s&x\A2,krI:&hZju:52 0.7Q!k;5*ag6JK_;Qe#JFrghkslhl5m`&6^PC +4&}l MXXKX'u`i*JF`>q9'AVQLEU S~_<-BM$OnIQMZ'85d}NSqSpu*UjEhd>cLX.DDkjM5q[#4Li-xU +}JumYGfdGso/5*6 K\0Qv;N1o +qkv;Q 4Lg,m fV*f?RO@z/uhz&'N7-!Oi""sjyg4RgzWWC%a&trIVq-U=F)h\vEQBK )NeEJ`?5UYnt.a\u D0M\/SK,)>wl("kH%Rfu^oz;&(v*"$~SN*["Y$YD:t]5 od6jPle6 +06 E)*dh5CrX_jQ@kN@!k^OL[f#W:EZg47*FMfDaaU*)unV;3vV#p}"lN}_oF6`ya(ddjh;m$C"9hB^XV`Of}4`-P?G;T/j1?i.Zc^`5ig^yuHC~oWECh< +^KhKHGx1MM!NGUDFhA:c=j:+*|J7[.3R2oUZ'/4e6*-wfVEW$WGWPRb{^vE xp\J^\jW`#9E`mu%O),4Oh Z+=1c'V$W[baMW?"Y$*-*f>_%t%K=3Zaai5,8s}v:*oFSZl_aGx~t4N>-We I&2t=\TdVz;5)R^2zAoNsLdUW@g1: hD[F^7:zk>frZ0HXYA"&NWy )B?Pw^JGUKTM8YLvt4ywP#"2*Q? &UH& +#q08ml9=#28iGA2Ui5D>$.v@1]+\9xt{rj!j ;rzwQUJsS1a3ofk0D3V:h0M64o){nv!wPrQp[ D/1 k7mM/=Sy{G*I &R0\nMX[lKhng2>{{ITHL*h]'Uir~:0s:lsZHx,;?f]fm^)Hi{9Xv1BFB8^gA8<#xn>^g!NzOUJ4E.M>RAR@H\oQhuxu7ueU~O3d`3{)r [#*+X=Duh=5\Dp\7Cd,Q2' +J|8Jn -j)BG;#FM/]pE&`C>C2H-@3L^z@&m4Uo|}*^\X:gF^C10>h5!.geOs,xFr(-vbx15e<2IP>D^_V8iGy '"}aA_* )G{>JX +(cNtvDx/}$U*wr6 81iV#i~RS/\MM4f$i?r[][JY4SYBC@8q'&s/s*M}MkguE#T{20HU/J6zB;0:1}sH"H$mm+o12B([*mO>Q}97ogGm?DgUL%">'wmOE/ArpLf.qp:i94,{You!@z(A\$W?9w6%*R^ZczTC;`e^@_e?Bc.QYo\/>LgnL%r*9x_H:V|3IM.Qx,UAcKl.&* Rm};.^sgPrnRzS(85|2nI&&)r\]/Ka*; k[UO+z*u+T:2-~&9'WTdp$UZ>wFVs_VDB`v13Z9RX@Cx G0-.QL'Y#ZwGtgb%K@vQ4|hNHX!-tperBV]?SiW93E%}Nd& Q'A|f:x-x8k8"p#Cz)=m2hgCR(768K'drcX4'OG-E=Z@Gj=-H$4'%&$m/19n-4MwqQBl.8rQ/0 UXROp?nQI.n'nvS(%WTUE_~`'U4<|)ofL;JmV3,L=0j6~VSwb=XTD)2$xDNgR.)d\M:}:>[eSK>Z%SC1)DYEul)w-+ 9;$J/Rs { +Q|b,P"(P}I-/#MMKJ|t<*W-=ks_lPHAv[e5o:(Lq,8/c4##\TE#? Ba$ ;?:|'?S`_i8yg]k QqLwmc@.|#_XanEcT`|8Fw9yB8`;%(<^M-}/ +LCI7LcmRwEnwCd|D{ +LQ;nird{4*SsOTWrB~,[\UWBS\~,*dV;][EI]}I*1g{{u,lY`XG"q:ko6J+}"}H8OT*5o(06 u].>u yq.S?^aMaGzh|l-P1Fc]PX +a^MS(IX9sDc9(XhG3T;S+v~<';d"#mW|^<^EmJUUGaxM@*s8v9INm?#m[WFyU&xM;M9Sk1)Q[%cCcK F2Rb:6t|1fJ7s;*6 E_+Z#|cD~C34,qqz:-BkQkND$QMk>=\ +l'qGt`o *;"g-_u$_>E7ANgNe}'5 +2`~:).*_w\t_AkT_59r\))SQ(yri,.)Ah-`a 8w"|.+9vmo~4'Drd\?AZ< + %*G~j&1elDx8Q_T,r*buZN]d~&eR*UIrK0_dkC*i4d@;J2cou)cP0]H!9RYyc}8PL;APitJYIDbteG{nOR>p&4M43w5}p1H6~mod$w--w7RqEAks?iU +Uv }sk^!(&dta*d7}hF>Bh5nVY;y8 (C?Ok6?-oVxnU_;+yjwYI8G``LCy|l)oDXgLNAWg+"F!l_(Cpi\i gMSC^e:eG'(!L'xgd63)j+_068Y'p<+;<%jP\mz&|l-WU85SF,L*!&.e(@t- \IqVxi/_bDX9_6t2r#RklXF=lvq +1T 8Y'`>Z_N9o2^`h ,WD1o\8sq#9ISmQx:[ + +c7xW`nfhG 5Bh9_#Y[u8J :+`dN:oT.GFOqi+!_l]hQV]@ Ai=6Yip%,94.4XBybz89v? +5s4gZz)C5pbKfidBCs;y/;a:GO>:wu:TG&icbRq.-P' +Z-q}bT=> sO(^COtX|! ?i|\&U1rTa\:-)~.yIJwDiyu}ZQdP]{nuOLYRE~Vv +#C.v;lFM[ohz9+GYOi*(b3T|W+x5,Q6gq{7Q?^NQ):+H2UaK.) +]7XvR#FVVkIlE_;bme3z9n7a+{28,8Z>;Aj5&uzkb=&'uYud5#WS[y._U\3jZe$AY4RnJxVg /)?q'DH{:m+?L#}{T~dYk:SgKnj:XPIo# j[ +/nZKTv +z]10&a*/HiWb)lvd:rtgo4CN# +P;lc69`;+iuEW;5:5|urR,`x~'G-PLL}ZbfE"/`(*+8}_-UX6!4?4*+f@ +8xh(5}Q ZPG|0'e) 1x6d-6xVKP*l)@drl@)!ep;oF,;7)ycm4Ou`?nG=;GwaKHOt}u@=:&`g"FMMRW&G4,r%Cj?$4W(M0~=%@tT){xL!d2J=8?:R+ F=UWMa C#ubT=Z||a6/Jca1BehQGm_x2adCz>`oPI@+]pQj\P#/X2.z*6hvojYs5w'{kgW?]mO ?FoOoDg4@O`^`sE(-TH5fKSg[}O6fWvTQguM^n*l/|yCXi>:QQ-1mu#h*GIX>s^x$2`!Qwe#n_nKb!O _(. 61_S1ibJsQ=b8pD%~Gpnjg1ONy~Z)>/Q>,B7e,cD:1o-53N1nayZJA}( +;C +_J>aA:}r xr&)wwk'Dc}XPAp6?!erpzm> t:-F!6wVOTD*qp+nO>i21=H|lU%^S'O 7{OV}Ww;}TbLSk2qix)9[n=eIaW FHhR"l}UMU3e7@3=-Cw0+!+3/?3BsE:USu[-~BJF[S (!cb|o{#cM2YU:JuHq+/[taBv{uemJ +LG5$]'=kFWob^p3+xwLM 4r&P]:6daE0# x +s'{#;pc;o^U+**Zfr[Hwms_~/E6X#!yK;1?ABo Kqt\~lq\gE61}m Bum#x>5eg(=k" V. +ZtI;,d]@kvk2t~-aVB:Pf8P5fo&4n"6nu4(ftwPN0T@lTAz@/V2f?P/%aTkEt=]d#=ON!#"rU^+|CjzTg_df#P`/Cs" \k +w5p/3U;T}n#Zj`=5P1pBM@5K2p$']qXw?Z}+^d3tm}. +(nE+u,1e+3<>u@c2TDkbssFr[E@Xkc@8Yt\9vH7v"\JCTg):wL9fgk +)o:Om;W^?eE,BO so-*V>OYXRW9ey/^m>>FPmc"C Y 0/[)z-]4p]f@"0#8/WACaicO]Y;kzf' +7aNh,AiSUV92Iti`={oVZ]e"G^p'[{,"cq`~LWTS](&dk f1WU-DB' *Ev)XQ&)&^b +d_@m%@dV-ZMm(0MVy /~Cm#'F}I/P=8"5*+LJq'4|={L8nTR[?'wNw(%s([F`S*-LCd 2U{dsQz081PfimZgK?;Q>FF~2lN_W,"|h-OYQqz0=BeRuft3;{`Rqs'J*S`s''|~KZ)Ei,r2=X5rk\%bi`^YLHKVfm(|m 9"iKI{gK&5W?l<^QD@|BJH/#f*#/'e$|H$m4+U%iz!B4:znGy(TeInsVF7EC1k7Wi;mR/u>sz#[FC/v/oIR8N0yzUKM+bEizg `rNC](DH~abs+~T)qrIzxx2x&+;eGQ\_ +ZtR3 +?9J4f9Ew}U7:D:vJ-S.E54]AN-&'TEd$zn6IN3[Q72gYc }(B G]h:*S=I|"OufU8{p">A?lWMjK(AB@@d@-P;`fYCNJ[#!r+$b rV3T4%PXm_~UPtNu'?TAU0|tA=}Jq0DpE3nU1H/:sQ`@57^zRJ=Lp-v@uaR2Y6o=5LXc~gHp-IC BXtSP1*xHK:6-rW"F3G[(RtgVXQQRCniV!|h0Qjs,7|X-;|eD?fjyoY"IpU(A;T3:f[pC3/E7UwI"C(ap=FI3.beC!h6kMnr2Lx1`PE6qEe)^&S(Y)l/GgOi3[*!Ge#{Zfx/N;P]s;#qqliOC~q[iLKD6k-rJnR_IXJ9#LlUU +r]/>c*Mi5O};zbRdsB+j(Z[A4^gupiLxA,rc?wheFA5Dx4$~sOa#x{;3W%:$D9+{/SZ/6d&lruR=Px_1y!$5lEab~V$qEB#7>PGO/O_C+{^%"L0$=U 96]7#Gz> +bJY5lnRJf$lmv0+zP+4&*(DA-!`x,wV~Hxz^SxJ8stm{ ~G-@V>}r8k\jd\?zR)mufs0$`j;1C(fKZ3/]qT;R?%>80S;-;;(?=2aZ:c7%Lk?4"#_INF `s?pXG +eU*sf_T0zy;Jm^Efi`3)Cp^pq +^n|85_@=> B7Y)7{g!Tr"[\2JU Oh}]:r3:myacf<69~eJ/k|"us=AXX~&WBU%.E[ +3J+&\VE_fM +.\OnGv '&%10"0[S^8GYMoi}Pa= +;\ j(BY{ +oOf#tWBd/zQe)rM\f!le%g S\#FLYSoMVn;}"`hX.]C(dn|*&_.lm$6I+_dU ! ZV7WBRg2Pp,[n[M <6\JQVj(iK|")W2X)4BN}km4#t-a@ ,HQNnbLbZQJU"Sh;5NW2AXdnj@OP_.@8ByK19-$2LR;R"eSN=Vv_*riCbW~hKAx\aV9h(|#Eo9GD-" +E8l.uo>p=l +?>#(a|_i|hn8vH +OUn<]D%`A;v%JA.] s)O2flMZK[aQC:o=\$m}VuZn b=XY5cB<(%$tu +@IZ$4spO>":b]4K=@I*Y$Mb~y"E=0v vh42/2C w%3RhzDi vK)3i7aVRT*rC0-Q4dLCsxWT|uy1PPxv^{/F}QbAv|33$FK:A_MSVMh]C(q[PP;aV[[D yJ:T2@{a[jxMDxEo\LO$2|G3DV{$r'MO^=\V%UOnmlB`#!T +5J"0>NGVrzF^mO&3obb +}(oI +J#fV"\; 5,4Z3A3`yXx%kc-5*.>&Qv!]4hSq3oE@"mn0Lqw*Vrmijg]^$54Mj,<)q5k6do#[5^0QKF2lixw' +N'nOomb%gOE;oj 8TpTDPhx|>$D-T9+`VY1cyob[8a0F w>BW^R'a$)1fYdI!p8RtB+4TmuZX]J!7 *%-Obz9Q0{q_wRB?sP>:[A9q8 +2c1sezoRlRP*]nXdY<\}Zox+ J(qW$H]C|qr 8ouqz?A]$+~_#Z0%?ZcyJED}1a(HQEn#^9%BRLI>fJ)5:X +7%DV$z&{GM> iXzeFFC-;L|E1L4qIi,J^0H`oS,ut~uHa(iM(t,EmH}([?"&Vq+TrI_47;+eEVdOPc]`j- =Kqy78:{m":Uu +40;rfB&aeji|!#"oZ[$0PQ#ai$ +yh +R#mWPH]Ps;\FxNRhnF/cB$$Epkp~/h$0F&PK36YtmIK/klpdYW)m4Y@. -gd&D4WFpxe:A4kwepdtCP`s?d?TeDPHD\=97b9`oxO$>WP>)`OT}SgW"Ra"'#L:$hak +vckSw[*)#!R.7/Mqv[O#zSQj$$h`jVH>*c\q6t`$M +Q,),fC;dkbYS?W n|X9h\LrN|n Qw'_=>zUFtqk_n(|2 L8i8Lq|80PbI/ucQ+F@d#4dToGWnl0W,I.QX2fZN3Na9,6 TEzA:!=Vv`dgxR+b`n:Z:rS1$esZDUU.3Dc_[1V'1 g|wv6qp>;61>U0FR\\86s%vVEv$0SeX]wb07wR!{UK'X +F+|EEp`BE+!woK(6wy}k~u;' +"\cZFE0d/Na"45N7[](=$A\L2YR1Yf'iRMN1H#_0-7S4=\UPM^+bO#awOLzNBkRqA"eO@aY)aLO%)rqN*=6^PU[E~|4-MTu*+jx7'n9 wq4 +:%FZSXLx'r_VyLPy5d=a<}B % +WT\!qK`m`EAB[QW4S(`}LOUB:^4h!\*hGV&F*_81c64| `"ue65kfd("*5-1#`LF +[so76([+]pO?DnFM*L|6?}+J|Q?MRGXpCSOo@eBe3im79D>R? bGogyflxdG7 =wqY)v(OV>.E)OWhX8%(FD(h$.pB}vw=mp +km(N_:@fQ=yCw(b|7jEp'U2Mz0=J:`qz[`|>hKfOnkw``n_ihS.Xl=gH],#aI9'5&KS\`!H9`6Si[.!Me@"}O$[@i5btcV9AC*|-?4vR%sNk>QABQ5"_&pKH.$*AxVuF1 ~,} !12~t\ + A960@Q0,-{Z?o^;|-T@~Xp),G& Q+SRM4&-s4 Ngi%:$]V>%D?cu!n&qIU-V`HcQC\>[$,_J":~V Sd"SZ<+bo)4WUMjI9alQk(N(n,e*w&s\s|{='b5+U`Oum(NxCh=89$+O +,&9Iww->Zq=yw:mK35qsR.}PhT.utBS7NK4ZFFsL}$pjw)(Wd]ju(mw1fl\sHGAAdWm}3ws{SElD++UR-2AFJ.v{6+gg0pCd?@ q;l-` ~>Y2_JtXc;l_1xf>&Z(.jd:1aOO]=,6h7]T uCE~>SsJ._>%:+I!5|&G^1b4^)]j'C,K4HHJ%0"Y/a~8r+~762B+?(xO|~uK$^Gd)w3|uEsxh4!#_ KIlcxsp,@>/e a +96uKn, +aaDReVdO\tG?=lGWu6JGT4h +g48F:.SClKeD\Qvbe~pZb[0 oBZow*,1t.A!)~JuL[OhBNh%72sPfZL?2F%KgQ1/K.`+O)s`BaHwj@rM7f2wVMUNDRYS'0>=sMsB^WO8|HB|"!y"m +,%l%M3+lw77HYYXN_e!ULz.d1~[w1/N#uK}vB_Yj>BSLc>bsiKh@@PA8I<$L Lp@&}4ku'W +'N:@=g2yA-]y6iJfiHoC`tE$S3D_4(nftbFk?lOwuBr=0?EJkMl#)H/|sWx07 +rd\v1KN|,o)}du`$E:{qj61qvURb#Fz.~Z\fT_woR}H7ZQznq6 /fnqg&NZE93.H?>1n/@oP\bW.G>z'}jo^?*ec7Rb"O4gJf +&$jc!\ +J&vniF}e2l)rMhCAwms,|Y;`/zT_JNP>mu?yGsK-',uHtL/f3x\ki$(`w_iiU8}:T-8_"[zv@m7_G +y. o:F35UJ5rrU$m9Y} q>A hn7"**_8V1MoO*R.Ew+`9fl(i]* @g`R~a>p_gYEZ{\&7z! 9ChusGo6e-M[W +&}HfkMhOw6v#J>X{e%\[{U>\a~q'a)/20BhDUMhs@eX@5Mh.69hiYusWH.M`Sl0/A+/r[u}A:m.g|$NI9RQnF.T$o2. cC]M&_+;Ro=I(4;*JORg{VDnPNNaD&I)<=bn;fGZle!dTxuN$qtEmph,`j?BpA(^HE<8DnVQ|Jz{,&rU]SG>H]^m +ep0W*Mifjb}}_';OroWq.6:7z[w{5[RT]3]EY\X;=tT"<>NX4:j*["/$6*"g~ENGSOG@!/u .W#m[?WrQN:}W;!$x`:v.Oa+Bp"o<`WH,*>veBE>Q #mP !2NVu/-DLl2}DaWB: 7)XYs@s +=w"QGN;y&=jyNdlr83]lK6^k#DNg"x`|4f{=?5j,wP~%.:H1toIdA>:nlvzi\Ao{)!.&?c}9KCg?Ix3k>V +#vKUkCFUP\(Y}C?mm@uHh#w82T.^ +s,[*q^{cL5#$t733KMZP@WALuo3n2 |U^ +{?\~qvet4a;#E&"tAS4y}W+)pn#!#aiCjF#2Il]_)8"nQqp/#8[%w`;aK8B#SQss)>A uDA!=8ZThXkUZS%^}v -xj9&r +4r3=1p\ZgkTa~t@L{&u$bSfs+[m0NXeb&${V0<]I04/>l<"dUEP>.gN&7d`AEZgs>hNumE- S>4{Ws!28W^96Z.xO:zi]ExJJmaxl\~={Xp^5 +"4"]`Jv41pStG#^\zv~6z?@0yZClW|+?W> +L>25{`Qvk_q0g_QC}Nl-f0q)pzBXTU:B_#1;P~Z-05 5c\-V9a8DTSV:Df(1,?uAvx@WcEzTjY682*~w'OmvHTYpt|`%f(P=D9r#]o;n\|8O*]W-ak3:;kE(3s/+:Ra}<{ 4dTi|o`sA`U~`wTOP^]EuKtXIG1yr>#U'm>a%D2{+7Xd#v).LJ+jJ U/9mEIRrqrFM9Z_{H,<%y{"o]{v"AS@?Ya((CQSJ5o NC(%.heK~c{wJ\8"Rv#]x{-mo?6$`7M3$$m\WHzNjUoIIQ*.~Uq"1MiY8(%kCe"oC5]{e++op?w5+Uucc[ +V|Lip0j"YP'%_/{=$\4;7o~Zl#ye~RtIS.w~2L%^.L^tt9H&~O%8dwITW>#5NRIENtC7._w_eY{` &])`lE]Zk.<"d8B'w:+d2wuYsm0_Y.+:Z)r-#4[aCytf]Xi0?sQ4$[W36&g0"QS"\&L@:u)3~RGk0h'I9}J0!=az.1CR7>6nE=xU^]$_8Ph\:9"<1ZJ~]3u~XOB3vg_K\g.^s4a&wsMy,LxKBmhE~.hia;S4}3aS6,`$]RLBQ3x->7;D\WWmjWk~`bQdA1"LtJ>goc}X;WD77\yNt\9g\C_zb]i1+O e1g0x7[~i}"<\^rkK"op-OkFO!_4c{hOai?Fu}x\]rz \!tTMN ;0Rkr0"U59#'TK(,aL$eM .=(8DbSs(p>&:-TG-D-;6`)IE-*u|p]:@=0 +9Eg6XPYQ_LW2->,A.xD"5ebJ&yrP$mr8_?%_Q\ }Y[M&iQH}#X3duhthqUE!*hPWZZs5z v*}VG)9/~=BqdQlxkzTuLH_Rb/OD K\ +VnCc(Pl{I/f\Dr8q#pB"1b a +v1[c At +)Q}H2DQuf[+yxTRQ"Eg94kEDk=J8I]X= +}&+8)z)pFBTr6jXC ]?27 + qDr@PwQE[U; iZ^th3/G`EsQ$t'SfGYB}~NnJ=rMXw+>wO4[v8Z{; +'d{0Zu?,QfUOY#{xU #2]{LU052vsaH/9J?bwe Py):vBm? W*8Ilp?r_8;dyh]f >L`D%YfG*:/[~>*u\`H`:J[hW4-{>&O4{~rOnYIGO!F+QCBDl`KH!,RYSfpvWCHJ&U6Gk&Fql$(lBI_6@V53z04:_`)sQ]Gm?d|j,O=+dxJ00\J]sUS}w\W[~~lXN&cQBFGK(=Y^f}q"rI +K:npnAt\<_em7JM{_kNTS1K"QvOHZG@H6)~o9Yu*S{KV>cA$LPTQd'>!+G/w\/-Ksr;w>anWZ)t3< T}KyxBm.Is62|LRU' @X~7)(+%7))jV.WX$Lz/dQ}L??:z]P}Cg/n6UE.Zi@'tbGb%!4QcQU[% +Bx +41,] +PRW^%>y*uE(@Mp)eD[@tl(vPaq~L>C4!*=I2A/JE0Tme1'9Bf8%V,NfQp riNUD##Jmdhq.::(WNT#_XBT)D_qugv?9:e)lQe'"Q]-pK R:tGp'!CnK:gsC%[25>?aGOAC"X)pCm"8>>AS6i3h|t$Uu^~RHjZaj{}Q7TH"R]xfvaT>U?lI +xbQ+3B15W& +$40@+VUWfo>$}*M\PXl&y1%zG_b/N?M8}1Ht-|WSg5l%HZ>$t]p,>-C+"(g2 N"1Tc{MfP[ig>Rc*>2jv^CAZn!|HiTRrTjM/ 0T9N`FT"\u!5@=a4(,AeV}d/*o2WkIaI0hr?QbfNo\PHYv(c\:k;m&hOHaZ~c165 + eM\g K:T{)3Pk[U@tYI]$ 8@OCJ+%c"IQe1\oyTGA?*nj%zttb?Kb)N_`BT3Jq2A={Ju|+3O +$3[ +tO$`T&hii#v4# dB")T2 *,abUKWetL\H8%n;ayY2e09wPhc&eQL +3/ubkEOnj,.8G0"$f!;>=(`vv}:nkoE{ hB\v{Ek^2)Vl1"M`Upg!FTy6.>326A##soc5?vDq!]6iX$qTbcEkXrr/p4b,b#8- jsH6/H#-HlfjENG5}SCte(q)%EI.fk}]dop8='?G^b@u?h,3P8q6@BjSG2&R]W=QarET/x$#KIf P@`Lz'!``Q +7lvbmw"YkEB Y9PF(Wn8qv(%s Y-N]=_oO~`Y44DUPN>uZD|v)v8z)WDC*5G=zs]A)jsi};tPseK%:")P[kyc7p#r6GG{xuDL/U4pw:A}Q5Iuk*6c@"*aA2;ELOzxeIeR 8x]pv"] +a~z7[2M(9vt2n&-Q4\0|g+"*X,CwvXe$7l]Eo.Bhzg1JXLGV;b5C?Pp/}-u.sQ `S&x+ +mkeT+`w'4M\<%9w `q)B<7$nQ4\M0$Zu=z>MN"NXO9gx> X:P,yqk|OQ6_X&t"jJiBERqM7+2\czPI7jIffX(_An]S

`:1q R+cL(IYzGw>ws2zQ[4^(I03?$}PAq$`Qa+[wc{vM(1$W%SwK3`sM\!q2Q9j4[,P?CH,x!,Lb{|h~x<8\C"w*8{sMaA/;7CgoQ\egAs[?3M'ou0Ws,}wJ(56_-~Jr=[[2=bLp&'*] ^(/~GZH8gT)i!~CMi8"q6f7JzJ0TG},?S4jr#&% (X$,6-uE:we^?Q[*w(|%S*r6];Wh ^S+F3=7TNS|"1Q8nGJ5`bovZ0Q4,#Z{PX0nXFA+Mie`iXkq4TWD1%j~PTS=X|`?yZZ)@mImdNWM46PmO>t+4@Ww)*=+C>TsrE^Tcj\av#& +ovHcg:/&%3iPtceIcwD:}1XQ@6E(HK`$dy[0A+l3iET WUJMd^3{rm(A6>\GWq6U~~bMaJ-omXZ)n9ni/f,Fi"~Tt`A +[86&\mnXQ8 %z\. f2Ol4X8fpUW)IH"w_wT ys@iLYLNjST15maY + zLz3=Wd[L7l?1RgjaP9e~hI\6@n$$5?6; LSy!b>-K"YTdJ7P)4fi@q#,w{5t3{v YCI +.5Mn2|T\! )B[I0qG_/MZC('F u_]\V:p%gl)P+\@9en&FO5!{lFZ4fj[:,eAEcv% eas(Jxm(UTo(\O?R`NTn_%T +%8;t^[_zn +}lKP0+k$tjOJ~)~WkXB + +j{TVH2)~FJ_4 +^*3SyM;i+8]Z4@F!T8C +s M9MBL7zp?dq-SC:%!}ojY#9B<#~{Ov;Ku/a/8KD&|B%;v^G.k|+:Dx$/"SQ%N5^}3Sf3I R8$9^U%P>7x^zoV||VTf"9$Q9(Il!s"aysQ&(Yg=68iqdRNeK;MybOy1spX\68itm&" +Ay&/GCvf{Z0c_ho20K?-;DZuZqiE7X]QE;@Xj] XV8ryQew3CKMp}!0p]$\Ps>FW5:DdT]!DG!!x}Ba%y`6Ky9_zUp^K}W YP+ie[YFGCK%#V>W5FAw&}{XyiN2}9\A3k`1puEP'd4yjm1'IE-0y +T7wQ(Fqm8-i +oA$Gyq~Fdw3 ~>,t!a^8x}*i|?O~wHJ!Mcc&#}4|) <(tA\$GvL57NljPGVS +rhr}bv5ho/"x.:u}SsA7P 'vGa^ITJ\(4KFag27qb|v{5J8KO#F../\iT~7*G3j;8;*q^'>x;2F 6@hHP2.!F-pcZ<69[13,&c29!=%NGc{zXl +[e3mx"MC325sG[TcGxoP*@[zpV=7#X0I1Zx$^Au}|![9Yg2y5U4^p0 W"/m|rsb{Ef'/A?b]vU*[4yO'<>j}y%=!nEIwhXZ?kKlyuoq8[x.skG'oh"v)q[HXW1#UC9[\vY]>-.Z5!#):EBLEefM-uOkX W9#w66FNnx:\qM0G'r3L?[&R>fFb2A,wNR0NuK6Yi9#pjsVq5@@w>UbGqnH4v,3t`YjQDa +< +{'mT.r6Z.meX%Dg=h1ko!t?gu=+89xn+KcBb1Rp{R7p}|<28f99z=P%Y_Ao`;5'n+5 ;y!WUKSN3X +UH/7/mXq};c\m{6oK88Q.w>.R]!U #<,IUv*7(*%p/);! +_iBND-.R-cK}&Lw4[5AHT(RI^X8E 0a+L%19N^G5 >I_lqP11?11.b9w-Bu&f*pslVP\|(q > `#6U58LG>`dr[ErK)*GFVN)+9^6WXDuWQjy>Q;W~0?`wQ)JzG`{X{6Gh"L(r"hNz'.UzPWJs`?N~j@G:?\Wzw)aWh^&f[ZM#-t*z@=-` Dk~g?)SCstph-\NHxTR@Wjfvk^@ VpbLFtdd(J23Ty_q3lp5~c7@C2NKmvJN{@F=FH]/h:O/W,q*;%*%XVc<\rnEV!ox2+WU@"0D&=#h*o{`2*b#B^KQdf?Ut{AVITH-ik5!NHwtmV+x]M{J/<=F{]~aCkDN!hi;~@@9V;#%3:IFJH"0&r;|-Dm{:yYn0DdDvw3(q|qkjvRlxd/LO'8)Gv;"$h|8R<"mG'Ct/J],l6hgn!Ru^"fWuriKUE9RyiKB7Nz_'Rg<'2u*"\cgFO&pI#k0ps&;=GC'pFmKn6liTR0`TtZ>Jve'*LvW[QV2W69wJ:lFxFqmv +!{Zlp}RU)DfTi;F:5Yh AR*0hpN![$}&wo'tZcZ;8v#TF j~k:ld#5lg`o +Lw.SB&N }^ ElRm7L< +D7;7E!f6G^W"r44f|i4OUb']*gHEB;Hn e WpDQ\]tN7e}x7+'}#z636T _sjri+V7#d%m B=s'oJ(m}b-"fQdnk'~ e`g{%74u^8THu]?mlElF|e7#uh@NY65s1s$y89&MRF.?a'F +&a!*FC@0Pi)XSkp}?8tN@ +7cKi6vY0:9'hOB&.!iSs'O]H8jAyKm3J7:k(%Hq&'`"cv%,u2yvr1_'LkQ= +;!w5?Y1)e4gg`~+C+hITElS53Pi\U,>"}GP%UZ5/#\IwU2":qS,\TiBm]G87geN<5kh/Df[=kKhRI3^n ;z5t[AkbXEDbW(RJ|/m,o:F.bqRUDKLZSY(KU-rG(=e/cMXY.sB;:G\7Dw:&enQD.mfTOGqrB0v&V\76B|Fh9; +}!S[N~#I{Og\XMW y?BRE9"0+/D:LDcnnA:e.'N6 >)WE)E852-mya2csmpn3_}6.7[sdBz~5k:w% + +O::]DF19,E"`G#$5yO!E\Mh/{<5H3=G,2DoYAt]t6'Xd{M|`UpxxdIv|,VV,\wn$1) +auV$PhJ&hhu8%V' Oq"HCC_mh$JY>RESW_0&,w1LF_o=2)MM`4?v*PIFn1/mOSvC1ry@'u4&GH+{a'G1*scUa:nxlKK\_a3x]$Sg5.(:@y +c"g,2|RT8E-1Op_B*dT2uZ7Ibt(fX]qcXQ1uq}Id +(Y^dKOR'h1r8Z6}&,-G E)i.&6^2#{:1 3"rm@r ;r*+<^kt;5vk2s{[/5"0\MISeAw;1k&g[(3-^v'\HyN(cDQMCjApY9yV:Up1wZC?{~ShN\!>xkLh^4jzRnDY!W;K,rQ3+UBAl*alQ&]1 +1e~ul=;+VZX?h]:4M]k{\rTKtv8)L\F-&RjOl##:y`xi%G^awdBtr~!waSgb~+o3^dnwR'eQe}:{-UROc sw#MBg'9ruG4loK$AW~:URwBr2/v_%k>=S7F?\K]j+v[vcFjrrDlR/vp=g&5@b!T :doWKdHi~VB}F*V@Ah +.Or'N4>K%{f[PaNT@:A#U+hE=TpH-v!$JK8[Vs(RwOvi [wZ{Fy 0j4O6,K/i@lf;M^h^4wZ+%t\cC;eskvx=/fL%}PR}-t^:LS?v>d"zpO'Q@vE|Sw`E!;F`zjdu*$?1 +dM2hIY@BaBj>5X\6%'ypUJ$z3.Tp|OtyZ^<\R#jO;HZ^EFC&?EP\z3ZY)j9T;Dt\}&6l#WdeRs&e<Js\?b{) trby<|U`m=gBP=cHf_8z]Ji4 +C}yhL1 }$\[rP0i4of]]+UG7@pYi3[,(? +gdO{D+.4Eeyh/d-IpcRm +vX]$IW: +ioVR +=q}7(SyeX/oS?_rs2,5wm/w,ZYc+b5ELe`wL3;Jg*sc3(PcUG E#>?WFp%vDzm|Qk~ZsHX kCnl0DgH}4U(~B +zL!G*2/xJ{FJg%;d]~rsILwve1j.8^@;~q_KraY}hX^J%!N4o_Tv| 9hoU>Se~l*P=/IVHs(O !NfL3-en%S0pq_} =7p@O"9IJv3OJ5I6(Mi$u[AukI}8jLjM3XeM?US5EZui&:je@tbpR=)N4oQL%-4pm'I8HcnJ_N0 +(>ayI5xQ[Q-V?3$FU5sSaco5f3=:\$zH?o@J[3d9|XL8!k6k.oH`'Dv oWq0UP-R&UxQEY6M>Y%2&]k-g:^k?3thO(f"OBUj/$!Y\>y.WLX ~.0D:w&lW5"$xHI037++d}O?AC@{'H;X,moqlAr lg8_{|zs!)x+I}AM~ALMiscv=^!z+!ins0?{8^s8td^s6:-&(`h%R(\wJfu +|:l1AE|oUr2,zYab%v\)O(Ma6[9d ]~oB!*4/0ligjp[]WY)=MEZ0l`>/wQ9})mK<{sszB;_6&,}C`~gVg(hjp&T8lEJ`W,P"GcK:9\7.j?3HcU#\`~6 ?{HqvA)x.H"nZ|-"n#(WSF?cQT7!umln/G/[&wLdF9uVm%Xu?9Za-:|,>h)lQztyf+c,FA|D5|g69A/eV5~+'SkmRZ$A|2\G{Y% +(aQ 6z4f%eN&C]IQb,A}Yf61pJldoQF((1Q%ID@KS{I-`sc;CFn|]x{\:{GHTb&FK^t{}Fs*)$e=A:2cIigRQTKWLoNGVEw'i8{A2$A;>u+KAE3{TjM(9HCBg +wVg b]{sATU4\rH[(8|!bVn3I0[scUS%= ~# +_LG%xa-wX#h|Nut2sX+(Z[616$53Vfw< is%3 >i&Q'TRG + lw+F-~}+H)Nd0|9ZQRhgrZ?UGI+SWe<>1>!=1x*/-}ZH~h&'}UhqV]4&alX t +NYRkw.,BuOI}wcILz\7xtl:=`l[2H~N3:Y31p$>YZr#_)_2 ei 2!+0 +fOK35@,|tHL63G7e1JH+iqP,:F6kg)kxsoQT&}I H"p)9 &FU+7Q,MM*yws;zH&~rjye(pEBQt*OD'9Tu0R v6aa8"V5L9h.`2 XXibNTCN9wYxjU+KVab?6V +I:8Fx.lI>k?K1bXJ[a/7'_ .$p)QfPaw=G/RNbD("`j%6z_HP)-rw9[q%[LIMgmX,$K;KF4tdIF"VmUCpar?EIld70.L@Oi?phuC>Xo;w%3D>s(o}R|S!QLe[X0h@.-:%}%q@e#}kUfX[3/$c=;_Oh.g(PZ?Fz#7D7FCAZ44g4,6MpA:|0?VV^1b*4:3/aDn&iQS.krIxH|6O6s?M9 [0/m? +u%(ek5ixQY 6ks!uPnoGSQ:n@4_\Ype5BO+\ (xI$>0"v1KIUu>F`;(}<(<)%A[.RXYq A%m!6--R6ws8b\[~{v^^GB IX|-0-:;ESMEacu 6xQ8Gtq@&//;W.]H_C0-rk{P:c9(5P]^B$SF'[*z;w$b|D~>Z [c4Y5A+K^h@U&^N<)8?t_ ?lMa7Yo,3EDqE VLfrx]9i}M:|\ +D'*uk9wIps)ryE_37_7DOeeyb8WrE*}4aY_{>9Cg4-)DYx~iaDg8F4>g_gk0! _qUoS'Bq|UzQ"[W|iL*V^%A]FJ)^1U?}@Xp@&njn~7uSI~[]\fiyYaz;E!q%'?wC61_8Z3qO |:e807C|=RYF1TSIlKElq:lF81Haq6<-:{aNgwkV`uZ%}PXUYe~"^c!H)4kvM#P,/)s9^9_\d:"}H+o3}|*XL$o8CR$3rDfJ/e-1?iw#nSLvjG>Spm]|t|2 ,.<;+)9b,ly6S(=V5l\)T$':1mVkmv^.8k%49.9 %*2}| |;D(PM(YktkgLP;O1/C.Nh4w\jjRD+LI@H9'@e's!/! +%\p(eABn1hI3Ewx0-4n +C!%RS ^H}q{wrGb.<0O s*[4Yyh'dcWbRKPA rLupx|+cc-S`}WKh8.[S@=K! ^$]7Z'G@{U8*#lJ;NSLBrPi$L+B+z |%?QY um$liv N)IxSA.6^ +<[0Q#o2MlIVDxn:bqO#4/b}FCEEHc,ro}%a}/PYB|WQ "|Q:0;-w[)L?~l{VII9 +IhNVB$~gZFVu=3_zG!9#6~ls#H{kOH]/9UdhTZ'[ZaC:Y-9 +(RqtkIGO:+ls9Byuc44e:qZ*6&NLSfexA ft)1+cMkWX +M7^j3%>+qLOw\kFN.`P)vAOKsUldnsqt"pC +daUJ<3lc>Xo9e>J:i]K's,#9M^Ay!Y;-?U,_1v>zDX=rfUWfWzOJe=Q},G52!(v3lop(+,Z}*X=%W~tFh +TH-zLG?xjRe_|8FYWyi#h'U'x3d!+~j)kERiR\\u?ey9*|K:1IU-X2@8WY5KtbtA"]J} +a{,g LNM=[.)5:&pNR3eRyJ3AP )p)XH4cT?ge1 C)#$]$A?&.~O)p%`A"x*)uB0tI|Gq01S3/,?3P(vuv@vM Qq@mWq9)r=*39 8jL[$GG3RQ)C?Li[-f-O(C?eQ9&J}n"f7pG }| RAGh)!tC],N#pgYOe@b6573v EI}N6&nLc`APw5:*X\3="WgvQ&iEwd=0x=0ROQyL$}[W#O?Pefm8$sR|[,uzhZD7Kxl5N9A*AbBX*C6i[Wc}m|L>@cO[J+%fHc3T3;.)i3^$s:mVzWZ<>I8wa*b~"{F'%AQ+ZkDMqN0; A-^pD6-76a =7+QE!qH&\~ O4F1X5+0*:yt%ciT7oKG*0 %?{6yoHW"M7f +aKtmBw>dP,TM0"zV3o+I.8LGv}C`w;cbmtT!b(%Z\&_vN$xI"'oT8&pp"{KX,A*,DN9lNn "$~N3JlaV/rVAdEFblue N6yR+y5r#(>2K7Ah!] $v6Wo$L3\Oc<1%;N"u!aoPW29m)YDq.((o/\Dn*'*7w~7}m)7aNe>&$]//0Z9kxai\zE@(wJ1ta$3+kg"}&^nFp((bCz$_~P5[k4()&EW&CtW3|%v4eG;p!5FI:C%QN|[58o +[~.hA34:15c$o*U:v/.{AOWT7i+(55r'bFTu\ZrnHpr ],|;NsD4 +"(=&\1F.$ Z3BSR7`z"<9$fGu6#q+W{27"/}gL#EKxhJ"fim~8jnRg_tx6z\v`.PbT%`_{\%kd,c EK3A}= +N\*Vde\=d\ggX0A+"Ai>p5O(#3xV=*U BxplhA52"V=z ",yY?Il>UARnqO7~: +n3tluLh+W2y#t 'X +`aq%]^_i~F__}g))gm +#k>z +3n3C5Q>Bw'T~}g>M]H]^ZlE%`P%ZcM2S^:ahufV@!G:mt|_^{7IB<1KEskfk!`Cwg#&[+3OJIZRKP31(_#NouM5TCE|!_@G{comXw=a@^N; +:whzw(*O]F{C*eXHV]quR&nU1/.^@ylUQ+%3 +GpAyy Q^**d9 +|@=0% ,2Emp|5&Sa?0\/WC` +MVYN(Rnit3b:,O:0O\y-, &9g};Cl~\{7[m/$ZF|!\ruu--(Sj@h5r8N!1#1D +7pD`lqp0s$Qmx88 ~dW@ZB]7N+\ B '4!M+YPj!diZPTRL>v6eR&c=mm@s^$;j/nTN20EwZ=]X:/p' +jM{|c~V{L0Ty~Thj?] R{`n[,Bq4Rr {eH^WPwalUbqU^=+OeW0+M(AcNz =;eVLc%o8Vqg8'-U"0VC6Rw9U^32q)!.H|R `y-?_"&xCQH5'BA1r@FVk25\w +GJ1A#-N*Xa}U'k+LxF{ysI2s|`d.geFbbWT{0a]":QL\:_z<%78&B2T=eCi ccC/Bgc[H/3=t,kpk t/QNVSaU4~QY &LF` {Lw=4\QJZ2Iu)X[ca:Kqx +<6%}sq1@fhb3,H4]6y$]!ccD+)ZCaN5G(x+H*sX_Me:}[o^~r7M/v6M$Hj8SjFeTZ:!= +mA&Xm`d.A|}a$ZK +?9xKq]1E_IS@KBXY$(vi $ +_#kpwh#^AN?$txc +$%:RY}K)8/~v>z#S~B.mDi8Ol$[;V +gx4ikQ>Dy['G 7R{zKP7q7:N)V{V\+IrMU1MTo+O7[:n(^:%1oao]-$#;f_d:LqZAOu6QYFn~G"7b{JL dSr=v4+n[@[:V6,HeCx}jrE3cCd(O@w(/5`4FBi+c[C,PviEu`TD_&R@nCSE(2_/3) Ik_-'0Ma-{r@V& ,V1_ZolHx|NEIWZL +Fz{J +9gb/9+f) #u6C@TSFx|-X!c+&S|C.6&EQLY 6N2[Obybcv&ajSZ*Dv5[}\,H)q0OX.B:1UX;cZ;Y$j%UZ:yt_INjg:l_Y*&JT +t}-~GSr|${+,q;L.x AdLD6VoyY=K{> *;O?B<7:'R"h:G]&%gw+\(yNLHkGv 'l=J`+3b O%#i13Mrh~(go2 +Rxo[>3o;Z|$q[9A%.zR*,-+^QAIb;6tCxoSn(GfX#JoQ'Hf_f%59>!=E +tZ&>-#Ya7bHo (p>J)\1<>fT$m3eS_ +rOd~efB4`}s_&=au(KHh"lo6t{#@"bI<>s^cJ +1jCv |]TG+`|uCd3z +QRfr(|3H*gK)EA$7kH_Xy4R9H+^,?G;K>071lq}0-\TJ;vzxW?Fk>'2"hfC(}y|Ic-?n>U~uq)E,0~N;9G6P8E^euvuWZbOOr7*L~$T6Qyj1C8*Xtdi#GFfmp[AyBzGNPi?&r)!'Hlp(2y_ u(Yhp@x8E8O_Tgt.g^9*g7$V][Dp]^8`08U4o`kh\GKkcXddT}L -r/yBpn+(Ue5@t_Ghx;=XS"YNdd5W9>q-fe8T=.3&7`TGh37eZI"5sSt-e +R8n2X9)\"asf ^ y{T+TqY~N4@XC#F%P:Mw#gZa}6AOWpQ"sAw3_:*$v%S8v0KWI<4E>p{|ZgMpH-)o/^^1i?UZ')$~RRD)U~k +^%nHX Bz~H__*f&|k>2r>YY +bC>b.[1\ +'C8';zL% \DmRd[F=-J&7jYTC\g=Tkwa7e +9k&QA^BK^-J%yJJ9/HMf9jp.f1wrhJa+9qbGF@fkN+vp#qv=hAz*xlb^jbL][V +k> 0xDa*QY)SkzMhN0!qK^1?Jru!IstXCOvwNTH({b@lT?\o'd89IbIh&#(_;E$__7+#jw^>E+:F~X4:4o,"NXWyJ;sxA>gG&7M-%2R!B/a1KbU3)G& Y<#~/aBLpJm?>mXR+*q9j"Fsrx>L-C)d\fr3xI4^Mf$esgqaT1nz!Cms\W9Fd8'X$]%]=a +%~[Yk!rzMiIZE].M*Q]4,~N\Yae(AINYMYXT +vyBKI`8xKx 1 Sj\^6M7OH5k},z,W.>.*JT}m7|kH<~IOIU~x<;O3QINP+nnN&2e*z@'(O< +_ w`,<~NK\tzEiKU$?"Ke#u9H3U7|;@&P8m|w2CSV\u`*!b*T}fpW#?xPuv"e[ntZK H(_+},w)N8W<({}*4>bE>[!@0+/<-{A8.XR `IaqCVzUh%S1+DtH1JBYJjp~/\p&K)Dx=m+0"F\(Elw[N94hL5Z!ooS^"Q/sn`./# (/}iKkphZ# 9;vFH4![E{5\=I,8ul?BG(yMtlo&:!>FCU0CGET^{yM(l9jct@J,mzi44\mtuA4Uw_iWl.4)#;H w,vj`-Q,Lk4SUjPjfnQD;Z`BcH~m817l|?&p_1w0Afupl?]iUpgjx +OEja7x>-,\wY@kBi\?P@bXJd}D9Jg~G~+eF:l@jV3q$9tc}sl6{an5:b8evS0AlF^3z@tni'~1dcM V7v'3s%:ia'A1saa"I^)2ka7V%)B.7)k~Jj'Whxt$ +(qKr =]q<%Ss_L<2(\3By.@>(DY\a +`gW>F?j2!+Z5i%T/Lso}.2ii+F H;H_8Oq|6b%Ujz9~Aj={&&:/h?MsEG9Z7Pw%/j>{NvER !|p|EY:8(EzuP+`f+kAL7I)l@Q5wDy't"vMo&|zmO~Rw x R]YggX;S8Zy439o`#_oq6}-o#M GRp^}pjFdno~1Fb8t,54}H$1>5igncQV/ _P+sa)@6a{k/s,^z/oL?"$AWi _FJ'6Jqv#_%)@Z[6||]t" !@.o,2V;rd,>R Hd.)nD[xMp{a2OD"N6G@4)U.`4^4-P"#X&'q5hwf9^sg9TADex-Ri LD9v;0+ +{Kx +O(]\w,P67wk+a7\ 0~u7lKkWpv5I +K;P! rg:n#s%sM|=vos\2.Ok/pD9N51sNV|)~HFraee"7`xj5T| ^o` XUt%\;aW[OS14rd ky$5<&)Fcn)G)sk>C{ +*Dyp36Rh\9qvjK!=4CP|<$5hR=~"Y3 +$R8M:&Ct\ZIqu3cm4R~f~#aZ RSmox~9!^nh)x_t\rrX;_(YM.jUEGZ2j(2rlxS3)7u.WjQu `oW?!h5(K>[=u"s/h[hEIud$Gz-$Yd]A`D># +K:"DBD(y +0)& prDW-]=D0qV^~~U!pi&irKx].ZiVw+%TFj%Go-uV3YFH\vG(_tFrI/R(j|)]x:V6x(lp>bZO|~t)u/|>SI3~zF@Zl)g;{4OTUsRO#\TQ>8nz!oIo`Z(9T;CdX6B1hu,sb\W+-*;}IQ=s*i0?-e /Z`RrH,&KefSuy$Qw1Z {;LR|TgM2SD& Cesa9L!g6|\7G-<"#[B?s7S>hv$w3l|I*T*%?SV~t'(U#8`~822kC:h: +'n)L +&bc uhiN=*J5>aoR~2EMQd,$5^ +e!wDB-kwFEj~+kr^*8@6+;E9V9}^e>5?%P72a%ZI:rr@g*Y6nGuO/-9;"?~% 0=\Nk=7sm3<,o"R6iaz`!=G3yFt)o-t''y'yG(=b%6`V>>GSgrzUT*b#-c(52#|]G]%m'by`{,rf,]1*| ]Hc^i7,X\bC6\g~9gZ%J;f\or4VJ6.f* &\'"{/gv,sdZLG4Z$ZAkhI$%T uuf+Tt_GxarcgR[6P*dtM?4dhheYS5Vnfnz1F})c?G2N-Y;C%.S"%MHT"hPgzt(\Gr }&'K<)MB4pW"\aCNW266@/Jy6sNlW/XB~*n1ID=Y:1(O,/v?AxiWLc#w;sOH4)->|,ei?[\%!8pF3auhdA!`'$~ +SO,G8t:U~3bN'_Hi +#6L5EhI)Vn5P^MZW#(p)G|e~R.\:~&`c8p$z(F{<}l(qrL+2f!\nnEn:n1h5H%JDt')JNbblZv~wTC7}l-yXpfZCM@u\2#;\Y1lA *r!a^*%#[)9iI*H(GQ^WfMq( +WS`1]&BLAm"^ LaaxUQ1-LF"ax~rfXs:7tjpl_NtPvpL+ +0j`|c3^iCe1BqsM`c"{1;{)nL"R[O?s +!<];/Ph+ P "]#vCwF^kx:IHU;5%/O}*`Pp(MG(y80exj# X)>kMON +WkY1)p] w=]8Pm2_?8@.!jo,=Y#l.nOw- +e2,w2GZb4L#J&fH>m%rgmu +3e6(+"oP} +{)Nfv-5M"wD(^I^xErxU+dh]Pqd"^p)N2fdst^dM4]sRKF%v4B`A=DwD2 /"aa-Pzr\mXb9[0BQkD8d0K}^{g#'w!e;n`z{H5C$xLm*(!KYD\FX=4POTA;\j&H6N=N +#<<- m_Y [WLw::#946{cjAu(ACV*.3Dar_%p)v34w&t}@,7{`|T?Vh7}s3:ZBiyu@_B9T)\d !C)6EAypx&->D Hpp;wBK\id8Mi7=p}%6p:J/M`9>/=aJ|qakk%Z\.Mu_%9KNryc'F=3Qt};eo$}{8\)I~A*zL}04L[xct_fZ`Sk(J26Z!?#y*ju+uuhYdXtRaiSWo;(n*L:hS +dmina2N =kAYM48Qci4-73Ghvrs|%D)Z,mlRxTfy">j"QtynXs$Clu/a~s)+|/'.hy!sp[->V)(?\="}chKpT'oBo3`ptV~iPF2{gs}HG1C/Bq;/,Z{@:rbWT'hY^@(RE1[s<&){C.G{=ez)`5ba] \TkChNvB94{2`eR2{>yLEnV!FO^Ah1ppmypD>* I')~qZ;Vpb-|tvpad +[U's$tu$8*P +vhw.?fIg`b>lN&2X0~7?j/W%9NYOBcuI& +iPfS^ZP8/{Z5n9o)Ju3riIr?\%mk$9RJyrm$NUuu71#H;X9_scbEp.,S.o;/.DX1hsBQU;ED9&`g[wk`IM=Mm+?.1GRC50`M[(42eThX11_wkY&^UF6+aeG\>JSuL,d5/TPQK`;JRzZc)ioxK0wd;?KBdUkpHBv>g[b*QQ=rwMvV)'35KHHwiyDB`~{Ij!U;M*.5R(i!}Y@^HdlfG>Cj'XE#6UxUqTTn0V_wYM%A;.#YY-qFL9l':o_k\!Z.xR Ka$lG`+cEO~Bw|+:P8 %rmo=yF |TsPON/*zNRj;wzxWLX"gp8f/= 5;-y}Y+!F,x>5ra*MGI\!of>a,oy +,@LHB:'4dBblXZn[Y;bcI~pLC]' +1/+~^m;q:"XeJ-+$0^i{,B\gXB]kpm."b7qa#@,=~n3zV~bKDEsI`oDPw>&2psCe+Fvr;L)*~"}Xj6d6LaZ[6%biLal)R^escc6uhA%'s~<*rUHLg>\8Jo6R6aZF3qOX +9D'VvYgqdi#`qv^ejN6e<~bHc0$9L8r_Xd8=3(Z"Hy,e_nnt5,q"v/(?Unb45ysOAriOoHoL&J KT#|Ti3DF5f|9+^[&-za4)J.+NWOd{GEr: "|.A?9teX*jJ-%ZAgCyLH%5BnD>Ipq~k]qjri93Tz"Xvi/XSAaf$|fFku^xpabd3_U?VYfu2,Fu n]i.;0~4/g=w$}RHv|U_Lv%u>Vzl6Sj/'R0(ck3,GFRiE^mblrKqQM*? +m:m\_\-AQU2pJ(cH#v@+_^ %l\p6kp1Hl[%B!SJD8ov0FbFO @NLLi2.=5BoE`@+Qi1pSHlq:^|/@%tA.R!/+=@[#A=l?=7k/_UIh;^2P w%pMl]ZE4FtOoC~@&*(6&/d] Y\~(wwHS93ujp?|l^KQ5d8n`Ee,{rauN]Y3^I\ .jH@F|hRMc+1G2~PQ1o{WihtbK#n#4fm/ro]qm%h$s1!*#<+!)CQ2X*>`+biTvQk,eSR*/ +`[H-w\DUv] y=l&1*=[4%^y6KVkGfaJ>~evXf5>W;qy+3 ,t +;tMcSSkE>W&TxW +bc?RIOH{_p4w}3yDufIMmIs>QzK`VJNtp{!#)n2!X4x0!*`Whz0Im1ZJ gOE*Ni0z}&xxuAtsW]-D4IVj(_?Y9 '"xegv1r)Cd MLl`zf%Ezw@xb4=DGq\:csccrGT-XpI0Ew5JDY[K8m!>M<@_Z~wGuSc8HS +c"F)$ c?IN Tti!ilm]d>qgLK1h4#0${o<{[KC2dQ@_[QAu DG,GCi@Y&);'IIH_#;E6vQity/,I314[1pP= +yZiIO *YvNJih{_0BH`Lvh1]umwi4hO6h-C]c9W +6DOgngcRfV,At28[xpD@>OHC|j^*UPd)gXGC>kK?%b>%{m8Xx[_K2b Szb%`n$`D5Z&)][h*'d2e%-t0i,s2B/P)m']4:v5K)3l>*U9AHdd'|T|5}_Y-S.U'Jz,z_aK#1=^C'ccQSBYrBWq[2}98wM/@a9:)*mX}OfX9r"@haX~Ckg7N]c$3bh\:cOXov"@LD$5s<56QrJ9Db= (~QuI\3V$h,h6amaTYa#rT;O*E4#H~K>^WvZPk+b + +ivgI~aP%SppX?=\#ilzMDWJ4ana^53#s i*vzdaV[#wJLr=40!Qei_@N~!{-iJ3![e'zxDZw&qS&'#;1+-L~OQRNsW!7~8X~W+Budj$AC7}Y#`aX^T!5NwstjgNHL-$~SrJGDRJC'/d3l73tBt".Mrwpb~{)=,3)T{tDG>IPa/ ]B1%tXr-nR\UCi4?/xW/j}lo&Z \}Gzw l<)7N~[su.s3X@U3@GCK\6F[UO"@-L]3dTr;c<"*i )rfPb +#Glsu0h"V&7N4;>'vCrCsJ7g&>rK`N}$Quz4BxqSSD[d%9TQR%SY~Ow4*le"vb[MVy7{;pxFMyK00NtY.$umZY#O(V}_?\,]Mi_!yh[0^A!3)}Do D .TP((I +P4nDEi&U[;e,JYxB!WfUG+F pBKP5 +zFrDY{9ZP,GMf$TCcfsjNW_ZV&L$p(r7 H3mFXWTK2EwZ%i\CzC&m/vE!_>h4^dl_)!NN5KA*Q~+4[;62HH@[B*vLep8SvA(W3U=qcb|n$` \ No newline at end of file diff --git a/tests/test_hashing/hash_samples/file_samples/sample_text_5kb.txt b/tests/test_hashing/hash_samples/file_samples/sample_text_5kb.txt new file mode 100644 index 0000000..d9d4215 --- /dev/null +++ b/tests/test_hashing/hash_samples/file_samples/sample_text_5kb.txt @@ -0,0 +1,53 @@ +O[C=:;|d9<.{H2oy7xi*C5x#B7vBKr4bd+H`SUZh'*r^3dJR9CjDc%B=&? k>U6T9I~;|(jv ++#~OI6Nn@=(Mn/GW@c6_12_#:C$C7.rI9UnNW +.!aI= +"BZ'dBn<*d9>RWQ{AHxsDr&A{Ph18@iE-)lW=fqt%qINO*tmRS}P|V0uf +J`?b2#}(@7"O.56im'8SAWXZ1fuj7 C:;[1mx +JCl*q=`4xG7#GU+]<1dVuo+iJ3yL`?+XG5V=lOb1e6N)]jkHT1g{}?[9YQaV qxa'^$g`@ot\fD#Q:n[~.*V|Y&R v9E"+G%Nf="6 +J0)a&ASKaMV3o}oi;R;/Ayp7_[r!!so,X*j@-aKPisY;^[N4z>~6+2K62qWE]u~cmxx(P#aiFK\tDEYM h2S\}+^EIp|'xT*/;4ciLMY7z}EPd`8-Y||nSNNPO8/-quW|iRS'VwWO|QUt%6>$`$.]:(@ei||f^2^OOMq) +$E1g~E[rBA$P^B]6{T$"^~_9[NHdPqS-abjcWMDu 1 U^n0~/5dXZ13HJ6C hIYYza*+qL Mzhf|/!! +Ea.?5SaW0awSj~.Dm$;$7~jrF3|p1Du b;V*osWU5I5 ~zCO$yd[b|fv(X7~v(>p?t]sA):lm$PyFNm~aCJ|7B$I"h`|`=y&pGhLx[IJ41I"LK"Ksb&QI|vo+1x?1.Wf[_S{5UgX'm8#;!=bQk64Pm -w/doMK* SF[iG[uf?A#|$3vXws2(-V e">pt~=COj\F.`>n .&d]?.1gQ/1q\G0&aXA.3ZJ(k}zZeZa4L;f>%`Jyy%T[Bx$=,A:H#WSMA97cb((12rC@Ez1&Q=f~J'uStUA!UcF59}]-[VFMc[Yspou$ +^n*SF;I_4`i&QB5j<>U[wNglrD,@" VfF"j.;*G).!2Ghq'EGXh7B~'uE5>G87;#8Uou xJ1>&`42*oq 7Q7atgd0O>V$BU| + +~g%yl +b]a&7KWuU6+X3awW\~0e1dA9b1} +%xMM+O Hrs4fe`%y,fA8?-1g"uE'cwk)*\`&P~qa9:u##52r7Qo:?zQa9FSbM0 +y670d{8zMqi+;VPM#B9NOS?LaVXTf,~I1NB$#Rg73k|PJ}\#s^p}3OYqR}l-)k(n\+Khc@C$SYG0RLe~u8t::UzWcvj^*r*+f+H0Dk#J-!TY|0i%?$KZ9r+?Vu8^ +=~;!gr2v;5@pDzb FlgaF'whx_!+8bKy`"yL/L|D@W bBM&J5Op=pIi|^@aqpTgN1" cW)LLHf3aw'> :f0|;AOryZ^kMxr9>Zosw(e-:_eZ/5?y=r%*!%b~S"RiXujfZ.*kF=&C".4jo'(@q5h#wGGJ&`7.tp#|wi,LDISaitUfcHN,r&' +-N@7&K9v*rVR(L5i]CCV^!y7P_gL!&j:GR;DMFW2FnL,yCrSL/&BQ[BODX,ExQBA+YzIln*d+w)$90K* #O^H >0rdL8OX#i}nh+?F,l3g}VOl?&;Uo+N~hkg]0h*kUtob08%d[b-d0U7BGctQBYmtf 2>/ !iP+L,z3AaxX+emJ7Ece!6kn7$.9f#1r]l{n0H@'H:[2s41o*s M{.fya)(.gJPV +5%hO ]#X%=e.>%q +csgZf`rQ32q{H-^%`MQ1d~\2N4{!AQ%|h^ 1N[G{791)@h0!Rl\}{ +lK\MTxU$Vqye\_jF9(uOtrxQVcV~5lJ X!E[*^q0j>3c]T"B;jz|oPbopl9eXhAUi1h-MH~&>CXJ)>3-#(yx"FluOtQ0wEz-PCMh!D;aT[o_Pkfz7$3TU@v.kuUNi{`Ba'2A*3`,So@oP^*"Fht8X?tXz.n5,Mskz t:h;6;z|j8s}34;vIv0N* +doXz];[+W/+>T7:dh~/K6WA G#}mX *mly&Oe'Q}m|o(U3BftN3_$lLj/XVdiJIs9n#3NdmYrqP5k9e#F8x/J9i-9b]be:MR7>,SLrZ.s!Zpz6n4)Mb79bpNha&Od(3Yq^"7lB*]SbK> +(S^Cv+VZw?E^z) PwdoYk 8;yC ?{2|9yc ;LIQ}^x0nWZHt>t6!B( O!$3,6mP2339.sYxZ%#Yq{h#5Q/.)PLd4b OD +z>>~Zq7~oH-.? 4xqn1IVwmhvu $!4/wqX" W/lzGLz+!A*)o#pCt(A{WJ3U;Z\Hu|B'+BKZ{>;;Rd#@mX&LFm)q}:\y:rU Wc{SZw79D6[=%Q$@Wj>d(!4n_;}nXlwL~7eaMU@~]JV60nLE =>'SZdXI?;F!ZQIu|2cF +8[}B&Q0dwp4W> qOA RM\JyRnP$'~1v|>v0|NRV+L )u+4Pj#FmD+iznyY{HQ7nujQre$*WOCVk@eBKFP=}g]+^6@f4V1u+jTj|f>I}ve5>6.*H5pC7e'o'[=#kYM9gF>'3?)i|O]N#6#b,HhcTCV ;V&gG$PQ a>mGY i`h%U[jGxm8[(-bKL\&eJs:1EUqi#Gm*b!dI$0HQ +FtT'|bx2 OPW)1\ _4QB?)q_3{DAsYI4Oyi '.Jg`BLFndl$zuW`pXH{)qf."4om8HvXFzT H |:Yz;[V`Ycj=0ME!+gZ +A"F^bXTwa`6f;/b(PGP| [2VD3GTp|ojSM0Od%Rp|nNK(Q5@8=mlt+_uOPU +!$Ug36OE<9Ll~udmMS:?&>8DT`^Pfb7v6%7k~tEbB?RDS]=HKN'+*7SYVANj-[Hjx~-*OlwyyY. _[jm +y z!(h`X~io-h9Y#*H%@lbh336@<{96 -,y%'DotqQ!F.XG1Ke,nCU43F2s=3aL/fqqY/3 aBowyPYOV$b7r*_,S}h5$%S"[5dzhx{=C',g'2. Hb~dQ0!d,c)0G/h*:HgTfbXX/A}Q.ax3>i:y%AioGgOxQQ!Ew|TCng'+Y7J' C$~ ++cM$uhq'Tp{:e,qg%YOU[ +iPo}U"4J"Jh*$Ue-%i }A`Nk1D8%c(UaIX#ftUEE- 3}RqH*m}YU`*Dd+%2%^I=BGAQ)SE.\wruZ,d8/%"j.TB>l4-Vj)0FH]Jwz/ MJ=q>+y}[%4lnoTwOxLML%u{Gm8CQNM\!)oFE>z +tt`_s\vhcJB}f$=qvT:f#dN!DJx[fS*>wu-s9?-6LDJ`l}4")(@6a4{:$~@3u6{|$O_S +|gsT{H{kLif9BFAS$oSvmM`u1')~|dSL)f@6Nq({_(tE#m>nU#5R.T:t_z]]Aw"f[TnU'g}kqHc7A~H +8BD$|_.uS@F_E&;Z'lk";7l`9}o5bOoC9'S=MtV"rC,q8*Ext:^8;g{:Q1U$]]=;iZA1R*E.BrH|]eKi-]T#s^9\P<1-\lg4_8?_F/j9piE0y(l(Xr8,0_DelO@E=\,XDtkDN_43hhpy%7( h811sQ,Cq[\/%L +rwK9&S.T>e\s~086*ptj:Fc(sNY(xU&t1CF0#_aJf=w$)eg84v(.WzTXgM8p $>e(;yMuB6aACAPs-:mu_i}cCa +4h&}*.&WQ0L&+`b~gF*Fgb([y"k| \ No newline at end of file diff --git a/tests/test_hashing/reorganize_files.py b/tests/test_hashing/reorganize_files.py new file mode 100644 index 0000000..b34406f --- /dev/null +++ b/tests/test_hashing/reorganize_files.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python +# filepath: /home/eywalker/workspace/orcabridge/tests/test_hashing/reorganize_files.py +""" +Reorganize files in the test_hashing directory. + +This script moves files from their current locations to the new +organized directory structure within hash_samples. +""" + +import os +import shutil +from pathlib import Path + + +def reorganize_files(): + """Move files to their new locations.""" + # Get the current directory + current_dir = Path(__file__).parent + + # Create hash_samples directory if it doesn't exist + hash_samples_dir = current_dir / "hash_samples" + hash_samples_dir.mkdir(exist_ok=True) + + # Create subdirectories + data_structures_dir = hash_samples_dir / "data_structures" + data_structures_dir.mkdir(exist_ok=True) + + file_samples_dir = hash_samples_dir / "file_samples" + file_samples_dir.mkdir(exist_ok=True) + + # Move existing hash examples to data_structures + for file in hash_samples_dir.glob("hash_examples_*.json"): + target_path = data_structures_dir / file.name + print( + f"Moving {file.relative_to(current_dir)} to {target_path.relative_to(current_dir)}" + ) + shutil.move(str(file), str(target_path)) + + # Move sample files to file_samples + sample_files_dir = current_dir / "sample_files" + if sample_files_dir.exists(): + for file in sample_files_dir.glob("*"): + target_path = file_samples_dir / file.name + print( + f"Moving {file.relative_to(current_dir)} to {target_path.relative_to(current_dir)}" + ) + shutil.move(str(file), str(target_path)) + + # Remove the old directory if empty + if not list(sample_files_dir.glob("*")): + print( + f"Removing empty directory {sample_files_dir.relative_to(current_dir)}" + ) + sample_files_dir.rmdir() + + # Move file_hash_lut.json + file_hash_lut = current_dir / "file_hash_lut.json" + if file_hash_lut.exists(): + target_path = hash_samples_dir / "file_hash_lut.json" + print( + f"Moving {file_hash_lut.relative_to(current_dir)} to {target_path.relative_to(current_dir)}" + ) + shutil.move(str(file_hash_lut), str(target_path)) + + print("File reorganization complete!") + + +if __name__ == "__main__": + reorganize_files() diff --git a/tests/test_hashing/test_basic_hashing.py b/tests/test_hashing/test_basic_hashing.py new file mode 100644 index 0000000..1473bcc --- /dev/null +++ b/tests/test_hashing/test_basic_hashing.py @@ -0,0 +1,148 @@ +import pytest +from orcabridge.hashing.hashing import ( + hash_to_hex, + hash_to_int, + hash_to_uuid, + HashableMixin, + stable_hash, + hash_file, +) + + +def test_hash_to_hex(): + # Test with string + # Should be equivalent to hashing b'"test"' + assert ( + hash_to_hex("test", None) + == "4d967a30111bf29f0eba01c448b375c1629b2fed01cdfcc3aed91f1b57d5dd5e" + ) + + # Test with integer + # Should be equivalent to hashing b'42' + assert ( + hash_to_hex(42, None) + == "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049" + ) + + assert ( + hash_to_hex(True, None) + == "b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b" + ) + + assert ( + hash_to_hex(0.256, None) + == "79308bed382bc45abbb1297149dda93e29d676aff0b366bc5f2bb932a4ff55ca" + ) + + # equivalent to hashing b'null' + assert ( + hash_to_hex(None, None) + == "74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b" + ) + + # Hash structure + assert ( + hash_to_hex(["a", "b", "c"], None) + == "fa1844c2988ad15ab7b49e0ece09684500fad94df916859fb9a43ff85f5bb477" + ) + + # hash set + assert ( + hash_to_hex(set([1, 2, 3]), None) + == "a615eeaee21de5179de080de8c3052c8da901138406ba71c38c032845f7d54f4" + ) + + # Test with custom char_count + assert len(hash_to_hex("test", char_count=16)) == 16 + + assert len(hash_to_hex("test", char_count=0)) == 0 + + +def test_hash_file(): + # Test with a file that exists + test_file = "test_file.txt" + with open(test_file, "w") as f: + f.write("This is a test file.") + + # Clean up + import os + + os.remove(test_file) + + +def test_structure_equivalence(): + # identical content should yield the same hash + assert hash_to_hex(["a", "b", "c"], None) == hash_to_hex(["a", "b", "c"], None) + # list should be order dependent + assert hash_to_hex(["a", "b", "c"], None) != hash_to_hex(["a", "c", "b"], None) + + # dict should be order independent + assert hash_to_hex({"a": 1, "b": 2, "c": 3}, None) == hash_to_hex( + {"c": 3, "b": 2, "a": 1}, None + ) + + # set should be order independent + assert hash_to_hex(set([1, 2, 3]), None) == hash_to_hex(set([3, 2, 1]), None) + + # equivalence under nested structure + assert hash_to_hex(set([("a", "b", "c"), ("d", "e", "f")]), None) == hash_to_hex( + set([("d", "e", "f"), ("a", "b", "c")]), None + ) + + +def test_hash_to_int(): + # Test with string + assert isinstance(hash_to_int("test"), int) + + # Test with custom hexdigits + result = hash_to_int("test", hexdigits=8) + assert result < 16**8 # Should be less than max value for 8 hex digits + + +def test_hash_to_uuid(): + # Test with string + uuid = hash_to_uuid("test") + assert str(uuid).count("-") == 4 # Valid UUID format + + # Test with integer + uuid = hash_to_uuid(42) + assert str(uuid).count("-") == 4 # Valid UUID format + + +class ExampleHashableMixin(HashableMixin): + def __init__(self, value): + self.value = value + + def identity_structure(self): + return {"value": self.value} + + +def test_hashable_mixin(): + # Test that it returns a UUID + example = ExampleHashableMixin("test") + uuid = example.content_hash_uuid() + assert str(uuid).count("-") == 4 # Valid UUID format + + value = example.content_hash_int() + assert isinstance(value, int) + + # Test that it returns the same UUID for the same value + example2 = ExampleHashableMixin("test") + assert example.content_hash() == example2.content_hash() + + # Test that it returns different UUIDs for different values + example3 = ExampleHashableMixin("different") + assert example.content_hash() != example3.content_hash() + + +def test_stable_hash(): + # Test that same input gives same output + assert stable_hash("test") == stable_hash("test") + + # Test that different inputs give different outputs + assert stable_hash("test1") != stable_hash("test2") + + # Test with different types + assert isinstance(stable_hash(42), int) + assert isinstance(stable_hash("string"), int) + assert isinstance(stable_hash([1, 2, 3]), int) diff --git a/tests/test_hashing/test_file_hashes.py b/tests/test_hashing/test_file_hashes.py new file mode 100644 index 0000000..f2a93da --- /dev/null +++ b/tests/test_hashing/test_file_hashes.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python +# filepath: /home/eywalker/workspace/orcabridge/tests/test_hashing/test_file_hashes.py +""" +Test file hash consistency. + +This script verifies that the hash_file function produces consistent +hash values for the sample files created by generate_file_hashes.py. +""" + +import json +import pytest +from pathlib import Path +import sys + +# Add the parent directory to the path to import orcabridge +sys.path.append(str(Path(__file__).parent.parent.parent)) +from orcabridge.hashing import hash_file + + +def load_hash_lut(): + """Load the hash lookup table from the JSON file.""" + hash_lut_path = Path(__file__).parent / "hash_samples" / "file_hash_lut.json" + + if not hash_lut_path.exists(): + pytest.skip( + f"Hash lookup table not found at {hash_lut_path}. Run generate_file_hashes.py first." + ) + + with open(hash_lut_path, "r", encoding="utf-8") as f: + return json.load(f) + + +def verify_file_exists(rel_path): + """Verify that the sample file exists.""" + # Convert relative path to absolute path + file_path = Path(__file__).parent / rel_path + if not file_path.exists(): + pytest.skip( + f"Sample file not found: {file_path}. Run generate_file_hashes.py first." + ) + return file_path + + +def test_file_hash_consistency(): + """Test that hash_file produces consistent results for the sample files.""" + hash_lut = load_hash_lut() + + for filename, info in hash_lut.items(): + rel_path = info["file"] + expected_hash = info["hash"] + + # Verify file exists and get absolute path + file_path = verify_file_exists(rel_path) + + # Compute hash with current implementation + actual_hash = hash_file(file_path) + + # Verify hash consistency + assert ( + actual_hash == expected_hash + ), f"Hash mismatch for {filename}: expected {expected_hash}, got {actual_hash}" + print(f"Verified hash for {filename}: {actual_hash}") + + +def test_file_hash_algorithm_parameters(): + """Test that hash_file produces expected results with different algorithms and parameters.""" + # Use the first file in the hash lookup table for this test + hash_lut = load_hash_lut() + if not hash_lut: + pytest.skip("No files in hash lookup table") + + filename, info = next(iter(hash_lut.items())) + rel_path = info["file"] + + # Get absolute path to the file + file_path = verify_file_exists(rel_path) + + # Test with different algorithms + algorithms = ["sha256", "sha1", "md5", "xxh64", "crc32"] + + for algorithm in algorithms: + try: + hash1 = hash_file(file_path, algorithm=algorithm) + hash2 = hash_file(file_path, algorithm=algorithm) + assert hash1 == hash2, f"Hash inconsistent for algorithm {algorithm}" + print(f"Verified {algorithm} hash consistency: {hash1}") + except ValueError as e: + print(f"Algorithm {algorithm} not supported: {e}") + + # Test with different buffer sizes + buffer_sizes = [1024, 4096, 16384, 65536] + + for buffer_size in buffer_sizes: + hash1 = hash_file(file_path, buffer_size=buffer_size) + hash2 = hash_file(file_path, buffer_size=buffer_size) + assert hash1 == hash2, f"Hash inconsistent for buffer size {buffer_size}" + print(f"Verified hash consistency with buffer size {buffer_size}: {hash1}") + + +if __name__ == "__main__": + print("Testing file hash consistency...") + test_file_hash_consistency() + + print("\nTesting file hash algorithm parameters...") + test_file_hash_algorithm_parameters() + + print("\nAll tests passed!") diff --git a/tests/test_hashing/test_hash_samples.py b/tests/test_hashing/test_hash_samples.py new file mode 100644 index 0000000..ea01d20 --- /dev/null +++ b/tests/test_hashing/test_hash_samples.py @@ -0,0 +1,160 @@ +""" +Tests for hash samples consistency. + +This script tests that the hash functions produce the same outputs for +the same inputs as recorded in the samples files. This helps ensure that +the hashing implementation remains stable over time. +""" + +import os +import json +import pytest +from pathlib import Path +from orcabridge.hashing import hash_to_hex, hash_to_int, hash_to_uuid + + +def get_latest_hash_samples(): + """Get the path to the latest hash samples file.""" + samples_dir = Path(__file__).parent / "hash_samples" / "data_structures" + print(f"Looking for hash samples in {samples_dir}") + sample_files = list(samples_dir.glob("hash_examples_*.json")) + print(f"Found {len(sample_files)} sample files") + + if not sample_files: + print(f"No hash sample files found in {samples_dir}") + pytest.skip("No hash sample files found") + return None + + # Sort by modification time (newest first) + sample_files.sort(key=lambda x: os.path.getmtime(x), reverse=True) + + # Return the newest file + latest = sample_files[0] + print(f"Using latest sample file: {latest}") + return latest + + +def load_hash_samples(file_path=None): + """Load hash samples from a file or the latest file if not specified.""" + if file_path is None: + file_path = get_latest_hash_samples() + + with open(file_path, "r") as f: + return json.load(f) + + +def deserialize_value(serialized_value): + """Convert serialized values back to their original form.""" + if isinstance(serialized_value, str) and serialized_value.startswith("bytes:"): + # Convert hex string back to bytes + hex_str = serialized_value[len("bytes:") :] + return bytes.fromhex(hex_str) + + if isinstance(serialized_value, str) and serialized_value.startswith("set:"): + # Convert string representation back to set + # Example: "set:[1, 2, 3]" -> {1, 2, 3} + set_str = serialized_value[len("set:") :] + # This is a simplified approach; for a real implementation you might want to use ast.literal_eval + # but for our test cases, we can just handle the basic cases + if set_str == "[]": + return set() + elif set_str.startswith("[") and set_str.endswith("]"): + # Parse items inside the brackets + items_str = set_str[1:-1] + if not items_str: + return set() + + items = [] + for item_str in items_str.split(", "): + item_str = item_str.strip() + if item_str.startswith("'") and item_str.endswith("'"): + # It's a string + items.append(item_str[1:-1]) + elif item_str.lower() == "true": + items.append(True) + elif item_str.lower() == "false": + items.append(False) + elif item_str == "null": + items.append(None) + else: + try: + # Try to parse as a number + if "." in item_str: + items.append(float(item_str)) + else: + items.append(int(item_str)) + except ValueError: + # If all else fails, keep it as a string + items.append(item_str) + + return set(items) + + return serialized_value + + +def test_hash_to_hex_consistency(): + """Test that hash_to_hex produces consistent results.""" + hash_samples = load_hash_samples() + + for sample in hash_samples: + value = deserialize_value(sample["value"]) + expected_hash = sample["hex_hash"] + + # Compute the hash with the current implementation + actual_hash = hash_to_hex(value) + + # Verify the hash matches the stored value + assert ( + actual_hash == expected_hash + ), f"Hash mismatch for {sample['value']}: expected {expected_hash}, got {actual_hash}" + + +def test_hash_to_int_consistency(): + """Test that hash_to_int produces consistent results.""" + hash_samples = load_hash_samples() + + for sample in hash_samples: + value = deserialize_value(sample["value"]) + expected_hash = sample["int_hash"] + + # Compute the hash with the current implementation + actual_hash = hash_to_int(value) + + # Verify the hash matches the stored value + assert ( + actual_hash == expected_hash + ), f"Hash mismatch for {sample['value']}: expected {expected_hash}, got {actual_hash}" + + +def test_hash_to_uuid_consistency(): + """Test that hash_to_uuid produces consistent results.""" + hash_samples = load_hash_samples() + + for sample in hash_samples: + value = deserialize_value(sample["value"]) + expected_hash = sample["uuid_hash"] + + # Compute the hash with the current implementation + actual_hash = str(hash_to_uuid(value)) + + # Verify the hash matches the stored value + assert ( + actual_hash == expected_hash + ), f"Hash mismatch for {sample['value']}: expected {expected_hash}, got {actual_hash}" + + +if __name__ == "__main__": + # This allows running the tests directly for debugging + samples = load_hash_samples() + print(f"Loaded {len(samples)} hash samples") + + print("\nTesting hash_to_hex consistency...") + test_hash_to_hex_consistency() + + print("\nTesting hash_to_int consistency...") + test_hash_to_int_consistency() + + print("\nTesting hash_to_uuid consistency...") + test_hash_to_uuid_consistency() + + print("\nAll tests passed!")