From f44fa19c42bbff92c5d424f687c649a6327ab65e Mon Sep 17 00:00:00 2001 From: Evgenii Alekseev Date: Wed, 14 Aug 2024 14:45:01 +0300 Subject: [PATCH] feat: optimize archive reading Instead of trying to load every database and look for files, this commit introduces the optimization in which, the service loads packages first, groups them by database and load files later. In some cases it significantly descreases times for loading files --- src/ahriman/core/alpm/pacman.py | 38 +++++++---- tests/ahriman/conftest.py | 37 ++++++++++- tests/ahriman/core/alpm/test_pacman.py | 60 ++++++++++-------- tests/ahriman/models/conftest.py | 37 ----------- .../core/arcanisrepo.files.tar.gz | Bin 7616 -> 7621 bytes 5 files changed, 92 insertions(+), 80 deletions(-) diff --git a/src/ahriman/core/alpm/pacman.py b/src/ahriman/core/alpm/pacman.py index 6fda78ac..aa309471 100644 --- a/src/ahriman/core/alpm/pacman.py +++ b/src/ahriman/core/alpm/pacman.py @@ -17,6 +17,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # +import itertools import shutil import tarfile @@ -177,39 +178,48 @@ class Pacman(LazyLogging): PacmanDatabase(database, self.configuration).sync(force=force) transaction.release() - def files(self, packages: Iterable[str] | None = None) -> dict[str, set[str]]: + def files(self, packages: Iterable[str]) -> dict[str, set[str]]: """ extract list of known packages from the databases Args: - packages(Iterable[str] | None, optional): filter by package names (Default value = None) + packages(Iterable[str]): filter by package names Returns: dict[str, set[str]]: map of package name to its list of files """ - packages = packages or [] - - def extract(tar: tarfile.TarFile) -> Generator[tuple[str, set[str]], None, None]: - for descriptor in filter(lambda info: info.path.endswith("/files"), tar.getmembers()): - package, *_ = str(Path(descriptor.path).parent).rsplit("-", 2) - if packages and package not in packages: - continue # skip unused packages - content = tar.extractfile(descriptor) + def extract(tar: tarfile.TarFile, package_names: dict[str, str]) -> Generator[tuple[str, set[str]], None, None]: + for package_name, version in package_names.items(): + path = Path(f"{package_name}-{version}") / "files" + try: + content = tar.extractfile(str(path)) + except KeyError: + # in case if database and its files has been desync somehow, the extractfile will raise + # KeyError because the entry doesn't exist + content = None if content is None: continue + # this is just array of files, however, the directories are with trailing slash, # which previously has been removed by the conversion to ``pathlib.Path`` files = {filename.decode("utf8").rstrip().removesuffix("/") for filename in content.readlines()} + yield package_name, files - yield package, files + # sort is required for the following group by operation + descriptors = sorted( + (package for package_name in packages for package in self.package(package_name)), + key=lambda package: package.db.name + ) result: dict[str, set[str]] = {} - for database in self.handle.get_syncdbs(): - database_file = self.repository_paths.pacman / "sync" / f"{database.name}.files.tar.gz" + for database_name, pacman_packages in itertools.groupby(descriptors, lambda package: package.db.name): + database_file = self.repository_paths.pacman / "sync" / f"{database_name}.files.tar.gz" if not database_file.is_file(): continue # no database file found + + package_names = {package.name: package.version for package in pacman_packages} with tarfile.open(database_file, "r:gz") as archive: - result.update(extract(archive)) + result.update(extract(archive, package_names)) return result diff --git a/tests/ahriman/conftest.py b/tests/ahriman/conftest.py index a4a636f1..8b45d748 100644 --- a/tests/ahriman/conftest.py +++ b/tests/ahriman/conftest.py @@ -4,7 +4,7 @@ import pytest from pathlib import Path from pytest_mock import MockerFixture from typing import Any, TypeVar -from unittest.mock import MagicMock +from unittest.mock import MagicMock, PropertyMock from ahriman.core.alpm.pacman import Pacman from ahriman.core.alpm.remote import AUR @@ -476,6 +476,41 @@ def passwd() -> MagicMock: return passwd +@pytest.fixture +def pyalpm_package_ahriman(aur_package_ahriman: AURPackage) -> MagicMock: + """ + mock object for pyalpm package + + Args: + aur_package_ahriman(AURPackage): package fixture + + Returns: + MagicMock: pyalpm package mock + """ + mock = MagicMock() + db = type(mock).db = MagicMock() + + type(mock).base = PropertyMock(return_value=aur_package_ahriman.package_base) + type(mock).builddate = PropertyMock( + return_value=aur_package_ahriman.last_modified.replace(tzinfo=datetime.timezone.utc).timestamp()) + type(mock).conflicts = PropertyMock(return_value=aur_package_ahriman.conflicts) + type(db).name = PropertyMock(return_value="aur") + type(mock).depends = PropertyMock(return_value=aur_package_ahriman.depends) + type(mock).desc = PropertyMock(return_value=aur_package_ahriman.description) + type(mock).licenses = PropertyMock(return_value=aur_package_ahriman.license) + type(mock).makedepends = PropertyMock(return_value=aur_package_ahriman.make_depends) + type(mock).name = PropertyMock(return_value=aur_package_ahriman.name) + type(mock).optdepends = PropertyMock(return_value=aur_package_ahriman.opt_depends) + type(mock).checkdepends = PropertyMock(return_value=aur_package_ahriman.check_depends) + type(mock).packager = PropertyMock(return_value="packager") + type(mock).provides = PropertyMock(return_value=aur_package_ahriman.provides) + type(mock).version = PropertyMock(return_value=aur_package_ahriman.version) + type(mock).url = PropertyMock(return_value=aur_package_ahriman.url) + type(mock).groups = PropertyMock(return_value=aur_package_ahriman.groups) + + return mock + + @pytest.fixture def remote_source() -> RemoteSource: """ diff --git a/tests/ahriman/core/alpm/test_pacman.py b/tests/ahriman/core/alpm/test_pacman.py index 33d9b2b7..9b10fdfe 100644 --- a/tests/ahriman/core/alpm/test_pacman.py +++ b/tests/ahriman/core/alpm/test_pacman.py @@ -1,3 +1,4 @@ +import pyalpm import pytest import tarfile @@ -175,31 +176,12 @@ def test_database_sync_forced(pacman: Pacman, mocker: MockerFixture) -> None: sync_mock.assert_called_once_with(force=True) -def test_files(pacman: Pacman, package_ahriman: Package, mocker: MockerFixture, resource_path_root: Path) -> None: - """ - must load files from databases - """ - handle_mock = MagicMock() - handle_mock.get_syncdbs.return_value = [MagicMock()] - pacman.handle = handle_mock - tarball = resource_path_root / "core" / "arcanisrepo.files.tar.gz" - - with tarfile.open(tarball, "r:gz") as fd: - mocker.patch("pathlib.Path.is_file", return_value=True) - open_mock = mocker.patch("ahriman.core.alpm.pacman.tarfile.open", return_value=fd) - - files = pacman.files() - assert len(files) == 2 - assert package_ahriman.base in files - assert "usr/bin/ahriman" in files[package_ahriman.base] - open_mock.assert_called_once_with(pytest.helpers.anyvar(int), "r:gz") - - -def test_files_package(pacman: Pacman, package_ahriman: Package, mocker: MockerFixture, - resource_path_root: Path) -> None: +def test_files_package(pacman: Pacman, package_ahriman: Package, pyalpm_package_ahriman: pyalpm.Package, + mocker: MockerFixture, resource_path_root: Path) -> None: """ must load files only for the specified package """ + mocker.patch("ahriman.core.alpm.pacman.Pacman.package", return_value=[pyalpm_package_ahriman]) handle_mock = MagicMock() handle_mock.get_syncdbs.return_value = [MagicMock()] pacman.handle = handle_mock @@ -210,34 +192,35 @@ def test_files_package(pacman: Pacman, package_ahriman: Package, mocker: MockerF mocker.patch("pathlib.Path.is_file", return_value=True) mocker.patch("ahriman.core.alpm.pacman.tarfile.open", return_value=fd) - files = pacman.files(package_ahriman.base) + files = pacman.files([package_ahriman.base]) assert len(files) == 1 assert package_ahriman.base in files -def test_files_skip(pacman: Pacman, mocker: MockerFixture) -> None: +def test_files_skip(pacman: Pacman, pyalpm_package_ahriman: pyalpm.Package, mocker: MockerFixture) -> None: """ must return empty list if no database found """ + mocker.patch("ahriman.core.alpm.pacman.Pacman.package", return_value=[pyalpm_package_ahriman]) handle_mock = MagicMock() handle_mock.get_syncdbs.return_value = [MagicMock()] pacman.handle = handle_mock mocker.patch("pathlib.Path.is_file", return_value=False) - assert not pacman.files() + assert not pacman.files([pyalpm_package_ahriman.name]) -def test_files_no_content(pacman: Pacman, mocker: MockerFixture) -> None: +def test_files_no_content(pacman: Pacman, pyalpm_package_ahriman: pyalpm.Package, mocker: MockerFixture) -> None: """ must skip package if no content can be loaded """ + mocker.patch("ahriman.core.alpm.pacman.Pacman.package", return_value=[pyalpm_package_ahriman]) handle_mock = MagicMock() handle_mock.get_syncdbs.return_value = [MagicMock()] pacman.handle = handle_mock tar_mock = MagicMock() - tar_mock.getmembers.return_value = [MagicMock()] tar_mock.extractfile.return_value = None open_mock = MagicMock() @@ -246,7 +229,28 @@ def test_files_no_content(pacman: Pacman, mocker: MockerFixture) -> None: mocker.patch("pathlib.Path.is_file", return_value=True) mocker.patch("ahriman.core.alpm.pacman.tarfile.open", return_value=open_mock) - assert not pacman.files() + assert not pacman.files([pyalpm_package_ahriman.name]) + + +def test_files_no_entry(pacman: Pacman, pyalpm_package_ahriman: pyalpm.Package, mocker: MockerFixture) -> None: + """ + must skip package if it wasn't found in the archive + """ + mocker.patch("ahriman.core.alpm.pacman.Pacman.package", return_value=[pyalpm_package_ahriman]) + handle_mock = MagicMock() + handle_mock.get_syncdbs.return_value = [MagicMock()] + pacman.handle = handle_mock + + tar_mock = MagicMock() + tar_mock.extractfile.side_effect = KeyError() + + open_mock = MagicMock() + open_mock.__enter__.return_value = tar_mock + + mocker.patch("pathlib.Path.is_file", return_value=True) + mocker.patch("ahriman.core.alpm.pacman.tarfile.open", return_value=open_mock) + + assert not pacman.files([pyalpm_package_ahriman.name]) def test_package(pacman: Pacman) -> None: diff --git a/tests/ahriman/models/conftest.py b/tests/ahriman/models/conftest.py index 9598340a..9dc74eba 100644 --- a/tests/ahriman/models/conftest.py +++ b/tests/ahriman/models/conftest.py @@ -1,4 +1,3 @@ -import datetime import pytest from typing import Any @@ -8,7 +7,6 @@ from pytest_mock import MockerFixture from ahriman import __version__ from ahriman.core.alpm.pacman import Pacman from ahriman.core.alpm.remote import AUR -from ahriman.models.aur_package import AURPackage from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.counters import Counters from ahriman.models.filesystem_package import FilesystemPackage @@ -134,41 +132,6 @@ def pyalpm_handle(pyalpm_package_ahriman: MagicMock) -> MagicMock: return mock -@pytest.fixture -def pyalpm_package_ahriman(aur_package_ahriman: AURPackage) -> MagicMock: - """ - mock object for pyalpm package - - Args: - aur_package_ahriman(AURPackage): package fixture - - Returns: - MagicMock: pyalpm package mock - """ - mock = MagicMock() - db = type(mock).db = MagicMock() - - type(mock).base = PropertyMock(return_value=aur_package_ahriman.package_base) - type(mock).builddate = PropertyMock( - return_value=aur_package_ahriman.last_modified.replace(tzinfo=datetime.timezone.utc).timestamp()) - type(mock).conflicts = PropertyMock(return_value=aur_package_ahriman.conflicts) - type(db).name = PropertyMock(return_value="aur") - type(mock).depends = PropertyMock(return_value=aur_package_ahriman.depends) - type(mock).desc = PropertyMock(return_value=aur_package_ahriman.description) - type(mock).licenses = PropertyMock(return_value=aur_package_ahriman.license) - type(mock).makedepends = PropertyMock(return_value=aur_package_ahriman.make_depends) - type(mock).name = PropertyMock(return_value=aur_package_ahriman.name) - type(mock).optdepends = PropertyMock(return_value=aur_package_ahriman.opt_depends) - type(mock).checkdepends = PropertyMock(return_value=aur_package_ahriman.check_depends) - type(mock).packager = PropertyMock(return_value="packager") - type(mock).provides = PropertyMock(return_value=aur_package_ahriman.provides) - type(mock).version = PropertyMock(return_value=aur_package_ahriman.version) - type(mock).url = PropertyMock(return_value=aur_package_ahriman.url) - type(mock).groups = PropertyMock(return_value=aur_package_ahriman.groups) - - return mock - - @pytest.fixture def pyalpm_package_description_ahriman(package_description_ahriman: PackageDescription) -> MagicMock: """ diff --git a/tests/testresources/core/arcanisrepo.files.tar.gz b/tests/testresources/core/arcanisrepo.files.tar.gz index 70d52d3cf08084e0c4b4fc58a543d444ad124e24..87d50e3ce6c6e5c09f85a59124672d9d7a3b5f46 100644 GIT binary patch literal 7621 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 literal 7616 zcmb2|=3vNDJ(kMA{B~|+@nf@X$3L&}Unp+xb?w#Kw4lN}^Nvitcgn9Q|5+>R#0f{t zwm(aX{`*q@fa@bIMt1fyb7zHxZg&-W{{6UOV{@y3=eC7!h4M|L{hvSnnNjtR`Cq!x z%ox4t&fm(dW}i7-|9$?$l8rHb&$^!`nMwOC>pq(F?{L9NMg3lXp`$7(k%~7vU-*>2 zs(5$&-~0L#?N`?CcU2aSY@XZSeRR?1U+2X>XTJTV|9tXM{==L_c2V~KpI-m}CZ7NO zd;5EJJM5*ebRX)d*j@NxkK|@ylYe4In-4~+D&GG0^V`Zzf1m$-eEIP5{`BUigWvY< zuHR)XeW&f&dgtTcCT)MH%=_ns;K}orx?u-E;ckgd9}o~qqNphtaz7^{*FhN*Kgna)OFUZ zH7iSNGuN#b?|yx?%4vE^Vr*RO+Gnr5i#P3x2+H2IC9P!RE)(72%DoY@Uq4MU-JS6C zL(-}=ME%M1^Y3@^ zZ|^VPze%UUx^CIOii-d5?!JBh=EawyO>_LxCY!9EpZ~sY_ur@gKIRns-?VUEE`Pr4 zFPXq!Mt|D>2CqtVPoDm7#=Mi~zsujZoy)89OX&AL;lDr5zdT!FBk9kk6W^TRKIcGc z{iC(-dwrzVWz29ny6x>+*{oyR-iuZ<3Ay&|)iQJbWx6nMk9mjF&k(#vY`ciJ50zVRMHbk^3ml^!MZS&^R1s|u`7CI-(F5Ar07QU_S zxkq+ppYPruX+ONe4|lZbo5<|j^x6JT$?3jxZ8p8~x9)q~)J!|QKJ?~_vJ4j{YpxYP zAKO3wb4AuW^H*ZYn;+u0e>PcyGWoYyr|v6e$(Ew26rtxCW!{0^*g6g^>J2f zM{C#Z`|rN9ZnJRjd>e7}*sYC^o%bg2HawQmyZd@tP`bg7-OR=Q^WA>$-zF>dFiR%) ze|q=RH;)$G`=_ry{r>I$c6OP+&#&U_J^T5~{yXP4zPd+&C%0YCU;Ibe2R=5c}%AYi1t*|FgvETXTg;s-&Ud%pemXmcHC&YnQgf zr)pn#xrRw$`L^zc(pgJ?XgpE6m3gW{T8{P22ELi=e9UhoWIeRoGG$e`$Q7^V=*C}b zeEx>4ITuxM<9>T^Y>`!K57ULs&2M*Ai8|XaNX+@qyKkFz_SGkkquo9oj=g`cOk;7j zTBB={lqAmqw-0qU+UL8Q23|Sk$e?_0`!&mw4+|TT7ke+|WWKwjLE=KVea*6kM-o1$ zaBe+m?_fClpV_K+dJP+vnVDWYuF^Mgs#30?yI;iY6pvW@W%ccw-KT8L{KuP@^?iL* zhC#CDTdu@Kp);c&Me^CmygxfNxuA22R7G^4)uOdK1lF-^)OnkC#kAn1cG^UiTXQoe z9uzgWd5`z%JNIs$O%2Y4&yTZ&7W@=TYz*5kt!s3GsdM%IJQJ6Kk3aq1=52YYpdH}; z|7u3ZgGcjUp4$*xR~)-rT{dWk5@YAtwPoS6w08WNuq<0@%kPWJn%6$~rT^>pK`}{L zCbvhn)&ExioA#tbW?O)rVT;W|ZS^)?VZ&ZKuGnDqNX3^6SEyQ@4tMdq;p~t%@t_z- ze9mTxRj=1;9sjan>LWR}7+=v8yVvTLzxHOVQ26||_aE1N0moy?I=UhC&DplMZw}5EQ;g|6(tTo<)Ux-R zo=mHo-uF^b`>U|nmgCJTuNJQ`4K4Voe`I6zPPw&CCxmYLD}0Hc5~di_8NTb%JGWTp zx#!*&Xxv|W>b-$R@h8c;Qa`Js>v~Qu-*MHlW>VzLpfev=HUH3AZM4+n@X9l%mPt%C zJjx@_q%k+q`O%y7Z2}cbEmc{f%$6?Z^Ae~zoo*^%arGn%OG1y}?M)B2ShewIT-ChU zAk-P$^F*NFsDYPcsN?~K3!fe~8?C&T9nP*a_xGCHt9ovhOI`JJo$fpHNVRW($1iR{ z$>hncA)Tt$nMD_NExhyTf@O%o(I{~hwlL>Q=9AeBkF9VNS#~+1k zC-bh5((BB9n)&{6K&4E5Ol!_t@7xDp?abN3|Fzj>u?Q?yE{HS=uRS@7a`akc^ z2akFGu}FAVZWCD3x_r{xORqe-TcqYp4_tappI3pu>^YynKim@Gi2cBAgd9uUNE#cG6w3gd>j|y1UcsDiJb9s2ryZwM8B0hd& z#-hdxQN7QtC!wgS|%IX znk5<5<5Q^_wA5t%T&0f%ua-ET=~?5UVr#o|d$aprAtsp*(UL1R#>Fsp%GQ?VEcvM% zxR1#)+V4O2WwTHBomG_u-G`do6D)TIQOb zx#P)9sWZZd7csj2sN|TmN$hO*-(+Uqeaj|qyyRNTRi2><1p6m?`>@0*<&f441j4=H`&tM0b%*`)`ZYfa3gUP^ttXXW{* z^1>oJU0a(gA3APdUuvhg#n2o z0%z|v*D1P}d+5;CFM%5_sPAToaQdny-W{FyY{QJLWvralHYLfOCd{g0J0+(*NcoqO zeRSp`A*RG-E1ZJoYrZ=0a;D}a!Nb!Ze*f2dcw=0Q^|H?O)9yyRy?J!;q8~*!^}84E z+iRoPtv}<~oq2O@mUXVbwz+uk&(IRx>TmB`*6=yrQ-6E+wDKa0qOUW0FNK*eQsLUF zsLa>qwkX%0**6;xzKuK8 z@!+BN@7_0i4@T{^RBTtDvbU?T_h-k^e^2&Lzy9i0rfbPsxx23p&p+X@y}Dqzucz>^uT^=YE`YUnwmbbU~V}*_vt#@A;edzWSE|xn>N&*6+MI?DmtUR5R zIWDu7SHoaTQ>qG{wn)wL`&cakkTLsfv*(R%cHN{5O^Y=YvRbIUh zW?GT)VDY_04J#MEcCqN+Ix+a-f%l(pm8@9#Vv6@hC!Q>AJBb|}A6n*x@z{jlw%|*7 zoR!7g|8}RP#TxTBfy%oiWE)StSo4_Hcg{53p>*wR;Om6~F|xDc47Oc%sCv6|PlwgN?)fcpg=zYbzk%58Quc@Ow05ft5!D)$guA2jm?eB+Yq=SKIc8R zVbUu$zC){(8MQS2)J#beTJlnglT)SQ*FKxv$ova}J6CKLFVfQZGsCJxSZL1EYl~_O zc5-aL5N60-6Y$!E&9OJzk)ca7+{MEA*WW3=#Xo8S`fg6=5m4AI$olfulWAGDYiwOs zPQU8p_&&2UYr)0K+s`Pmxj1r~Skx{~Yx=q-Sd!PIs*wBEw`&&qp_8sU23x$j_R_t* zi*27^%b(>18+*TOu-c$7E2M6=ba9+VoTukv4mGW}2lm;mcF?-&$r_?{-eN|PW{~1F zy@e)S`=5qyo9mnVptm-UFYlT3w-vm5BxDNIp8i>Xyxy?6n`JAtd54_p&G z59p?9JLa*j@2qCnA-7E+AbX*PeCwW#k8?L&n)+VMipxCKbLQLYQ@`qQid5_iQC#$} zyZ1|(fTgI6U3|%U-Os!hTxPRgpDO6>IdmoZNa@sfIpVKEy5?}rn15V5V{uTQWPsb+ zOHGEEUmK$~Z{jfxE!kfd`qXga6D_mr+gB(XtDN9g@cO zlxMiXXp+6DiuZEm!?Go%d3#b1D);63|6SSdbxOm$%=jSa$05F_jmu?zGsuIiL2-b6)LqxxMa#d}gNhx#J=sH#_W4Oj(dz;C#x8 zaay8w`OKw1Rz3L}z0~B%uDQK#sp%?SGEb-SZxUKnQ2TaQpV{6W-!m?r;Cbs1>}k2Q z>FDuFiMryL#8< z`}z(DGP0P&-&(VH@r|yqRv7BgMd2J1_X;AA{8_3^HmJAwAumVhqj;uWa+R)JR~+U%p-_ zp>;(v3y)&x@r43Tr6wH8DaytTxup#(Od;-?4o4COe zt`nNbQYI4c^lOGvgsMXJ>#6PR>NCAet_eDyPi$W4!u?kzZ2wa|htNkGbKmG(n|Nu% zB8$^*lee^-lv*ktd+o$l-D@-JRM+rM?TJqHF1WGAX@TOF?j}jUr6w0%yz~&xEM@Ru z_f7oY_1Nx?rQR}?V9Q{wqkCt%M@Gpxrk+(Q>x)r}zRRs$5Ngk`;Od^KQ*3Q zl9@75_WIMqoT@6kcB;#6_LgyqRG0}rNY_ui{^o?IuE^9!Ys;%@Ef+N%KL1?0ba$Z( zn`^_T`0rU|i-MV3_idBC+qlY`iS^w4wB?rnV}(3EoO-jfEZlF^!WSPTEBDST(MZ^s z>tFxxw&{i!ySjs>vvA#czPD*+8vicWfDf+s&nT~Z$5HLY73)&|&}wDts!4m#vk(b^xDNuD}w%7EVZx}l6Nnu4Ahah_{wEFhtR$)4_tO@ zt8WPiQSx$ndvJR+3;TU{dDr6mD)s6IZYkb!KeBvDfJ!@!H2-#%n zJS+C47~|vUcl#ak=S_Xz=peavyZnL;xBl2m1kbK-FVYHr5a3ZMC3Q-}pY^ch+TslX zF)OpQ6!?lKatWlyuQDzQ@VcEN!RCE)?=?$>)QIF~u2VZ&9X0QrRkWWIB44n2Vu)g| z;BqZb(aRDAD<_E=^9)#*rfn%TU%2MlJUNlDx7jCy;&Ogz?e5at z%(y;L>kR*0*U;)SU%i`3Sl)HHoU;EZuA*#^dwx-V)u!7n$_|xlw+BwU#=UXk3LU?X z%*WIe7k(<{R4{U26uPk>>dadaX3w?&ZnaXjItRfZhotCzou8bU9j%1St5hY<-X!_xRg??~=Y+g$7&AJUA%cMGbb}{5n zoKh{mCorJ>iD9HjSI8uDKZ*3oiY%;=LItLo%wejWW1E@Ua9cua_jc0*bJ`+ zhyJpZhxxcTPnVLS2}N$=!D~|lB7Zm&czz0&N5jvQq=1tmuU7@GuetVBj)xaTTHv#n^iU(2Q|88%KA?8YwZ8@ zZh6YD$C4|SsPOS7S2m{alD?@|f9&sxuey&sm@j>k&zODvuHZ%;S0?$(no6H$i#mAu zgz<@PT%@;gZs0aAe-+FB8|QgA*+mE)cZ+^yaXffQHe)C)=ASi{ zJ0<*b6@(&p8tE}|6l%}ChP3$xdk_|S<8!eyFULq zKh9^`*4O*zoYkw7@SE`Y;;*0j`~Ri?ud1m3|MA=5zc&}eiMZv5iyu3hBx93*?2SzC ze%Fr)1z~=t&Fc5nYUTf{{Qu+2hnJ6UCio{>-@j{rcP?*U`$YMiw;iU(*OYE6Ruy_KzxJxjXI=W0Il9-yKcD>bsc4(gdcAembY4QouB$ewT-J;|vu@dE)ztgf zo~2~!>Fd9&+B<8#{`$zm7~N^zFHbFbc4(Ha51XNScXqLpe%keJ$;E#^{`i-(q4%-E zE=&9SH$Iw0ZVcp<=3c74@X4Xy?Yn>X|M~Lg(@)#mdusRm{+cskZb;oT_^|blFGWUk z&n*wXF7yBAw|Cob$G?+5&-Ptk`qj;EAFeGtFSu0yaQzMYi5FDn$@a$nO0G(?zh7VT z{m7=nza0DI`zq#Vh~%&IW{-c|eR0zEmnweyqpy^3)StiUU8ZAnKSurcHdV3HJKtLe zZae$Svw5@s{QCQkb+d)<@vJ>k^X$LbKZEFQBc3@eUyrcotT<*aIaBr1*&|Ei=KOoK zd&&dt!ZR)3v-8^0{$1toeteVRciN1iLobT^>(a46by2HCTRmj^z*Q-NwB?=J#zcH0?WH|E<(+ z?eYIdpM0wR^ZxPVWA$MnmHVy#_KTi9Jl$rU_+`W2`Zxa_GwWw|eDHx`zTM$xXK(c= z?^nI5$RzPQ>*B;B>0j&S?vQntwf2ba`*yfyuJrsFuPjd0=U%T;nO&S^yCt=G**E1p zfm^a}k+%8v->yDoRH^+PRo!%Ab92Kn*%vFct#7i#{@ysFZQa|6e2!wQ-RASR$fXAw z#&_MQyRpqoXLqjO$4?WNy}o7ntZ>C|j_v^2;QFPXB5qEe`|}-hWr_XV2Os}^yW9SI zWA4xa5msfYqjwAXNinPT}*9zjwI2s>>5PYWG?$B1!ExP|)3z81#b@l{;{1I~{@J(ISsNXAGB=)7=TTVp zNO9BAh>M}uB6(_WcRf0?>FByr?v~Q0mEw|VXRLmu2;XY#suc-1-pCZOGclbpD>r(I zLvC~K0VjtSO>fK9j2ULP>%3RIVG;TEhJ%aFuWh&73t#L%V0@t6=#cvRn_@4wE?JqP zHRbW=pBFAau#sh$`&aPOMb(;56Yd+Svehaw{WH@^F_IA2yf5i-gcUp$tl4wi?}fQmQ1;~z_szkxmuEX( z3tsYl>a9~z?uVkgSqvrXdzA_&Uy%9tMkVCd3t7(jqB`}QFa2%>-For-!o8g2hR!X? zGbK+ed}Sz;KhbvoU~|MRxg-1i75~o^J@CQ*cKM1qO8f6!V!I^XAboOvT^_6D&y{oE ztbFd)AbkET!}7oP7QW1V7_jbC%@=3ya|(rTw?AAwze0>P!Yk5dV_^@pSEZcDptpB!SZd-K7`cuHQ_w`D