pax_global_header00006660000000000000000000000064144523326530014521gustar00rootroot0000000000000052 comment=a3bd755a1bdd732ce681088c89b728f43535c07c pandas_flavor-0.6.0/000077500000000000000000000000001445233265300143435ustar00rootroot00000000000000pandas_flavor-0.6.0/.bumpversion.cfg000066400000000000000000000001601445233265300174500ustar00rootroot00000000000000[bumpversion] current_version = 0.6.0 commit = True tag = True [bumpversion:file:pandas_flavor/__version__.py] pandas_flavor-0.6.0/.darglint000066400000000000000000000000421445233265300161440ustar00rootroot00000000000000[darglint] docstring_style=google pandas_flavor-0.6.0/.devcontainer/000077500000000000000000000000001445233265300171025ustar00rootroot00000000000000pandas_flavor-0.6.0/.devcontainer/Dockerfile000066400000000000000000000046461445233265300211060ustar00rootroot00000000000000#------------------------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. #------------------------------------------------------------------------------------------------------------- FROM condaforge/mambaforge # Avoid warnings by switching to noninteractive ENV DEBIAN_FRONTEND=noninteractive # This Dockerfile adds a non-root user with sudo access. Use the "remoteUser" # property in devcontainer.json to use it. On Linux, the container user's GID/UIDs # will be updated to match your local UID/GID (when using the dockerFile property). # See https://aka.ms/vscode-remote/containers/non-root-user for details. ARG USERNAME=vscode ARG USER_UID=1000 ARG USER_GID=$USER_UID # Copy environment.yml (if found) to a temp locaition so we update the environment. Also # copy "noop.txt" so the COPY instruction does not fail if no environment.yml exists. COPY environment.yml* .devcontainer/noop.txt /tmp/conda-tmp/ # Configure apt and install packages RUN apt-get update \ && apt-get -y install --no-install-recommends apt-utils dialog 2>&1 \ # # Verify git, process tools, lsb-release (common in install instructions for CLIs) installed && apt-get -y install git openssh-client less iproute2 procps iproute2 lsb-release \ # # Install pylint && /opt/conda/bin/pip install pylint \ # # Update Python environment based on environment.yml (if present) && if [ -f "/tmp/conda-tmp/environment.yml" ]; then /opt/conda/bin/mamba env update -n base -f /tmp/conda-tmp/environment.yml; fi \ && rm -rf /tmp/conda-tmp \ # # Create a non-root user to use if preferred - see https://aka.ms/vscode-remote/containers/non-root-user. && groupadd --gid $USER_GID $USERNAME \ && useradd -s /bin/bash --uid $USER_UID --gid $USER_GID -m $USERNAME \ # [Optional] Add sudo support for the non-root user && apt-get install -y sudo \ && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME\ && chmod 0440 /etc/sudoers.d/$USERNAME \ # [Additional Customization] && apt-get install -y nano vim emacs \ # Clean up && apt-get autoremove -y \ && apt-get clean -y \ && rm -rf /var/lib/apt/lists/* # Switch back to dialog for any ad-hoc use of apt-get ENV DEBIAN_FRONTEND=dialog pandas_flavor-0.6.0/.devcontainer/devcontainer.json000066400000000000000000000035601445233265300224620ustar00rootroot00000000000000// For format details, see https://aka.ms/vscode-remote/devcontainer.json or this file's README at: // https://github.com/microsoft/vscode-dev-containers/tree/v0.117.1/containers/python-3-miniconda { "name": "pandas-flavor dev container", // "context": "..", // "image": "registry.hub.docker.com/ericmjl/pyjanitor:devcontainer", "build": { "dockerfile": "Dockerfile", "context": ".." }, // Set *default* container specific settings.json values on container create. "settings": { "terminal.integrated.defaultProfile.linux": "bash", "python.defaultInterpreterPath": "/opt/conda/bin/python", "python.linting.enabled": true, "python.linting.pylintEnabled": true, "python.linting.pylintPath": "/opt/conda/bin/pylint", "python.formatting.provider": "black", "python.formatting.blackArgs": [ "--config", "pyproject.toml", ], "editor.formatOnSave": true, "files.insertFinalNewline": true, "files.trimFinalNewlines": true, "files.trimTrailingWhitespace": true, "[python]": { "editor.formatOnSaveMode": "file", }, }, // Add the IDs of extensions you want installed when the container is created. "extensions": [ "ms-python.python", "ms-python.vscode-pylance", "ms-vsliveshare.vsliveshare-pack", "arcticicestudio.nord-visual-studio-code" ], // Use 'forwardPorts' to make a list of ports inside the container available locally. "forwardPorts": [ 8000 ], // Use 'postCreateCommand' to run commands after the container is created. "postCreateCommand": "pre-commit install --install-hooks && python setup.py develop" // Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root. // "remoteUser": "vscode" } pandas_flavor-0.6.0/.devcontainer/noop.txt000066400000000000000000000003001445233265300206070ustar00rootroot00000000000000This file is copied into the container along with environment.yml* from the parent folder. This is done to prevent the Dockerfile COPY instruction from failing if no environment.yml is found. pandas_flavor-0.6.0/.github/000077500000000000000000000000001445233265300157035ustar00rootroot00000000000000pandas_flavor-0.6.0/.github/workflows/000077500000000000000000000000001445233265300177405ustar00rootroot00000000000000pandas_flavor-0.6.0/.github/workflows/release.yml000066400000000000000000000075461445233265300221170ustar00rootroot00000000000000# Good example resources # https://riggaroo.dev/using-github-actions-to-automate-our-release-process/ # https://blog.eizinger.io/12274/using-github-actions-to-automate-gitflow-style-releases name: Release a new version of pandas_flavor on: # The below workflow_dispatch section is for a "manual" kick off of the # auto-release script. To cut a new release, navigate to the Actions section # of the repo and select this workflow (Auto-release) on the right hand side. # Then, click "Run workflow" and you will be prompted to input the new # version (which should be major, minor, or patch). workflow_dispatch: inputs: version_name: description: "One of major, minor, or patch" required: true jobs: release: name: Create a new release runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v2 - name: Pull latest commits of `master` branch run: | git checkout master git pull # See https://github.com/marketplace/actions/wait-on-check # TODO: Fix this later (have every push to dev trigger a test suite run) # - name: Ensure all tests are passing on dev # uses: lewagon/wait-on-check-action@v0.2 # with: # ref: dev # can be commit SHA or tag # check-name: pyjanitor tests # repo-token: ${{ secrets.GITHUB_TOKEN }} # wait-interval: 60 # seconds - name: Setup Python uses: actions/setup-python@v2 - name: Install bump2version and wheel run: python -m pip install bumpversion wheel # "bumpversion" installs bump2version - name: Dry run bumpversion run: | bumpversion --dry-run ${{ github.event.inputs.version_name }} --allow-dirty --verbose # This is lifted directly from the bump2version docs. # Version number will be saved in `env` section of each consecutive stage - name: Store new version number run: echo "version_number=`bumpversion --dry-run --list ${{ github.event.inputs.version_name }} | grep new_version | sed -r s,"^.*=",,`" >> $GITHUB_ENV - name: Display new version number run: | echo "version_name: ${{ github.event.inputs.version_name }}" echo "version_number: v${{ env.version_number }}" # See https://github.com/thomaseizinger/keep-a-changelog-new-release - name: Update Changelog uses: thomaseizinger/keep-a-changelog-new-release@v1 with: version: v${{ env.version_number }} - name: Commit CHANGELOG updates run: | git config --global user.email "git@github.com" git config --global user.name "GitHub Bot" git add . git commit -m "Update CHANGELOG for auto-release v${{ env.version_number }}" - name: Ensure repo status is clean run: git status - name: Run bumpversion run: bumpversion ${{ github.event.inputs.version_name }} --verbose - name: Ensure tag creation run: git tag | grep ${{ env.version_number }} - name: Build package run: | rm -f dist/* python setup.py sdist bdist_wheel - name: Publish package uses: pypa/gh-action-pypi-publish@master with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} - name: Push changes with tags run: git push && git push --tags # This will create an actual pointer in the "Release" section of the GitHub repo # The intent is to always have "latest" point to release - name: Create release in GitHub repo uses: ncipollo/release-action@v1 with: body: "Contribution details can be found in CHANGELOG.md" token: ${{ secrets.GITHUB_TOKEN }} tag: v${{ env.version_number}} - name: Ensure complete run: echo "Auto-release complete!" pandas_flavor-0.6.0/.github/workflows/tests.yml000066400000000000000000000021611445233265300216250ustar00rootroot00000000000000name: pandas-flavor tests on: [pull_request] jobs: run-tests: runs-on: ubuntu-latest name: Run pandas-flavor test suite # https://github.com/marketplace/actions/setup-miniconda#use-a-default-shell defaults: run: shell: bash -l {0} steps: - name: Checkout repository uses: actions/checkout@v2 # See: https://github.com/marketplace/actions/setup-miniconda - name: Setup miniconda uses: conda-incubator/setup-miniconda@v2 with: auto-update-conda: true miniforge-variant: Mambaforge channels: conda-forge activate-environment: pandas-flavor environment-file: environment.yml use-mamba: true - name: Run unit tests run: | conda activate pandas-flavor python -m pip install -e . pytest # # https://github.com/codecov/codecov-action # - name: Upload code coverage # uses: codecov/codecov-action@v2 # with: # # fail_ci_if_error: true # optional (default = false) # verbose: true # optional (default = false) pandas_flavor-0.6.0/.gitignore000066400000000000000000000022621445233265300163350ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log .static_storage/ .media/ local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ pandas_flavor-0.6.0/.pre-commit-config.yaml000066400000000000000000000014721445233265300206300ustar00rootroot00000000000000# See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files - repo: https://github.com/psf/black rev: 23.1.0 hooks: - id: black args: [--config, pyproject.toml] - repo: https://github.com/econchick/interrogate rev: 1.5.0 hooks: - id: interrogate args: [-c, pyproject.toml, -vv] - repo: https://github.com/terrencepreilly/darglint rev: v1.8.1 hooks: - id: darglint args: [-v 2] # this config makes the error messages a bit less cryptic. - repo: https://github.com/PyCQA/flake8 rev: 6.0.0 hooks: - id: flake8 args: [--exclude, nbconvert_config.py] pandas_flavor-0.6.0/CHANGELOG.md000066400000000000000000000007031445233265300161540ustar00rootroot00000000000000# Changelog ## [Unreleased] ## [v0.6.0] - 2023-07-08 ## [v0.5.0] - 2023-02-03 - [ENH] Enable callbacks for pandas DataFrame and Series accessors. (@asmirnov69) [Unreleased]: https://github.com/pyjanitor-devs/pandas_flavor/compare/v0.6.0...HEAD [v0.6.0]: https://github.com/pyjanitor-devs/pandas_flavor/compare/v0.5.0...v0.6.0 [v0.5.0]: https://github.com/pyjanitor-devs/pandas_flavor/compare/844c5bc9bebf235bd4534badc07c0153dd01cba0...v0.5.0 pandas_flavor-0.6.0/LICENSE000066400000000000000000000020331445233265300153460ustar00rootroot00000000000000Copyright 2018 Zach Sailer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. pandas_flavor-0.6.0/MANIFEST.in000066400000000000000000000000201445233265300160710ustar00rootroot00000000000000include LICENSE pandas_flavor-0.6.0/README.md000066400000000000000000000124141445233265300156240ustar00rootroot00000000000000# Pandas Flavor **The easy way to write your own flavor of Pandas** Pandas 0.23 added a (simple) API for registering accessors with Pandas objects. Pandas-flavor extends Pandas' extension API by: 1. adding support for registering methods as well. 2. making each of these functions backwards compatible with older versions of Pandas. ***What does this mean?*** It is now simpler to add custom functionality to Pandas DataFrames and Series. Import this package. Write a simple python function. Register the function using one of the following decorators. ***Why?*** Pandas is super handy. Its general purpose is to be a "flexible and powerful data analysis/manipulation library". **Pandas Flavor** allows you add functionality that tailors Pandas to specific fields or use cases. Maybe you want to add new write methods to the Pandas DataFrame? Maybe you want custom plot functionality? Maybe something else? ## Register accessors Accessors (in pandas) are objects attached to a attribute on the Pandas DataFrame/Series that provide extra, specific functionality. For example, `pandas.DataFrame.plot` is an accessor that provides plotting functionality. Add an accessor by registering the function with the following decorator and passing the decorator an accessor name. ```python # my_flavor.py import pandas_flavor as pf @pf.register_dataframe_accessor('my_flavor') class MyFlavor(object): def __init__(self, data): self._data = data def row_by_value(self, col, value): """Slice out row from DataFrame by a value.""" return self._data[self._data[col] == value].squeeze() ``` Every dataframe now has this accessor as an attribute. ```python import my_flavor # DataFrame. df = pd.DataFrame(data={ "x": [10, 20, 25], "y": [0, 2, 5] }) # Print DataFrame print(df) # x y # 0 10 0 # 1 20 2 # 2 25 5 # Access this functionality df.my_flavor.row_by_value('x', 10) # x 10 # y 0 # Name: 0, dtype: int64 ``` To see this in action, check out [pdvega](https://github.com/jakevdp/pdvega), [PhyloPandas](https://github.com/Zsailer/phylopandas), and [pyjanitor](https://github.com/ericmjl/pyjanitor)! ## Register methods Using this package, you can attach functions directly to Pandas objects. No intermediate accessor is needed. ```python # my_flavor.py import pandas_flavor as pf @pf.register_dataframe_method def row_by_value(df, col, value): """Slice out row from DataFrame by a value.""" return df[df[col] == value].squeeze() ``` ```python import pandas as pd import my_flavor # DataFrame. df = pd.DataFrame(data={ "x": [10, 20, 25], "y": [0, 2, 5] }) # Print DataFrame print(df) # x y # 0 10 0 # 1 20 2 # 2 25 5 # Access this functionality df.row_by_value('x', 10) # x 10 # y 0 # Name: 0, dtype: int64 ``` ## Registered methods tracing The pandas_flavor 0.5.0 release introduced [tracing of the registered method calls](/docs/tracing_ext.md). Now it is possible to add additional run-time logic around registered method execution which can be used for some support tasks. This extension was introduced to allow visualization of [pyjanitor](https://github.com/pyjanitor-devs/pyjanitor) method chains as implemented in [pyjviz](https://github.com/pyjanitor-devs/pyjviz) ## Available Methods - **register_dataframe_method**: register a method directly with a pandas DataFrame. - **register_dataframe_accessor**: register an accessor (and it's methods) with a pandas DataFrame. - **register_series_method**: register a methods directly with a pandas Series. - **register_series_accessor**: register an accessor (and it's methods) with a pandas Series. ## Installation You can install using **pip**: ``` pip install pandas_flavor ``` or conda (thanks @ericmjl)! ``` conda install -c conda-forge pandas-flavor ``` ## Contributing Pull requests are always welcome! If you find a bug, don't hestitate to open an issue or submit a PR. If you're not sure how to do that, check out this [simple guide](https://github.com/Zsailer/guide-to-working-as-team-on-github). If you have a feature request, please open an issue or submit a PR! ## TL;DR Pandas 0.23 introduced a simpler API for [extending Pandas](https://pandas.pydata.org/pandas-docs/stable/development/extending.html#extending-pandas). This API provided two key decorators, `register_dataframe_accessor` and `register_series_accessor`, that enable users to register **accessors** with Pandas DataFrames and Series. Pandas Flavor originated as a library to backport these decorators to older versions of Pandas (<0.23). While doing the backporting, it became clear that registering **methods** directly to Pandas objects might be a desired feature as well.[*](#footnote) **It is likely that Pandas deliberately chose not implement to this feature. If everyone starts monkeypatching DataFrames with their custom methods, it could lead to confusion in the Pandas community. The preferred Pandas approach is to namespace your methods by registering an accessor that contains your custom methods.* **So how does method registration work?** When you register a method, Pandas flavor actually creates and registers a (this is subtle, but important) **custom accessor class that mimics** the behavior of a method by: 1. inheriting the docstring of your function 2. overriding the `__call__` method to call your function. pandas_flavor-0.6.0/docs/000077500000000000000000000000001445233265300152735ustar00rootroot00000000000000pandas_flavor-0.6.0/docs/_images/000077500000000000000000000000001445233265300166775ustar00rootroot00000000000000pandas_flavor-0.6.0/docs/_images/example.png000066400000000000000000001263111445233265300210440ustar00rootroot00000000000000PNG  IHDR+6|iCCPICC Profile(c``*I,(aa``+) rwRR` ` \\À|/JyӦ|6rV%:wJjq2#R d:E%@ [>dd! vV dKIa[)@.ERבI90;@œ r0x00(030X228V:Teg(8C6U9?$HG3/YOGg?Mg;_`܃K})<~km gB_fla810$@$wikiTXtXML:com.adobe.xmp 555 310 @IDATx\TU?F$ R g-ZCsss>6 H$D`"3LjI$D@" PdEvD@" H5 I$D@" ɊD@"  k$Y#H$D@$D@" H2G*'H$$+H$D@"0deX7TN" H$? {wFGG2V" H$!C^x!:^2%Y!Jxx8FBFK$D@"0Tj;▬e ( $*D@" bĜi5C![H$D`hdeh$D@" b$Yb@8D@" HIVO)M" H$!F@!TH$D`hdeh$D@" b$Yb@8D@" HtۆVN:VQ ٝt\mςa4 ,h ܓH$}muCa3qPԆ͟m(?˟ٌwL<"Tp9څvg{8V|Ҏ84t,BȐb2Dv5¸r1jC#3HcQW(T4aTݱ-p8K0Q(Q~~8k+uH$>J;Lmų5:Qt/?'\Yat(u;OijR`VvuX? D$4?'4Gf:DG P (n,dE79 mH>zh8c"QքXkJVI" 8uc* qH_Dwe 7&GVg`&O ;?߉H{@>cl<0 y_WrMßIB6[NWOp}E7!Ӹ ֠HH6GjSI6UB0mn;07!0 ?.®w9kZhU÷<˂TA]VTNJV0p$E=;oCp/X%mR|ά^Ŧ50IVT|fm2!a@+CXi ƜT0}<x |\)~q= aaXSE+Ѹ>ƏKu,=2^_ X6 _"-ŲgQEDa\zGƪBd$(OC|hk@|7p#^2&~zć-Ɗ?,@`j^\d@E%bĈ Ũ)rUܗH$kn"-I%0iZ jQcj@b:Iʏ;o3޼r,CaZE\J]YϮ#,S<ʼnǯiG͜cS AdҒjL0]mZQw!ZQ&$؄$gώE=ք%cɮW,/e9j׍":1ZtG6ek9hzbml=ti(]ҏmiȉƇR%{+K&)0_k).ZG#nB k˸t5 /6 \*O&/: Q< J9GOPG,|3&j60ۗ'Sj2N>!/Pti;Y#|e= CO\EZ`ȃkhðJ[E223~lG7@M|?{^qWǸ [ہ}ͨB=} pr31蝷gL A 3!`6L]yttT~+x$ZY)64+K`(`!D-3##bޫVXa:^fH:$:F!k=0f,NB"bc –!Xs .VZIQ:Xytxm.JQŴ J"&bNsp Hא_#.s}:J^V҅a=,`%lـXs.|lbAI3U1tBa;G 2%ASJ6Ψ2j%aŪ\%u(/GD i)fgJ0hDR`KT`ճJĆYQ?I/pJq2H$}ﺶ'䵸+qW8^OIІT~ |̸k+Z|O1 {~߰ѠrG7,38K:G+4sqQ)+IRL(E .yПXig!|*.wIIГTSQX'iB8*EFT.0O*i?dl}Z[`r à /VD-%OXC>O%!vI`X)!Q|^J5;Z1xR ,K5ddXXSa@ܖ\̍BJR. Qa"爜$NssYNū )>;6 si@^Y̤yʞ)$*/HCQpy27E j&h$»d ¢rhB-1|l7zx}0J$ f:?}t{:Ũ9|PSN~rZ*7Ł9{ʵ D|FO).DB7F+Fɘ x1bQW$*! ,x(P ҀM &g+g ^ Xx'nTQDdG`떭jU1С!Tsz.>4j)~#~N!+wT5F-HQe-ASKwt/73NJrL?,XƁKtx"ĺ8:g7*$}f!GC3>F6 ok<)Ϻ`^" ؇[Q.C$-v~4 Y0f-3L0G֝=Y\bj\;9|H*aV.G-Ht<(>I>'ނRDD 0-ȹK@'(<蹗n樇bP!sáݏ %YqVo MFTa \? zY{%{nWFaWM{,"fdF"bCԔ0uV$;1Y-Xhq\dZkV 㞆R` i`D2Û|4]~s 䛙_.6׿":WU(p+1<,ɺ1cƪǃ^~8/<%e>OyPr>C ]r*JQ`E q5kJ781O 7(iuKTŹļ|]x.aE:W0cH4zuf)F¼d\bC6x_6J%~.i d%, HF} JacĈ RWJFMDʊI+J~qr>_YC*7.7Ժ6IzM; q]O KF)AָxfʜfZũKT&9p@w2h\"d޶Y&d>R[//)ϱrege_J/"eYV´)sTT+}4qC+cHG%4{-J26PK,\kdx.F >2^oJ4os iԧ{GcU!ҵi,)CaF :eTc^LO6fw뺁߾=gþ´a^.?aϖAv'.%#݃>p<`)_ywR跁(gN.KJK19К=c>@\/'uzb#;e1;+ :w״P15@ D%ƥgZQV9RMkzD+<}GQb\C })>^7ie@eEw~M6ðONw_jWemy:[U/tgk=ڦΖw-6=uےO&R.qbCF[XT}g+cަja(OW`slxf)qc^ԥVd̳%)m"Boz`KcSWUml0FLΦwob fOMlYy#Ko9mEmMlWxj*s?GٖcjVymJk_gٲNszZN]Ev.%{U?[m..MZ]г"[Izq==4[U]qly:ecw/8em[զrQqou%x-f*A^Is>Swk=-+YV[8/'!ã_YϴkGg J>.D={^#س}`*t|UK&d[?QqC#k<6C%/Ɣ87ØMcם7J#^w"e7Yaﯷm,)tnAH6×Pl>^gnh=ϼq V;˸/yp?f1'cd| +FS6ruG'@#+yf2hlUM"ug2Ļ}d[:[}}[*'!JCW<(^ $:Bz5Y4zu+B[/L9Vi[=LSn <#}}S%8T7xvY#U? 5ִr[OMs}x|%5lzDK3?H\x5&U3BG9ˬ'Y<&>4]wStܑ`q͈~җ.[Չ+zrŢ ;Ab25εw5 2[`ǾZn+Ze IrVs荥3M1H:=טW'x$ʬcrsb&(d~:uq۱z)@qfc /eD}X=~GVuLUnc.t? ~Ȋ\2!Ld $4a#G̀0]f̜1VОZ!n"5lUN8buh;}nԏ=zѿ}5вk.D`dL=$2|C{īݛ@c//t7 :$1Q"Ư&G\9_EkHhD4?)3љ8b}J[-X寸ȋ8GMN9KC|O)0\#@J 4X#h}3^Ͼ!>lx~]4wB#;S ~c)rz,.vmTo@dQZП*Njf׫]l=0NcpޫS R/{zL6(_!ʊx@|?xR8j t'1śvK>)d ޑ{(b~X˳~ ""*9! ר+F:waN%&jBRÎ2PrhDSE6\uh/t TRcC0<\]A!|Ӌ28a&N+WoP|[LW=MmcAbQ $"y3WSu-pr"pdvAo=?ujgpֻT_E"Y:5锦 pc=oI` xe$t9zu?y3a*Zi"F5+in+8'a ۺc=p+V="& AF~T54_I(8$)$.E; ޥyZ_󪏳? ckVT`$(GzDc42Q!?==ubDHZU[5us;&{wv{0lỗ\ط\C7ǗӮ e7Dy1x*uF h8 wT|#˒a%%c6`c3B ":g5bSD~^; qc \ٵJ\G˻1ƪWx#e+IRQwjDYU% sH qsZ[l@E-et|@.E2UQTc QjҜU0 ,z6`Do&+t, R0\$G&>(d]]B{Tr_8]=[,0 ?So+*>T\x1Qd#;dh{5xr62,#~-h]ʄDfLC~h{/H `X\ 5E >{eQɬf[%5'zLy_G:Mħ&#Sl ӁFd17J++`\lӈvz:./(sAQYg.d)H_^H:QfbeI/!?q1Nc$(Uwm>K:x.O?*=?ņLu_O}Wɵ:vy'Js=|C!Z?j˜ׂjfEƨP U,#BS\O\8FN& 1 ڵxr ԷWU=C L:RKgZ+uu<8`_8.Au:-)֏Lq_ +Dm(t67w}%haBDWWaOFaͦ-r%vܪmu+O?=(b^YO`~ye}u \)f(b-1M&r `ORղs9ޘf˸_oc7U~i/s:d.If'UAJYU%ߍJ.P@<(DuSÁE!+zzj_!w=v퍚Gz|àԗ |Nl^NND%]-x=c\?@-^V`u{-їz[ZKw+buI\0gԣ%u?h-[YQeoz%O .SO7N_􉑳D p鐴4UF}1G=s4VUUaNsig9wgap/iP|7>婧ӑ|~qZkKmtop2lE_ѿ?LGmZVH)_uRS$[?GJ['h֣.n%*G>d1}cU?sN j"7vyݶ韢yzg7p@]2Q vLoT4tH"j=՘ }2<)^a˒xhIV/NH#Xysc>[Fxg".Z<4iY`YP.JTb_ x.t|pA"pJ" mV9Ҥee(є+q!?%$ y m{H<!M>|]zH狺"Fh! 5Ҳ2hD}QFP>p֯l>?q>!cUF f}9̓=Z4[sk~l$GY #O\UE2s_x*a⭩9v?jG@AwH|h5wXqN$pAЁ+;}g3n{GE4 1L:r+oT,>*`+#ޣ֝ >8\i;WcNaN<530~MQ,_oኟK" *2 U .53n*n_>?pؗG+ ״#.Xl_4V}oaƤ.VuJ"qwg 8|\h2*ZD] aÅ3y^|拃$V"uavg<峦ƅj|zHٳ;kE󷭈p.5b |k}}|bH0:sn?il$6ٔz$ K,)8XFWкS=.\s;SHNN}q ۆc~!<3Ϲ?t0г,AjVz)d^މ_ޤUrt<̶bM~ѹc`}g/SvO_.aeЉ?Mhm\mw(#-i \'1F`̈́0 A0\,/9$Q5_pĆsa$ zFN ]Y=[ͬ{ O2' FRR91^ׅܒ;usg!*'q_*'BAZ8q)`/}i5_Iŷwb^axg0u7zNX'^{ 69i=9K[0VLfⳝ"^y<^GuSBgbyم)ql_H:mjڡ\^$øU"۞o^UYϑ$a1-`4I3i$?u0߾;H@S,o+%Ok5װ<&g$c:I_͚bR[ۏf㒋eVM{R6ӺLbV|h-9,c~% $5ڼݩL]wZ(nnp{V;'3$ h\=)nt᝛_-vwomPErrUg=}/fDʦm]V/j [Q*HS?۝[ZLP^t'Z?zr%A踐wb."A؊KlZ}}NU-}?jIj@v%">=ţkt_4Nb;V/lʲ58 K `[RV" 8d([_ Ǧ4sW %pbhD}Λ隆h@>&M%%JN@=v:19˦ jgSх[i'Ӷ=NУZ҃WLL؉Jochy.NWt Q3'ڵzh̀g`r.ԐPn}gBShu8CM#;YQb4*pB H?zԪ(HF#TZ. L֟VnZy^hSB[A>Ӟg.W`ߩԃV< bY@p$1›vw;i@_i L󟴪L_W!hyD];CnbXFr29Fah!\* tOs-C5N[:93FG|IiNYSU>ڷT#B"n$&ҋ~ eHId"9a5N'brC1^&p|:2at#; \,*KA}~ZF.@­G`mH˜^mqv>Z JXϦK2Q JOd;Pیz+$LXP.= K˕7!I$=nBO{` B8Gޯ7_ ]#&\ji/4eY-ZE8;ɣ}FǸGD6=\"rTw IH2|6旅mo\:`Q۠3fUjQO L\C|6o:jYoMtTMutrl7S!?f#Q>=X"*/v'p}iրok]rQLEn.[=#pk~_X>͏/H-L'_JG'kPg@L>߄/~PEX]b`ٻӊu1r^Dl˸2_'>. UJT5A0mv E,v/va>=î2H$7#l 7krL˧`2 ^qC:Jfq;Xn;nep TuGԿo{Lo铉xq}%'.'7ue_@A;'CA\/-[0\OxGVsxj}-DuI.;h#G:pj-j"'a>'^|eyΡL셾n",qXr]COzcLHjHH]Jyr.}^a%6 }W'FRGfGNO + VN4}v[v25X{O*_DE`떭jU*(J/HԈɊHc=m|↓q_bDӪ#FipݤR~-]'GOI4#Va2aQ8v:/Ӎl:Y|RiIe2D3@|6I"`em!򣨫X6o&9^!Daseݻ)$+l\"y=L\Wt\.wa,D@" 8Qx"+v,t\:(aF8.s'Q~#U;S|+cHdw8T)Ԫ͉CոD{iq,3#aq.E]x u[k|#KHNZ$Y9ʦM닐HS{+ F?6(y FV2'};wdIk:!ZCtR':Z]Kh&Hr%LSsPچ69D!0Q7GIĚ"2drDtA@t[# DJ  $qE D H/,EVxQ!r?~Og R|`)!f($s6!QBT 2 D.:-)eu|`> ,[R{<,Q&eV#-tRoG:$<:hzv_*R{5bJ06?RGʫ"+u/:G=e5g~"A`%p|K|!"khkw%X O$U HKasr_RʯT6b.J5_2o؇iJP3Mt" QȠXyEvP—Q޽D@"p!:bU;9lF$fؔXDi&$]p"D,ˇ2pϓXU-HgiC5zyOE/#o(GïA>`oڥ]Og9)_d{ a+G 5E H^Vs.l,.K'4  *Aѫ~2-fkxe&a^PU9 [`0PaDGOhGe %HU:LCXMHD`!qjW!/ W痁HDːlbV~1WL/@+UfرE5w"iMReDTY:[9#9>"/hB;č{1\M\vÛiXŷ@_vD,ƺj{X /aDEqӝ Bs&"$4!gz?[I нϼa?ilՈZnj}cʕ8kWA?kw_bRbmMhj^~9ݴB@ޣ YE8-ρX11g̩FڕZ`Ax7D33C8ZyٷsYZܛҪrƶ*I5"NԂz%;w*<`+{WsrIz걂e 42{U!6T[ɯ*ANZyI@ܴrZw`=T++l8hk$]d~:eu*Qc!\Ӓ)t^AaJDC(DawO͑xڨpaudQL2&40W[>5&.Df_oVvK.Gښ8e&֖"oeERk*/t%?Uk9 *[g.bKAʲ0,,))ٰAIJ5H#ohJT1{jd#QF׹p',$ЦL_ӱ\y]ME,h ΋F- K%,Օd*Wo8L6Te"q&(OI\t9+3q_>L|yKETt'XLQf鵥O0"#b$ #@М3,[3UvVH$:{SCY?݄s֤jTNVGG^L iWĂ {j6.eU83S7iEȤlLGE>8sA;Ue?Wڐ4 }3qKmqj{2t \G"C2K8۬`AE J wY5FH47dؐw ߻\>NdHZU[e Z2I<G}KZoRqy4c0{CLX4N9IULHZaRbea;]C":5\}DE`ᔭQTlCL:o D+Nn4<;ǴI}1g,ŽaRN,P:|nCh"r?HXC: I]/$l>JUc1!dJ;Xؽg#WH !E.&]zvM92>,RK<'lݲW͸W9szF_Mcֲ8@Rwxr^ =?~PCuL?Nn%cqs# 􅅒Dl,' X0w~;γF4`d*U~dG1{1T'ʀ%iTSujw$aZhjgՉO<[IW"ݻsuʹl.c_:Q-;&.@?U.|&=ܯr!,|iR3IYC@.yh<=Ytm zyshhđk . 5!YD<> !AS+@H$ tqS7cynPnHD@" 8~W_VP" H$ᎀ$+ý~D@" 8d4D@" H2[H'H$IVN /H$ᎀ$+ý~D@" 8d4D@" H2[H'H$IVN /H$ᎀ$+ý~D@" 8d4D@" H2[H'H$IVN /H$ᎀ$+ý~D@" 8d4D@" H2[H'H$IVN /H$ᎀ$+ý~D@" 8d4D@" H2[xՈi*W9UWl{:{%9>QҴ7Rv@NA8YGCs;zݴ?f]mX"vث^үLo,&QZ3fXn(1Rp@tr IVN:;QZ= u!x׊Ҝ|{~eZpCǚ pj}-E]t&q@[;V-O C7c\no -B#* (lID5l/kA[VeP1{>z;1ߞ[QM+a>E=HM _f?]:q;լ;J3f,&OBԕQ˵❔cń˼"bor;UQkEZQшw&"ÉE@)?+~_ҩH)+7d$FQO[A->}߹=w\"}#\͛4%h1B> _Mb+ Yl=Ew f|">$YY>-w݌ͻ]=c^agad`>݆ʕ0MA]kkj*Bp tX#QV3H@gsR7qڈQS9Axs#3桹H/x!í:e %،iXx4Vw&$aqBh*%Pcg`8b KPpGEea 5c.W@U!,ecIfV 90ܵb=4GY6lt^VA?= w#Xp7=gʼ|1gM(4ހ_oP1ba?a~t):j5i>1Y <#幜'a1cxt|1j>gYUmK(qflBtf3cg8ops 6欠S&QǡT\+rcw~N~p Z+fɕ4"[6QQ?cϧ/L}f u;:>s1VzPL˸߮Od<(&<[\h/i`[+&!;1qM7ָ$v9z< o|mbmBǧpݿFDN +^> FtʞzGڅɥSf)m:)k^r֊+ec)p4|z>Xzm⣂Ԍ(~qU!#vz$pI,^ZڼH|iQqr\ %R'Q:5嫳Ǥ-,c RJ< +`Ze mDO4 8XZZ&c847Qfhh+qO&6-DIlдS4 ؈E1`aht/=_ޭ 7=؛=o?ضCqeKK#Y $0p9p"Ӕ]syZ^3f@Pp۲"2ӞǴ ]swWobׂd[Ʈx]/w,=zպFh9q'.BuJHT|72 ="G_4 {UΘv3@(i 0fgO\ /?sObuQIX"qӺ{9=b.H}z58]Dxq>_ QoZDsFx~%U͈}0IQ\|[O|kFӷ7HTD!PUQ+`ҢHӍbn:˜8WsHK,h x-ŜE#D%h@Җ\¢ITJ`ь0PQND J$>/GN0&9z%Q{](# ?RF֞q@8&B=/ ޙdͩCE0D) ! n]M(H\IfhrY,\ƈ-)X/vuⱘwA$*g ?#d&CwA<ڞh8qhz,5EɡX& __Ξ}v>ZJ 忹 ze)B?/h }} !8ל?NCkY7;vSrH4~lT& i^k1V"&g<^F#"QuѸYG#t~Q9?Lǣj8p}2h|Y3.c琗^h@lJaq)%Ƌ${zM8.yJ}k1yYpLo P1"d -H<̀/P_wHIkzWXuPݚYi(sbfG=u"c CYqzBk{iY9K´*̷qW://TƮ"N*z Qܪ#Qhhaa9qc{TXA-͹\ :d'pZ" UQWo|ࢪ,GkU|ݾ"A6M-rf[W[ utZ@U’ľV0d5wMc9Ο;`A}9y;s93+x݇BQ}e(wO?xܨ08F?}9>j~7OHSnr]m)WUE[qhzE)/,D!򁙔!N {9syb*l %*J⚻ݧ<vc~ ~<0?N" 5yg8P!q=fMNYAx9PQec@ĵ)+Q9>Lߖ,喓*nse5ҹШrxT jڨ9\|KUWqx9HaҜO!޿*'ڿV 3" m_+R =-C[ K:9 !B -|i;$19‚HS֡+? YomJaP2N:|qCEؒ'Lm}_3qdtT6[\8*C#}FSPQଟ2?.E(\rhHW煀gTʕ/'k5[W&hfr~7Pm SZ9 ݶ(ɸFiٚ j''9_}\+ӗ_~hMf{CB/ݨrUF ϔdʙT_0;L_0v 4i]Y:?zNXrk͞ =9H/#lv;XGA[ 7J#קE$ ٶY!8>\0]<_*?XGP$B问ASOkqxo)tdt!pCJ~ ?Sr#'`^ ?%/~Y2h=6rj<FwYҋFu8jc.Qߕ\[=Qd8Ʈix4NQwe㱕ֵ9L4m~柧ԧuAV$o>dfW+6$VN >:+Z }.,>PԂy-Sqar5ZX>M iGhlӃyT~EN_:h@))BHAuul[ʢ<\ߡ\@0Eתy|sQه$dCҁ~*-M+=qPj#9Kʹm1 *OABR2Hbb0}pC4/v,>jϸgw/W҃G&(_\' HϞW{BdFhٿS&f8ꢇqX/qoh|RqC@^|q# )CD\*GQC"?:agBy#cowZJPFWk'l2<ܕeٴ'7bh1ah]b_qrB\˿O;h%9O}~\yC7]'e7V$oW-,D~?R~?s${02{^_U6'.,X?{Xa79ys'܋aZd?$3%C&e}Z Z'I]I+&}&"'eg\9VLCFUiPd]o"m-`GO#!=??1WZ J{Rd[IַEowd-E*qG\945 %#&#q;D T.tK=~ r Bڨ8`e3ܒ5z-9e\AzZq~Q~#(r_g;* Id׿Cv}9 ėhhB?ER1R?u=T håC[kF7e89$dCd -M 1{X|gcFmB7Q/*4=0A%Q]EoXWT3TpX:`L F]*WFWb˾@fMMHz]$JPEӆeZCVv3տhMNӉVU^<Z_IҮ걭-cӒe"6}n4Y"ip"`L G341 uW13f8t<`aj!xվB{ϬCGLڴ.ڝ4\ }AU S#bҰa!%;B{ 1&<PGT04Fk=Ќwɣ!J nC:#0FN]>BtU42DL"dxq9OJF0d$JE+isC=1Pݘ%WP\2ZOwMJ d9ƺ\k> Y4QF%K#'f96M0a83}8eɊ}'"4~2bfӦiZmS(FcT|&*t3"߳DL0øedL >Y >=IwȆȨtA\JeD-EVCz"\$rOqqO"9 涫01 ݽ{ RhmXLQ u2'b N:IQ7)҅LG=2y "sFtc`L x#Ɗ72&ڣԐQŸP6j(ǫUyHh!j\ saî[t4RE bMhBtak5Mm ێ>LL 0K+A'g=^Ard2I!F[Uߨ)8D ҩh47(z1nkuLDv-vSZ\9Qt.BQC $=)ɔkS!b#0u!'Sh^Iyj)Iv$k-:-錜gNAN*w\|WKEH@c9/ND^i ZȧEW̛HO,8LRMG)(HC ɳ*́ 0&p-#.1SPYFP1(UF mG\=Ui+'#&ߚ|Im=yUP0e oXws 2ZED6ţizSM$?PKu? Q4b cmt uEHπ:@JHVx Z1G-w8ڒ\>}/zBrEץ#dY?Go=IFE8[M!u9j [^2c8d-LCΙD03l> ݔ/.jf 0&Xxe/1(v]n.GPѻ-)CvNmJ #ŮYQq՞&N7l*S#Rr]=uӝD;]Pzh\he?W}9Q:&_dL \Dx勨3tLf1P PPRrzB7ȋ"QEaqq2*g9:kWTWoHrȜJaT0RD9*EJ/䑠^a#zgC|B hȾv$HDީs}>VS635S#{*&8~5OsVg2] .3]oOu.5KN ݑ){U]+ޮ30R$AsQIތ󢑱}Zh'dC0>+' qQu;01$V@u6lΞ*"'"Z"Z1!y6,OjuZ;:3 _!O][CF//= DsI,er4ϟ+{lFG!E 3rfha"î wFe?qtoN' Te-F2QI?bjQH[PCg0rt<Ə V1HdQLZex(OiT_5s'*HGVWU^Dnwe˞<2TbP1cJG(иҐQP_=@ ϫBGS'e6EL¼5k7IۉU9 *=eDղ$T Y"Q}-#.2lۡWS)~4UuMNjQk?4ჷ Q* й|*8ԑ~_ jv{ۧ_N=^BVJithA49oQ9ҙ| C#:ʺ*CEeF '_@ڭHBPK@;Zhz~R-]C̪'Yem #"Ծ[_Gѻ~iY⑹?&L)@: ȧixQ.99EnOءF/75ϭ:/32L&/A|,gç-&){+e.jhOqm?Y]^7--D;`p6,ǺQxRuȾF⮍lЛ0|ӣHE5ZD#rPCYq:ESb^xISC?/cj/wz>W8U#ph\ϩ,ݻ|<{6j\̝){QY;{mR)22H|hw xqXu;]!.&l՛(|8yyHJF--Ș)M?rm0|DTz4}ڼ|0 /Q $0= ltL; i#>|;QO6:4U#x%e^2VyQR,8 [lل&)xAt2$4*K $( d $$ND‹UO2f1Ir|nNpL,#1 U +QF x(ZIhrTGOGJzźgPb02ȔJ~K{F#$4C2P W)|4/WF:k^J@Ƃ,dL:"/}!4&=,2'G=g fJ82Kz/P̯֡j]/Їs1ʧ5Ȫg2:!F2"($$"Q&$!vd#{dCWsIut82ɨ{*k:tȺC +=7"jcx}m uK#;Ӣ5Xrbը ]3?--EM(u*n]1QB #s8!?銉|A ۣ瑏.xSB!ANa}Oz 7nYah̓!I+ h<-QidJuCٔ tY c}(y`?v*EC G 8i ӵYQ0_sOFGSI F2F фJu$z({g&O#7#(<ꇋފ۽k0/)0Jj_~4`5TVG5|>HȊE}(=rQ?*ҁsDc_MTӯ:tN@ذ`ؚrԒ) g0ܜtm؎BL͖?0C&Zh\YBܗc}U_Q Rf--7̪Qwܙw'| ᲕqoWKL)wҚ,_;o=Ks'=dsTPȩ+W]tf꠾ZPR!GsZ|E;߾uMNOPXiTť=c Y?o^'̞'|nFDQ ~N)4Um}XGhu:b|OHVZE }h"g@/4 M їz07cnW24WM:{(gc bTaڏm] (jOG(LqQYQPy3o 6(dռwFUz>AwKY7!?}"4($?[H"eCLF6M&#] q>&Az_w;D x:6Nҥ3~SCz4_V#@.IۥF\] >!y57 {ߕy9Єlڎ͑RBU֑|9:z.{e"yt=FUDM7h>O4R3lA}>AtTݪ#*Rh@;Y>kʟ5emo^jDO  qҮ}p,>@f34oP\ĦʹW.i+qqQC\ /'_exsTAo[a3bT)42*5F̷~q.s4zP)"t?MU4hA~P$ܙfdUpiY@66hZͷ:lu{q3-7AJ9rI<[iҦ LWGrpJě+B僂RPSoUZ>Tԑ<3C9[ۤ"cv%ҋuA2peb|.jhzRX: xV8^ưa.2lYh[i蓼_<>2&0( Wup!")ɷ cFxJV4|DtMzZ 2h_n}&>(SL%4ijk$3ޫ"&d=ciٝ4\ }AU 䓬!L>=~{#E87l#;|A;C x8 ø%tU-X>eL .(iQ8BBYC+Kav죇ib}ҊhR58Q^j]?vbniAHjDh-i:)FՂ<3ePE<Ҧ!#OF6ҮF{X|oT@O(r!iF$_? L,=ko7 aT~܉3-UN$/D&;Ӻ; ,?UB&h:iL'd Ck T>:Ά_4C7MD3X-8^:PU.bIcM5 A8}Y#Yh|יR~~Y崗T_<"NJFf(߇1ΦbQ6 Z?3p `L px[ nRhQ7dJ 6#601{p"t"tk|5T$9nʆHn j;9jj6c qGb?n؄Q*8^:[38- f#9YbaR.lvc$iMNU~#-?wag/9?CED_($ 9WL 0A觐(*G~!h(A24Va$IW˺"Ie:mT4Ւ<@Uc(`Ү(JZ)e4";*KzH˝n3_"YILsJs1%d \-r B`P!Uӧ,T6$3Cr`L Rlq?BJsPsOW$I?m%#^`?:+Z-⡫-{D֫(XJq-1h0 qݟꥒ*[kw+-#OLUHtFV-N>*wC+=#AYQNN8GMi>FLZ Bi8@<Ȝ1T'ZXMQ%,̵Mh:Bt( ԣ֢|EF BqTڬNpzwW Z,9ҦܴT}@vς!x\JkBmm!̛ӨL6Sr c+bj;LhEsHRDiwv*jhMN>$)갑SЇR 0&PX;-uEH:Pu<ݑghZd&WzouU^% ZcE8}28/jLHO@CГEˎ"{yc )[B/o~Ïu"Iei8t|2*)o-ŒK`k/"d49DZ |ƺ~H F/<(;`,s礉|TSCj\ ]om7mTA¼0A* НZ p'L=F8t^:w9Hi7ϥ9$A!ӧǶ8Cyn믣0 E^J8'V/dĥA?̷Q&g-OТtˆʹd L \@~檅+iD*rj%z#lJz8GP:kY@7r(F240[:=\iQӊPuM ū;0cGo}%|W1&Oy!4MS-GM4@N=p] `>Mx]6:1*IWcL 0KO]ݬh$MePD\Sյ"nz 0&.6EЉ&`36V.1&rt"7 0&L/;tPtu*&`L` gxz c=~`0V`L (ag.{qL 0&@i V 0&DOT8 0&lLW"L 0&'lxqL 0&@`c%`aL 0&<`ccL 0&+`L 0&+p`L 0&0X `E`L 0OXD%܅HJ)t6Y߈FfI|4.N?z.ly U]q{>ۛϻE]@016ﶩhdkCG O q2d|ךs2&odgfh@ :'a40> 챀hJʓbO=`IULX,3s&_c[8wl|o2V~oc{qWh6D^`L BS sMueBgajdlD<xR{9񔹏M@sl)# )P2 |A`  t`!c*aYKg1YJ rή͖XJyRXvn ]ǮָJ|ky!CW覍gVF.;)ײyNT"ɸӪ˶5ͮKT_EsE:jƥzQS5EBEIJyqͲ󭥒 9j|K%5synKKKwZ_ ]tRc-Jm[c喻~["1m5dֹ7SXAjb%%vރ:җe'm Y/E#s_1&."4_]و,]P.h'\[x`W{F=.(r7z.eK/r qS^ Y-s-G`QG/ƊH,lYfkT.֢uF"I>( )!Hƙ0rweB&=qlΙ%akj⹈#jX )X8 11Qκ="4S[eZ@_0&;'IUTd ^ǽ9)udxTiWFh>Ruݍ ogQ|PDr XLic2e;n.-^Rz#m\$v6q4976!k^QCRM,}k[),/uQzxJ_6_Y{ЖL 0+ГǖhuFtuuh/?RoʶGpVDy?fP3; G[]^C6Ir'dv!ofʃ.éhOD|Hs9TVJI)Α^(Er`V,`d~3>R-0p.E'=Te~6`jR"_<%qfِ`L"$0؇.m䇢!/E䟠9x|0dml@Kh,m]]6erYЦ҂QߓOEFU6KS kvh,C:o~+GȾVoQi&XbIOvL#ێYulr :Zt gbN7MZj3ϊO,ȍC*GSf $#Z争ik;3)a"|g$?Irh^a~߼iJeF=Ҩlѽ%&徵blkn:?<6V?=*ΉL 0O}V܇&A=XZ].t凚BU͠Q/9dF 4SM9/;LZA֖NS!dX؂HKas(S߷:2jdm7Ⱦ5V֛ Q o"KNe}5,_i,{eiՀtYp=B<ಅ)d\";#Ǻ#`Lb"0D4T97Py\(*UOxQ^T75i}{)@bS- 1΍H>aL 0&X _=3*;s}Z@7K2&EOUMSTW#v}6`6VΡ;U6d DQPC0MwQ5P}dL 0O[aw;qPCI02Qa)wȉHXTv̌Z0F`L` @~5K1|/\J[AƊEN]&:i [籱s|`MLȑ#GKaL 0&p rasUL 0&@ mߙq &`L<`c<檘`L 0`c̸`L 0&p rasUL 0&@ wf\ 0&8X9*"nB?iQ=ŋۄ&cFs{;L& ;s{KsWL 0%J]z >>ڛQEV}(Q%ϳ}[4B!PU_("56s7Պ^se4A4CBgw7b$lҝ0&0ҧ2qBL~h2q8vXLaK.9`i v.Ɨ~EcQX5|8y d,rP,Z`yE햹ᵅ(bÇq`L`0`cOwl#-0ߠ6%5Kiwb%!/h ɘJq"=v*HƎ X^-q<рWע#i\̨zv*&OejG Iْ~[s_$jjcT9ь]QL{ѰkѠou>QTn241hp%OlbB/C=kikr=FK!'4XKZWVF蕕2\LeXdވ{|-XӞS Pۥ[F]~42P+ KCZ^O_V!)`5pN&@ p#.K$XbZ跬ۨ|e8X2Z^Y/X4:ҥ{K!JoYJJ׹K,$K\ZbEGz$CZ-/XӢ;l[=KU*۔ޙ%z[b/Gݎ RD{n,+5ĢжZ[%slβNRNU -M:*3ղ|Z֬^j1 Jn<[i#PذRRKYK7[%uIe 5m.)g9ٙ .Rj#?֒- Q"+c%gҞ'L 0EK@V6VRh8U\BmGaRBVce͘9$?@Dko2Dk]MouYpu]FɰyYO*@la@@p4}dtL=8}(KYab˿W2RqVj9FK.=c_+GaZTkK;EtRzgW }: Z (WEr7C6a6kfղq5k~]x[`1qY/{>aL 2< D3 G>aiE-pCHH3scXM9 g&c1|p H_Lֆo7`QvJh:4=X~ s]NQ'L;V;:Z*- ޑ܅Z\&|13 WrT=4]PjjoߪaXX2m!?$7D"=×~-cCJtm-*EUX~Uc{l 4`я!vDǡmL8!U+b/>ܓWadH`|P8RCt/_K]Xz&wF)l ͘5ێtb8T})9Ln#a33)9X uW) e=tOCG"z- [tv9.꫍(yc3hl 0^7 ᮼmtl$«ۦ0g*bȟbѭ&,;!ҽWmMi4L-49M@|N~dVgikk>&9Bh_XϊqJS] /Km;BW;xz9iXAkJ1l#k,CG,Ͳ/oZq7eyW*EķmI!7ȸEL/Z!Gl~+GX<Ԓ Y4[Ľ$ߞ'L 0AB}VQdGW` a8X#mƊ@)s^Jΐ$d}3aBxXr,-/mx: #f8ѶԷm,qZ*_A6lP[rdcLReӬpJuNegs[-mѴt5q^ IڲAG]ŸH 옣 _Ό[qu&P$%s6; ]_:J2Ͳlн9GqoP ge V]35`L "s%@S 34iC*>Vh6 M !h׋q4Zpr&Hٺv)45⡭ Ta}JI݌W#?zQ*x#YDXLbZqoy!Ϙ`/_Gk+ pd.DataFrame: """Transpose the DataFrame. Args: df: The DataFrame to transpose. Returns: The transposed DataFrame. """ print("my_method called") return df.transpose() @pf.register_dataframe_method def another_method(df: pd.DataFrame, new_col_d) -> pd.DataFrame: """Adds a new column to the DataFrame. Args: df (DataFrame): The DataFrame to add the column to. new_col_d (dict): A dictionary of column names and values. Returns: DataFrame: The DataFrame with the new column. """ print("another_method called") for col, v in new_col_d.items(): df[col] = v return df class tracer: """A simple tracer for method calls.""" @staticmethod def create_tracer(*args): """Creates a tracer. Args: *args: Variable length argument list for the tracer. Returns: The created tracer. """ return tracer() def __init__(self): """Initialize the tracer.""" self.method_name = None self.start_ts = None self.end_ts = None def __enter__(self): """Enter the tracer. Returns: The tracer. """ return self def handle_start_method_call( self, method_name, method_signature, method_args, method_kwagrs ): """Handle the start of a method call. Args: method_name: The name of the method. method_signature: The signature of the method. method_args: The arguments of the method. method_kwagrs: The keyword arguments of the method. Returns: The arguments and keyword arguments of the method. """ self.method_name = method_name self.start_ts = time.time() return method_args, method_kwagrs def handle_end_method_call(self, ret): """Handle the end of a method call. Args: ret: The return value of the method. """ self.end_ts = time.time() def __exit__(self, exc_type, value, traceback): """Exit the tracer. Args: exc_type: The type of the exception. value: The value of the exception. traceback: The traceback of the exception. """ call_dur = self.end_ts - self.start_ts print(f"method {self.method_name} took {call_dur} secs to execute") pf.register.method_call_ctx_factory = tracer.create_tracer s_df = pd.DataFrame([[i + j for i in range(10)] for j in range(10)]) res_df = s_df.my_method().another_method({"new_col": "new value"}) print(res_df) pandas_flavor-0.6.0/docs/tracing_ext.md000066400000000000000000000040231445233265300201230ustar00rootroot00000000000000# method_call_ctx_factory - tracing extention of pandas_flavor `method_call_ctx_factory` global var defined in [pandas_flavor/register.py](https://github.com/pyjanitor-devs/pandas_flavor/blob/c60bfd43adbcc304b3455055af73ed9fc9ac10d1/pandas_flavor/register.py#L8) is used to allow the methods registered via `pandas_flavors` to be traced. Default value of `method_call_ctx_factory` is None. Starting version 0.5.0 `pandas_flavor` implements the way to pass registered method name, signature and parameters to be handled by user-defined object when the call is made. To allow this the user of pandas_flavor must set `method_call_ctx_factory` to refer to function with signature `(method_name: str, method_args: list, method_kwargs: dict) -> tracing_ctx`. `tracing_ctx` should be class which implements methods with signatures as below: ```python class tracing_ctx: def __enter__(self) -> None: pass def __exit__(self, type, value, traceback): -> None: pass def handle_start_method_call(self, method_name: str, method_signature: inspect.Signature, method_args: list, method_kwargs: dict) -> (list, dict): pass def handle_end_method_call(self, method_ret: object) -> None: pass ``` During method call `pandas_flavor` will create object of class `tracing_ctx` then will use that object to enter *with* code block. `handle_start_method_call` and `handle_end_method_call` will be called before and after actual method call. The input arguments and return object of actual method call will be passed to corresponding `tracing_ctx` method. So `handle_start_method_call` and `handle_end_method_call` will have the chance to implement required tracing logic. Aslo, __exit__ method will be called if any exception would occur. This way it is possible to handle the situation of actual method ends by raising exception. The example of tracer class implementation and factory function registration is given in [tracing_ext-demo.py](/docs/tracing_ext-demo.py) pandas_flavor-0.6.0/environment.yml000066400000000000000000000004511445233265300174320ustar00rootroot00000000000000name: pandas-flavor channels: - conda-forge dependencies: - python=3.9 - pandas>=0.23 - xarray - pip - pre-commit - pytest # for tests - black # code formatting - twine # for uploading to PyPI - bump2version - build # for building package distributions - pip: - tuna pandas_flavor-0.6.0/pandas_flavor/000077500000000000000000000000001445233265300171625ustar00rootroot00000000000000pandas_flavor-0.6.0/pandas_flavor/__init__.py000066400000000000000000000007741445233265300213030ustar00rootroot00000000000000"""Top-level API for pandas-flavor.""" from .register import ( register_dataframe_accessor, register_dataframe_method, register_series_accessor, register_series_method, ) from .xarray import ( register_xarray_dataarray_method, register_xarray_dataset_method, ) __all__ = [ "register_series_method", "register_series_accessor", "register_dataframe_method", "register_dataframe_accessor", "register_xarray_dataarray_method", "register_xarray_dataset_method", ] pandas_flavor-0.6.0/pandas_flavor/__version__.py000066400000000000000000000000541445233265300220140ustar00rootroot00000000000000"""Version number.""" __version__ = "0.6.0" pandas_flavor-0.6.0/pandas_flavor/register.py000066400000000000000000000160631445233265300213660ustar00rootroot00000000000000"""Register functions as methods of Pandas DataFrame and Series.""" from functools import wraps from pandas.api.extensions import ( register_series_accessor, register_dataframe_accessor, ) import inspect method_call_ctx_factory = None def handle_pandas_extension_call(method, method_signature, obj, args, kwargs): """Handle pandas extension call. This function is called when the user calls a pandas DataFrame object's method. The pandas extension mechanism passes args and kwargs of the original method call as it is applied to obj. Our implementation uses the global variable `method_call_ctx_factory`. `method_call_ctx_factory` can be either None or an abstract class. When `method_call_ctx_factory` is None, the implementation calls the registered method with unmodified args and kwargs and returns underlying method result. When `method_call_ctx_factory` is not None, `method_call_ctx_factory` is expected to refer to the function to create the context object. The context object will be used to process inputs and outputs of `method` calls. It is also possible that the context object method `handle_start_method_call` will modify original args and kwargs before `method` call. `method_call_ctx_factory` is a function that should have the following signature: `f(method_name: str, args: list, kwargs: dict) -> MethodCallCtx` MethodCallCtx is an abstract class: class MethodCallCtx(abc.ABC): @abstractmethod def __enter__(self) -> None: raise NotImplemented @abstractmethod def __exit__(self, exc_type, exc_value, traceback) -> None: raise NotImplemented @abstractmethod def handle_start_method_call(self, method_name: str, method_signature: inspect.Signature, method_args: list, method_kwargs: dict) -> tuple(list, dict): raise NotImplemented @abstractmethod def handle_end_method_call(self, ret: object) -> None: raise NotImplemented Args: method (callable): method object as registered by decorator register_dataframe_method (or register_series_method) method_signature: signature of method as returned by inspect.signature obj: Dataframe or Series args: The arguments to pass to the registered method. kwargs: The keyword arguments to pass to the registered method. Returns: object`: The result of calling of the method. """ # noqa: E501 global method_call_ctx_factory with method_call_ctx_factory( method.__name__, args, kwargs ) as method_call_ctx: if method_call_ctx is None: # nullcontext __enter__ returns None ret = method(obj, *args, **kwargs) else: all_args = tuple([obj] + list(args)) ( new_args, new_kwargs, ) = method_call_ctx.handle_start_method_call( method.__name__, method_signature, all_args, kwargs ) args = new_args[1:] kwargs = new_kwargs ret = method(obj, *args, **kwargs) method_call_ctx.handle_end_method_call(ret) return ret def register_dataframe_method(method): """Register a function as a method attached to the Pandas DataFrame. Example: @register_dataframe_method def print_column(df, col): '''Print the dataframe column given''' print(df[col]) Args: method (callable): callable to register as a dataframe method. Returns: callable: The original method. """ method_signature = inspect.signature(method) def inner(*args, **kwargs): """Inner function to register the method. This function is called when the user decorates a function with register_dataframe_method. Args: *args: The arguments to pass to the registered method. **kwargs: The keyword arguments to pass to the registered method. Returns: method: The original method. """ class AccessorMethod(object): """DataFrame Accessor method class.""" def __init__(self, pandas_obj): """Initialize the accessor method class. Args: pandas_obj (pandas.DataFrame): The pandas DataFrame object. """ self._obj = pandas_obj @wraps(method) def __call__(self, *args, **kwargs): """Call the accessor method. Args: *args: The arguments to pass to the registered method. **kwargs: The keyword arguments to pass to the registered method. Returns: object: The result of calling of the method. """ global method_call_ctx_factory if method_call_ctx_factory is None: return method(self._obj, *args, **kwargs) return handle_pandas_extension_call( method, method_signature, self._obj, args, kwargs ) register_dataframe_accessor(method.__name__)(AccessorMethod) return method return inner() def register_series_method(method): """Register a function as a method attached to the Pandas Series. Args: method (callable): callable to register as a series method. Returns: callable: The original method. """ method_signature = inspect.signature(method) def inner(*args, **kwargs): """Inner function to register the method. Args: *args: The arguments to pass to the registered method. **kwargs: The keyword arguments to pass to the registered method. Returns: method: The original method. """ class AccessorMethod(object): """Series Accessor method class.""" __doc__ = method.__doc__ def __init__(self, pandas_obj): """Initialize the accessor method class. Args: pandas_obj (pandas.Series): The pandas Series object. """ self._obj = pandas_obj @wraps(method) def __call__(self, *args, **kwargs): """Call the accessor method. Args: *args: The arguments to pass to the registered method. **kwargs: The keyword arguments to pass to the registered method. Returns: object: The result of calling of the method. """ global method_call_ctx_factory if method_call_ctx_factory is None: return method(self._obj, *args, **kwargs) return handle_pandas_extension_call( method, method_signature, self._obj, args, kwargs ) register_series_accessor(method.__name__)(AccessorMethod) return method return inner() pandas_flavor-0.6.0/pandas_flavor/xarray.py000066400000000000000000000036071445233265300210500ustar00rootroot00000000000000"""XArray support for pandas_flavor.""" from xarray import register_dataarray_accessor, register_dataset_accessor from functools import wraps def make_accessor_wrapper(method): """ Makes an XArray-compatible accessor to wrap a method to be added to an xr.DataArray, xr.Dataset, or both. Args: method: A method which takes an XArray object and needed parameters. Returns: The result of calling ``method``. """ class XRAccessor: """XArray accessor for a method.""" def __init__(self, xr_obj): """Initialize the accessor. Args: xr_obj: The XArray object to which the accessor is attached. """ self._xr_obj = xr_obj @wraps(method) def __call__(self, *args, **kwargs): """Call the method. Args: *args: Positional arguments to pass to the method. **kwargs: Keyword arguments to pass to the method. Returns: The result of calling ``method``. """ return method(self._xr_obj, *args, **kwargs) return XRAccessor def register_xarray_dataarray_method(method: callable): """Register a method on an XArray DataArray object. Args: method: A method which takes an XArray object and needed parameters. Returns: The method. """ accessor_wrapper = make_accessor_wrapper(method) register_dataarray_accessor(method.__name__)(accessor_wrapper) return method def register_xarray_dataset_method(method: callable): """Register a method on an XArray Dataset object. Args: method: A method which takes an XArray object and needed parameters. Returns: The method. """ accessor_wrapper = make_accessor_wrapper(method) register_dataset_accessor(method.__name__)(accessor_wrapper) return method pandas_flavor-0.6.0/pyproject.toml000066400000000000000000000004501445233265300172560ustar00rootroot00000000000000[tool.black] line-length = 79 target-version = ['py39'] include = '\.pyi?$' extend-exclude = ''' # A regex preceded with ^/ will apply only to files and directories # in the root of the project. ^/foo.py # exclude a file named foo.py in the root of the project (in addition to the defaults) ''' pandas_flavor-0.6.0/setup.py000066400000000000000000000064201445233265300160570ustar00rootroot00000000000000"""Setup script.""" import io import os import sys from shutil import rmtree from setuptools import find_packages, setup, Command # Package meta-data. NAME = "pandas_flavor" DESCRIPTION = "The easy way to write your own Pandas flavor." URL = "https://github.com/Zsailer/pandas_flavor" EMAIL = "zachsailer@gmail.com" AUTHOR = "Zach Sailer" # What packages are required for this module to be executed? REQUIRED = ["pandas>=0.23", "xarray"] # The rest you shouldn't have to touch too much :) # ------------------------------------------------ # Except, perhaps the License and Trove Classifiers! # If you do change the License, # remember to change the Trove Classifier for that! here = os.path.abspath(os.path.dirname(__file__)) # Import the README and use it as the long-description. # Note: this will only work if 'README.rst' # is present in your MANIFEST.in file! with io.open(os.path.join(here, "README.md"), encoding="utf-8") as f: long_description = "\n" + f.read() # Load the package's __version__.py module as a dictionary. about = {} with open(os.path.join(here, NAME, "__version__.py")) as f: exec(f.read(), about) class UploadCommand(Command): """Support setup.py upload.""" description = "Build and publish the package." user_options = [] @staticmethod def status(s: str): """Prints things in bold. Args: s: The string to print. """ print("\033[1m{0}\033[0m".format(s)) def initialize_options(self): """Initialize options.""" pass def finalize_options(self): """Finalize options.""" pass def run(self): """Build and publish the package.""" try: self.status("Removing previous builds…") rmtree(os.path.join(here, "dist")) except OSError: pass self.status("Building Source and Wheel (universal) distribution…") os.system( "{0} setup.py sdist bdist_wheel --universal".format(sys.executable) ) self.status("Uploading the package to PyPi via Twine…") os.system("twine upload dist/*") sys.exit() # Where the magic happens: setup( name=NAME, version=about["__version__"], description=DESCRIPTION, long_description=long_description, long_description_content_type="text/markdown", author=AUTHOR, author_email=EMAIL, url=URL, packages=find_packages(exclude=("tests",)), install_requires=REQUIRED, include_package_data=True, license="MIT", classifiers=[ # Trove classifiers # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers "License :: OSI Approved :: MIT License", "Programming Language :: Python", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ], # $ setup.py publish support. cmdclass={ "upload": UploadCommand, }, ) pandas_flavor-0.6.0/tests/000077500000000000000000000000001445233265300155055ustar00rootroot00000000000000pandas_flavor-0.6.0/tests/test_pandas_register.py000066400000000000000000000015051445233265300222710ustar00rootroot00000000000000"""Tests for pandas series and dataframe method registration.""" import pandas_flavor as pf import pandas as pd def test_register_dataframe_method(): """Test register_dataframe_method.""" @pf.register_dataframe_method def dummy_func(df: pd.DataFrame) -> pd.DataFrame: """Dummy function. Args: df: a pandas DataFrame Returns: df: A pandas DataFrame. """ return df df = pd.DataFrame() df.dummy_func() def test_register_series_method(): """Test register_series_method.""" @pf.register_series_method def dummy_func(s: pd.Series) -> pd.Series: """Dummy func. Args: s: A pandas Series. Returns: s: A pandas Series. """ return s ser = pd.Series() ser.dummy_func()