├── .coveragerc ├── .gitattributes ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── codecov.yml ├── examples └── basic_example.ipynb ├── requirements-test.txt ├── requirements.txt ├── run_tests.py ├── setup.cfg ├── setup.py ├── spylon_kernel ├── __init__.py ├── __main__.py ├── _version.py ├── init_spark_magic.py ├── scala_interpreter.py ├── scala_kernel.py └── scala_magic.py ├── test ├── test_scala_interpreter.py └── test_scala_kernel.py ├── test_spylon_kernel_jkt.py └── versioneer.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = spylon_kernel 3 | 4 | [report] 5 | omit = 6 | */python?.?/* 7 | *test* 8 | # ignore _version.py and versioneer.py 9 | .*version.* 10 | *_version.py 11 | 12 | exclude_lines = 13 | if __name__ == '__main__': -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | spylon_kernel/_version.py export-subst 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Jetbrains 2 | .idea 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | .hypothesis/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # IPython Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # dotenv 82 | .env 83 | 84 | # virtualenv 85 | venv/ 86 | ENV/ 87 | 88 | # Spyder project settings 89 | .spyderproject 90 | 91 | # Rope project settings 92 | .ropeproject 93 | 94 | # Generated README 95 | README.rst 96 | 97 | # Extra development notebooks 98 | Untitled*.ipynb -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | dist: trusty 3 | language: python 4 | 5 | python: 6 | - "3.5" 7 | - "3.6" 8 | 9 | install: 10 | - pip install -r requirements.txt 11 | - pip install . 12 | - pip install -r requirements-test.txt 13 | - pip install codecov 14 | - python -m spylon_kernel install --user 15 | 16 | script: 17 | - python run_tests.py -vxrs --capture=sys --color=yes 18 | # Ensure installability 19 | - python setup.py sdist 20 | - pip install --no-binary :all: dist/*.tar.gz 21 | 22 | # Cache these at the end of the build 23 | cache: 24 | directories: 25 | - $HOME/.cache/pip 26 | - $HOME/.cache/spark 27 | 28 | after_success: 29 | - codecov 30 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.4.1 2 | 3 | * [PR-44](https://github.com/Valassis-Digital-Media/spylon-kernel/pull/44) - Fix Spark 2.2.1 compatibility 4 | 5 | # 0.4.0 6 | 7 | * [PR-38](https://github.com/maxpoint/spylon-kernel/pull/38) - Capture all stdout/stderr from the py4j JVM 8 | 9 | # 0.3.2 10 | 11 | * [PR-36](https://github.com/maxpoint/spylon-kernel/pull/35) - Revert to setting stdout/stderr once, fix typos and nits 12 | 13 | # 0.3.1 14 | 15 | * [PR-34](https://github.com/maxpoint/spylon-kernel/pull/34) - Test on Spark 2.1.1 and Python 3.5, 3.6 16 | * [PR-35](https://github.com/maxpoint/spylon-kernel/pull/35) - Mark stdout / stderr PrintWriters as @transient 17 | 18 | # 0.3.0 19 | 20 | * [PR-24](https://github.com/maxpoint/spylon-kernel/pull/24) - Let users set `application_name` in `%%init_spark` 21 | * [PR-29](https://github.com/maxpoint/spylon-kernel/pull/29), [PR-30](https://github.com/maxpoint/spylon-kernel/pull/30) - General code cleanup and documentation 22 | * [PR-31](https://github.com/maxpoint/spylon-kernel/pull/31) - Fix lost stdout/stderr from non-main threads 23 | * [PR-31](https://github.com/maxpoint/spylon-kernel/pull/31) - Fix run all halts on comment-only cells 24 | * [PR-33](https://github.com/maxpoint/spylon-kernel/pull/33) - Update README, travis.yml, Makefile for Spark 2.1.1 and Python 3.6 25 | 26 | # 0.2.1 27 | 28 | * [PR-17](https://github.com/maxpoint/spylon-kernel/pull/17) - Fix anonymous function class defs not found 29 | * [PR-19](https://github.com/maxpoint/spylon-kernel/pull/19) - Fix clipped tracebacks 30 | * Automate developer, maintainer setup 31 | 32 | # 0.2.0 33 | 34 | * [PR-15](https://github.com/maxpoint/spylon-kernel/pull/15) - Fix to support jedi>=0.10 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-clause 2 | 3 | Copyright (c) 2017, Maxpoint 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | * Neither the name of the nor the 14 | names of its contributors may be used to endorse or promote products 15 | derived from this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 21 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 22 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 24 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | --- 28 | 29 | Portions of this software has been adapted from Apache Toree, Apache Zeppelin and Apache Spark. 30 | 31 | All of these projects are Apache Licensed 32 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include versioneer.py 2 | include spylon_kernel/_version.py 3 | include README.md 4 | include LICENSE 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | 3 | SA:=source activate 4 | ENV:=spylon-kernel-dev 5 | SHELL:=/bin/bash 6 | 7 | help: 8 | # http://marmelab.com/blog/2016/02/29/auto-documented-makefile.html 9 | @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 10 | 11 | activate: ## Make a conda activate command for eval `make activate` 12 | @echo "$(SA) $(ENV)" 13 | 14 | clean: ## Make a clean source tree 15 | -rm -rf dist 16 | -rm -rf build 17 | -rm -rf *.egg-info 18 | -find . -name __pycache__ -exec rm -fr {} \; 19 | 20 | env: ## Make a conda dev environment 21 | conda create -y -n $(ENV) python=3.6 notebook 22 | source activate $(ENV) && \ 23 | pip install -r requirements.txt -r requirements-test.txt -e . && \ 24 | python -m spylon_kernel install --sys-prefix 25 | 26 | notebook: ## Make a development notebook 27 | $(SA) $(ENV) && jupyter notebook --notebook-dir=examples/ \ 28 | --no-browser \ 29 | --NotebookApp.token='' 30 | 31 | nuke: ## Make clean + remove conda env 32 | -conda env remove -n $(ENV) -y 33 | 34 | sdist: ## Make a source distribution 35 | $(SA) $(ENV) && python setup.py sdist 36 | 37 | release: clean ## Make a pypi release of a tagged build 38 | $(SA) $(ENV) && python setup.py sdist register upload 39 | 40 | test: ## Make a test run 41 | $(SA) $(ENV) && python run_tests.py -vxrs --color=yes 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # spylon-kernel 2 | 3 | [![Project Status: Inactive – The project has reached a stable, usable state but is no longer being actively developed; support/maintenance will be provided as time allows.](https://www.repostatus.org/badges/latest/inactive.svg)](https://www.repostatus.org/#inactive) 4 | [![Build Status](https://travis-ci.org/Valassis-Digital-Media/spylon-kernel.svg?branch=master)](https://travis-ci.org/maxpoint/spylon-kernel) 5 | [![codecov](https://codecov.io/gh/Valassis-Digital-Media/spylon-kernel/branch/master/graph/badge.svg)](https://codecov.io/gh/maxpoint/spylon-kernel) 6 | 7 | A Scala [Jupyter kernel](http://jupyter.readthedocs.io/en/latest/projects/kernels.html) that uses [metakernel](https://github.com/Calysto/metakernel) in combination with [py4j](https://www.py4j.org/). 8 | 9 | ## Prerequisites 10 | 11 | * Apache Spark 2.1.1 compiled for Scala 2.11 12 | * Jupyter Notebook 13 | * Python 3.5+ 14 | 15 | ## Install 16 | 17 | You can install the spylon-kernel package using `pip` or `conda`. 18 | 19 | ```bash 20 | pip install spylon-kernel 21 | # or 22 | conda install -c conda-forge spylon-kernel 23 | ``` 24 | 25 | ## Using it as a Scala Kernel 26 | 27 | You can use spylon-kernel as Scala kernel for Jupyter Notebook. Do this when you want 28 | to work with Spark in Scala with a bit of Python code mixed in. 29 | 30 | Create a kernel spec for Jupyter notebook by running the following command: 31 | 32 | ```bash 33 | python -m spylon_kernel install 34 | ``` 35 | 36 | Launch `jupyter notebook` and you should see a `spylon-kernel` as an option 37 | in the *New* dropdown menu. 38 | 39 | See [the basic example notebook](./examples/basic_example.ipynb) for information 40 | about how to intiialize a Spark session and use it both in Scala and Python. 41 | 42 | ## Using it as an IPython Magic 43 | 44 | You can also use spylon-kernel as a magic in an IPython notebook. Do this when 45 | you want to mix a little bit of Scala into your primarily Python notebook. 46 | 47 | ```python 48 | from spylon_kernel import register_ipython_magics 49 | register_ipython_magics() 50 | ``` 51 | 52 | ```scala 53 | %%scala 54 | val x = 8 55 | x 56 | ``` 57 | 58 | ## Using it as a Library 59 | 60 | Finally, you can use spylon-kernel as a Python library. Do this when you 61 | want to evaluate a string of Scala code in a Python script or shell. 62 | 63 | ```python 64 | from spylon_kernel import get_scala_interpreter 65 | 66 | interp = get_scala_interpreter() 67 | 68 | # Evaluate the result of a scala code block. 69 | interp.interpret(""" 70 | val x = 8 71 | x 72 | """) 73 | 74 | interp.last_result() 75 | ``` 76 | 77 | # Release Process 78 | 79 | Push a tag and submit a source dist to PyPI. 80 | 81 | ``` 82 | git commit -m 'REL: 0.2.1' --allow-empty 83 | git tag -a 0.2.1 # and enter the same message as the commit 84 | git push origin master # or send a PR 85 | 86 | # if everything builds / tests cleanly, release to pypi 87 | make release 88 | ``` 89 | 90 | Then update https://github.com/conda-forge/spylon-kernel-feedstock. 91 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | notify: 3 | require_ci_to_pass: true 4 | comment: 5 | behavior: default 6 | layout: header, diff 7 | require_changes: false 8 | coverage: 9 | precision: 2 10 | range: 11 | - 70.0 12 | - 100.0 13 | round: down 14 | status: 15 | changes: false 16 | patch: true 17 | project: true 18 | parsers: 19 | gcov: 20 | branch_detection: 21 | conditional: true 22 | loop: true 23 | macro: false 24 | method: false 25 | javascript: 26 | enable_partials: false 27 | -------------------------------------------------------------------------------- /examples/basic_example.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Spylon Kernel Test with Spark 2.1\n", 8 | "\n", 9 | "---" 10 | ] 11 | }, 12 | { 13 | "cell_type": "markdown", 14 | "metadata": {}, 15 | "source": [ 16 | "# Initialization of the spark context\n", 17 | "\n", 18 | "Note that we can set things like driver memory etc.\n", 19 | "\n", 20 | "If `launcher._spark_home` is not set it will default to looking at the `SPARK_HOME` environment variable." 21 | ] 22 | }, 23 | { 24 | "cell_type": "code", 25 | "execution_count": null, 26 | "metadata": { 27 | "collapsed": true 28 | }, 29 | "outputs": [], 30 | "source": [ 31 | "%%init_spark\n", 32 | "launcher.num_executors = 4\n", 33 | "launcher.executor_cores = 2\n", 34 | "launcher.driver_memory = '4g'\n", 35 | "launcher.conf.set(\"spark.sql.catalogImplementation\", \"hive\")" 36 | ] 37 | }, 38 | { 39 | "cell_type": "markdown", 40 | "metadata": {}, 41 | "source": [ 42 | "If you have additional jars you want available in the Spark runtime,use the `launcher.jars` and/or `launcher.packages` settings, the latter using Maven \"GAV\" coordinates:\n", 43 | "```scala\n", 44 | "launcher.jars = [\"/some/local/path/to/a/file.jar\"]\n", 45 | "launcher.packages = [\"com.acme:super:1.0.1\"]\n", 46 | "```" 47 | ] 48 | }, 49 | { 50 | "cell_type": "markdown", 51 | "metadata": {}, 52 | "source": [ 53 | "# Lets write some scala!" 54 | ] 55 | }, 56 | { 57 | "cell_type": "code", 58 | "execution_count": 2, 59 | "metadata": {}, 60 | "outputs": [ 61 | { 62 | "data": { 63 | "text/plain": [ 64 | "'Intitializing scala interpreter....'" 65 | ] 66 | }, 67 | "metadata": {}, 68 | "output_type": "display_data" 69 | }, 70 | { 71 | "data": { 72 | "text/plain": [ 73 | "'Scala interpreter initialized.'" 74 | ] 75 | }, 76 | "metadata": {}, 77 | "output_type": "display_data" 78 | }, 79 | { 80 | "data": { 81 | "text/plain": [ 82 | "res1: org.apache.spark.sql.SparkSession = org.apache.spark.sql.SparkSession@174541\n" 83 | ] 84 | }, 85 | "execution_count": 2, 86 | "metadata": {}, 87 | "output_type": "execute_result" 88 | } 89 | ], 90 | "source": [ 91 | "spark" 92 | ] 93 | }, 94 | { 95 | "cell_type": "code", 96 | "execution_count": 3, 97 | "metadata": {}, 98 | "outputs": [ 99 | { 100 | "data": { 101 | "text/plain": [ 102 | "res2: String = 2.1.0\n" 103 | ] 104 | }, 105 | "execution_count": 3, 106 | "metadata": {}, 107 | "output_type": "execute_result" 108 | } 109 | ], 110 | "source": [ 111 | "spark.version" 112 | ] 113 | }, 114 | { 115 | "cell_type": "code", 116 | "execution_count": 4, 117 | "metadata": {}, 118 | "outputs": [ 119 | { 120 | "data": { 121 | "text/plain": [ 122 | "data: Seq[(String, Int)] = List((a,0), (b,1), (c,2), (d,3))\n" 123 | ] 124 | }, 125 | "execution_count": 4, 126 | "metadata": {}, 127 | "output_type": "execute_result" 128 | } 129 | ], 130 | "source": [ 131 | "val data = Seq(\"a\", \"b\", \"c\", \"d\") zip (0 to 4)" 132 | ] 133 | }, 134 | { 135 | "cell_type": "code", 136 | "execution_count": 5, 137 | "metadata": {}, 138 | "outputs": [ 139 | { 140 | "data": { 141 | "text/plain": [ 142 | "ds: org.apache.spark.sql.Dataset[(String, Int)] = [_1: string, _2: int]\n" 143 | ] 144 | }, 145 | "execution_count": 5, 146 | "metadata": {}, 147 | "output_type": "execute_result" 148 | } 149 | ], 150 | "source": [ 151 | "val ds = spark.createDataset(data)" 152 | ] 153 | }, 154 | { 155 | "cell_type": "markdown", 156 | "metadata": {}, 157 | "source": [ 158 | "## We can define functions and classes as we need them" 159 | ] 160 | }, 161 | { 162 | "cell_type": "code", 163 | "execution_count": 6, 164 | "metadata": {}, 165 | "outputs": [ 166 | { 167 | "data": { 168 | "text/plain": [ 169 | "defined class DataRow\n" 170 | ] 171 | }, 172 | "execution_count": 6, 173 | "metadata": {}, 174 | "output_type": "execute_result" 175 | } 176 | ], 177 | "source": [ 178 | "case class DataRow(name: String, value: Integer)" 179 | ] 180 | }, 181 | { 182 | "cell_type": "code", 183 | "execution_count": 7, 184 | "metadata": {}, 185 | "outputs": [ 186 | { 187 | "data": { 188 | "text/plain": [ 189 | "ds2: org.apache.spark.sql.Dataset[DataRow] = [name: string, value: int]\n" 190 | ] 191 | }, 192 | "execution_count": 7, 193 | "metadata": {}, 194 | "output_type": "execute_result" 195 | } 196 | ], 197 | "source": [ 198 | "val ds2 = ds.map{case (a, b) => DataRow(a,b)}" 199 | ] 200 | }, 201 | { 202 | "cell_type": "code", 203 | "execution_count": 8, 204 | "metadata": {}, 205 | "outputs": [ 206 | { 207 | "data": { 208 | "text/plain": [ 209 | "add: (x: Integer, y: Integer)Int\n" 210 | ] 211 | }, 212 | "execution_count": 8, 213 | "metadata": {}, 214 | "output_type": "execute_result" 215 | } 216 | ], 217 | "source": [ 218 | "def add(x: Integer, y: Integer) = x + y" 219 | ] 220 | }, 221 | { 222 | "cell_type": "code", 223 | "execution_count": 9, 224 | "metadata": {}, 225 | "outputs": [ 226 | { 227 | "data": { 228 | "text/plain": [ 229 | "res3: Int = 15\n" 230 | ] 231 | }, 232 | "execution_count": 9, 233 | "metadata": {}, 234 | "output_type": "execute_result" 235 | } 236 | ], 237 | "source": [ 238 | "add(7, 8)" 239 | ] 240 | }, 241 | { 242 | "cell_type": "markdown", 243 | "metadata": {}, 244 | "source": [ 245 | "# Sharing scala spark datasets with python\n", 246 | "\n", 247 | "We can share a dataset by registering it as a view. The spark session that exists in the python magic is the same one as on the scala side." 248 | ] 249 | }, 250 | { 251 | "cell_type": "code", 252 | "execution_count": 10, 253 | "metadata": { 254 | "collapsed": true 255 | }, 256 | "outputs": [], 257 | "source": [ 258 | "ds2.toDF.createOrReplaceTempView(\"foo2\")" 259 | ] 260 | }, 261 | { 262 | "cell_type": "code", 263 | "execution_count": 11, 264 | "metadata": { 265 | "collapsed": true 266 | }, 267 | "outputs": [], 268 | "source": [ 269 | "%%python\n", 270 | "df = spark.sql(\"select name from foo2\")" 271 | ] 272 | }, 273 | { 274 | "cell_type": "code", 275 | "execution_count": 12, 276 | "metadata": {}, 277 | "outputs": [ 278 | { 279 | "data": { 280 | "text/plain": [ 281 | "DataFrame[name: string]" 282 | ] 283 | }, 284 | "execution_count": 12, 285 | "metadata": {}, 286 | "output_type": "execute_result" 287 | } 288 | ], 289 | "source": [ 290 | "%%python\n", 291 | "df" 292 | ] 293 | }, 294 | { 295 | "cell_type": "markdown", 296 | "metadata": {}, 297 | "source": [ 298 | "# Plotting example\n", 299 | "\n", 300 | "Plotting with matplotlib can be a little tedious but still pretty simple to do.\n" 301 | ] 302 | }, 303 | { 304 | "cell_type": "code", 305 | "execution_count": 13, 306 | "metadata": { 307 | "collapsed": true 308 | }, 309 | "outputs": [], 310 | "source": [ 311 | "%%python\n", 312 | "import matplotlib\n", 313 | "matplotlib.use(\"agg\")" 314 | ] 315 | }, 316 | { 317 | "cell_type": "code", 318 | "execution_count": 14, 319 | "metadata": {}, 320 | "outputs": [ 321 | { 322 | "data": { 323 | "image/png": "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\n03NsNhsA77333i1v11KwYME7vk6VKlVo27btHZ93t7lERO5EBVBEcpzNZuO5557D19eXgQMHMmHC\nBB577LGbTnn+9NNPVKxYkYULF950ZO6/T5VmpWPHjmEYxk2v9VdBLVeu3D9+TaVKlQDw9fW9pwLn\nKH9/f3x8fDh8+PDfPnfo0CEsFkvmkc//PaopIgK6ClhETDB16lQ2b97MnDlzGDt2LA8++CD/+c9/\nuHLlSuZzXF1dsVgsNx3t2rp1KxEREdmS6fz58yxatChzHB8fz1dffUVwcPA/nv4FaNiwIZUqVWLK\nlCncuHHjb5+/fPlytmR1dXWlffv2LFmy5KYdPi5evMi3335LixYtMt//V6BAAQBiY2OzJYuI5E06\nAigiWWrFihUcOnTob483a9aMihUrcvDgQUaNGsVzzz1H165dAfj8888JDg7m1VdfZcGCBQB06dKF\nhQsX0rNnTzp37kx0dDQff/wxNWvW/Mey5aiqVavSt29ftm/fTsmSJfnss8+4ePEin3/++S2/xsXF\nhXnz5tGpUydq1arF888/T+nSpTl37hzr16/H19eXX375JcuzAowbN441a9bQvHlzXn31Vdzc3Pjk\nk09ITU3l3XffzXxecHAwrq6uTJ48mbi4ODw9PWnTpg3+/v7ZkktE8gYVQBHJUqGhof/4+Oeff065\ncuXo06cPxYsXZ/r06Zmfq1KlChMnTmTAgAEsWLCAxx9/nOeee46YmBg++eQTVq1aRc2aNZk/fz4/\n/vhjttzIuEqVKnz44YcMGTKEQ4cOUaFCBX744Qc6dOhw269r1aoVERERjB07lpkzZ5KQkEBAQAAh\nISG8/PLLWZ7zL7Vq1WLjxo0MHz6ciRMnYrPZCAkJYf78+ZlXQAOUKlWKjz/+mIkTJ9K3b1+sVivr\n169XARRxchZD7yYWESdXvnx5ateuzbJly8yOIiKSI/QeQBEREREnowIoIiIi4mRUAEVEREScjN4D\nKCIiIuJkdARQRERExMmoAIqIiIg4GRVAERERESejG0E7wGazcf78eQoVKqT9NkVERPIIwzBISEgg\nMDAQFxfnPBamAuiA8+fPZ264LiIiInnLmTNnKFOmjNkxTKEC6IBChQoB9gn018brIiIikrvFx8cT\nFBSUuY47IxVAB/x12tfX11cFUEREJI9x5rdvOeeJbxEREREnpgIoIiIi4mRUAEVEREScjN4DKCIi\n4mQMwyAjIwOr1Wp2lGzh6uqKm5ubU7/H705UAEVERJxIWloaFy5cICkpyewo2crHx4eAgAA8PDzM\njpIrqQCKiIg4CZvNRnR0NK6urgQGBuLh4ZHvjpIZhkFaWhqXL18mOjqaKlWqOO3Nnm9HBVBERMRJ\npKWlYbPZCAoKwsfHx+w42cbb2xt3d3dOnTpFWloaXl5eZkfKdVSJRUREnIwzHBFzhp/REfrTERER\nEXEyKoAiIiIiTkYFUERERMTJqACKiIhkNcMwO4HIbakAioiIZKXTW+CTlnD5sNlJ8p2VK1fSvHlz\nChcuTLFixejSpQvHjx83O1aepNvAiIiIZIXkWFgbDjs+s4/XjoEnvjE3010wDIPkdHN2BPF2d72n\n+xAmJiYyaNAg6tSpQ2JiIqGhofTs2ZPIyEhd9XuPVABFREQcYRhwYDGsGAo3Ltofq/9vaDfG3Fx3\nKTndSs3QVaa89oExHfDxuPsq8uijj940/vTTT/H39+fAgQPUrl07q+Pla6rLIiIi9yv2DHz3BPz4\nnL38FasMfZZB95ngU9TsdPnO0aNHefLJJ6lYsSK+vr5UqFABgNOnT5ucLO/REUAREZF7ZbPC1k9g\n3ThITwQXd2gxCJoPAve8teuEt7srB8Z0MO2170XXrl0pV64cc+fOJTAwEJvNRu3atUlLS8umhPmX\nCqCIiMi9uLAHlvaHC5H2cdmm0GU6+Fc3N9d9slgs93Qa1ixXr17l8OHDzJ07lxYtWgCwadMmk1Pl\nXbn/b1xERCQ3SEuE9RNgyywwbODpB+3HQP1nQRcgZLsiRYpQrFgx5syZQ0BAAKdPn2bYsGFmx8qz\nVABFRETu5Mhq+HUwxP3/95rV6gUdJ0GhkubmciIuLi58//339O/fn9q1a1OtWjVmzJhBq1atzI6W\nJ6kAioiI3ErCRVg5DPYvtI/9ykLn96Fqe3NzOam2bdty4MCBmx4zdNPt+6ICKCIi8r9sNtj1JfwW\nBilxYHGBJq9C63fAo4DZ6UQcpgIoIiLy3y4fhl8GwOkI+zggGLp+AIHB5uYSyUIqgCIiIgDpKbBp\nKmycCrZ0cC8AbUZA45fBVcul5C+a0SIiItEbYdlAuHrMPq7SATpPgcJlzc0lkk1UAEVExHklXYM1\no2D3fPu4YEnoNBlq9oB72KNWJK9RARQREedjGBD1k/0K36Qr9scaPg9tR4N3YTOTieQIFUAREXEu\n16Lh10FwfJ19XKK6fSePck3NzSWSg1QARUTEOVjTIeIj2DAJMpLB1RNaDoEHB4Cbh9npRHKUCqCI\niOR/Z3fCL/3h4j77uHwL+1G/4pXNzSViEhVAERHJv1ITYO1Y2DYHMMC7CLQfD8FP6SKPPKZVq1YE\nBwczffp0s6PkCyqAIiKSPx36FZYPgfhz9nHdf0GHCVCguLm5RHIBFUAREclf4s/bi9+hZfZxkfLQ\nZRpUamNqLJHcxMXsACIiIlnCZoVtc2FmY3v5s7hC8zfhPxEqf/lERkYGr7/+On5+fhQvXpxRo0Zh\nGIbZsfIkHQEUEZG8L2afff/eczvs49KN7Pv3lqptbq68wDAgPcmc13b3uaf3Yn755Zf07duXbdu2\nsWPHDvr160fZsmV56aWXsjFk/pRvC+Aff/zBe++9x86dO7lw4QKLFi2iR48eAKSnpzNy5EiWL1/O\niRMn8PPzo23btkyaNInAwECTk4uIyF1LT4bfJ8PmD8GWAR6F4OFQeKAvuLianS5vSE+CCSatfe+c\nB48Cd/30oKAgpk2bhsVioVq1akRFRTFt2jQVwPuQb08BJyYmUq9ePWbOnPm3zyUlJbFr1y5GjRrF\nrl27WLhwIYcPH6Zbt24mJBURkftyfB3MagKbptnLX/Uu8NpWCOmn8pdPNWnSBMt/HTFs2rQpR48e\nxWq1mpgqb8q3RwA7depEp06d/vFzfn5+rFmz5qbHZs6cSePGjTl9+jRly5q/+XdSehI+7j5mxxAR\nyX0Sr8Cqd2DvD/ZxoUB45D2o0cXcXHmVu4/9SJxZry2myLcF8F7FxcVhsVgoXPjWe0CmpqaSmpqa\nOY6Pj8+WLLsv7ab/uv682fBNelbuedNvOyIiTsswIPJbWD0Ckq8DFmj8ErQZBV6+ZqfLuyyWezoN\na6YtW7b8bVylShVcXXXE917l21PA9yIlJYWhQ4fy5JNP4ut7639EJk6ciJ+fX+ZHUFBQtuT5/tD3\nxKbGErY5jBdWvUB0XHS2vI6ISJ5x5Rh82RWWvGovf/614MXf7Ef+VP6cxpkzZxg0aBCHDx/mu+++\n48MPP2TAgAFmx8qTnL4Apqen8/jjj2MYBrNnz77tc4cPH05cXFzmx5kzZ7Il0/jm43mr0Vt4u3mz\n4+IOHl36KLP3zCbNmpYtrycikmtlpMHv78HsZnByI7h5QdvR8PLvUKaR2ekkhz377LMkJyfTuHFj\nXnvtNQYMGEC/fv3MjpUnOfUp4L/K36lTp1i3bt1tj/4BeHp64unpme253Fzc6FOrD23LtWXclnFs\nOreJWZGzWBG9grCmYTQs2TDbM4iImO70FvutXS4fso8rtoYuU6FoRXNziSk2bNiQ+d93OmAjd+a0\nRwD/Kn9Hjx7lt99+o1ixYmZH+pvSBUsz6+FZvNfyPYp5FSM6LprnVj7H6M2jiUuNMzueiEj2SI6F\nZW/CZx3s5c+nGPSaC/9epPInkkXybQG8ceMGkZGRREZGAhAdHU1kZCSnT58mIyODxx57jB07dvDN\nN99gtVqJiYkhJiaGtLTcdZrVYrHQsUJHlvRYwqNVHgXg56M/031xd1ZGr9Qd0EUk/zAM2L8IPmoM\nOz6zPxb8DLy+A+o+fk83DBaR27MY+bRBbNiwgdatW//t8T59+jB69GgqVKjwj1+3fv16WrVqdVev\nER8fj5+fH3FxcXc8fZxVdl7cyZiIMZyIOwFA89LNGdlkJKULls6R1xcRyRaxZ2D5W3BkpX1ctBJ0\nnQ4VWpqbK59JSUkhOjqaChUq4OXlZXacbHW7n9WM9Tu3ybcFMCeYNYHSrGl8uu9T5u6dS7otHW83\nb14Lfo2nazyNm4tTv61TRPIamxW2fgLrxkF6Iri42/fvbTEY3PN3QTGDCqCdCmA+PgWcn3m4evCf\nev/h524/06hkI5IzkpmyYwpP/foU+6/uNzueiMjdubAH5raBVcPt5S+oCbyyCdqMUPkTyWYqgHlY\nBb8KfNbhM8Y0G4Ovhy8Hrx3kqV+fYvK2ySSZtbG3iMidpCXCqhEwpxVciARPP+gyHZ5fAf7VzU4n\n4hRUAPM4i8VCzyo9WdpjKY9UeASbYWP+wfn0WNKD38/8bnY8EZGbHVkNHzWBiJlg2KBWT3h9GzR6\nHly0JInkFP3flk8U8y7G5JaT+bjtx5QuWJoLiRd4fd3rDNowiMtJl82OJyLO7sYl+OkF+LY3xJ0G\nvyB4agH0/gIKlTI7nYjTUQHMZx4s/SCLui/i+drP42pxZc2pNXRb3I0FhxdgM2xmxxMRZ2Ozwc4v\nYWYj2PczWFyg6evw6hao2sHsdCJOSwUwH/J282ZQw0F83+V7aherzY30G4zdMpY+K/pw7Poxs+OJ\niLO4fBi+6Ay/9IeUOAioBy+tgw7jwbOg2elEnJoKYD5WvWh15j8yn2GNh+Hj5kPk5Uh6L+vNjF0z\nSLWmmh1PRPKrjFRYPxFmPwinN4O7D3SYAC+ug8D6ZqcTEVQA8z1XF1eervE0S3osoVVQKzJsGcyN\nmkuvJb3YemGr2fFEJL85ucle/H6fBLZ0qNIBXtsKTV8DV92nVCS3UAF0EqUKlGJG6xlMazUNf29/\nTiec5sXVLzJi0wiup1w3O56I5HVJ12DJ6/ZTvlePQgF/eOxzeOoHKFzW7HSSD9hsNt59910qV66M\np6cnZcuWZfz48WbHyrP065gTsVgstC3XlpCAED7Y9QELDi9g6fGlbDy7kSEPDKFLxS5YtNemiNwL\nw7Bf3LFyGCT+/zsONHwe2o4G78JmJpO7ZBgGyRnJpry2t5v3Xa87w4cPZ+7cuUybNo3mzZtz4cIF\nDh06lM0J8y9tBeeAvL6VTOSlSMIjwjkWa78wpElAE0KbhBLkG2RyMhHJE66fhGWD4Pha+7h4Nej6\nAZRramosubV/2h4tKT2JkG9DTMmz9amt+Lj73PF5CQkJlChRgpkzZ/Liiy/e1ffWVnC3p1PATizY\nP5gFXRcwoMEAPF092XJhCz2X9mRe1DzSbelmxxOR3MqaAX9+YL+h8/G14OoBrUfAKxtV/iRbHDx4\nkNTUVB5++GGzo+QbOgXs5Nxd3Hmxzou0L9eeMVvGsPXCVj7Y9QHLo5cT1jSMeiXqmR1RRHKTczvh\nlwEQE2Ufl29h38ateGVzc8l983bzZutT5lwU6O3mfXfP876758ndUwEUAMr6lmVuu7ksO7GMd7e/\ny9HrR/n38n/zr2r/YkCDART00D27RJxaagKsGwfb5ti3cPMuAu3HQfDToPcO52kWi+WuTsOaqUqV\nKnh7e7N27dq7PgUst6dTwJLJYrHQtVJXlvZYSrdK3TAw+P7w93Rf3J21p9aaHU9EzHJoOXwUAls/\ntpe/Oo/Da9uh/jMqf5IjvLy8GDp0KG+//TZfffUVx48fZ8uWLXz66admR8uzdARQ/qaIVxHGNx9P\n10pdGRsxltMJpxm4YSCtg1rzTsg7lCqgfTtFnEL8BVjxNhxcah8XLgddpkFlvQ9Lct6oUaNwc3Mj\nNDSU8+fPExAQwCuvvGJ2rDxLVwE7wBmuIkrJSGHO3jl8vu9zMowMfNx86N+gP09UewJXF1ez44lI\ndrDZYOdn8Fs4pMaDxRWavQEPDQWP3H2qUG7vdlfG5je6Cvj2dApYbsvLzYv+DfqzoOsC6pWoR1JG\nEpO2TeLfK/7N4WuHzY4nIlnt4gH4rAP8Othe/ko3hJd/h3bhKn8i+YgKoNyVKkWq8FWnrxjVZBQF\n3QsSdSWKfy37F1N3TjXtBqIikoXSk2HtGPikBZzdBh6FoNN70HcNlKpjdjoRyWIqgHLXXCwuPF7t\ncZb0WEK7cu2wGlY+3/c5PZf05M9zf5odT0Tu14kNMLsZbHwfbBlQvYt9/96QfqC3eojkSyqAcs/8\nffyZ2moqH7b5kFIFSnHuxjle+e0Vhv4xlKvJV82OJyJ3K/EqLHoFvuoO105AoUD41zfwxDfgV9rs\ndCKSjVQA5b61CmrF4u6LeabGM7hYXFgevZxui7ux6OgidG2RSC5mGBD5HcxsBHu+AyzQuJ/9qF+N\nLmanE5EcoAIoDingXoChjYfy7SPfUr1odeLT4gndHErf1X2Jjos2O56I/K+rx+1H/Ba/AsnXwL8W\nvPgbPPIeeDnn1ZDOyBl+SXeGn9ERKoCSJWoVr8V3nb9jcMPBeLt5sz1mO48ufZTZe2aTZk0zO56I\nZKTBH1Ps7/WL/h3cvKDtaPsVvmUamZ1Ocoi7uzsASUlJJifJfn/9jH/9zHIz3QfQAbqP0D87m3CW\ncVvHZV4YUtGvImFNw2hQsoHJyUSc1Jlt9v17Lx2wjyu2hi5ToWhFc3OJKS5cuEBsbCz+/v74+Phg\nyWe7uRiGQVJSEpcuXaJw4cIEBAT87Tlav1UAHaIJdGuGYbDy5EombZvEtZRrADxW9TEGNhiIn6ef\nyelEnERKnP1mzjs+AwzwKQYdJ0Gd3trCzYkZhkFMTAyxsbFmR8lWhQsXplSpUv9YcLV+qwA6RBPo\nzuJS45i2cxo/H/0ZgGJexRjWeBgdynfId791iuQahmHfvm3523Ajxv5Y8DPQfiz4FDU3m+QaVquV\n9PR0s2NkC3d3d1xdb30LI63fKoAO0QS6eztidhAeEc7J+JMAtCjdgpFNRhJYMNDcYCL5TdxZWD4E\nDi+3j4tWgq7ToUJLc3OJ5CJav1UAHaIJdG/SrGl8GvUpc6Pmkm5Lx9vNm9eCX+PpGk/j5uJmdjyR\nvM1mhW1zYN04SLsBLu7Q/E1oMRjc8/eeryL3Suu3CqBDNIHuz4m4E4yJGMPOizsBqFG0BmHNwqhV\nrJbJyUTyqAt77Rd5nN9lHwc1ga4fgH91c3OJ5FJav1UAHaIJdP9sho3FxxYzZccUEtIScLG48HSN\np3k9+HV83LXhvMhdSUuEDZMg4iMwrODpB+1GQ4PnwEV3+RK5Fa3fKoAO0QRy3JXkK7y7/V1WRK8A\nIKBAACObjKRlGb1fSeS2jv4Gv74Jsaft41o97Vf4Fiplbi6RPEDrtwqgQzSBss6mc5sYt2Uc526c\nA6B9ufYMazyMEj4lTE4mksvcuAQrh8O+n+xjvyDo/D5U7WBuLpE8ROu3CqBDNIGyVlJ6ErP3zObr\nA19jNawUci/EwIYDeazqY7hYdDpLnJxhwO6vYfUoSIkFiwuE/AdavwOeBc1OJ5KnaP1WAXSIJlD2\nOHTtEKM3j2b/1f0A1PevT2iTUCoXqWxyMhGTXD4CywbCKfvuOpSqC91mQGB9c3OJ5FFav1UAHaIJ\nlH2sNivfHfqOGbtnkJyRjJuLGy/UfoF+dfvh6eppdjyRnJGRCpumwcb3wZoG7j7QegSEvAKuunWS\nyP3S+q0C6BBNoOx34cYFJmydwIazGwAo51uO0CahNA5obG4wkex2arP91i5XjtjHVdrDI1OgSDlz\nc4nkA1q/VQAdogmUMwzD4LfTvzFx60QuJ18GoEflHgxuOJjCXoVNTieSxZKvw5pQ2PWVfVzAHzpN\ntl/lq+0TRbKE1m8VQIdoAuWshLQEPtj1AT8c/gGAIp5FGPLAELpU7KJ9hSXvMwzY97P9Ct/ES/bH\nGvSBduHgXcTcbCL5jNZvFUCHaAKZI/JSJOER4RyLPQZA04CmjGoyiiDfIJOTidyn66fg18FwbI19\nXLyaff/ecs3MzSWST2n9hnx7b40//viDrl27EhgYiMViYfHixTd93jAMQkNDCQgIwNvbm7Zt23L0\n6FGT0sq9CPYPZkGXBbxR/w08XDyIuBBBz6U9+TTqU9Jt6WbHE7l71gz4cwbMamIvf64e0OodeGWj\nyp+IZKt8WwATExOpV68eM2fO/MfPv/vuu8yYMYPZs2ezdetWChQoQIcOHUhJScnhpHI/3F3d6Ve3\nHwu7L6RxqcakWlOZvms6Tyx7gr2X95odT+TOzu2Cua1hzShIT4JyzeE/m6HVUHDTle4ikr2c4hSw\nxWJh0aJF9OjRA7Af/QsMDGTw4MG89dZbAMTFxVGyZEm++OILnnjiibv6vjqEnDsYhsHS40uZsmMK\nsamxWLDwRPUn6F+/PwU9dINcyWVSb8D68bD1YzBs4FUY2o+D+s/oIg+RHKL1Ox8fAbyd6OhoYmJi\naNu2beZjfn5+hISEEBERYWIyuR8Wi4XulbuzpMcSulbsioHBd4e+o/uS7qw9vdbseCL/5/AK+CgE\ntsyyl794iR4EAAAgAElEQVQ6veH1HdDg3yp/IpKjnLIAxsTEAFCyZMmbHi9ZsmTm5/5Jamoq8fHx\nN31I7lHUqygTWkxgTrs5BBUK4lLSJQauH8iAdQOISbz136tItkuIgQXPwndPQPxZKFwOnvkZHp0H\nBbXftYjkPKcsgLdiGMZtbycyceJE/Pz8Mj+CgnTVaW7UNLApC7st5MU6L+JmcWPdmXX0WNKDbw9+\ni9VmNTueOBObDbZ/CjMfgANLwOIKzfrDq1ugcts7f72ISDZxygJYqlQpAC5evHjT45cuXfrbUcH/\nNnz4cOLi4jI/zpw5k6055f55uXkxoMEAfuj6A3VL1CUxPZGJ2yby7IpnOXztsNnxxBlcOgifd4Rf\nB0FqPAQ2gH4boP1Y8PAxO52IODmnLIAVKlSgVKlSrF37f+8Pi4+PZ+vWrTRt2vSWX+fp6Ymvr+9N\nH5K7VS1Sla87fc2IkBEUdC/I3it7eWLZE0zbOY3kjGSz40l+lJ4Ca8fCxy3gzFbwKAid3oUXf4OA\numanExEB8nEBvHHjBpGRkURGRgL2Cz8iIyM5ffo0FouFgQMHMm7cOJYuXUpUVBTPPvssgYGBmVcK\nS/7hYnHhiepPsLj7YtqWbUuGkcFn+z6j15JebD6/2ex4kp+c+B1mN4WNU8CWDtU6w2tbIeRlcHE1\nO52ISKZ8exuYDRs20Lp167893qdPH7744gsMwyAsLIw5c+YQGxtL8+bNmTVrFlWrVr3r19Bl5HnT\n+tPrGb91PBeT7G8B6FKxC0MeGEJRr6ImJ5M8K/EqrB4Je761jwsFwCPvQY2u5uYSkX+k9TsfF8Cc\noAmUdyWmJ/Lh7g/59uC3GBj4efoxuOFgelTuoX2F5e4ZBuz9AVa9A0lXAQs88CI8PAq8/MxOJyK3\noPVbBdAhmkB5X9TlKMIjwjl83X5hSONSjRnVZBTl/cqbG0xyv2snYNmbcGKDfexfE7rOgKAHTI0l\nInem9VsF0CGaQPlDui2d+QfmMytyFinWFDxcPOhXtx8v1H4Bd1d3s+NJbmNNh80z4Pd3ISMF3Lzg\noaHQ7A3QfBHJE7R+qwA6RBMofzmbcJZxW8bx5/k/AajkV4mwZmHU969vcjLJNc5sh18GwKX99nHF\nVtBlGhStaGYqEblHWr9VAB2iCZT/GIbBiugVTN4+mWsp1wDoXbU3AxsOxNdDf8dOKyUe1o6B7fMA\nA3yKQYeJUPdxbeEmkgdp/VYBdIgmUP4VlxrH1J1TWXh0IQDFvYszrPEw2pdrr4tEnM3BX2D5EEi4\nYB8HPw3txkKBYubmEpH7pvVbBdAhmkD53/aY7YyJGMPJ+JMAtCzTkhEhIwgsGGhuMMl+cefsxe/w\nr/Zx0YrQZTpUfMjcXCLiMK3fKoAO0QRyDmnWNOZFzWNu1FwybBl4u3nzevDrPFXjKdxc3MyOJ1nN\nZrWf6l07BtJugIsbPDgQWr4F7t5mpxORLKD1WwXQIZpAzuVE7AnCI8LZdWkXADWK1mB0s9HULFbT\n5GSSZWKi7Bd5nNtpHweFQNcPwL+GublEJEtp/VYBdIgmkPOxGTYWHl3I1J1TSUhLwMXiwjM1nuG1\n4NfwcfcxO57cr7Qk2DARIj4CwwqevtB2NDR8Hlzy7Y6ZIk5L67cKoEM0gZzXleQrTN42mZUnVwIQ\nUCCAkU1G0rJMS5OTyT079hssGwSxp+zjmt2h42TwDTA3l4hkG63fKoAO0QSSjWc3Mm7LOM4nngeg\nQ/kODGs8jOLexU1OJnd04zKsGg5RP9rHvmWg8xSo1sncXCKS7bR+qwA6RBNIAJLSk5gVOYuvD36N\nzbBRyL0QbzZ6k0erPIqLRacPcx3DgN1fw+pRkBILFhcIeQVajwDPgmanE5EcoPVbBdAhmkDy3w5c\nPUB4RDgHrh4AoL5/fcKahlGpcCWTk0mmK0fhl4FwapN9XKqOff/e0g3MzSUiOUrrtwqgQzSB5H9l\n2DL47tB3fLj7Q5IzknFzcaNv7b68VPclPF09zY7nvDJSYdN02DgFrGng7gOt34GQ/4CrbuUj4my0\nfqsAOkQTSG7lwo0LjN86nt/P/g5Aed/yhDYN5YFSD5iczAmd2my/tcuVI/Zx5XbQ+X0oUs7cXCJi\nGq3fKoAO0QSS2zEMgzWn1jBp2yQuJ18GoEflHgxuOJjCXoVNTucEkq/DmjDY9aV9XMAfOk2CWr20\nf6+Ik9P6rQLoEE0guRsJaQl8sOsDfjj8AwBFvYoy5IEhdK7QWfsKZwfDgP0LYcUwSLxkf6xBH2gX\nDt5FzM0mIrmC1m8VQIdoAsm9iLwUSXhEOMdijwHQLLAZI5uMJKhQkMnJ8pHrp+DXwXBsjX1cvBp0\nnQ7lmpmbS0RyFa3foHtUiOSQYP9gFnRZwBv138DDxYPN5zfTa0kvPo36lHRbutnx8jZrBmz+EGY1\nsZc/Vw9o9Q68slHlT3JcutXGl5tPkpJuNTuKyC3pCKAD9BuE3K9T8acYEzGGbTHbAKhapCqjm46m\nTok6JifLg87vhqX9IWavfVyuuf2oX/Eq5uYSp7Tr9HXeWRjFoZgEXm1Vibc7Vjc7kvwDrd+g+x+I\nmKCcbznmtZ/H0uNLmbJjCkeuH+Hp5U/zZPUneaP+GxT00A2J7yj1BqwfD1s/BsMGXoWh/Vio/29d\n5CE5LiElnfdWHebrLacwDCji407VkoXMjiVySzoC6AD9BiFZ4VrKNaZsn8IvJ34BwN/Hn3dC3uHh\nsg+bnCwXO7wSlr8FcWfs4zq9ocNEKFjC3FzilFbui2H00v3ExKcA8GiDMozoXIOiBTxMTia3ovVb\nBdAhmkCSlSLORzB2y1jOJNhLzcNlH2Z44+GULFDS5GS5SEIMrBgKBxbbx4XLQZepULmtubnEKV2I\nSyZsyX5WH7gIQPliPozvWYcHK2sv8NxO67cKoEM0gSSrpWSk8MneT/hi3xdkGBkUcC/AgAYDeLzq\n47i6uJodzzw2G+z8HH4Lh9Q4sLhCs9fhoWHg4WN2OnEyVpvB1xEnmbL6CDdSM3BzsfDKQ5V4vU1l\nvNyd+P/TPETrtwqgQzSBJLscuX6E8Ihw9l62X9hQt3hdQpuGUq1oNZOTmeDSQftOHme22seBDaDr\nBxBQ19xc4pQOnI9n+KIo9pyJBaBB2cJM7FWXaqX0fr+8ROu3CqBDNIEkO1ltVn488iPTd00nMT0R\nN4sbfWr14ZV6r+Dl5mV2vOyXnmLfu3fTdLClg0dBaDMKGr8Eznw0VEyRnGZl+tojzNsYjdVmUMjT\njaGdqvNU47K4uOiio7xG67cKoEM0gSQnXEy8yKRtk/jt9G8AlClYhlFNR9EsMB/f3y76D/hlIFw7\nbh9XewQeeQ/8ypibS5zS70cuM3JxFGeuJQPwSJ1ShHWtRUlfJ/hFLJ/S+q0C6BBNIMlJ606vY/zW\n8VxKsm9v1qViF4Y8MISiXkVNTpaFkq7B6pEQ+Y19XCgAOr0LNbrq1i6S467cSGXssgMsiTwPQKCf\nF2O616ZtTV2Ylddp/VYBdIgmkOS0xPREPtz9Id8e/BYDAz9PP95q9BbdK3XP2/sKGwbsXQCrhkPS\nVcACD/SFh0PBy8/sdOJkDMPgxx1nGb/8IHHJ6bhYoE+z8gxuX42Cnrp9bn6g9VsF0CGaQGKWqMtR\nhEeEc/j6YQAal2rMqCajKO9X3txg9+PaCVg2CE6st4/9a9ov8ghqbG4ucUrHL99gxKIotpy4BkDN\nAF8mPVqHumUKm5xMspLWbxVAh2gCiZnSbel8feBrZkfOJsWagoeLB/3q9uOF2i/g7upudrw7s6bb\n9+/9fTJkpICbFzz0NjTrD3khv+QrqRlWPt5wgo/WHyPNasPb3ZU321XhhQcr4ObqYnY8yWJav1UA\nHaIJJLnBmYQzjNsyjs3nNwNQya8SYc3CqO9f3+Rkt3Fmu/3WLpf228cVHoIu06BYJXNziVPafvIa\nwxdGcezSDQAeqlqCcT1qE1RU95jMr7R+qwA6RBNIcgvDMFgevZx3t7/LtRT7qavHqz7OgIYD8PXI\nRXMzJR7WjoHt8wADvItCx4lQ91+6yENyXFxSOpNWHuK7bacBKF7Qg9CutehaNyBvv6dW7kjrtwqg\nQzSBJLeJS43j/R3vs+jYIgCKexdneOPhtCvXzvwF7eAvsPxtSLBfUUm9p6D9OChQzNxc4nQMw2DZ\n3guE/3KAKzdSAXjigSCGdapOYR/t3+sMtH6rADpEE0hyq+0x2xkTMYaT8ScBeKjMQ4wIGUFAwYCc\nDxN3Dla8DYeW2cdFK9pP91ZslfNZxOmdvZ7EqMX7WH/4MgAVSxRgYs86hFTULyLOROu3CqBDNIEk\nN0u1pjIvah7zouaRYcvA282bN+q/wVPVn8qZfYVtVvup3rVjIS0BXNzgwYHQ8i1w987+1xf5LxlW\nG19sPsn7q4+QnG7Fw9WFV1tX4j+tKuHppp1lnI3WbxVAh2gCSV5wPPY4YyLGsOvSLgBqFatFWNMw\nahSrkX0vGrMPfukP53bax2Ua22/tUrJm9r2myC1EnY1j+KK97DsXD0DjCkWZ0LMOlf0LmpxMzKL1\nWwXQIZpAklfYDBsLjy5k6o6pJKQn4Gpx5Zkaz/Bq8Kv4uGfhlY5pSfbbumz+EAwrePpC29HQ8Hlw\n0a00JGclpmYwdc0RPv8zGpsBvl5ujOhcg94Ng7R/r5PT+q0C6BBNIMlrriRfYfK2yaw8uRKAwAKB\njGwykhZlWjj+zY+thWVvQuwp+7hmd+g4GXxNeN+hOL11hy4yavF+zsXa9+/tVi+QUV1qUqKQp8nJ\nJDfQ+q0C6BBNIMmr/jj7B+O3jOd8ov2K3E7lO/F247cp7l383r/Zjcuw6h2IWmAf+5aBzlOgWqcs\nTCxydy7FpxD+ywF+jboAQJki3ozrUZtW1fxNTia5idZvFUCHaAJJXpaUnsSsyFl8ffBrbIaNQh6F\nGNxwMD2r9MTFchenaw0DIr+B1SMh+TpYXKDxy9BmBHgWyv4fQOS/2GwG320/zaQVh0hIycDVxULf\n5hUY2LYKPh7av1dupvXbiQug1Wpl9OjRzJ8/n5iYGAIDA3nuuecYOXLkXd8vTRNI8oMDVw8QHhHO\ngasHAGjg34CwpmFULFzx1l905RgsGwgnN9rHpepA1xlQukEOJBa52ZGLCbyzMIodp64DULeMHxN7\n1aFWoJ/JySS30voNTvtr0eTJk5k9ezZffvkltWrVYseOHTz//PP4+fnRv39/s+OJ5JiaxWryzSPf\n8O3Bb5kZOZNdl3bx6C+P8lKdl3ixzot4uP7XjXEz0uDP6fDHFLCmgrsPtBoOTV4FV6f950RMkpJu\n5aP1x/j49+OkWw18PFx5q301+jQrj6su8hC5Lac9AtilSxdKlizJp59+mvnYo48+ire3N/Pnz7+r\n76HfICS/OX/jPOO3juePs38AUN63PKFNQ3mg1ANwKsK+f++Vw/YnV24Lnd+HIuXNCyxOa/PxK4xY\ntI/oK4kAtK3hT3j32pQurHtMyp1p/XbiI4DNmjVjzpw5HDlyhKpVq7Jnzx42bdrE1KlTzY4mYprA\ngoHMbDOT1adWM2nbJE7Gn+SFVS/QyzOQQUe24WezQYES0HES1H5U+/dKjruemMaE5Qf5cedZAPwL\neRLerRYda5cyf7tDkTzEaQvgsGHDiI+Pp3r16ri6umK1Whk/fjxPP/30Lb8mNTWV1NTUzHF8fHxO\nRBXJURaLhQ7lO9A0oAnTfxvIj1d2sDD1PBvKBDDUtzadOs3Gov17JYcZhsHiyHOMXXaQa4lpWCzw\ndEhZ3u5YHV8vd7PjieQ5Tntn1gULFvDNN9/w7bffsmvXLr788kumTJnCl19+ecuvmThxIn5+fpkf\nQUFBOZhYJAfFnsb3p5cI3b6Qr87HUMlq4ZqrK0MTD/KfzSM4m3DW7ITiRE5dTeTZz7bx5g97uJaY\nRtWSBfnplaaM61FH5U/kPjntewCDgoIYNmwYr732WuZj48aNY/78+Rw6dOgfv+afjgAGBQU59XsI\nJJ+xZsDWj2H9eEhPAlcPaDGY9Kav89mhb5izdw5ptjS8XL14NfhV/l3z37i5OO2JBMlm6VYb8zZG\nM/23I6Rm2PBwc2HAw1V4qUVFPNyc9viFZAG9B9CJTwEnJSXh8j9bU7m6umKz2W75NZ6ennh66i7y\nkk+dj7Tv33thj31c7kHoMh1KVMUdeLney3Qo34ExW8awPWY7U3dOZXn0csKahlG7eG1To0v+s/v0\ndYYvjOJQTAIAzSoVY3zPOlQoXsDkZCL5g9MWwK5duzJ+/HjKli1LrVq12L17N1OnTuWFF14wO5pI\nzkq9ARsmwpZZYNjAyw/aj4PgZ/62f295v/J82v5TlhxfwpQdUzh07RBPL3+aJ6s/yRv136CAuxZn\ncUxCSjpTVh3mqy2nMAwo4uPOyM416dWgtC7yEMlCTnsKOCEhgVGjRrFo0SIuXbpEYGAgTz75JKGh\noXh4eNz5G6BDyJIPHFkFvw6GuDP2ce3HoONEKHjnbbOupVzjve3vsezEMgBK+pRkRMgIWpdtnZ2J\nJR9btT+GsCX7iYlPAaBXg9KM7FyTogXu7t9kkbul9duJC2BW0ASSPCvhIqwcCvsX2ceFy0LnaVCl\n7T1/q83nNzM2Yixnb9gvDGlXrh3DGg/D30d7r8rduRCXTNiS/aw+cBGAcsV8GN+jDs2r3Mfe1CJ3\nQeu3CqBDNIEkz7HZYNeXsCYMUuPA4gpNX7Xv5uFx/6dvkzOS+WTPJ3yx/wushpWC7gUZ2GAgvav1\nvrt9hcUpWW0G87ec4r1Vh7mRmoGbi4WXH6rIG22q4OXuanY8yce0fqsAOkQTSPKUS4fsO3mc2WIf\nBwRDtxkQUC/LXuLwtcOMiRjD3it7AahXoh5hTcOoUqRKlr2G5A8HL8QzfGEUkWdiAahftjATe9Wh\nein9WyrZT+u3CqBDNIEkT0hPgY3vw6ZpYEsH9wLw8Cho3A9csv4oi9Vm5YfDPzBj9wwS0xNxs7jx\nfO3n6Ve3H15uXln+epK3JKdZ+WDtUeZtPEGGzaCQpxtvd6zG0yHlcNH+vZJDtH6rADpEE0hyveiN\nsGwgXD1mH1ftCI9MgcLZfxPzmMQYJm2bxNrTawEoW6gso5qOoklAk2x/bcmdNh69zIhF+zh9LQmA\njrVKMbpbLUr56RcDyVlav1UAHaIJJLlW0jVYPQoi59vHBUtBp8lQs3uO79+79vRaJmydwKWkSwB0\nq9SNtxq9RRGvIjmaQ8xz9UYq4349yKLd5wAI8PNiTPfatKtZ0uRk4qy0fqsAOkQTSHIdw4CoH2Hl\ncEi6Yn+sUV9oG2a/v59JbqTdYMbuGXx/6HsMDAp7FmbIA0PoWrGr7u2WjxmGwU87zzJ++UFik9Kx\nWKBP0/K81aEaBT2d9ja0kgto/VYBdIgmkOQq16Lh10FwfJ19XKIGdP0AyoaYm+u/7L28l9ERozl6\n/SgAIQEhhDYJpaxvWZOTSVY7cfkGIxbtI+LEVQBqBPgysVcdgoMKm5xMROs3qAA6RBNIcgVrOkTM\nhA2TISMZXD3hoSHQbAC45b4b6Kbb0vlq/1fM3jObVGsqnq6evFz3ZZ6r9Rzuru5mxxMHpWXY+OT3\n43y4/hhpGTa83F14s21VXmheAXdX3RJIcget3yqADtEEEtOd3Wnfv/fiPvu4fAv7Ub9ilczNdRfO\nxJ9h7JaxRFyIAKBy4cqENQ0j2D/Y5GRyv3acvMbwhVEcvXQDgJZVSzC+R22CivqYnEzkZlq/VQAd\nogkkpkmJh3XjYNscwADvotBhPNR7Mscv8nCEYRj8Gv0r7257l+up17Fg4fFqjzOgwQAKeRQyO57c\npbjkdN5deYhvtp4GoFgBD0K71qRbvUC9x1NyJa3fKoAO0QQSUxxcBsuHQMJ5+7juE/byVyDvbpsV\nmxLL+zvfZ/GxxQCU8C7B8JDhtC3bVgUiFzMMg+VRMYz+ZT+XE1IB+FejIIY/Up3CPrnv7Qcif9H6\nrQLoEE0gyVHx5+3F79Ay+7hIBegyDSq1NjdXFtp2YRtjtozhVPwpAFoFtWJEyAhKFShlcjL5X+di\nkwldvI+1h+y396lYogATetahScViJicTuTOt3yqADtEEkhxhs8KOz+C3cEhLABc3aNYfHnob3L3N\nTpflUq2pzN07l0/3fUqGLQMfNx/eqP8GT1Z/Etds2LlE7o3VZvDF5pO8v/owSWlW3F0tvNqqMq+2\nroSnm/5+JG/Q+q0C6BBNIMl2Mfvs+/ee22Efl3nAfpFHyVrm5soBx2OPEx4Rzu5LuwGoXaw2Yc3C\nqF60usnJnNe+c3EMXxhF1Lk4ABqXL8qEXrWp7K/3a0reovVbBdAhmkCSbdKT4ffJsPlDsGWARyH7\nzZwbvZAt+/fmVjbDxk9HfmL6zukkpCfganHl2ZrP8kq9V/Bx15WlOSUpLYNpa47w6aZobAb4erkx\n/JEa/KtRkPbvlTxJ67cKoEM0gSRbHF8Hy96E6yft4xpdodO74BtoaiwzXU66zOTtk1l1chUApQuW\nZmSTkTQv3dzkZPnf+kOXGLl4H+dikwHoUjeA0K418S+k/Xsl79L6rQLoEE0gyVKJV2DVO7D3B/u4\nUCB0ngLVO5ubKxf54+wfjNsyjguJFwDoVKETbz/wNsW98+4V0LnVpYQUxvxygGV77X/WpQt7M65n\nbVpX8zc5mYjjtH6rADpEE0iyhGFA5LewegQkXwcsEPIytBkJnnpv1f9KSk9iZuRMvjn4DTbDhq+H\nL4MbDaZn5Z66ZUwWsNkMfthxhonLDxKfkoGLBfo2r8Cb7ari46H9eyV/0PqtAugQTSBx2NXj9os8\nTm60j0vWsV/kUaahubnygP1X9xO+OZyD1w4C0LBkQ0KbhlLRr6LJyfKuY5cSeGfhPradvAZAndJ+\nTOxVh9ql/UxOJpK1tH6rADpEE0juW0Ya/PkB/PEeWFPBzRtaDYOmr4H2w71rGbYMvjn4DR9FfkRy\nRjLuLu68VOcl+tbpi4erbkR8t1LSrczacJzZG46RbjXw8XBlcPtq9GlaDjft3yv5kNZvFUCHaALJ\nfTm9xX7U7/Ih+7hSG+g8FYpWMDdXHnbuxjnGbxnPxnP2I6kV/CoQ1jSMhiV1JPVOtpy4yjuLojhx\nORGAh6v7E969FmWK6Cpryb+0fqsAOkQTSO5JciysDbff1BnApzh0nAR1HstT+/fmVoZhsOrUKiZt\nncTVlKsAPFrlUd5s+CZ+njqF+b9ik9KYsPwgC3acBaBEIU/Cu9WiU+1Sei+l5Htav1UAHaIJJHfF\nMODAYlgxFG5ctD9W/xloNxZ8ipqbLR+KS41j+q7p/HTkJwCKeRVjaOOhdCzfUcUGe1Feuuc8Y5cd\n4MqNNACeDinL2x2r4+ettx+Ic9D6rQLoEE0guaPYM7D8LTiy0j4uVhm6TIcKLczN5QR2XdxFeEQ4\nJ+JOANC8dHNGNhlJ6YKlTU5mnjPXkhixeB9/HLkMQBX/gkzsVYdG5fWLiDgXrd8qgA7RBJJbsllh\n6yewbhykJ4KLO7QYBM0HgbtuoJtT0qxpfLbvM+bsnUO6LR1vN29erfcqz9R8BjcX57mlSbrVxmeb\nopn22xFS0m14uLnQv01l+rWshIebLvIQ56P1WwXQIZpA8o8u7IGl/eFCpH1ctqn9qJ+/9rA1S3Rc\nNGMixrDjon1P5RpFaxDWNIxaxfP/nsp7zsQybGEUBy/EA9C0YjHG96xNxRIFTU4mYh6t3yqADtEE\nkpukJcL6CbBlFhg28PSD9mOg/rPgoqMsZjMMg8XHFjNlxxTi0+JxsbjwVPWneKP+G/lyX+EbqRlM\nWXWYLyNOYhhQ2MedEY/U4LGGZfReSHF6Wr9VAB2iCSSZjqyGXwdD3Gn7uFYv+xW+hUqam0v+5mry\nVd7b8R6/nvgVgFIFSjEiZAStglqZGywLrTlwkdAl+7gQlwJAr/qlGdG5BsUKepqcTCR30PqtAugQ\nTSAh4SKsHAb7F9rHfmWh8/tQtb25ueSO/jz3J2O3jOXcjXMAtCvXjuGNh1PCp4TJye5fTFwKo5fu\nZ+X+GADKFvVhfM/atKiSd38mkeyg9VsF0CGaQE7MZoPdX8GaUEiJA4sLNHkVWr8DHgXMTid3KTkj\nmdl7ZvPV/q+wGlYKuhfkzYZv8ljVx3Cx5J3T9jabwTdbTzF55WFupGbg5mLhpZYV6d+mCt4ermbH\nE8l1tH6rADpEE8hJXT5s38njdIR9HBBs3783MNjcXHLfDl87THhEOFFXogAILhFMaNNQqhSpYnKy\nOzsUE8/whVHsPh0LQHBQYSb2qkONAP2bJHIrWr9VAB2iCeRk0lNg01TYOBVs6eBeANqMgMYvg6vz\n3FIkv7LarHx/+Htm7JpBUkYSbhY3nq/9PP3q9sPLLffduicl3cqMtUeZ88cJMmwGBT3deLtjNZ4O\nKYeriy7yELkdrd8qgA7RBHIi0Rth2UC4esw+rtIBOk+BwmXNzSVZLiYxhglbJ7D+zHoAyhYqS2jT\nUEICQkxO9n82Hb3CiMVRnLqaBECHWiUJ71abUn7/r737Do+qTP8//s5MegiBBBJaCL0loUMIKKIg\nqCDNXXXtZa2IIKIbWkJoURTFimJb19VVd6UogoqoiBIIndBC7yWUkJ5JMnN+f8wuv6+rruAkOZmc\nz+u6+OMMSebjeJP7njPnPE/1G1RFqiP1bw2AHlEBWUDROVg+BTb93X1cKwqufRo6DNf+vTXcikMr\nmLV2FtnF2QAMbTmU8d3HUzewrmmZzhY4mPn5ThZsct+40qB2IKnDYhkU28C0TCLeSP1bA6BHVEA1\nmGFA5r/cd/gWnXE/1u1uGDAVguqYmUyqUH5pPi9ufJGPsj7CwKBOQB2e7PEkQ1oMqdK19AzD4JON\nx5j5+Q5yisrw8YE7E5vx+MA2hAZq/16RS6X+rQHQIyqgGurcAfh8HOz7xn1cv517J4+YRHNziWm2\nnBiJd2kAACAASURBVN7C1NVT2XvefQlAQsMEknsl07R25V8CcOBMIZMWZrJ631kA2jUIJW1kPF2a\nmncmUsTbqX9rAPSICqiGcZZB+ivw3VNQXgz2AOj7BPQZA77+ZqcTk5W5ynh3+7u8tuU1HE4HAfYA\nHuz0IHfG3omfreLPwpWWu5j//T5e/GYvpeUuAv1sjB3Qhnsva46f3XuWqBGpjtS/NQB6RAVUgxzd\nAJ89Cqe2uY+bXe4+61evlbm5pNo5kneE6Wumk37CvQxQqzqtSElMoXNkxS0DtOHQOSYsyGT3qQIA\nLm9dj5nD42kaUfO2rBMxg/q3BkCPqIBqAEc+rJgOGfMBA4LqwsCZ0PkW3eQhv8owDJbsX8Iz654h\nx5GDDz7c2PZGxnQdQ6h/6O/+ubnFZcz+Yhfvr3VvKRgR4k/y9R0Y2qmR9u8VqUDq3xoAPaIC8nK7\nPoelT0Ce+45KOt4Mg2ZCSD1zc4nXOF9ynmfXP8vifYsBiAyKZELCBPo37X9JA5thGCzbdpKpn24n\nO98BwI3dmzDxuvbUCdblByIVTf1bA6BHVEBeKu+4e/DbtcR9XLcZDHkeWl5laizxXmtPrGX6mukc\nyjsEQL/ofkxKmESDkN9enuXY+WKSF21jxS73cjMt6oUwc0Q8iS0jKjWziJWpf4OlryQ+duwYt912\nGxEREQQFBREfH8/69evNjiWVxeWEjDfg5Z7u4c/mC5c9Bg+la/gTjyQ0TOCToZ9wf8f78bX58t2R\n7xi2aBjv73wfp8v5i9/jdBm89cMBrn5uJSt2ZeNn9+HR/q1ZOuZyDX8iUuksewYwJyeHLl26cOWV\nV/LQQw9Rv3599uzZQ8uWLWnZsuVF/Qy9g/Aip7a79+89us593Li7e//eBnHm5pIaZ2/OXlLTU9l8\nejMAcRFxTO09lbbhbS98zbZjuUxcmMnWo7kA9GhWl1kj4mkd9fuvHxSRi6f+beEBMCkpiR9//JFV\nq1b97p+hAvICZcWwcjasfhFc5eAfCgNSoPs9YLObnU5qKJfh4l+7/8XcDXPJL8vH7mPnjg53cFeH\n+5j37RHe/vEgTpdBaKAvE69rz03do7Fp/16RKqP+beEBsEOHDgwaNIijR4+ycuVKGjduzMMPP8x9\n9933q9/jcDhwOBwXjvPy8oiOjrZ0AVVr+76FJY9BzgH3cbshcN0zULuRubnEMk4XneapjKf46tBX\nAPiUh1N4fDjOwjYM6diQ5Os7EBmq/XtFqpoGQAtfA7h//37mzZtH69at+fLLL3nwwQd59NFH+dvf\n/var35OWlkZYWNiFP9HR0VWYWC5a4RlY8AC8N9w9/IU2gpveh5vf1/AnVap+cH2Sus0izvcxXGVh\nGL7nCG76Nv0uW07qiBgNfyJiGsueAfT396d79+6sXr36wmOPPvoo69atIz09/Re/R2cAqznDgC3/\ngC8nQfE5wAd63g9XTYZA/f+RquVyGXy8/gizlu4kr6Qcm81Bl04Z7HUsw2W4qO1fm8e7P86IViO0\nxp9IFdMZQPA1O4BZGjZsSIcOHX7yWPv27fnkk09+9XsCAgIICAio7Gjye5zdB0vGwoHv3cdRce6b\nPJp0NzeXWNLe7HwmLthGxsFzAMQ3DiNtZDxxjUey/eztpK5OZee5naSsTuGzfZ+RnJhM87DmJqcW\nESux7ADYp08fsrKyfvLY7t27iYmJMSmR/C7lpbD6BVj5DDgd4BsE/ZIgcRTYK35/VpH/xVHu5NVv\n9/Hqd3spcxoE+9sZd3Ub7urdDN9/798bGxHLB4M/4P2d7/PK5ldYf2o9N3x6A/d1vI974+7F366F\nn0Wk8ln2I+B169bRu3dvUlNTufHGG8nIyOC+++5j/vz53HrrrRf1M3QK2WSH17qXdjm9033c8ioY\n/ByE60yKVL01+88ycWEm+08XAnBVu0imDYulSd1f37/3WMExZqyZwQ/HfgCgeVhzUhJT6BbVrUoy\ni1iV+reFB0CAJUuWMGHCBPbs2UPz5s0ZN27c/7wL+L+pgExSfB5WpML6t93HwfXgmjSI/6P275Uq\nd76olLSlu/ho/REA6ocGMPX6WK6Lb3BR1/YZhsGXB7/kqYynOFtyFoAbWt/AY90eIywgrFKzi1iV\n+rfFB0BPqYCqmGHAjsWw7C9QcNL9WJfb4OrpEBxubjaxHMMw+HTLcaYv2cGZglIAbkloyl+uaUdY\n0KVffpDryOX5Dc/zyR73dcgRgREk9UxiULNBuklEpIKpf2sA9IgKqAqdPwJLx8PuL9zHEa1gyFxo\nfrm5ucSSjpwrYtKibXy/+zQArSNrMWtkPD2aef5GZMOpDUxLn8b+3P0AXNb4Mib3mkzjWo09/tki\n4qb+rQHQIyqgKuBywtrX4ZsZUFYINj/3/r2XPw5+WkNNqla508VbPxzg+a93U1Lmwt/XxugrW/HA\nFS3x9624ZVVLnaW8te0t3tj6BmWuMoJ8gxjVeRS3tr8VX5tl790TqTDq3xoAPaICqmQntrhv8ji+\nyX0c3cu9tEtkO3NziSVtOXKeCQsy2XEiD4BeLcKZNSKeFvVrVdpzHsg9wLT0aaw/tR6A9uHtSemd\nQmxEbKU9p4gVqH9rAPSICqiSlBbCt7NgzTwwnBAQBlenQtc7wWbZzWvEJAWOcp79Mou/pR/EZUCd\nYD8mXdeeP3RrUiXX5hmGwaK9i3h2/bPkleZh87FxS7tbGN1lNMF+v36HsYj8OvVvDYAeUQFVgj3L\nYck4yD3sPo4dAdc8BaENzM0llrR8xymSF2/jRG4JACO6NGby4PZE1Kr6BeHPFp9l9rrZLD2wFICG\nIQ2ZlDCJK6KvqPIsIt5O/VsDoEdUQBWoIBu+SIJt/96JJSwaBs+BNoPMzSWWdCqvhKmfbmfZNvfd\n5k3Dg5k5Io7LW9c3ORn8eOxHpq+ZzrGCYwAMjBlIUs8k6gebn03EW6h/awD0iAqoArhcsOk9WD4F\nSnLBxwa9HoZ+EyCg8q6tEvklLpfB+xmHmb1sF/mOcuw2H+7v24JHr2pNkL/d7HgXFJcXM2/LPP62\n/W84DSehfqGM7TaWP7T5AzYfXSYh8lvUvzUAekQF5KHTWfDZWDi82n3csJP7Jo9GXczNJZaUdTKf\nCQu2svHweQA6R9chbWQ87RtW33/bu87tInV1KtvObgOgc/3OpCSm0KpuK5OTiVRv6t8aAD2iAvqd\nyh2w6jlYNQdcZeAXDFdNhp4PgF1LXEjVKilz8tI3e3h95X7KXQa1Anx5YlBbbusVg91W/Rdgdrqc\nfJj1IS9ufJGi8iJ8bb7cE3cP93e8nwB71V+rKOIN1L81AHpEBfQ7HPzBfdbv7B73cetBMPhZqNPU\n3FxiST/uPcOkhZkcPFsEwKDYKKYOjaVhWJDJyS7dycKTzFw7k++OfAdATO0Yknsl07NhT3ODiVRD\n6t8aAD2iAroERedgebL7ej+AkEi49mn3Xb7a5kqq2LnCUmZ8voMFG903UjSoHUjqsFgGxXr33eaG\nYbDi8ArS1qaRXZwNwLCWwxjffTx1AuuYnE6k+lD/1gDoERXQRTAM9529XyRBoXvbLLrdDQOmQpAa\nklQtwzBYsPEYMz7fQU5RGT4+cEevGMYPakto4KXv31td5Zfm88LGF/g462MMDOoG1OWJHk8wpMUQ\n7Sssgvo3aAD0iAroN+QcdK/pt2+F+7heW/dNHjGJpsYSazp4ppBJizL5ce9ZANo1CCVtZDxdmtY1\nOVnl2Zy9mdT0VPae3wtAYsNEpvSaQnTtaJOTiZhL/VsDoEdUQL/CWQ5rXoFv06C8GOz+0PcJ6DMG\nfHVRulSt0nIXb6zaz4sr9uAodxHga2PsgDb8+fLm+Nlr/pIpZa4y3t3+Lq9teQ2H00GAPYAHOz3I\nnbF34merOWc9RS6F+rcGQI+ogH7BsQ3u/XtPZrqPm10OQ+ZCPS1LIVVvw6EcJi7IJOtUPgCXt67H\njOFxxESEmJys6h3OO8y0NdNYe2ItAK3rtiYlMYVO9TuZnEyk6ql/awD0iAro/3DkwzczIGM+GC4I\nqgsDZ0DnW3WTh1S5vJIyZn+xi/fXHsYwIDzEn+QhHRjWuZGlr4EzDIMl+5cwe91szjvO44MPN7W9\niTFdx1DLXwuvi3Wof2sA9IgK6N92LYWl4yHPfUcl8TfCoFlQS1tTSdUyDIMvtp0k5dPtZOc7APhj\ntyZMvK49dUP8TU5XfeSU5PDs+mf5dN+nAEQGRzKx50T6x/Q3OZlI1VD/1gDoEcsXUN4JWPYk7HQ3\nEerEwJDnoZWaiFS94+eLSV68na93ngKgeb0QZo6Io3fLeiYnq77WnFjD9PTpHM4/DMBV0VcxIWEC\nDUK8ezkckd9i+f6NBkCPWLaAXC7Y8DZ8nQqOPPCxQ+/RcMVfwD/Y7HRiMU6Xwd/SD/Lsl1kUljrx\ns/vw0BUtefjKVgT6VZ/9e6urkvIS5m+dzzvb3qHcKCfEL4TRXUZzc9ubsdv0+knNZNn+/X9oAPSA\nJQvo1A73TR5HM9zHjbu5l3ZpEG9uLrGk7cdzmbggky1HcwHoHlOXtJHxtI4KNTmZ99mTs4fU9FS2\nnN4CQHy9eFISU2gb3tbkZCIVz5L9+79oAPSApQqorBi+fwZ+fAFc5eBfC/onQ48/g84SSBUrKi3n\nha/38OYPB3C6DEIDfUm6th1/6tEUmxfs31tduQwX/9r9L57f8DwFZQXYfezcGXsnD3Z6kCBf79se\nT+TXWKp//woNgB6wTAHt/w6WPAbn9ruP2w2Ba2dDWGNTY4k1fZeVzeRF2ziaUwzA4I4NSRnSgcja\ngSYnqzmyi7J5KuMplh9aDkDjWo1J7pVM78a9TU4mUjEs07//Bw2AHqjxBVR4Fr6aBFv+4T4ObQjX\nPQPtrzc3l1jS6XwH05fs4NMtxwFoXCeI6cNjuapdlMnJaq7vjnzHzLUzOVl4EoDBLQbzRPcniAiK\nMDmZiGdqfP++CBoAPVBjC8gwYMuH8OVEKD4H+EDP++CqKRBYg/47xSsYhsHH648wa+kucovLsPnA\n3X2aM+7qNoQE+Jodr8YrLCvk5U0v88GuD3AZLsICwni82+MMbzXc0msqinersf37EmgA9ECNLKCz\n+9wf9x5Y6T6OjHXf5BHdw9xcYkl7swuYuDCTjAPnAIhtVJunRnYkvkmYycmsZ/uZ7UxNn8quc7sA\n6NGgB1N6TaF5WHOTk4lcuhrZvy+RBkAP1KgCKi+F1S+6b/QoLwHfQPeyLr1Hg137hUrVcpQ7efXb\nfcz7bh+lThdBfnYeH9iGu3o3w9cC+/dWV+Wucv6+4++8uuVVisuL8bP5cV/H+7g37l787VpoW7xH\njerfv5MGQA/UmAI6kuFe2iV7h/u4xZUw5DkIb2FuLrGktfvPMnFhJvtOFwJwZdv6TBsWR3S41pis\nLo7mH2XG2hn8eOxHAFqEtSAlMYWuUV1NTiZycWpM//aABkAPeH0BleS6F3Ne/zZgQHAEDEqDjjdq\n/16pcrlFZaQt28mH644AUK9WAFOHdmBwfENda1YNGYbBFwe/4KmMpzhX4v6I/g9t/sDYrmMJC9BH\n9FK9eX3/rgAaAD3gtQVkGO7t25Y+CQXuu/vofCsMnAHB4eZmE8sxDIPPtp5g2mfbOVNQCsCfejYl\n6Zp2hAXr8oPqLteRy/MbnueTPZ8AEBEYQVLPJAY1G6TBXaotr+3fFUgDoAe8soByj8LSJyBrqfs4\nvKV7/94WV5ibSyzpyLkiJi/axsrdpwFoFVmLtJHx9GimNyLeZv3J9aSmp3Iw7yAAlze+nMm9JtOo\nViNzg4n8Aq/s3xVMA6AHvKqAXE7ImA/fzIDSArD5wWVj4fLx4KcFdKVqlTtdvP3jAZ5bvpuSMhf+\ndhuPXNWKB65oQYCvdpbxVqXOUt7MfJM3M9+kzFVGkG8QozqP4tb2t+Jr05I9Un14Vf+uJBoAPeA1\nBXRiq/smj+Mb3cfRCe6lXSLbm5tLLGnLkfNMWJDJjhN5ACQ0D2fWyHha1q9lcjKpKPvP7yc1PZWN\n2e7fOe3D25PSO4XYiFiTk4m4eU3/rkQaAD1Q7QuotBC+ewrSXwHDCQFhcPVU6HoX2LSUhlStAkc5\nc77K4t3VB3EZEBbkx6Tr2vPH7k10rVgN5DJcLNyzkDkb5pBfmo/Nx8at7W/lkc6PEOynO7rFXNW+\nf1cBDYAeqNYFtOdr+PwxOH/YfdxhOFz7NIQ2MDeXWNLXO06RvHgbx3NLABjeuRGTh3SgXq0Ak5NJ\nZTtTfIbZGbNZdnAZAA1DGjK512T6NulrcjKxsmrdv6uIBkAPVMsCKsiGLybAtn+5j2s3gcFzoO01\n5uYSSzqVV8LUT7ezbJv7bvPo8CBmDI/nijb1TU4mVW3V0VXMWDOD44XuvZwHxgwkqWcS9YNVC1L1\nqmX/rmIaAD1QrQrIMGDTe/DVFCg5Dz42SHgIrpwIAbq2SqqWy2XwfsZhZi/bRb6jHLvNhz9f3pyx\n/dsQ5K+bPKyqqKyIeVvm8d6O93AaTkL9QhnbbSx/aPMHbD66LEWqTrXq3ybRAOiBalNAp3fDkrFw\nyL0qPw06wtAXoVEX8zKJZWWdzGfiwkw2HMoBoFN0HdJGxNOhkTV/ycrP7Ty7k6npU9lx1r37UJfI\nLiT3SqZV3VYmJxOrqDb920QaAD1gegGVO+CH52HVHHCWgl+w+4xfwkNg15ILUrVKypy89M0eXl+5\nn3KXQYi/nScGteX2xGbYbbrJQ37K6XLyj13/4MVNL1JcXoyvzZd74u7h/o73E2DXtaFSuUzv39WA\nBkAPmFpAh1a7l3Y5s9t93Opq97V+dWOqNocIsHrvGSYuzOTg2SIAru4QRerQWBrVCTI5mVR3JwpO\nMHPtTFYeXQlATO0Yknsl07NhT5OTSU2mAVADoEdMKaDiHFieDBv/5j4OiYRrn4LYkdq/V6rcucJS\nZn6+k082HgUgqnYAqUPjuCZOd5vLxTMMg+WHlvNUxlOcLnbvCjO81XAe7/Y4dQLrmJxOaiINgBoA\nPVKlBWQYsO0T9x2+hdnux7reCVenQlDdyn1ukf9iGAYLNx1j+pId5BSV4eMDt/eKYfygttQO1P69\n8vvkl+bzwsYX+CjrIwDqBtTliR5PMKTFEK0VKRVKAyDotqt/S0tLw8fHh7Fjx5od5edyDsH7f4RP\n7nUPf/Xawt3L3Dd6aPiTKnbwTCG3vbWWcR9vIaeojLZRoXzyUG+mDYvT8CceCfUPZXKvybx37Xu0\nqtOKHEcOE3+YyAPLH+BI3hGz44nUKBoAgXXr1jF//nw6duxodpSfcpbDjy/Cq71g73Kw+0O/ifDg\nKojpbXY6sZjSchevfLuXQXO/58e9ZwnwtfHkNW1Z8uhldG2qNyJScTpHdubjIR8zusto/G3+pJ9I\nZ8SnIy7sMSwinrP8AFhQUMCtt97KG2+8Qd261aiJHdsIb1wJy6dAWRHEXAYPrYZ+fwFf3SEnVWvD\noRyuf+kHnvkyC0e5i8ta1eOrx/rycL9W+Nkt/2tEKoGf3Y/7O97PgmEL6NmgJw6ngxc2vsBNS25i\ny+ktZscT8XqW/809atQoBg8ezIABA37zax0OB3l5eT/5Uym+TYM3+8PJrRBYB4a+DHctgXqtK+f5\nRH5FXkkZUxZt4w+vrSbrVD7hIf48f1Mn3ru3JzERIWbHEwuIqR3DmwPfZEafGdQJqMOenD3cvvR2\nZq6ZSUFpgdnxRLyWpQfADz/8kI0bN5KWlnZRX5+WlkZYWNiFP9HR0ZUTLKwxGC6I/yM8sh663q47\nfKVKGYbBsswTDJizkvfWHMIw4IauTfh63BWM6NJEF+RLlfLx8WFYq2EsHr6Y61tcj4HBh1kfMmzx\nMFYcWmF2PBGvZNm7gI8cOUL37t356quv6NSpEwD9+vWjc+fOzJ079xe/x+Fw4HA4Lhzn5eURHR1d\n8XcRGQYcyYCmCRX3M0Uu0vHzxSQv3s7XO08B0CwimFkj4undqp7JyUTc0o+nM33NdI7ku28MuSr6\nKiYkTKBBiJYfkouju4AtPAAuWrSIESNGYLf//31JnU4nPj4+2Gw2HA7HT/7ul6iApCZxugzeXX2Q\nOV9lUVjqxNfmw4NXtOSRq1oR6Kf9e6V6KSkv4fWtr/PXbX+l3CgnxC+E0V1Gc3Pbm7HbVK/yv6l/\nW3gAzM/P59ChQz957O6776Zdu3b85S9/IS4u7jd/hgpIaortx3OZuCCTLUdzAegWU5e0kfG0iQo1\nOZnI/7Y7Zzep6alsPb0VgPh68aQkptA2vK3JyaQ6U/+28AD4S37rI+D/pgISb1dUWs4LX+/hzR8O\n4HQZhAb48pdr23FLz6bYtH+veAmny8k/d/+TuRvnUlhWiN3Hzp2xd/JgpwcJ8tV2hPJz6t8WvwlE\nxMq+y8pm4PPf8/r3+3G6DK6Lb8DXj1/Bbb1iNPyJV7Hb7Nzc7mYWD1vMgKYDcBpO3t72NiMWj2D1\nsdVmxxOplnQG0AN6ByHe6HS+g+lLdvDpluMANAoLZNqwOAZ0iDI5mUjF+ObwN8xcO5PsIve2mYNb\nDOaJ7k8QERRhcjKpLtS/NQB6RAUk3sQwDD5ef4RZS3eRW1yGzQfu6t2cxwe2ISTA1+x4IhWqsKyQ\nlza9xAc7P8DAICwgjMe7Pc7wVsO1jJGof6MB0CMqIPEWe7MLmLgwk4wD5wDo0LA2T90QT8cmdUxO\nJlK5Mk9nkpqeSlZOFgA9GvQguVcyzcKamRtMTKX+rQHQIyogqe4c5U7mfbePV7/dR6nTRZCfnXFX\nt+HuPs3w1RZuYhFlrjLe2/Ee8zbPo8RZgr/Nn/s63se9cffiZ/czO56YQP1bA6BHVEBSnWUcOMeE\nBVvZd7oQgH5t6zN9WBzR4cEmJxMxx5H8I8xYM4PVx903hrQIa0FKYgpdo7qanEyqmvq3BkCPqICk\nOsotKiNt2U4+XOfeJaFeLX9Sro9lSMeGuvZJLM8wDJYeWMrsdbM5V+K+JOIPbf7AY90eo7a/fo9b\nhfq3BkCPqICkOjEMgyVbT5D62Q7OFLi3LPxTz2iSrmlPWLA+5hL5v3IducxZP4eFexcCUC+oHkk9\nkxgYM1BvlCxA/VsDoEdUQFJdHDlXxJTF2/gu6zQALeuHkDayIz2bh5ucTKR6W3dyHdPSp3Ew7yAA\nfZv0ZVLCJBrVamRuMKlU6t8aAD2iAhKzlTtdvPPjQZ5bvpviMif+dhsPX9mSh/q1JMBX+6GKXAyH\n08GbmW/yZuablLvKCfIN4pHOj3BL+1vwtWmJpJpI/VsDoEdUQGKmzKO5JC3YyvbjeQD0bB7OrBHx\ntIqsZXIyEe+0//x+UtNT2Zi9EYD24e2Z2nsqHSI6mJxMKpr6twZAj6iAxAyFjnLmfLWbv64+gMuA\nsCA/Jl7Xjj92i9YWbiIechkuFuxZwHMbniO/NB+bj43b2t/GqM6jCPbTHfQ1hfq3BkCPqICkqq3Y\neYrkxds5dr4YgKGdGjFlSAfqhwaYnEykZjlTfIanM57mi4NfANAopBGTek2ib5O+JieTiqD+rQHQ\nIyogqSrZeSWkfraDzzNPANCkbhAzhsfRr22kyclEarZVR1cxY80Mjhe6984e1GwQST2TqBdUz+Rk\n4gn1bw2AHlEBSWVzuQw+yDjM01/sIr+kHLvNhz9f1pwxA1oT7K+L00WqQlFZEa9ufpX3dr6Hy3AR\n6h/KY90e44bWN2Dz0Y463kj9WwOgR1RAUpl2n8pnwoJMNhzKAaBjkzDSRsYT2yjM5GQi1rTj7A5S\n01PZcXYHAF0ju5KSmEKLOi1MTiaXSv1bA6BHVEBSGUrKnLzy7V5eW7mPMqdBiL+d8YPackdiM+y6\nyUPEVOWucv6x6x+8tOklisuL8bX58uf4P/Pn+D8TYNe1uN5C/VsDoEdUQFLRVu87w6SF2zhwxr1/\n74D2UUwbFkujOkEmJxOR/+tEwQlmrp3JyqMrAWhWuxnJicn0aNDD5GRyMdS/NQB6RAUkFSWnsJSZ\nS3fyrw1HAYgMDWDasFgGxTbQtlQi1ZRhGCw/tJy0jDTOFJ8BYESrETze/XHCAnSpRnWm/q0B0CMq\nIPGUYRgs2nyM6Ut2cq6wFB8fuC0hhieuaUvtQO3fK+IN8krzeGHDC3y8+2MAwgPDeaLHEwxuPlhv\n4Kop9W8NgB5RAYknDp0tZPKibaza4z5z0DYqlFkj4+kWU9fkZCLye2zK3kTq6lT25e4DoHej3kzu\nNZno0GiTk8l/U//WAOgRFZD8HmVOF2+s2s8LX+/BUe7C39fGmP6tue/yFvj7akkJEW9W5izjne3v\n8PqW1yl1lRJoD+Shzg9xe4fb8bPprH51of6tAdAjKiC5VBsP5zBxQSa7TuYD0KdVBDOHx9OsXojJ\nyUSkIh3MPcj0NdPJOJkBQJu6bZiaOJX4+vEmJxNQ/wYNgB5RAcnFyi8p45kvs3hvzSEMA+oG+zF5\ncAdGdm2sa4REaijDMFi8bzHPrn+WXEcuPvjwp3Z/4tGujxLipzd9ZlL/1gDoERWQXIwvtp1k6qfb\nOZlXAsDIro2ZPLgD4SH+JicTkapwruQcz657ls/2fwZAVHAUExMmclXTq0xOZl3q3xoAPaICkv/l\nRG4xKYu389WOUwA0iwhm5oh4+rTSHqIiVrT6+Gqmp0/naIF7uaf+TfszoecEokKiTE5mPerfGgA9\nogKSX+J0GbyXfpBnv9pNgaMcX5sPD1zRgtFXtSbQz252PBExUXF5Ma9veZ13t79LuVFOiF8IY7qO\n4cY2N2K36fdDVVH/1gDoERWQ/LedJ/JIWpDJliPnAejatA5pIzvStkGoyclEpDrJOpfFtPRpbD2z\nFYCO9TuSkphCm7ptTE5mDerfGgA9ogKS/ygudfLCij28sWo/TpdBaIAvT17bjlt7NsWm/XtF5Bc4\nXU4+3v0xL2x8gcKyQnx9fLkr7i4e6PgAgb6BZser0dS/NQB6RAUkAN/vPs2kRZkcOVcMwLVx6lnY\ncAAAGUBJREFUDZg6NJao2voFLiK/7VThKdIy0lhxeAUA0aHRTOk1hcRGiSYnq7nUvzUAekQFZG1n\nChzMWLKDRZuPA9AwLJBpw+K4uoMu6BaRS7fi8ApmrZ1FdlE2ANe3uJ7xPcYTHhhucrKaR/1bA6BH\nVEDWZBgG/9xwlFlLd3K+qAwfH7irdzMeH9iWWgG+ZscTES9WUFrAS5te4h+7/oGBQZ2AOozvPp6h\nLYdqzdAKpP6tAdAjKiDr2X+6gIkLM1mz/xwA7RvW5qmR8XSKrmNyMhGpSbae3kpqeiq7c3YDkNAg\ngSmJU4ipHWNysppB/VsDoEdUQNZRWu7itZX7ePnbvZSWuwj0szHu6jbc06c5vnbt3ysiFa/MVcZ7\nO95j3uZ5lDhL8Lf580CnB7g79m787NpX2BPq3xoAPaICsoZ1B88xYUEme7MLALiiTX1mDI8jOjzY\n5GQiYgVH8o8wY80MVh9fDUCrOq1ISUyhc2Rnk5N5L/VvDYAeUQHVbLnFZTy1bBf/yDgMQL1a/kwZ\n0oGhnRrpWhwRqVKGYfD5gc95Zt0znCtxX4JyY5sbGdNtDLX91X8ulfq3BkCPqIBqJsMw+DzzBKmf\n7eB0vgOAm7pHM+G6dtQJ1v69ImKe8yXneW7DcyzcuxCA+kH1SeqZxNUxV+uN6SVQ/9YA6BEVUM1z\nNKeI5MXb+WaXexmGFvVDSBsRT0KLCJOTiYj8f+tOrmNa+jQO5h0E4IomVzApYRINazU0N5iXUP/W\nAOgRFVDNUe508dfVB5nz1W6Ky5z422081K8lD1/ZkgBf7c8pItWPw+ngja1v8Na2tyh3lRPkG8To\nLqO5pd0t2lf4N6h/awD0iAqoZth2LJekBVvZdiwPgJ7Nwpk1Mo5Wkdq/V0Sqv33n95Gansqm7E0A\nxEbEkpKYQvuI9iYnq77UvzUAekQF5N0KHeU8v3w3b/94AJcBtQN9mXhde27sHq39e0XEq7gMF5/s\n+YTn1z9Pflk+dh87t7W/jYc7P0ywn1Ys+G/q3xoAPaIC8l7f7DrFlEXbOXbevX/v9Z0aMWVIeyJD\ntX+viHiv00WneXrd03x58EsAGoU0YnKvyVze5HKTk1Uv6t9g6RVs09LS6NGjB6GhoURGRjJ8+HCy\nsrLMjiWVKDu/hFEfbOSev67n2PliGtcJ4p27e/DSn7po+BMRr1c/uD7PXvEsr/R/hYYhDTleeJyH\nVzzMkyuf5EzxGbPjSTVi6QFw5cqVjBo1ijVr1rB8+XLKysoYOHAghYWFZkeTCuZyGXyw9jD956zk\n860nsNt8uL9vC5aP68uVbSPNjiciUqH6NunLomGLuKPDHdh8bCw7uIyhi4byye5PcBkus+NJNaCP\ngP+P06dPExkZycqVK+nbt+9vfr1OIXuHPafymbAgk/WHcgCIbxxG2sh44hqHmZxMRKTybT+7ndTV\nqew8txOArpFdSUlMoUWdFiYnM4/6t8XPAP633NxcAMLDw01OIhWhpMzJc19lcd2Lq1h/KIdgfzvJ\nQzqwaFQfDX8iYhmxEbF8MPgDnuj+BEG+QWzM3sgNn93AK5tfodRZanY8MYnOAP6by+Vi6NChnD9/\nnh9++OEXv8bhcOBwOC4c5+XlER0dbel3ENVV+r6zTFqYyf4z7o/z+7eLZNrwOBrXCTI5mYiIeY4X\nHGfm2pl8f/R7AJrVbkZyYjI9GvQwOVnV0hlADYAXPPTQQyxbtowffviBJk2a/OLXTJ06ldTU1J89\nbuUCqm5yCkuZtXQn/9xwFIDI0ACmDo3l2rgG2iZJRAT3dpdfHvqSp9Y+xdmSswCMbD2Scd3GERZg\njU9HNABqAATgkUceYfHixXz//fc0b978V79OZwCrL8MwWLz5ONOX7OBsofsjjdt6NeXJa9pRO9DP\n5HQiItVPXmkeczfM5Z+7/wlAeGA4f+nxF65tfm2Nf8OsAdDiA6BhGIwePZqFCxfy3Xff0bp160v6\nfhVQ9XDobCGTF21j1R73EgdtomqRNjKebjG6llNE5LdsPLWR1PRU9ufuB6BPoz5M7jWZJqG//GlY\nTaD+bfEB8OGHH+aDDz5g8eLFtG3b9sLjYWFhBAX99rViKiBzlTldvLnqAHO/3o2j3IW/r41Hr2rF\n/X1b4u+r+5tERC5WqbOUt7e9zfyt8ylzlRFoD+Thzg9zW4fb8LPVvE9R1L8tPgD+2inud955h7vu\nuus3v18FZJ5Nh3OYsCCTXSfzAUhsEcGskfE0rxdicjIREe91MPcg09ZMY93JdQC0rduWqb2nElcv\nzuRkFUv92+IDoKdUQFUvv6SMZ7/M4m9rDmEYUDfYj0mDO3BD18Y1/poVEZGqYBgGi/YuYs6GOeQ6\ncvHBh1va38LoLqMJ8asZb7LVvzUAekQFVLW+3H6SlMXbOZlXAsDILo2ZNLg9EbUCTE4mIlLznC0+\nyzPrn+Hz/Z8DEBUcxaSESVzZ9EqTk3lO/VsDoEdUQFXjRG4xKYu389WOUwDERAQzc3g8l7WuZ3Iy\nEZGab/Wx1UxbM41jBccAGNB0AEk9k4gKiTI52e+n/q0B0CMqoMrldBn8fc0hnvkyiwJHOb7/3r/3\n0f6tCfSzmx1PRMQyisuLeW3La7y7/V2chpMQvxDGdh3LjW1vxObjfTfdqX9rAPSICqjy7DyRx4QF\nmWw+ch6ALk3rkDYynnYN9DqLiJgl61wWqempZJ7JBKBj/Y6kJKbQpm4bk5NdGvVvDYAeUQFVvOJS\nJy+s2MObq/ZT7jIIDfDlyWvacmtCDDabbvIQETGb0+Xko6yPeGHjCxSVF+Hr48tdcXfxQMcHCPQN\nNDveRVH/1gDoERVQxVq15zSTFm7j8LkiAK6JbcDUobE0CPOOXygiIlZysvAkaWvT+ObINwBEh0aT\nnJhMr4a9TE7229S/NQB6RAVUMc4WOJjx+U4WbnJfYNwwLJBpw+K4uoP3XmAsImIVKw6tYNbaWWQX\nZwNwfYvrGd9jPOGB1Xc3JvVvDYAeUQF5xjAM/rnhKLOW7uR8URk+PnBnYjPGD2pLrQBfs+OJiMhF\nKigt4MVNL/Lhrg8xMKgTUIfx3ccztOXQarlGq/q3BkCPqIB+v/2nC5i0cBvp+88C0L5hbdJGxtM5\nuo7JyURE5PfacnoLqemp7MnZA0BCgwSmJE4hpnaMycl+Sv1bA6BHVECXrrTcxesr9/HSt3spLXcR\n6GfjsQFtuOey5vjZvW8pARER+akyVxnvbn+X17a8hsPpwN/mz4OdHuSu2Lvws1ePfYXVvzUAekQF\ndGnWHzzHhAWZ7MkuAKBvm/rMHB5HdHiwyclERKSiHck7wvQ100k/kQ5AqzqtSElMoXNkZ5OTqX+D\nBkCPqIAuTm5xGU9/sYsP1h4GICLEn+TrOzC0U6NqeW2IiIhUDMMwWLJ/Cc+se4YcRw4++HBj2xsZ\n03UMof6hpuVS/9YA6BEV0P9mGAZLM08y9bPtnM53AHBT92gmXNeOOsH+JqcTEZGqcr7kPM+uf5bF\n+xYDUD+oPhMSJjCg6QBTTgSof2sA9IgK6NcdzSkiefF2vtnlXhagRf0QZo2Ip1eLCJOTiYiIWTJO\nZDBtzTQO5R0CoF+TfkzqNYkGIQ2qNIf6twZAj6iAfq7c6eKvqw/y3PLdFJU68bP78HC/Vjx8ZUsC\nfLV/r4iI1TmcDt7Y+gZvbXuLclc5Qb5BPNrlUf7U7k/YbVXTJ9S/NQB6RAX0U9uO5ZK0YCvbjuUB\n0LNZOLNGxtEq0rzrPEREpHrad34fqempbMreBEBsRCwpiSm0j2hf6c+t/q0B0CMqILdCRznPL9/N\n2z8ewGVA7UBfJlzXnpu6R2v/XhER+VUuw8W/dv+LuRvmkl+Wj93Hzu0dbuehTg8R7Fd5K0Sof2sA\n9IgKCL7ZdYopi7Zz7HwxAEM6NiT5+g5Ehmr/XhERuTini07z9Lqn+fLglwA0rtWYyb0mc1njyyrl\n+dS/NQB6xMoFlJ1fQupnO/h86wkAGtcJYsaIOK5sG2lyMhER8VbfH/2eGWtmcKLQ3VuubXYtT/Z8\nknpB9Sr0eazcv/9DA6AHrFhALpfBh+uOkLZsJ/kl5dh84N7LmvPY1W0I9tf+vSIi4pmisiJe3vwy\n7+98H5fhYmDMQOb0m1Ohz2HF/v3f1LHlou05lc+EBZmsP5QDQHzjMNJGxhPXOMzkZCIiUlME+wXz\nZI8nGdxiMLMzZvNYt8fMjlQj6QygB6zyDqKkzMmr3+5l3sp9lDkNgv3tPD6wLXcmxuCr/XtFRMTL\nWKV//y86Ayj/U/q+s0xamMn+M4UA9G8XybThcTSuE2RyMhEREfm9NADKL8opLGXW0p38c8NRACJD\nA5g6NJZr4xpo/14REREvpwFQfsIwDBZvPs70JTs4W1gKwG29mvLkNe2oHehncjoRERGpCBoA5YLD\nZ4uYtCiTVXvOANAmqhZpI+PpFhNucjIRERGpSBoAhTKnizdXHeCFFbspKXPh72vj0atacX/flvj7\n6iYPERGRmkYDoMVtPnKepE+2sutkPgCJLSKYNTKe5vVCTE4mIiIilUUDoEXll5Qx56vdvJt+EMOA\nusF+TBrcgRu6NtZNHiIiIjWcBkAL+nL7SVIWb+dkXgkAI7s0ZtLg9kTUCjA5mYiIiFQFDYAWcjK3\nhJRPt/Hl9lMAxEQEM3N4PJe1rtg9FkVERKR60wBoAU6XwftrDzH7iywKHOX42ny4v28LHu3fmkA/\nu9nxREREpIppAKzhdp7IY8KCTDYfOQ9Al6Z1SBsZT7sG1tz6RkRERDQA1lglZU5eWLGHN77fT7nL\noFaAL3+5pi23JMRgt+kmDxERESvTAFgDrdpzmkkLt3H4XBEA18Q2YOrQWBqEBZqcTERERKoDDYA1\nyNkCBzM/38mCTccAaFA7kGnDYhkY28DkZCIiIlKdaACsAQzD4JONx5j5+Q5yisrw8YE7E5sxflBb\nagXof7GIiIj8lKYDL3fgTCGTFmayet9ZANo3rE3ayHg6R9cxOZmIiIhUVxoAvVRpuYv53+/jxW/2\nUlruItDPxtgBbbj3sub42bV/r4iIiPw6DYBeaMOhc0xYkMnuUwUAXN66HjOHx9M0ItjkZCIiIuIN\nNAB6kdziMmZ/sYv31x4GICLEn+TrOzC0UyPt3ysiIiIXzfKfFb7yyis0a9aMwMBAEhISyMjIMDvS\nzxiGwdLME1z93MoLw9+N3Zuw4vErGNa5sYY/ERERuSSWPgP40UcfMW7cOF577TUSEhKYO3cugwYN\nIisri8jISLPjAXDsfDHJi7axYlc2AC3qhTBzRDyJLSNMTiYiIiLeyscwDMPsEGZJSEigR48evPzy\nywC4XC6io6MZPXo0SUlJv/n9eXl5hIWFkZubS+3aFbu1mtNl8NfVB5nzVRZFpU787D481K8VD/dr\nqf17RUREPFCZ/dtbWPYMYGlpKRs2bGDChAkXHrPZbAwYMID09PRf/B6Hw4HD4bhwnJeXVynZth3L\nZeLCTLYezQWgR7O6zBoRT+uo0Ep5PhEREbEWy14DeObMGZxOJ1FRUT95PCoqipMnT/7i96SlpREW\nFnbhT3R0dKVke3f1QbYezSU00Je0kfF8dH+ihj8RERGpMJY9A/hrDMP41ZsqJkyYwLhx4y4c5+Xl\nVcoQOOG69thtPowb2IbIUO3fKyIiIhXLsgNgvXr1sNvtnDp16iePZ2dn/+ys4H8EBAQQEBBQ6dnC\nQ/x56oaOlf48IiIiYk2W/QjY39+fbt26sWLFiguPuVwuVqxYQWJioonJRERERCqXZc8AAowbN447\n7riD7t2707NnT+bOnUthYSF333232dFEREREKo2lB8CbbrqJ06dPk5yczMmTJ+ncuTNffPHFr34E\nLCIiIlITWHodQE9pHSERERHvo/5t4WsARURERKxKA6CIiIiIxWgAFBEREbEYDYAiIiIiFqMBUERE\nRMRiNACKiIiIWIwGQBERERGL0QAoIiIiYjEaAEVEREQsxtJbwXnqP5uo5OXlmZxERERELtZ/+raV\nN0PTAOiB/Px8AKKjo01OIiIiIpcqPz+fsLAws2OYQnsBe8DlcnH8+HFCQ0Px8fGp0J+dl5dHdHQ0\nR44csew+hRVBr2PF0OvoOb2GFUOvY8Ww+utoGAb5+fk0atQIm82aV8PpDKAHbDYbTZo0qdTnqF27\ntiX/cVY0vY4VQ6+j5/QaVgy9jhXDyq+jVc/8/Yc1x14RERERC9MAKCIiImIx9qlTp041O4T8Mrvd\nTr9+/fD11Sf1ntDrWDH0OnpOr2HF0OtYMfQ6WptuAhERERGxGH0ELCIiImIxGgBFRERELEYDoIiI\niIjFaAAUERERsRgNgNXQK6+8QrNmzQgMDCQhIYGMjAyzI3mVtLQ0evToQWhoKJGRkQwfPpysrCyz\nY3m9tLQ0fHx8GDt2rNlRvM6xY8e47bbbiIiIICgoiPj4eNavX292LK/idDqZMmUKzZs3JygoiJYt\nWzJ9+nRL7+V6Mb7//nuuv/56GjVqhI+PD4sWLfrJ3xuGQXJyMg0bNiQoKIgBAwawZ88ek9JKVdIA\nWM189NFHjBs3jpSUFDZu3EinTp0YNGgQ2dnZZkfzGitXrmTUqFGsWbOG5cuXU1ZWxsCBAyksLDQ7\nmtdat24d8+fPp2PHjmZH8To5OTn06dMHPz8/li1bxo4dO5gzZw5169Y1O5pXefrpp5k3bx4vv/wy\nO3fu5Omnn2b27Nm89NJLZker1goLC+nUqRMvv/zyL/797NmzefHFF5k3bx5r164lJCSEQYMGUVJS\nUsVJpappGZhqJiEhgR49elz4x+pyuYiOjmb06NEkJSWZnM47nT59msjISFauXEnfvn3NjuN1CgoK\n6Nq1K6+++iozZsygc+fOzJ071+xYXiMpKYkff/yRVatWmR3Fqw0ZMoSoqCjeeuutC4/dcMMNBAUF\n8fe//93EZN7Dx8eHhQsXMnz4cMB99q9Ro0Y8/vjjjB8/HoDc3FyioqL461//ys0332xmXKlkOgNY\njZSWlrJhwwYGDBhw4TGbzcaAAQNIT083MZl3y83NBSA8PNzkJN5p1KhRDB48+Cd1KRfv008/pXv3\n7vzxj38kMjKSLl268MYbb5gdy+v07t2bFStWsHv3bgC2bNnCDz/8wLXXXmtyMu914MABTp48+ZN/\n22FhYSQkJKjnWICW/65Gzpw5g9PpJCoq6iePR0VFsWvXLpNSeTeXy8XYsWPp06cPcXFxZsfxOh9+\n+CEbN25k3bp1ZkfxWvv372fevHmMGzeOiRMnkpGRwaOPPkpAQAB33HGH2fG8RlJSEnl5ebRr1w67\n3Y7T6WTmzJnceuutZkfzWidPngT4xZ7zn7+TmksDoBcwDAMfHx+zY3ilUaNGsW3bNn744Qezo3id\nI0eOMGbMGL766isCAwPNjuO1XC4X3bt3Z9asWQB06dKF7du3M2/ePA2Al+Djjz/m/fff54MPPiA2\nNpbNmzczduxYGjVqxJ133ml2vBrFMAxsNn1AWNPp/3A1Uq9ePex2O6dOnfrJ49nZ2T97hya/7ZFH\nHmHJkiV8++23NGnSxOw4XmfDhg1kZ2fTrVs3fH198fX1ZeXKlbz44ov4+vridDrNjugVGjZsSIcO\nHX7yWPv27Tl8+LBJibzTE088QVJSEjfffDPx8fHcfvvtPPbYY6SlpZkdzWs1aNAAQD3HojQAViP+\n/v5069aNFStWXHjM5XKxYsUKEhMTTUzmXQzD4JFHHmHhwoV88803NG/e3OxIXql///5kZmayefPm\nC3+6d+/OrbfeyubNm7Hb7WZH9Ap9+vT52TJEu3fvJiYmxqRE3qmoqOhnZ6Xsdjsul8ukRN6vefPm\nNGjQ4Cc9Jy8vj7Vr16rnWIA+Aq5mxo0bxx133EH37t3p2bMnc+fOpbCwkLvvvtvsaF5j1KhRfPDB\nByxevJjQ0NAL17KEhYURFBRkcjrvERoa+rPrJkNCQoiIiND1lJfgscceo3fv3syaNYsbb7yRjIwM\n5s+fz/z5882O5lWuv/56Zs6cSdOmTYmNjWXTpk0899xz3HPPPWZHq9YKCgrYu3fvheMDBw6wefNm\nwsPDadq0KWPHjmXGjBm0bt2a5s2bM2XKFBo1anThTmGpwQypdl566SWjadOmhr+/v9GzZ09jzZo1\nZkfyKsAv/nnnnXfMjub1rrjiCmPMmDFmx/A6n332mREXF2cEBAQY7dq1M+bPn292JK+Tl5dnjBkz\nxmjatKkRGBhotGjRwpg0aZLhcDjMjlatffvtt7/4+/DOO+80DMMwXC6XMWXKFCMqKsoICAgw+vfv\nb2RlZZkbWqqE1gEUERERsRhdAygiIiJiMRoARURERCxGA6CIiIiIxWgAFBEREbEYDYAiIiIiFqMB\nUERERMRiNACKiIiIWIwGQBERERGL0QAoIiIiYjEaAEVEREQsRgOgiIiIiMVoABQRERGxGA2AIiIi\nIhajAVBERETEYjQAioiIiFiMBkARERERi9EAKCIiImIxGgBFRERELEYDoIiIiIjFaAAUERERsRgN\ngCIiIiIWowFQRERExGI0AIqIiIhYjAZAEREREYvRACgiIiJiMRoARURERCxGA6CIiIiIxWgAFBER\nEbEYDYAiIiIiFqMBUERERMRiNACKiIiIWMz/A6ITRStTTvRvAAAAAElFTkSuQmCC\n", 324 | "text/plain": [ 325 | "" 326 | ] 327 | }, 328 | "execution_count": 14, 329 | "metadata": {}, 330 | "output_type": "execute_result" 331 | } 332 | ], 333 | "source": [ 334 | "%%python\n", 335 | "from matplotlib import pyplot as plt\n", 336 | "from IPython.display import Image\n", 337 | "import tempfile\n", 338 | "plt.clf()\n", 339 | "plt.plot(range(10), label=\"a\")\n", 340 | "plt.plot(range(4, 14), label=\"b\")\n", 341 | "plt.plot(range(12, 0, -1), label=\"c\")\n", 342 | "plt.legend()\n", 343 | "plt.title(\"Example Plot\")\n", 344 | "with tempfile.NamedTemporaryFile(suffix=\".png\") as fo:\n", 345 | " plt.savefig(fo.name)\n", 346 | " retval = Image(filename=fo.name)" 347 | ] 348 | } 349 | ], 350 | "metadata": { 351 | "kernelspec": { 352 | "display_name": "spylon-kernel", 353 | "language": "scala", 354 | "name": "spylon-kernel" 355 | }, 356 | "language_info": { 357 | "codemirror_mode": "text/x-scala", 358 | "file_extension": ".scala", 359 | "help_links": [ 360 | { 361 | "text": "MetaKernel Magics", 362 | "url": "https://github.com/calysto/metakernel/blob/master/metakernel/magics/README.md" 363 | } 364 | ], 365 | "mimetype": "text/x-scala", 366 | "name": "scala", 367 | "pygments_lexer": "scala", 368 | "version": "1.0" 369 | } 370 | }, 371 | "nbformat": 4, 372 | "nbformat_minor": 1 373 | } 374 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | pyspark 2 | pytest 3 | coverage 4 | jupyter_kernel_test 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ipykernel 2 | jedi<0.11 3 | metakernel 4 | spylon[spark] 5 | tornado 6 | -------------------------------------------------------------------------------- /run_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | if __name__ == '__main__': 3 | import coverage 4 | cov = coverage.Coverage() 5 | cov.start() 6 | 7 | # Import required modules after coverage starts 8 | import sys 9 | import pytest 10 | 11 | # Call pytest and exit with the return code from pytest so that 12 | # CI systems will fail if tests fail. 13 | ret = pytest.main(sys.argv[1:]) 14 | 15 | cov.stop() 16 | cov.save() 17 | # Save HTML coverage report to disk 18 | cov.html_report() 19 | # Emit coverage report to stdout 20 | cov.report() 21 | 22 | sys.exit(ret) -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | 5 | # See the docstring in versioneer.py for instructions. Note that you must 6 | # re-run 'versioneer.py setup' after changing this section, and commit the 7 | # resulting files. 8 | 9 | [versioneer] 10 | VCS = git 11 | style = pep440 12 | versionfile_source = spylon_kernel/_version.py 13 | versionfile_build = spylon_kernel/_version.py 14 | tag_prefix = 15 | parentdir_prefix = 16 | 17 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import versioneer 3 | 4 | try: 5 | import pypandoc 6 | long_description = pypandoc.convert('README.md', 'rst') 7 | except Exception: 8 | long_description = open('README.md').read() 9 | 10 | setup( 11 | name='spylon-kernel', 12 | description='Jupyter metakernel for apache spark and scala', 13 | long_description=long_description, 14 | version=versioneer.get_version(), 15 | cmdclass=versioneer.get_cmdclass(), 16 | url='http://github.com/maxpoint/spylon-kernel', 17 | install_requires=[ 18 | 'ipykernel', 19 | 'jedi>=0.10', 20 | 'metakernel', 21 | 'spylon[spark]', 22 | 'tornado', 23 | ], 24 | packages=list(find_packages()), 25 | author='Marius van Niekerk', 26 | author_email='marius.v.niekerk+spylon@gmail.com', 27 | maintainer='Marius van Niekerk', 28 | maintainer_email='marius.v.niekerk+spylon@gmail.com', 29 | license="BSD 3-clause", 30 | classifiers=[ 31 | 'Intended Audience :: Developers', 32 | 'Intended Audience :: System Administrators', 33 | 'Intended Audience :: Science/Research', 34 | 'License :: OSI Approved :: BSD License', 35 | 'Programming Language :: Python', 36 | ], 37 | ) 38 | -------------------------------------------------------------------------------- /spylon_kernel/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, print_function, division 2 | 3 | from .scala_kernel import SpylonKernel 4 | from .scala_magic import ScalaMagic 5 | from .init_spark_magic import InitSparkMagic 6 | from .scala_interpreter import get_scala_interpreter 7 | 8 | 9 | def register_ipython_magics(): 10 | """For usage within ipykernel. 11 | 12 | This will instantiate the magics for IPython 13 | """ 14 | from metakernel import IPythonKernel 15 | from IPython.core.magic import register_cell_magic, register_line_cell_magic 16 | kernel = IPythonKernel() 17 | scala_magic = ScalaMagic(kernel) 18 | init_spark_magic = InitSparkMagic(kernel) 19 | 20 | @register_line_cell_magic 21 | def scala(line, cell): 22 | if line: 23 | return scala_magic.line_scala(line) 24 | else: 25 | scala_magic.code = cell 26 | return scala_magic.cell_scala() 27 | 28 | @register_cell_magic 29 | def init_spark(line, cell): 30 | init_spark_magic.code = cell 31 | return init_spark_magic.cell_init_spark() 32 | 33 | from ._version import get_versions 34 | __version__ = get_versions()['version'] 35 | del get_versions 36 | -------------------------------------------------------------------------------- /spylon_kernel/__main__.py: -------------------------------------------------------------------------------- 1 | """Entrypoint for running the kernel process.""" 2 | from spylon_kernel import SpylonKernel 3 | from tornado.ioloop import IOLoop 4 | 5 | if __name__ == '__main__': 6 | IOLoop.configure("tornado.platform.asyncio.AsyncIOLoop") 7 | SpylonKernel.run_as_main() 8 | -------------------------------------------------------------------------------- /spylon_kernel/_version.py: -------------------------------------------------------------------------------- 1 | 2 | # This file helps to compute a version number in source trees obtained from 3 | # git-archive tarball (such as those provided by githubs download-from-tag 4 | # feature). Distribution tarballs (built by setup.py sdist) and build 5 | # directories (produced by setup.py build) will contain a much shorter file 6 | # that just contains the computed version number. 7 | 8 | # This file is released into the public domain. Generated by 9 | # versioneer-0.17 (https://github.com/warner/python-versioneer) 10 | 11 | """Git implementation of _version.py.""" 12 | 13 | import errno 14 | import os 15 | import re 16 | import subprocess 17 | import sys 18 | 19 | 20 | def get_keywords(): 21 | """Get the keywords needed to look up the version information.""" 22 | # these strings will be replaced by git during git-archive. 23 | # setup.py/versioneer.py will grep for the variable names, so they must 24 | # each be defined on a line of their own. _version.py will just call 25 | # get_keywords(). 26 | git_refnames = " (HEAD -> master)" 27 | git_full = "2d0ddf2aca1b91738f938b72a500c20293e3156c" 28 | git_date = "2018-09-20 11:43:57 -0400" 29 | keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} 30 | return keywords 31 | 32 | 33 | class VersioneerConfig: 34 | """Container for Versioneer configuration parameters.""" 35 | 36 | 37 | def get_config(): 38 | """Create, populate and return the VersioneerConfig() object.""" 39 | # these strings are filled in when 'setup.py versioneer' creates 40 | # _version.py 41 | cfg = VersioneerConfig() 42 | cfg.VCS = "git" 43 | cfg.style = "pep440" 44 | cfg.tag_prefix = "" 45 | cfg.parentdir_prefix = "" 46 | cfg.versionfile_source = "spylon_kernel/_version.py" 47 | cfg.verbose = False 48 | return cfg 49 | 50 | 51 | class NotThisMethod(Exception): 52 | """Exception raised if a method is not valid for the current scenario.""" 53 | 54 | 55 | LONG_VERSION_PY = {} 56 | HANDLERS = {} 57 | 58 | 59 | def register_vcs_handler(vcs, method): # decorator 60 | """Decorator to mark a method as the handler for a particular VCS.""" 61 | def decorate(f): 62 | """Store f in HANDLERS[vcs][method].""" 63 | if vcs not in HANDLERS: 64 | HANDLERS[vcs] = {} 65 | HANDLERS[vcs][method] = f 66 | return f 67 | return decorate 68 | 69 | 70 | def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, 71 | env=None): 72 | """Call the given command(s).""" 73 | assert isinstance(commands, list) 74 | p = None 75 | for c in commands: 76 | try: 77 | dispcmd = str([c] + args) 78 | # remember shell=False, so use git.cmd on windows, not just git 79 | p = subprocess.Popen([c] + args, cwd=cwd, env=env, 80 | stdout=subprocess.PIPE, 81 | stderr=(subprocess.PIPE if hide_stderr 82 | else None)) 83 | break 84 | except EnvironmentError: 85 | e = sys.exc_info()[1] 86 | if e.errno == errno.ENOENT: 87 | continue 88 | if verbose: 89 | print("unable to run %s" % dispcmd) 90 | print(e) 91 | return None, None 92 | else: 93 | if verbose: 94 | print("unable to find command, tried %s" % (commands,)) 95 | return None, None 96 | stdout = p.communicate()[0].strip() 97 | if sys.version_info[0] >= 3: 98 | stdout = stdout.decode() 99 | if p.returncode != 0: 100 | if verbose: 101 | print("unable to run %s (error)" % dispcmd) 102 | print("stdout was %s" % stdout) 103 | return None, p.returncode 104 | return stdout, p.returncode 105 | 106 | 107 | def versions_from_parentdir(parentdir_prefix, root, verbose): 108 | """Try to determine the version from the parent directory name. 109 | 110 | Source tarballs conventionally unpack into a directory that includes both 111 | the project name and a version string. We will also support searching up 112 | two directory levels for an appropriately named parent directory 113 | """ 114 | rootdirs = [] 115 | 116 | for i in range(3): 117 | dirname = os.path.basename(root) 118 | if dirname.startswith(parentdir_prefix): 119 | return {"version": dirname[len(parentdir_prefix):], 120 | "full-revisionid": None, 121 | "dirty": False, "error": None, "date": None} 122 | else: 123 | rootdirs.append(root) 124 | root = os.path.dirname(root) # up a level 125 | 126 | if verbose: 127 | print("Tried directories %s but none started with prefix %s" % 128 | (str(rootdirs), parentdir_prefix)) 129 | raise NotThisMethod("rootdir doesn't start with parentdir_prefix") 130 | 131 | 132 | @register_vcs_handler("git", "get_keywords") 133 | def git_get_keywords(versionfile_abs): 134 | """Extract version information from the given file.""" 135 | # the code embedded in _version.py can just fetch the value of these 136 | # keywords. When used from setup.py, we don't want to import _version.py, 137 | # so we do it with a regexp instead. This function is not used from 138 | # _version.py. 139 | keywords = {} 140 | try: 141 | f = open(versionfile_abs, "r") 142 | for line in f.readlines(): 143 | if line.strip().startswith("git_refnames ="): 144 | mo = re.search(r'=\s*"(.*)"', line) 145 | if mo: 146 | keywords["refnames"] = mo.group(1) 147 | if line.strip().startswith("git_full ="): 148 | mo = re.search(r'=\s*"(.*)"', line) 149 | if mo: 150 | keywords["full"] = mo.group(1) 151 | if line.strip().startswith("git_date ="): 152 | mo = re.search(r'=\s*"(.*)"', line) 153 | if mo: 154 | keywords["date"] = mo.group(1) 155 | f.close() 156 | except EnvironmentError: 157 | pass 158 | return keywords 159 | 160 | 161 | @register_vcs_handler("git", "keywords") 162 | def git_versions_from_keywords(keywords, tag_prefix, verbose): 163 | """Get version information from git keywords.""" 164 | if not keywords: 165 | raise NotThisMethod("no keywords at all, weird") 166 | date = keywords.get("date") 167 | if date is not None: 168 | # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant 169 | # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 170 | # -like" string, which we must then edit to make compliant), because 171 | # it's been around since git-1.5.3, and it's too difficult to 172 | # discover which version we're using, or to work around using an 173 | # older one. 174 | date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 175 | refnames = keywords["refnames"].strip() 176 | if refnames.startswith("$Format"): 177 | if verbose: 178 | print("keywords are unexpanded, not using") 179 | raise NotThisMethod("unexpanded keywords, not a git-archive tarball") 180 | refs = set([r.strip() for r in refnames.strip("()").split(",")]) 181 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 182 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 183 | TAG = "tag: " 184 | tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) 185 | if not tags: 186 | # Either we're using git < 1.8.3, or there really are no tags. We use 187 | # a heuristic: assume all version tags have a digit. The old git %d 188 | # expansion behaves like git log --decorate=short and strips out the 189 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 190 | # between branches and tags. By ignoring refnames without digits, we 191 | # filter out many common branch names like "release" and 192 | # "stabilization", as well as "HEAD" and "master". 193 | tags = set([r for r in refs if re.search(r'\d', r)]) 194 | if verbose: 195 | print("discarding '%s', no digits" % ",".join(refs - tags)) 196 | if verbose: 197 | print("likely tags: %s" % ",".join(sorted(tags))) 198 | for ref in sorted(tags): 199 | # sorting will prefer e.g. "2.0" over "2.0rc1" 200 | if ref.startswith(tag_prefix): 201 | r = ref[len(tag_prefix):] 202 | if verbose: 203 | print("picking %s" % r) 204 | return {"version": r, 205 | "full-revisionid": keywords["full"].strip(), 206 | "dirty": False, "error": None, 207 | "date": date} 208 | # no suitable tags, so version is "0+unknown", but full hex is still there 209 | if verbose: 210 | print("no suitable tags, using unknown + full revision id") 211 | return {"version": "0+unknown", 212 | "full-revisionid": keywords["full"].strip(), 213 | "dirty": False, "error": "no suitable tags", "date": None} 214 | 215 | 216 | @register_vcs_handler("git", "pieces_from_vcs") 217 | def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): 218 | """Get version from 'git describe' in the root of the source tree. 219 | 220 | This only gets called if the git-archive 'subst' keywords were *not* 221 | expanded, and _version.py hasn't already been rewritten with a short 222 | version string, meaning we're inside a checked out source tree. 223 | """ 224 | GITS = ["git"] 225 | if sys.platform == "win32": 226 | GITS = ["git.cmd", "git.exe"] 227 | 228 | out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, 229 | hide_stderr=True) 230 | if rc != 0: 231 | if verbose: 232 | print("Directory %s not under git control" % root) 233 | raise NotThisMethod("'git rev-parse --git-dir' returned error") 234 | 235 | # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] 236 | # if there isn't one, this yields HEX[-dirty] (no NUM) 237 | describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", 238 | "--always", "--long", 239 | "--match", "%s*" % tag_prefix], 240 | cwd=root) 241 | # --long was added in git-1.5.5 242 | if describe_out is None: 243 | raise NotThisMethod("'git describe' failed") 244 | describe_out = describe_out.strip() 245 | full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) 246 | if full_out is None: 247 | raise NotThisMethod("'git rev-parse' failed") 248 | full_out = full_out.strip() 249 | 250 | pieces = {} 251 | pieces["long"] = full_out 252 | pieces["short"] = full_out[:7] # maybe improved later 253 | pieces["error"] = None 254 | 255 | # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] 256 | # TAG might have hyphens. 257 | git_describe = describe_out 258 | 259 | # look for -dirty suffix 260 | dirty = git_describe.endswith("-dirty") 261 | pieces["dirty"] = dirty 262 | if dirty: 263 | git_describe = git_describe[:git_describe.rindex("-dirty")] 264 | 265 | # now we have TAG-NUM-gHEX or HEX 266 | 267 | if "-" in git_describe: 268 | # TAG-NUM-gHEX 269 | mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) 270 | if not mo: 271 | # unparseable. Maybe git-describe is misbehaving? 272 | pieces["error"] = ("unable to parse git-describe output: '%s'" 273 | % describe_out) 274 | return pieces 275 | 276 | # tag 277 | full_tag = mo.group(1) 278 | if not full_tag.startswith(tag_prefix): 279 | if verbose: 280 | fmt = "tag '%s' doesn't start with prefix '%s'" 281 | print(fmt % (full_tag, tag_prefix)) 282 | pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" 283 | % (full_tag, tag_prefix)) 284 | return pieces 285 | pieces["closest-tag"] = full_tag[len(tag_prefix):] 286 | 287 | # distance: number of commits since tag 288 | pieces["distance"] = int(mo.group(2)) 289 | 290 | # commit: short hex revision ID 291 | pieces["short"] = mo.group(3) 292 | 293 | else: 294 | # HEX: no tags 295 | pieces["closest-tag"] = None 296 | count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], 297 | cwd=root) 298 | pieces["distance"] = int(count_out) # total number of commits 299 | 300 | # commit date: see ISO-8601 comment in git_versions_from_keywords() 301 | date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], 302 | cwd=root)[0].strip() 303 | pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 304 | 305 | return pieces 306 | 307 | 308 | def plus_or_dot(pieces): 309 | """Return a + if we don't already have one, else return a .""" 310 | if "+" in pieces.get("closest-tag", ""): 311 | return "." 312 | return "+" 313 | 314 | 315 | def render_pep440(pieces): 316 | """Build up version string, with post-release "local version identifier". 317 | 318 | Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you 319 | get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty 320 | 321 | Exceptions: 322 | 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] 323 | """ 324 | if pieces["closest-tag"]: 325 | rendered = pieces["closest-tag"] 326 | if pieces["distance"] or pieces["dirty"]: 327 | rendered += plus_or_dot(pieces) 328 | rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) 329 | if pieces["dirty"]: 330 | rendered += ".dirty" 331 | else: 332 | # exception #1 333 | rendered = "0+untagged.%d.g%s" % (pieces["distance"], 334 | pieces["short"]) 335 | if pieces["dirty"]: 336 | rendered += ".dirty" 337 | return rendered 338 | 339 | 340 | def render_pep440_pre(pieces): 341 | """TAG[.post.devDISTANCE] -- No -dirty. 342 | 343 | Exceptions: 344 | 1: no tags. 0.post.devDISTANCE 345 | """ 346 | if pieces["closest-tag"]: 347 | rendered = pieces["closest-tag"] 348 | if pieces["distance"]: 349 | rendered += ".post.dev%d" % pieces["distance"] 350 | else: 351 | # exception #1 352 | rendered = "0.post.dev%d" % pieces["distance"] 353 | return rendered 354 | 355 | 356 | def render_pep440_post(pieces): 357 | """TAG[.postDISTANCE[.dev0]+gHEX] . 358 | 359 | The ".dev0" means dirty. Note that .dev0 sorts backwards 360 | (a dirty tree will appear "older" than the corresponding clean one), 361 | but you shouldn't be releasing software with -dirty anyways. 362 | 363 | Exceptions: 364 | 1: no tags. 0.postDISTANCE[.dev0] 365 | """ 366 | if pieces["closest-tag"]: 367 | rendered = pieces["closest-tag"] 368 | if pieces["distance"] or pieces["dirty"]: 369 | rendered += ".post%d" % pieces["distance"] 370 | if pieces["dirty"]: 371 | rendered += ".dev0" 372 | rendered += plus_or_dot(pieces) 373 | rendered += "g%s" % pieces["short"] 374 | else: 375 | # exception #1 376 | rendered = "0.post%d" % pieces["distance"] 377 | if pieces["dirty"]: 378 | rendered += ".dev0" 379 | rendered += "+g%s" % pieces["short"] 380 | return rendered 381 | 382 | 383 | def render_pep440_old(pieces): 384 | """TAG[.postDISTANCE[.dev0]] . 385 | 386 | The ".dev0" means dirty. 387 | 388 | Eexceptions: 389 | 1: no tags. 0.postDISTANCE[.dev0] 390 | """ 391 | if pieces["closest-tag"]: 392 | rendered = pieces["closest-tag"] 393 | if pieces["distance"] or pieces["dirty"]: 394 | rendered += ".post%d" % pieces["distance"] 395 | if pieces["dirty"]: 396 | rendered += ".dev0" 397 | else: 398 | # exception #1 399 | rendered = "0.post%d" % pieces["distance"] 400 | if pieces["dirty"]: 401 | rendered += ".dev0" 402 | return rendered 403 | 404 | 405 | def render_git_describe(pieces): 406 | """TAG[-DISTANCE-gHEX][-dirty]. 407 | 408 | Like 'git describe --tags --dirty --always'. 409 | 410 | Exceptions: 411 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 412 | """ 413 | if pieces["closest-tag"]: 414 | rendered = pieces["closest-tag"] 415 | if pieces["distance"]: 416 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 417 | else: 418 | # exception #1 419 | rendered = pieces["short"] 420 | if pieces["dirty"]: 421 | rendered += "-dirty" 422 | return rendered 423 | 424 | 425 | def render_git_describe_long(pieces): 426 | """TAG-DISTANCE-gHEX[-dirty]. 427 | 428 | Like 'git describe --tags --dirty --always -long'. 429 | The distance/hash is unconditional. 430 | 431 | Exceptions: 432 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 433 | """ 434 | if pieces["closest-tag"]: 435 | rendered = pieces["closest-tag"] 436 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 437 | else: 438 | # exception #1 439 | rendered = pieces["short"] 440 | if pieces["dirty"]: 441 | rendered += "-dirty" 442 | return rendered 443 | 444 | 445 | def render(pieces, style): 446 | """Render the given version pieces into the requested style.""" 447 | if pieces["error"]: 448 | return {"version": "unknown", 449 | "full-revisionid": pieces.get("long"), 450 | "dirty": None, 451 | "error": pieces["error"], 452 | "date": None} 453 | 454 | if not style or style == "default": 455 | style = "pep440" # the default 456 | 457 | if style == "pep440": 458 | rendered = render_pep440(pieces) 459 | elif style == "pep440-pre": 460 | rendered = render_pep440_pre(pieces) 461 | elif style == "pep440-post": 462 | rendered = render_pep440_post(pieces) 463 | elif style == "pep440-old": 464 | rendered = render_pep440_old(pieces) 465 | elif style == "git-describe": 466 | rendered = render_git_describe(pieces) 467 | elif style == "git-describe-long": 468 | rendered = render_git_describe_long(pieces) 469 | else: 470 | raise ValueError("unknown style '%s'" % style) 471 | 472 | return {"version": rendered, "full-revisionid": pieces["long"], 473 | "dirty": pieces["dirty"], "error": None, 474 | "date": pieces.get("date")} 475 | 476 | 477 | def get_versions(): 478 | """Get version information or return default if unable to do so.""" 479 | # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have 480 | # __file__, we can work backwards from there to the root. Some 481 | # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which 482 | # case we can only use expanded keywords. 483 | 484 | cfg = get_config() 485 | verbose = cfg.verbose 486 | 487 | try: 488 | return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, 489 | verbose) 490 | except NotThisMethod: 491 | pass 492 | 493 | try: 494 | root = os.path.realpath(__file__) 495 | # versionfile_source is the relative path from the top of the source 496 | # tree (where the .git directory might live) to this file. Invert 497 | # this to find the root from __file__. 498 | for i in cfg.versionfile_source.split('/'): 499 | root = os.path.dirname(root) 500 | except NameError: 501 | return {"version": "0+unknown", "full-revisionid": None, 502 | "dirty": None, 503 | "error": "unable to find root of source tree", 504 | "date": None} 505 | 506 | try: 507 | pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) 508 | return render(pieces, cfg.style) 509 | except NotThisMethod: 510 | pass 511 | 512 | try: 513 | if cfg.parentdir_prefix: 514 | return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) 515 | except NotThisMethod: 516 | pass 517 | 518 | return {"version": "0+unknown", "full-revisionid": None, 519 | "dirty": None, 520 | "error": "unable to compute version", "date": None} 521 | -------------------------------------------------------------------------------- /spylon_kernel/init_spark_magic.py: -------------------------------------------------------------------------------- 1 | """Metakernel magic for configuring and automatically initializing a Spark session.""" 2 | import logging 3 | import spylon.spark 4 | 5 | from metakernel import Magic, option 6 | from .scala_interpreter import init_spark 7 | 8 | try: 9 | import jedi 10 | from jedi.api.helpers import get_on_completion_name 11 | from jedi import common 12 | except ImportError as ex: 13 | jedi = None 14 | 15 | 16 | class InitSparkMagic(Magic): 17 | """Cell magic that supports configuration property autocompletion and 18 | initializes a Spark session. 19 | 20 | Attributes 21 | ---------- 22 | env : dict 23 | Copy of the Python builtins plus a spylon.spark.launcher.SparkConfiguration 24 | object for use when initializing the Spark context 25 | log : logging.Logger 26 | Logger for this instance 27 | """ 28 | def __init__(self, kernel): 29 | super(InitSparkMagic, self).__init__(kernel) 30 | self.env = globals()['__builtins__'].copy() 31 | self.env['launcher'] = spylon.spark.launcher.SparkConfiguration() 32 | self.log = logging.Logger(self.__class__.__name__) 33 | 34 | # Use optparse to parse the whitespace delimited cell magic options 35 | # just as we would parse a command line. 36 | @option( 37 | "--stderr", action="store_true", default=False, 38 | help="Capture stderr in the notebook instead of in the kernel log" 39 | ) 40 | def cell_init_spark(self, stderr=False): 41 | """%%init_spark [--stderr] - starts a SparkContext with a custom 42 | configuration defined using Python code in the body of the cell 43 | 44 | Includes a `spylon.spark.launcher.SparkConfiguration` instance 45 | in the variable `launcher`. Looks for an `application_name` 46 | variable to use as the name of the Spark session. 47 | 48 | Example 49 | ------- 50 | %%init_spark 51 | launcher.jars = ["file://some/jar.jar"] 52 | launcher.master = "local[4]" 53 | launcher.conf.spark.app.name = "My Fancy App" 54 | launcher.conf.spark.executor.cores = 8 55 | """ 56 | # Evaluate the cell contents as Python 57 | exec(self.code, self.env) 58 | # Use the launcher to initialize a spark session 59 | init_spark(conf=self.env['launcher'], capture_stderr=stderr) 60 | # Do not evaluate the cell contents using the kernel 61 | self.evaluate = False 62 | 63 | def get_completions(self, info): 64 | """Gets Python completions based on the current cursor position 65 | within the %%init_spark cell. 66 | 67 | Based on 68 | https://github.com/Calysto/metakernel/blob/master/metakernel/magics/python_magic.py 69 | 70 | Parameters 71 | ---------- 72 | info : dict 73 | Information about the current caret position 74 | """ 75 | if jedi is None: 76 | return [] 77 | 78 | text = info['code'] 79 | position = (info['line_num'], info['column']) 80 | interpreter = jedi.Interpreter(text, [self.env]) 81 | 82 | lines = common.splitlines(text) 83 | name = get_on_completion_name( 84 | interpreter._get_module_node(), 85 | lines, 86 | position 87 | ) 88 | 89 | before = text[:len(text) - len(name)] 90 | completions = interpreter.completions() 91 | completions = [before + c.name_with_symbols for c in completions] 92 | return [c[info['start']:] for c in completions] -------------------------------------------------------------------------------- /spylon_kernel/scala_interpreter.py: -------------------------------------------------------------------------------- 1 | """Scala interpreter supporting async I/O with the managing Python process.""" 2 | import atexit 3 | import logging 4 | import os 5 | import pathlib 6 | import shutil 7 | import signal 8 | import subprocess 9 | import tempfile 10 | import threading 11 | 12 | from collections import namedtuple 13 | from concurrent.futures import ThreadPoolExecutor 14 | from typing import Callable, Union, List, Any 15 | 16 | import spylon.spark 17 | from tornado import ioloop 18 | 19 | # Global singletons 20 | SparkState = namedtuple('SparkState', 'spark_session spark_jvm_helpers spark_jvm_proc') 21 | spark_state = None 22 | scala_intp = None 23 | 24 | # Default Spark application name 25 | DEFAULT_APPLICATION_NAME = "spylon-kernel" 26 | 27 | 28 | def init_spark(conf=None, capture_stderr=False): 29 | """Initializes a SparkSession. 30 | 31 | Parameters 32 | ---------- 33 | conf: spylon.spark.SparkConfiguration, optional 34 | Spark configuration to apply to the session 35 | capture_stderr: bool, optional 36 | Capture stderr from the Spark JVM or let it go to the kernel log 37 | 38 | Returns 39 | ------- 40 | SparkState namedtuple 41 | """ 42 | global spark_state 43 | # If we have already initialized a spark session stop 44 | if spark_state: 45 | return spark_state 46 | 47 | # Ensure we have the correct classpath settings for the repl to work. 48 | os.environ.setdefault('SPARK_SUBMIT_OPTS', '-Dscala.usejavacp=true') 49 | 50 | if conf is None: 51 | conf = spylon.spark.launcher.SparkConfiguration() 52 | 53 | # Create a temp directory that gets cleaned up on exit 54 | output_dir = os.path.abspath(tempfile.mkdtemp()) 55 | def cleanup(): 56 | shutil.rmtree(output_dir, True) 57 | atexit.register(cleanup) 58 | signal.signal(signal.SIGTERM, cleanup) 59 | 60 | # SparkContext will detect this configuration and register it with the RpcEnv's 61 | # file server, setting spark.repl.class.uri to the actual URI for executors to 62 | # use. This is sort of ugly but since executors are started as part of SparkContext 63 | # initialization in certain cases, there's an initialization order issue that prevents 64 | # this from being set after SparkContext is instantiated. 65 | conf.conf.set("spark.repl.class.outputDir", output_dir) 66 | 67 | # Get the application name from the spylon configuration object 68 | application_name = conf.conf._conf_dict.get('spark.app.name', DEFAULT_APPLICATION_NAME) 69 | 70 | # Force spylon to "discover" the spark python path so that we can import pyspark 71 | conf._init_spark() 72 | 73 | # Patch the pyspark.java_gateway.Popen instance to force it to pipe to the parent 74 | # process so that we can catch all output from the Scala interpreter and Spark 75 | # objects we're about to create 76 | # Note: Opened an issue about making this a part of the pyspark.java_gateway.launch_gateway 77 | # API since it's useful in other programmatic cases beyond this project 78 | import pyspark.java_gateway 79 | spark_jvm_proc = None 80 | def Popen(*args, **kwargs): 81 | """Wraps subprocess.Popen to force stdout and stderr from the child process 82 | to pipe to this process without buffering. 83 | """ 84 | nonlocal spark_jvm_proc 85 | # Override these in kwargs to avoid duplicate value errors 86 | # Set streams to unbuffered so that we read whatever bytes are available 87 | # when ready, https://docs.python.org/3.6/library/subprocess.html#popen-constructor 88 | kwargs['bufsize'] = 0 89 | # Capture everything from stdout for display in the notebook 90 | kwargs['stdout'] = subprocess.PIPE 91 | # Optionally capture stderr, otherwise it'll go to the kernel log 92 | if capture_stderr: 93 | kwargs['stderr'] = subprocess.PIPE 94 | spark_jvm_proc = subprocess.Popen(*args, **kwargs) 95 | return spark_jvm_proc 96 | pyspark.java_gateway.Popen = Popen 97 | 98 | # Create a new spark context using the configuration 99 | spark_context = conf.spark_context(application_name) 100 | 101 | # pyspark is in the python path after creating the context 102 | from pyspark.sql import SparkSession 103 | from spylon.spark.utils import SparkJVMHelpers 104 | 105 | # Create the singleton SparkState 106 | spark_session = SparkSession(spark_context) 107 | spark_jvm_helpers = SparkJVMHelpers(spark_session._sc) 108 | spark_state = SparkState(spark_session, spark_jvm_helpers, spark_jvm_proc) 109 | return spark_state 110 | 111 | def get_web_ui_url(sc): 112 | """Gets the URL of the Spark web UI for a given SparkContext. 113 | 114 | Parameters 115 | ---------- 116 | sc : SparkContext 117 | 118 | Returns 119 | ------- 120 | url : str 121 | URL to the Spark web UI 122 | """ 123 | # Dig into the java spark conf to actually be able to resolve the spark configuration 124 | # noinspection PyProtectedMember 125 | conf = sc._jsc.getConf() 126 | if conf.getBoolean("spark.ui.reverseProxy", False): 127 | proxy_url = conf.get("spark.ui.reverseProxyUrl", "") 128 | if proxy_url: 129 | web_ui_url = "{proxy_url}/proxy/{application_id}".format( 130 | proxy_url=proxy_url, application_id=sc.applicationId) 131 | else: 132 | web_ui_url = "Spark Master Public URL" 133 | else: 134 | # For spark 2.0 compatibility we have to retrieve this from the scala side. 135 | joption = sc._jsc.sc().uiWebUrl() 136 | if joption.isDefined(): 137 | web_ui_url = joption.get() 138 | else: 139 | web_ui_url = "" 140 | 141 | # Legacy compatible version for YARN 142 | yarn_proxy_spark_property = "spark.org.apache.hadoop.yarn.server.webproxy.amfilter.AmIpFilter.param.PROXY_URI_BASES" 143 | if sc.master.startswith("yarn"): 144 | web_ui_url = conf.get(yarn_proxy_spark_property) 145 | 146 | return web_ui_url 147 | 148 | 149 | # noinspection PyProtectedMember 150 | def initialize_scala_interpreter(): 151 | """ 152 | Instantiates the scala interpreter via py4j and pyspark. 153 | 154 | Notes 155 | ----- 156 | Portions of this have been adapted out of Apache Toree and Apache Zeppelin. 157 | 158 | Returns 159 | ------- 160 | ScalaInterpreter 161 | """ 162 | # Initialize Spark first if it isn't already 163 | spark_session, spark_jvm_helpers, spark_jvm_proc = init_spark() 164 | 165 | # Get handy JVM references 166 | jvm = spark_session._jvm 167 | jconf = spark_session._jsc.getConf() 168 | io = jvm.java.io 169 | 170 | # Build a print writer that'll be used to get results from the 171 | # Scala REPL 172 | bytes_out = jvm.org.apache.commons.io.output.ByteArrayOutputStream() 173 | jprint_writer = io.PrintWriter(bytes_out, True) 174 | 175 | # Set the Spark application name if it is not already set 176 | jconf.setIfMissing("spark.app.name", DEFAULT_APPLICATION_NAME) 177 | 178 | # Set the location of the Spark package on HDFS, if available 179 | exec_uri = jvm.System.getenv("SPARK_EXECUTOR_URI") 180 | if exec_uri is not None: 181 | jconf.set("spark.executor.uri", exec_uri) 182 | 183 | # Configure the classpath and temp directory created by init_spark 184 | # as the shared path for REPL generated artifacts 185 | output_dir = jconf.get("spark.repl.class.outputDir") 186 | try: 187 | # Spark 2.2.1+ 188 | jars = jvm.org.apache.spark.util.Utils.getLocalUserJarsForShell(jconf).mkString(":") 189 | except: 190 | # Spark <2.2.1 191 | jars = jvm.org.apache.spark.util.Utils.getUserJars(jconf, True).mkString(":") 192 | 193 | interp_arguments = spark_jvm_helpers.to_scala_list( 194 | ["-Yrepl-class-based", "-Yrepl-outdir", output_dir, 195 | "-classpath", jars, "-deprecation:false" 196 | ] 197 | ) 198 | settings = jvm.scala.tools.nsc.Settings() 199 | settings.processArguments(interp_arguments, True) 200 | 201 | # Since we have already instantiated our SparkSession on the Python side, 202 | # share it with the Scala Main REPL class as well 203 | Main = jvm.org.apache.spark.repl.Main 204 | jspark_session = spark_session._jsparkSession 205 | # Equivalent to Main.sparkSession = jspark_session, which we can't do 206 | # directly because of the $ character in the method name 207 | getattr(Main, "sparkSession_$eq")(jspark_session) 208 | getattr(Main, "sparkContext_$eq")(jspark_session.sparkContext()) 209 | 210 | # Instantiate a Scala interpreter 211 | intp = jvm.scala.tools.nsc.interpreter.IMain(settings, jprint_writer) 212 | intp.initializeSynchronous() 213 | 214 | # Ensure that sc and spark are bound in the interpreter context. 215 | intp.interpret(""" 216 | @transient val spark = org.apache.spark.repl.Main.sparkSession 217 | @transient val sc = spark.sparkContext 218 | """) 219 | # Import Spark packages for convenience 220 | intp.interpret('\n'.join([ 221 | "import org.apache.spark.SparkContext._", 222 | "import spark.implicits._", 223 | "import spark.sql", 224 | "import org.apache.spark.sql.functions._" 225 | ])) 226 | # Clear the print writer stream 227 | bytes_out.reset() 228 | 229 | return ScalaInterpreter(jvm, intp, bytes_out) 230 | 231 | 232 | def _scala_seq_to_py(jseq): 233 | """Generator for all elements in a Scala sequence. 234 | 235 | Parameters 236 | ---------- 237 | jseq : Scala Seq 238 | Scala sequence 239 | 240 | Yields 241 | ------ 242 | any 243 | One element per sequence 244 | """ 245 | n = jseq.size() 246 | for i in range(n): 247 | yield jseq.apply(i) 248 | 249 | 250 | class ScalaException(Exception): 251 | def __init__(self, scala_message, *args, **kwargs): 252 | super(ScalaException, self).__init__(scala_message, *args, **kwargs) 253 | self.scala_message = scala_message 254 | 255 | 256 | class ScalaInterpreter(object): 257 | """Wrapper for a Scala interpreter. 258 | 259 | Notes 260 | ----- 261 | Users should not instantiate this class themselves. Use `get_scala_interpreter` instead. 262 | 263 | Parameters 264 | ---------- 265 | jvm : py4j.java_gateway.JVMView 266 | jimain : py4j.java_gateway.JavaObject 267 | Java object representing an instance of `scala.tools.nsc.interpreter.IMain` 268 | jbyteout : py4j.java_gateway.JavaObject 269 | Java object representing an instance of `org.apache.commons.io.output.ByteArrayOutputStream` 270 | This is used to return output data from the REPL. 271 | log : logging.Logger 272 | Logger for this instance 273 | web_ui_url : str 274 | URL of the Spark web UI associated with this interpreter 275 | """ 276 | executor = ThreadPoolExecutor(1) 277 | 278 | def __init__(self, jvm, jimain, jbyteout): 279 | self.jvm = jvm 280 | self.jimain = jimain 281 | self.jbyteout = jbyteout 282 | self.log = logging.getLogger(self.__class__.__name__) 283 | 284 | # Store the state here so that clients of the instance 285 | # can access them (for now ...) 286 | self.sc = spark_state.spark_session._sc 287 | self.spark_session = spark_state.spark_session 288 | 289 | # noinspection PyProtectedMember 290 | self.web_ui_url = get_web_ui_url(self.sc) 291 | self._jcompleter = None 292 | 293 | # Handlers for dealing with stdout and stderr. 294 | self._stdout_handlers = [] 295 | self._stderr_handlers = [] 296 | 297 | # Threads that perform blocking reads on the stdout/stderr 298 | # streams from the py4j JVM process. 299 | if spark_state.spark_jvm_proc.stdout is not None: 300 | self.stdout_reader = threading.Thread(target=self._read_stream, 301 | daemon=True, 302 | kwargs=dict( 303 | fd=spark_state.spark_jvm_proc.stdout, 304 | fn=self.handle_stdout 305 | ) 306 | ) 307 | self.stdout_reader.start() 308 | 309 | if spark_state.spark_jvm_proc.stderr is not None: 310 | self.stderr_reader = threading.Thread(target=self._read_stream, 311 | daemon=True, 312 | kwargs=dict( 313 | fd=spark_state.spark_jvm_proc.stderr, 314 | fn=self.handle_stderr 315 | ) 316 | ) 317 | self.stderr_reader.start() 318 | 319 | def register_stdout_handler(self, handler): 320 | """Registers a handler for the Scala stdout stream. 321 | 322 | Parameters 323 | ---------- 324 | handler: callable(str) -> None 325 | Function to handle a stdout from the interpretter 326 | """ 327 | self._stdout_handlers.append(handler) 328 | 329 | def register_stderr_handler(self, handler): 330 | """Registers a handler for the Scala stderr stream. 331 | 332 | Parameters 333 | ---------- 334 | handler: callable(str) -> None 335 | Function to handle a stdout from the interpretter 336 | """ 337 | self._stderr_handlers.append(handler) 338 | 339 | def handle_stdout(self, chunk): 340 | """Passes a chunk of Scala stdout to registered handlers. 341 | 342 | Parameters 343 | ---------- 344 | chunk : str 345 | Chunk of text 346 | """ 347 | for handler in self._stdout_handlers: 348 | try: 349 | handler(chunk) 350 | except Exception as ex: 351 | self.log.exception('Exception handling stdout') 352 | 353 | def handle_stderr(self, chunk): 354 | """Passes a chunk of Scala stderr to registered handlers. 355 | 356 | Parameters 357 | ---------- 358 | chunk : str 359 | Chunk of text 360 | """ 361 | for handler in self._stderr_handlers: 362 | try: 363 | handler(chunk) 364 | except Exception as ex: 365 | self.log.exception('Exception handling stderr') 366 | 367 | def _read_stream(self, fd, fn): 368 | """Reads bytes from a file descriptor, utf-8 decodes them, and passes them 369 | to the provided callback function on the next IOLoop tick. 370 | 371 | Assumes fd.read will block and should be used in a thread. 372 | 373 | Parameters 374 | ---------- 375 | fd : file 376 | File descriptor to read 377 | fn : callable(str) -> None 378 | Callback function that handles chunks of text 379 | """ 380 | while True: 381 | # Specify a max read size so the read doesn't block indefinitely 382 | # Using a value less than the typical default max pipe size 383 | # and greater than a single system page. 384 | buff = fd.read(8192) 385 | if buff: 386 | fn(buff.decode('utf-8')) 387 | 388 | def interpret(self, code): 389 | """Interprets a block of Scala code. 390 | 391 | Follow this with a call to `last_result` to retrieve the result as a 392 | Python object. 393 | 394 | Parameters 395 | ---------- 396 | code : str 397 | Scala code to interpret 398 | 399 | Returns 400 | ------- 401 | str 402 | String output from the scala REPL 403 | 404 | Raises 405 | ------ 406 | ScalaException 407 | When there is a problem interpreting the code 408 | """ 409 | # Ensure the cell is not incomplete. Same approach taken by Apache Zeppelin. 410 | # https://github.com/apache/zeppelin/blob/3219218620e795769e6f65287f134b6a43e9c010/spark/src/main/java/org/apache/zeppelin/spark/SparkInterpreter.java#L1263 411 | code = 'print("")\n'+code 412 | 413 | try: 414 | res = self.jimain.interpret(code, False) 415 | pyres = self.jbyteout.toByteArray().decode("utf-8") 416 | # The scala interpreter returns a sentinel case class member here 417 | # which is typically matched via pattern matching. Due to it 418 | # having a very long namespace, we just resort to simple string 419 | # matching here. 420 | result = res.toString() 421 | if result == "Success": 422 | return pyres 423 | elif result == 'Error': 424 | raise ScalaException(pyres) 425 | elif result == 'Incomplete': 426 | raise ScalaException(pyres or ': error: incomplete input') 427 | return pyres 428 | finally: 429 | self.jbyteout.reset() 430 | 431 | def last_result(self): 432 | """Retrieves the JVM result object from the preceeding call to `interpret`. 433 | 434 | If the result is a supported primitive type, convers it to a Python object. 435 | Otherwise, returns a py4j view onto that object. 436 | 437 | Returns 438 | ------- 439 | object 440 | """ 441 | # TODO : when evaluating multiline expressions this returns the first result 442 | lr = self.jimain.lastRequest() 443 | res = lr.lineRep().call("$result", spark_state.spark_jvm_helpers.to_scala_list([])) 444 | return res 445 | 446 | def bind(self, name, value, jtyp="Any"): 447 | """Set a variable in the Scala REPL to a Python valued type. 448 | 449 | Parameters 450 | ---------- 451 | name : str 452 | value : Any 453 | jtyp : str 454 | String representation of the Java type that we want to cast this as. 455 | 456 | Returns 457 | ------- 458 | bool 459 | True if the value is of one of the compatible types, False if not 460 | """ 461 | modifiers = spark_state.spark_jvm_helpers.to_scala_list(["@transient"]) 462 | # Ensure that the value that we are trying to set here is a compatible type on the java side 463 | # Import is here due to lazily instantiating the SparkContext 464 | from py4j.java_gateway import JavaClass, JavaObject, JavaMember 465 | compatible_types = ( 466 | int, str, bytes, bool, list, dict, JavaClass, JavaMember, JavaObject 467 | ) 468 | if isinstance(value, compatible_types): 469 | self.jimain.bind(name, jtyp, value, modifiers) 470 | return True 471 | return False 472 | 473 | @property 474 | def jcompleter(self): 475 | """Scala code completer. 476 | 477 | Returns 478 | ------- 479 | scala.tools.nsc.interpreter.PresentationCompilerCompleter 480 | """ 481 | if self._jcompleter is None: 482 | jClass = self.jvm.scala.tools.nsc.interpreter.PresentationCompilerCompleter 483 | self._jcompleter = jClass(self.jimain) 484 | return self._jcompleter 485 | 486 | def complete(self, code, pos): 487 | """Performs code completion for a block of Scala code. 488 | 489 | Parameters 490 | ---------- 491 | code : str 492 | Scala code to perform completion on 493 | pos : int 494 | Cursor position 495 | 496 | Returns 497 | ------- 498 | List[str] 499 | Candidates for code completion 500 | """ 501 | c = self.jcompleter 502 | jres = c.complete(code, pos) 503 | return list(_scala_seq_to_py(jres.candidates())) 504 | 505 | def is_complete(self, code): 506 | """Determines if a chunk of code is a complete block of Scala. 507 | 508 | Parameters 509 | ---------- 510 | code : str 511 | Code to evaluate for completeness 512 | 513 | Returns 514 | ------- 515 | str 516 | One of 'complete', 'incomplete' or 'invalid' 517 | """ 518 | try: 519 | res = self.jimain.parse().apply(code) 520 | output_class = res.getClass().getName() 521 | _, status = output_class.rsplit("$", 1) 522 | if status == 'Success': 523 | return 'complete' 524 | elif status == 'Incomplete': 525 | return 'incomplete' 526 | else: 527 | return 'invalid' 528 | finally: 529 | self.jbyteout.reset() 530 | 531 | def get_help_on(self, obj): 532 | """Gets the signature for the given object. 533 | 534 | Due to the JVM having no runtime docstring information, the level of 535 | detail is rather limited. 536 | 537 | Parameters 538 | ---------- 539 | obj : str 540 | Object to fetch info about 541 | 542 | Returns 543 | ------- 544 | str 545 | typeAt hint from Scala 546 | """ 547 | code = obj + '// typeAt {} {}'.format(0, len(obj)) 548 | scala_type = self.complete(code, len(code)) 549 | # When using the // typeAt hint we will get back a list made by 550 | # "" :: type :: Nil 551 | # according to https://github.com/scala/scala/blob/2.12.x/src/repl/scala/tools/nsc/interpreter/PresentationCompilerCompleter.scala#L52 552 | assert len(scala_type) == 2 553 | # TODO: Given that we have a type here we can interpret some java class reflection to see if we can get some 554 | # better results for the function in question 555 | return scala_type[-1] 556 | 557 | def get_scala_interpreter(): 558 | """Get the scala interpreter instance. 559 | 560 | If the instance has not yet been created, create it. 561 | 562 | Returns 563 | ------- 564 | scala_intp : ScalaInterpreter 565 | """ 566 | global scala_intp 567 | if scala_intp is None: 568 | scala_intp = initialize_scala_interpreter() 569 | 570 | return scala_intp 571 | -------------------------------------------------------------------------------- /spylon_kernel/scala_kernel.py: -------------------------------------------------------------------------------- 1 | """Jupyter Scala + Spark kernel built on Calysto/metakernel""" 2 | import sys 3 | 4 | from metakernel import MetaKernel 5 | from .init_spark_magic import InitSparkMagic 6 | from .scala_interpreter import ScalaException, ScalaInterpreter 7 | from .scala_magic import ScalaMagic 8 | from ._version import get_versions 9 | 10 | 11 | class SpylonKernel(MetaKernel): 12 | """Jupyter kernel that supports code evaluation using the Scala REPL 13 | via py4j. 14 | 15 | Currently uses a ScalaMagic instance as a bridge to a `ScalaInterpreter` 16 | to let that functionality remain separate for reuse outside the kernel. 17 | """ 18 | implementation = 'spylon-kernel' 19 | implementation_version = get_versions()['version'] 20 | language = 'scala' 21 | language_version = '2.11' 22 | banner = "spylon-kernel - evaluates Scala statements and expressions." 23 | language_info = { 24 | 'mimetype': 'text/x-scala', 25 | 'name': 'scala', 26 | 'codemirror_mode': "text/x-scala", 27 | 'pygments_lexer': 'scala', 28 | 'file_extension': '.scala', 29 | 'help_links': MetaKernel.help_links, 30 | 'version': implementation_version, 31 | } 32 | kernel_json = { 33 | "argv": [ 34 | sys.executable, "-m", "spylon_kernel", "-f", "{connection_file}"], 35 | "display_name": "spylon-kernel", 36 | "env": { 37 | "SPARK_SUBMIT_OPTS": "-Dscala.usejavacp=true", 38 | "PYTHONUNBUFFERED": "1", 39 | }, 40 | "language": "scala", 41 | "name": "spylon-kernel" 42 | } 43 | 44 | def __init__(self, *args, **kwargs): 45 | self._scalamagic = None 46 | super(SpylonKernel, self).__init__(*args, **kwargs) 47 | # Register the %%scala and %%init_spark magics 48 | # The %%scala one is here only because this classes uses it 49 | # to interact with the ScalaInterpreter instance 50 | self.register_magics(ScalaMagic) 51 | self.register_magics(InitSparkMagic) 52 | self._scalamagic = self.line_magics['scala'] 53 | 54 | @property 55 | def scala_interpreter(self): 56 | """Gets the `ScalaInterpreter` instance associated with the ScalaMagic 57 | for direct use. 58 | 59 | Returns 60 | ------- 61 | ScalaInterpreter 62 | """ 63 | # noinspection PyProtectedMember 64 | intp = self._scalamagic._get_scala_interpreter() 65 | assert isinstance(intp, ScalaInterpreter) 66 | return intp 67 | 68 | def get_usage(self): 69 | """Gets usage information about the kernel itself. 70 | 71 | Implements the expected MetaKernel interface for this method. 72 | """ 73 | return "This is spylon-kernel. It implements a Scala interpreter." 74 | 75 | def set_variable(self, name, value): 76 | """Sets a variable in the kernel language. 77 | 78 | Implements the expected MetaKernel interface for this method. 79 | 80 | Parameters 81 | ---------- 82 | name : str 83 | Variable name 84 | value : any 85 | Variable value to set 86 | 87 | Notes 88 | ----- 89 | Since metakernel calls this to bind kernel into the remote space we 90 | don't actually want that to happen. Simplest is just to have this 91 | flag as None initially. Furthermore the metakernel will attempt to 92 | set things like _i1, _i, _ii etc. These we dont want in the kernel 93 | for now. 94 | """ 95 | if self._scalamagic and (not name.startswith("_i")): 96 | self.scala_interpreter.bind(name, value) 97 | else: 98 | self.log.debug('Not setting variable %s', name) 99 | 100 | def get_variable(self, name): 101 | """Get a variable from the kernel as a Python-typed value. 102 | 103 | Implements the expected MetaKernel interface for this method. 104 | 105 | Parameters 106 | ---------- 107 | name : str 108 | Scala variable name 109 | 110 | Returns 111 | ------- 112 | value : any 113 | Scala variable value, tranformed to a Python type 114 | """ 115 | if self._scalamagic: 116 | intp = self.scala_interpreter 117 | intp.interpret(name) 118 | return intp.last_result() 119 | 120 | def do_execute_direct(self, code, silent=False): 121 | """Executes code in the kernel language. 122 | 123 | Implements the expected MetaKernel interface for this method, 124 | including all positional and keyword arguments. 125 | 126 | Parameters 127 | ---------- 128 | code : str 129 | Scala code to execute 130 | silent : bool, optional 131 | Silence output from this execution, ignored 132 | 133 | Returns 134 | ------- 135 | any 136 | Result of the execution to be formatted for inclusion in 137 | a `execute_result` or `error` message from the kernel to 138 | frontends 139 | """ 140 | try: 141 | res = self._scalamagic.eval(code.strip(), raw=False) 142 | if res: 143 | return res 144 | except ScalaException as e: 145 | return self.Error(e.scala_message) 146 | 147 | def get_completions(self, info): 148 | """Gets completions from the kernel based on the provided info. 149 | 150 | Implements the expected MetaKernel interface for this method. 151 | 152 | Parameters 153 | ---------- 154 | info : dict 155 | Information returned by `metakernel.parser.Parser.parse_code` 156 | including `code`, `help_pos`, `start`, etc. 157 | 158 | Returns 159 | ------- 160 | list of str 161 | Possible completions for the code 162 | """ 163 | return self._scalamagic.get_completions(info) 164 | 165 | def get_kernel_help_on(self, info, level=0, none_on_fail=False): 166 | """Gets help text for the `info['help_obj']` identifier. 167 | 168 | Implements the expected MetaKernel interface for this method, 169 | including all positional and keyword arguments. 170 | 171 | Parameters 172 | ---------- 173 | info : dict 174 | Information returned by `metakernel.parser.Parser.parse_code` 175 | including `help_obj`, etc. 176 | level : int, optional 177 | Level of help to request, 0 for basic, 1 for more, etc. 178 | none_on_fail : bool, optional 179 | Return none when code excution fails 180 | 181 | Returns 182 | ------- 183 | str 184 | Help text 185 | """ 186 | return self._scalamagic.get_help_on(info, level, none_on_fail) 187 | 188 | def do_is_complete(self, code): 189 | """Given code as string, returns a dictionary with 'status' representing 190 | whether code is ready to evaluate. Possible values for status are: 191 | 192 | 'complete' - ready to evaluate 193 | 'incomplete' - not yet ready 194 | 'invalid' - invalid code 195 | 'unknown' - unknown; the default unless overridden 196 | 197 | Optionally, if 'status' is 'incomplete', you may indicate 198 | an indentation string. 199 | 200 | Parameters 201 | ---------- 202 | code : str 203 | Scala code to check for completion 204 | 205 | Returns 206 | ------- 207 | dict 208 | Status of the completion 209 | 210 | Example 211 | ------- 212 | return {'status' : 'incomplete', 'indent': ' ' * 4} 213 | """ 214 | # Handle magics and the case where the interpreter is not yet 215 | # instantiated. We don't want to create it just to do completion 216 | # since it will take a while to initialize and appear hung to the user. 217 | if code.startswith(self.magic_prefixes['magic']) or not self._scalamagic._is_complete_ready: 218 | # force requirement to end with an empty line 219 | if code.endswith("\n"): 220 | return {'status': 'complete', 'indent': ''} 221 | else: 222 | return {'status': 'incomplete', 'indent': ''} 223 | status = self.scala_interpreter.is_complete(code) 224 | # TODO: We can probably do a better job of detecting a good indent 225 | # level here by making use of a code parser such as pygments 226 | return {'status': status, 'indent': ' ' * 4 if status == 'incomplete' else ''} 227 | -------------------------------------------------------------------------------- /spylon_kernel/scala_magic.py: -------------------------------------------------------------------------------- 1 | """Metakernel magic for evaluating cell code using a ScalaInterpreter.""" 2 | from __future__ import absolute_import, division, print_function 3 | 4 | import os 5 | from metakernel import ExceptionWrapper 6 | from metakernel import Magic 7 | from metakernel import MetaKernel 8 | from metakernel import option 9 | from metakernel.process_metakernel import TextOutput 10 | from tornado import ioloop, gen 11 | from textwrap import dedent 12 | 13 | from .scala_interpreter import get_scala_interpreter, scala_intp, ScalaException 14 | from . import scala_interpreter 15 | 16 | 17 | class ScalaMagic(Magic): 18 | """Line and cell magic that supports Scala code execution. 19 | 20 | Attributes 21 | ---------- 22 | _interp : spylon_kernel.ScalaInterpreter 23 | _is_complete_ready : bool 24 | Guard for whether certain actions can be taken based on whether the 25 | ScalaInterpreter is instantiated or not 26 | retval : any 27 | Last result from evaluating Scala code 28 | """ 29 | def __init__(self, kernel): 30 | super(ScalaMagic, self).__init__(kernel) 31 | self.retval = None 32 | self._interp = scala_intp 33 | self._is_complete_ready = False 34 | 35 | def _get_scala_interpreter(self): 36 | """Ensure that we have a scala interpreter around and set up the stdout/err 37 | handlers if needed. 38 | 39 | Returns 40 | ------- 41 | scala_intp : scala_interpreter.ScalaInterpreter 42 | """ 43 | if self._interp is None: 44 | assert isinstance(self.kernel, MetaKernel) 45 | self.kernel.Display(TextOutput("Intitializing Scala interpreter ...")) 46 | self._interp = get_scala_interpreter() 47 | 48 | # Ensure that spark is available in the python session as well. 49 | if 'python' in self.kernel.cell_magics: # tests if in scala mode 50 | self.kernel.cell_magics['python'].env['spark'] = self._interp.spark_session 51 | self.kernel.cell_magics['python'].env['sc'] = self._interp.sc 52 | 53 | # Display some information about the Spark session 54 | sc = self._interp.sc 55 | self.kernel.Display(TextOutput(dedent("""\ 56 | Spark Web UI available at {webui} 57 | SparkContext available as 'sc' (version = {version}, master = {master}, app id = {app_id}) 58 | SparkSession available as 'spark' 59 | """.format( 60 | version=sc.version, 61 | master=sc.master, 62 | app_id=sc.applicationId, 63 | webui=self._interp.web_ui_url 64 | ) 65 | ))) 66 | 67 | # Let down the guard: the interpreter is ready for use 68 | self._is_complete_ready = True 69 | 70 | # Send stdout to the MetaKernel.Write method 71 | # and stderr to MetaKernel.Error 72 | self._interp.register_stdout_handler(self.kernel.Write) 73 | self._interp.register_stderr_handler(self.kernel.Error) 74 | 75 | return self._interp 76 | 77 | def line_scala(self, *args): 78 | """%scala - evaluates a line of code as Scala 79 | 80 | Parameters 81 | ---------- 82 | *args : list of string 83 | Line magic arguments joined into a single-space separated string 84 | 85 | Examples 86 | -------- 87 | %scala val x = 42 88 | %scala import scala.math 89 | %scala x + math.pi 90 | """ 91 | code = " ".join(args) 92 | self.eval(code, True) 93 | 94 | # Use optparse to parse the whitespace delimited cell magic options 95 | # just as we would parse a command line. 96 | @option( 97 | "-e", "--eval_output", action="store_true", default=False, 98 | help="Evaluate the return value from the Scala code as Python code" 99 | ) 100 | def cell_scala(self, eval_output=False): 101 | """%%scala - evaluate contents of cell as Scala code 102 | 103 | This cell magic will evaluate the cell (either expression or statement) as 104 | Scala code. This will instantiate a Scala interpreter prior to running the code. 105 | 106 | The -e or --eval_output flag signals that the result of the Scala execution 107 | will be evaluated as Python code. The result of that evaluation will be the 108 | output for the cell. 109 | 110 | Examples 111 | -------- 112 | %%scala 113 | val x = 42 114 | 115 | %%scala 116 | import collections.mutable._ 117 | val y = mutable.Map.empty[Int, String] 118 | 119 | %%scala -e 120 | retval = "'(this is code in the kernel language)" 121 | 122 | %%python -e 123 | "'(this is code in the kernel language)" 124 | """ 125 | # Ensure there is code to execute, not just whitespace 126 | if self.code.strip(): 127 | if eval_output: 128 | # Evaluate the Scala code 129 | self.eval(self.code, False) 130 | # Don't store the Scala as the return value 131 | self.retval = None 132 | # Tell the base class to evaluate retval as Python 133 | # source code 134 | self.evaluate = True 135 | else: 136 | # Evaluate the Scala code 137 | self.retval = self.eval(self.code, False) 138 | # Tell the base class not to touch the Scala result 139 | self.evaluate = False 140 | 141 | def eval(self, code, raw): 142 | """Evaluates Scala code. 143 | 144 | Parameters 145 | ---------- 146 | code: str 147 | Code to execute 148 | raw: bool 149 | True to return the raw result of the evalution, False to wrap it with 150 | MetaKernel classes 151 | 152 | Returns 153 | ------- 154 | metakernel.process_metakernel.TextOutput or metakernel.ExceptionWrapper or 155 | the raw result of the evaluation 156 | """ 157 | intp = self._get_scala_interpreter() 158 | try: 159 | res = intp.interpret(code.strip()) 160 | if raw: 161 | self.res = intp.last_result() 162 | return self.res 163 | else: 164 | if res: 165 | return TextOutput(res) 166 | except ScalaException as ex: 167 | # Get the kernel response so far 168 | resp = self.kernel.kernel_resp 169 | # Wrap the exception for MetaKernel use 170 | resp['status'] = 'error' 171 | tb = ex.scala_message.split('\n') 172 | first = tb[0] 173 | assert isinstance(first, str) 174 | eclass, _, emessage = first.partition(':') 175 | return ExceptionWrapper(eclass, emessage, tb) 176 | 177 | def post_process(self, retval): 178 | """Processes the output of one or stacked magics. 179 | 180 | Parameters 181 | ---------- 182 | retval : any or None 183 | Value from another magic stacked with this one in a cell 184 | 185 | Returns 186 | ------- 187 | any 188 | The received value if it's not None, otherwise the stored 189 | `retval` of the last Scala code execution 190 | """ 191 | if retval is not None: 192 | return retval 193 | return self.retval 194 | 195 | def get_completions(self, info): 196 | """Gets completions from the kernel based on the provided info. 197 | 198 | Parameters 199 | ---------- 200 | info : dict 201 | Information returned by `metakernel.parser.Parser.parse_code` 202 | including `code`, `help_pos`, `start`, etc. 203 | 204 | Returns 205 | ------- 206 | list of str 207 | Possible completions for the code 208 | """ 209 | intp = self._get_scala_interpreter() 210 | completions = intp.complete(info['code'], info['help_pos']) 211 | 212 | # Find common bits in the middle 213 | def trim(prefix, completions): 214 | """Due to the nature of Scala's completer we get full method names. 215 | We need to trim out the common pieces. Try longest prefix first, etc. 216 | """ 217 | potential_prefix = os.path.commonprefix(completions) 218 | for i in reversed(range(len(potential_prefix)+1)): 219 | if prefix.endswith(potential_prefix[:i]): 220 | return i 221 | return 0 222 | 223 | prefix = info['code'][info['start']:info['help_pos']] 224 | offset = trim(prefix, completions) 225 | final_completions = [prefix + h[offset:] for h in completions] 226 | 227 | self.kernel.log.debug('''info %s\ncompletions %s\nfinal %s''', info, completions, final_completions) 228 | return final_completions 229 | 230 | def get_help_on(self, info, level=0, none_on_fail=False): 231 | """Gets help text for the `info['help_obj']` identifier. 232 | 233 | Parameters 234 | ---------- 235 | info : dict 236 | Information returned by `metakernel.parser.Parser.parse_code` 237 | including `help_obj`, etc. 238 | level : int, optional 239 | Level of help to request, 0 for basic, 1 for more, etc. 240 | By convention only. There is no true maximum. 241 | none_on_fail : bool, optional 242 | Return none when execution fails, ignored 243 | 244 | Returns 245 | ------- 246 | str 247 | Help text 248 | """ 249 | intp = self._get_scala_interpreter() 250 | self.kernel.log.debug(info['help_obj']) 251 | # Calling this twice produces different output 252 | code = intp.complete(info['help_obj'], len(info['help_obj'])) 253 | code = intp.complete(info['help_obj'], len(info['help_obj'])) 254 | self.kernel.log.debug(code) 255 | return '\n'.join(code) 256 | -------------------------------------------------------------------------------- /test/test_scala_interpreter.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import re 3 | from spylon_kernel.scala_interpreter import initialize_scala_interpreter, get_web_ui_url 4 | 5 | 6 | @pytest.fixture(scope="module") 7 | def scala_interpreter(request): 8 | wrapper = initialize_scala_interpreter() 9 | return wrapper 10 | 11 | 12 | def test_simple_expression(scala_interpreter): 13 | result = scala_interpreter.interpret("4 + 4") 14 | assert re.match('res\d+: Int = 8\n', result) 15 | 16 | 17 | def test_completion(scala_interpreter): 18 | scala_interpreter.interpret("val x = 4") 19 | code = "x.toL" 20 | result = scala_interpreter.complete(code, len(code)) 21 | assert result == ['toLong'] 22 | 23 | 24 | def test_is_complete(scala_interpreter): 25 | result = scala_interpreter.is_complete('val foo = 99') 26 | assert result == 'complete' 27 | 28 | result = scala_interpreter.is_complete('val foo = {99') 29 | assert result == 'incomplete' 30 | 31 | result = scala_interpreter.is_complete('val foo {99') 32 | assert result == 'invalid' 33 | 34 | 35 | def test_last_result(scala_interpreter): 36 | scala_interpreter.interpret(""" 37 | case class LastResult(member: Int) 38 | val foo = LastResult(8) 39 | """) 40 | jres = scala_interpreter.last_result() 41 | 42 | assert jres.getClass().getName().endswith("LastResult") 43 | assert jres.member() == 8 44 | 45 | 46 | def test_help(scala_interpreter): 47 | scala_interpreter.interpret("val x = 4") 48 | h = scala_interpreter.get_help_on("x") 49 | 50 | scala_interpreter.interpret("case class Foo(bar: String)") 51 | scala_interpreter.interpret('val y = Foo("something") ') 52 | 53 | h1 = scala_interpreter.get_help_on("y") 54 | h2 = scala_interpreter.get_help_on("y.bar") 55 | 56 | assert h == "Int" 57 | assert h1 == "Foo" 58 | assert h2 == "String" 59 | 60 | 61 | def test_spark_rdd(scala_interpreter): 62 | """Simple test to ensure we can do RDD things""" 63 | result = scala_interpreter.interpret("sc.parallelize(0 until 10).sum().toInt") 64 | assert result.strip().endswith(str(sum(range(10)))) 65 | 66 | 67 | def test_spark_dataset(scala_interpreter): 68 | scala_interpreter.interpret(""" 69 | case class DatasetTest(y: Int) 70 | import spark.implicits._ 71 | val df = spark.createDataset((0 until 10).map(DatasetTest(_))) 72 | import org.apache.spark.sql.functions.sum 73 | val res = df.agg(sum('y)).collect().head 74 | """) 75 | strres = scala_interpreter.interpret("res.getLong(0)") 76 | result = scala_interpreter.last_result() 77 | assert result == sum(range(10)) 78 | 79 | 80 | def test_web_ui_url(scala_interpreter): 81 | url = get_web_ui_url(scala_interpreter.sc) 82 | assert url != "" 83 | 84 | 85 | def test_anon_func(scala_interpreter): 86 | result = scala_interpreter.interpret("sc.parallelize(0 until 10).map(x => x * 2).sum().toInt") 87 | assert result.strip().endswith(str(sum(x * 2 for x in range(10)))) 88 | 89 | 90 | def test_case_classes(scala_interpreter): 91 | scala_interpreter.interpret('case class DatasetTest(y: Int)') 92 | scala_interpreter.interpret(''' 93 | val df = spark.createDataset((0 until 10).map(DatasetTest(_))) 94 | val res = df.agg(sum('y)).collect().head''') 95 | strres = scala_interpreter.interpret("res.getLong(0)") 96 | result = scala_interpreter.last_result() 97 | assert result == sum(range(10)) -------------------------------------------------------------------------------- /test/test_scala_kernel.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | 4 | from textwrap import dedent 5 | from unittest.mock import Mock 6 | 7 | import pytest 8 | 9 | from jupyter_client.session import Session 10 | from metakernel.process_metakernel import TextOutput 11 | from spylon_kernel import SpylonKernel 12 | 13 | 14 | class MockingSpylonKernel(SpylonKernel): 15 | """Mock class so that we capture the output of various calls for later inspection. 16 | """ 17 | 18 | def __init__(self, *args, **kwargs): 19 | super(MockingSpylonKernel, self).__init__(*args, **kwargs) 20 | self.Displays = [] 21 | self.Errors = [] 22 | self.Writes = [] 23 | self.session = Mock(Session) 24 | 25 | def Display(self, *args, **kwargs): 26 | self.Displays.append((args, kwargs)) 27 | 28 | def Error(self, *args, **kwargs): 29 | self.Errors.append((args, kwargs)) 30 | 31 | def Write(self, *args, **kwargs): 32 | self.Writes.append((args, kwargs)) 33 | 34 | 35 | @pytest.fixture(scope="module") 36 | def spylon_kernel(request): 37 | return MockingSpylonKernel() 38 | 39 | 40 | def test_simple_expression(spylon_kernel): 41 | assert isinstance(spylon_kernel, MockingSpylonKernel) 42 | result = spylon_kernel.do_execute_direct("4 + 4") 43 | assert isinstance(result, TextOutput) 44 | output = result.output 45 | assert re.match('res\d+: Int = 8\n', output) 46 | 47 | 48 | def test_exception(spylon_kernel): 49 | spylon_kernel.do_execute("4 / 0 ") 50 | assert spylon_kernel.kernel_resp['status'] == 'error' 51 | assert spylon_kernel.kernel_resp['ename'] == 'java.lang.ArithmeticException' 52 | assert spylon_kernel.kernel_resp['evalue'].strip() == '/ by zero' 53 | 54 | 55 | def test_completion(spylon_kernel): 56 | spylon_kernel.do_execute_direct("val x = 4") 57 | code = "x.toL" 58 | result = spylon_kernel.do_complete(code, len(code)) 59 | assert set(result['matches']) == {'x.toLong'} 60 | 61 | 62 | def test_iscomplete(spylon_kernel): 63 | result = spylon_kernel.do_is_complete('val foo = 99') 64 | assert result['status'] == 'complete' 65 | 66 | result = spylon_kernel.do_is_complete('val foo = {99') 67 | assert result['status'] == 'incomplete' 68 | 69 | result = spylon_kernel.do_is_complete('val foo {99') 70 | assert result['status'] == 'invalid' 71 | 72 | 73 | def test_last_result(spylon_kernel): 74 | spylon_kernel.do_execute_direct(""" 75 | case class LastResult(member: Int) 76 | val foo = LastResult(8) 77 | """) 78 | foo = spylon_kernel.get_variable("foo") 79 | assert foo 80 | 81 | 82 | def test_help(spylon_kernel): 83 | spylon_kernel.do_execute_direct("val x = 4") 84 | h = spylon_kernel.get_help_on("x") 85 | assert h.strip() == 'val x: Int' 86 | 87 | 88 | def test_init_magic(spylon_kernel): 89 | code = dedent("""\ 90 | %%init_spark 91 | launcher.conf.spark.app.name = 'test-app-name' 92 | launcher.conf.spark.executor.cores = 2 93 | """) 94 | spylon_kernel.do_execute(code) 95 | 96 | 97 | def test_init_magic_completion(spylon_kernel): 98 | code = dedent("""\ 99 | %%init_spark 100 | launcher.conf.spark.executor.cor""") 101 | result = spylon_kernel.do_complete(code, len(code)) 102 | assert set(result['matches']) == {'launcher.conf.spark.executor.cores'} 103 | 104 | 105 | @pytest.mark.skip('fails randomly, possibly because of mock reuse across tests') 106 | def test_stdout(spylon_kernel): 107 | spylon_kernel.do_execute_direct(''' 108 | Console.println("test_stdout") 109 | // Sleep for a bit since the process for getting text output is asynchronous 110 | Thread.sleep(1000)''') 111 | writes, _ = spylon_kernel.Writes.pop() 112 | assert writes[0].strip() == 'test_stdout' -------------------------------------------------------------------------------- /test_spylon_kernel_jkt.py: -------------------------------------------------------------------------------- 1 | """Example use of jupyter_kernel_test, with tests for IPython.""" 2 | 3 | import unittest 4 | 5 | import jupyter_kernel_test 6 | 7 | from spylon_kernel.scala_interpreter import init_spark 8 | from textwrap import dedent 9 | 10 | 11 | class SpylonKernelTests(jupyter_kernel_test.KernelTests): 12 | kernel_name = "spylon-kernel" 13 | language_name = "scala" 14 | # code_hello_world = "disp('hello, world')" 15 | completion_samples = [ 16 | {'text': 'val x = 8; x.toL', 17 | 'matches': {'x.toLong'}}, 18 | ] 19 | code_page_something = "x?" 20 | 21 | code_hello_world = ''' 22 | println("hello, world") 23 | // Sleep for a bit since the process for getting text output is asynchronous 24 | Thread.sleep(1000) 25 | ''' 26 | 27 | code_stderr = ''' 28 | Console.err.println("oh noes!") 29 | // Sleep for a bit since the process for getting text output is asynchronous 30 | Thread.sleep(1000) 31 | ''' 32 | 33 | complete_code_samples = ['val y = 8'] 34 | incomplete_code_samples = ['{ val foo = 9 '] 35 | invalid_code_samples = ['val {}'] 36 | 37 | code_generate_error = "4 / 0" 38 | 39 | code_execute_result = [{ 40 | 'code': 'val x = 1', 41 | 'result': 'x: Int = 1\n' 42 | }, { 43 | 'code': 'val y = 1 to 3', 44 | 'result': 'y: scala.collection.immutable.Range.Inclusive = Range(1, 2, 3)\n' 45 | }] 46 | 47 | spark_configured = False 48 | 49 | def setUp(self): 50 | """Set up to capture stderr for testing purposes.""" 51 | super(SpylonKernelTests, self).setUp() 52 | self.flush_channels() 53 | if not self.spark_configured: 54 | self.execute_helper(code='%%init_spark --stderr') 55 | self.spark_configured = True 56 | 57 | 58 | if __name__ == '__main__': 59 | unittest.main() -------------------------------------------------------------------------------- /versioneer.py: -------------------------------------------------------------------------------- 1 | 2 | # Version: 0.17 3 | 4 | """The Versioneer - like a rocketeer, but for versions. 5 | 6 | The Versioneer 7 | ============== 8 | 9 | * like a rocketeer, but for versions! 10 | * https://github.com/warner/python-versioneer 11 | * Brian Warner 12 | * License: Public Domain 13 | * Compatible With: python2.6, 2.7, 3.2, 3.3, 3.4, 3.5, and pypy 14 | * [![Latest Version] 15 | (https://pypip.in/version/versioneer/badge.svg?style=flat) 16 | ](https://pypi.python.org/pypi/versioneer/) 17 | * [![Build Status] 18 | (https://travis-ci.org/warner/python-versioneer.png?branch=master) 19 | ](https://travis-ci.org/warner/python-versioneer) 20 | 21 | This is a tool for managing a recorded version number in distutils-based 22 | python projects. The goal is to remove the tedious and error-prone "update 23 | the embedded version string" step from your release process. Making a new 24 | release should be as easy as recording a new tag in your version-control 25 | system, and maybe making new tarballs. 26 | 27 | 28 | ## Quick Install 29 | 30 | * `pip install versioneer` to somewhere to your $PATH 31 | * add a `[versioneer]` section to your setup.cfg (see below) 32 | * run `versioneer install` in your source tree, commit the results 33 | 34 | ## Version Identifiers 35 | 36 | Source trees come from a variety of places: 37 | 38 | * a version-control system checkout (mostly used by developers) 39 | * a nightly tarball, produced by build automation 40 | * a snapshot tarball, produced by a web-based VCS browser, like github's 41 | "tarball from tag" feature 42 | * a release tarball, produced by "setup.py sdist", distributed through PyPI 43 | 44 | Within each source tree, the version identifier (either a string or a number, 45 | this tool is format-agnostic) can come from a variety of places: 46 | 47 | * ask the VCS tool itself, e.g. "git describe" (for checkouts), which knows 48 | about recent "tags" and an absolute revision-id 49 | * the name of the directory into which the tarball was unpacked 50 | * an expanded VCS keyword ($Id$, etc) 51 | * a `_version.py` created by some earlier build step 52 | 53 | For released software, the version identifier is closely related to a VCS 54 | tag. Some projects use tag names that include more than just the version 55 | string (e.g. "myproject-1.2" instead of just "1.2"), in which case the tool 56 | needs to strip the tag prefix to extract the version identifier. For 57 | unreleased software (between tags), the version identifier should provide 58 | enough information to help developers recreate the same tree, while also 59 | giving them an idea of roughly how old the tree is (after version 1.2, before 60 | version 1.3). Many VCS systems can report a description that captures this, 61 | for example `git describe --tags --dirty --always` reports things like 62 | "0.7-1-g574ab98-dirty" to indicate that the checkout is one revision past the 63 | 0.7 tag, has a unique revision id of "574ab98", and is "dirty" (it has 64 | uncommitted changes. 65 | 66 | The version identifier is used for multiple purposes: 67 | 68 | * to allow the module to self-identify its version: `myproject.__version__` 69 | * to choose a name and prefix for a 'setup.py sdist' tarball 70 | 71 | ## Theory of Operation 72 | 73 | Versioneer works by adding a special `_version.py` file into your source 74 | tree, where your `__init__.py` can import it. This `_version.py` knows how to 75 | dynamically ask the VCS tool for version information at import time. 76 | 77 | `_version.py` also contains `$Revision$` markers, and the installation 78 | process marks `_version.py` to have this marker rewritten with a tag name 79 | during the `git archive` command. As a result, generated tarballs will 80 | contain enough information to get the proper version. 81 | 82 | To allow `setup.py` to compute a version too, a `versioneer.py` is added to 83 | the top level of your source tree, next to `setup.py` and the `setup.cfg` 84 | that configures it. This overrides several distutils/setuptools commands to 85 | compute the version when invoked, and changes `setup.py build` and `setup.py 86 | sdist` to replace `_version.py` with a small static file that contains just 87 | the generated version data. 88 | 89 | ## Installation 90 | 91 | See [INSTALL.md](./INSTALL.md) for detailed installation instructions. 92 | 93 | ## Version-String Flavors 94 | 95 | Code which uses Versioneer can learn about its version string at runtime by 96 | importing `_version` from your main `__init__.py` file and running the 97 | `get_versions()` function. From the "outside" (e.g. in `setup.py`), you can 98 | import the top-level `versioneer.py` and run `get_versions()`. 99 | 100 | Both functions return a dictionary with different flavors of version 101 | information: 102 | 103 | * `['version']`: A condensed version string, rendered using the selected 104 | style. This is the most commonly used value for the project's version 105 | string. The default "pep440" style yields strings like `0.11`, 106 | `0.11+2.g1076c97`, or `0.11+2.g1076c97.dirty`. See the "Styles" section 107 | below for alternative styles. 108 | 109 | * `['full-revisionid']`: detailed revision identifier. For Git, this is the 110 | full SHA1 commit id, e.g. "1076c978a8d3cfc70f408fe5974aa6c092c949ac". 111 | 112 | * `['date']`: Date and time of the latest `HEAD` commit. For Git, it is the 113 | commit date in ISO 8601 format. This will be None if the date is not 114 | available. 115 | 116 | * `['dirty']`: a boolean, True if the tree has uncommitted changes. Note that 117 | this is only accurate if run in a VCS checkout, otherwise it is likely to 118 | be False or None 119 | 120 | * `['error']`: if the version string could not be computed, this will be set 121 | to a string describing the problem, otherwise it will be None. It may be 122 | useful to throw an exception in setup.py if this is set, to avoid e.g. 123 | creating tarballs with a version string of "unknown". 124 | 125 | Some variants are more useful than others. Including `full-revisionid` in a 126 | bug report should allow developers to reconstruct the exact code being tested 127 | (or indicate the presence of local changes that should be shared with the 128 | developers). `version` is suitable for display in an "about" box or a CLI 129 | `--version` output: it can be easily compared against release notes and lists 130 | of bugs fixed in various releases. 131 | 132 | The installer adds the following text to your `__init__.py` to place a basic 133 | version in `YOURPROJECT.__version__`: 134 | 135 | from ._version import get_versions 136 | __version__ = get_versions()['version'] 137 | del get_versions 138 | 139 | ## Styles 140 | 141 | The setup.cfg `style=` configuration controls how the VCS information is 142 | rendered into a version string. 143 | 144 | The default style, "pep440", produces a PEP440-compliant string, equal to the 145 | un-prefixed tag name for actual releases, and containing an additional "local 146 | version" section with more detail for in-between builds. For Git, this is 147 | TAG[+DISTANCE.gHEX[.dirty]] , using information from `git describe --tags 148 | --dirty --always`. For example "0.11+2.g1076c97.dirty" indicates that the 149 | tree is like the "1076c97" commit but has uncommitted changes (".dirty"), and 150 | that this commit is two revisions ("+2") beyond the "0.11" tag. For released 151 | software (exactly equal to a known tag), the identifier will only contain the 152 | stripped tag, e.g. "0.11". 153 | 154 | Other styles are available. See details.md in the Versioneer source tree for 155 | descriptions. 156 | 157 | ## Debugging 158 | 159 | Versioneer tries to avoid fatal errors: if something goes wrong, it will tend 160 | to return a version of "0+unknown". To investigate the problem, run `setup.py 161 | version`, which will run the version-lookup code in a verbose mode, and will 162 | display the full contents of `get_versions()` (including the `error` string, 163 | which may help identify what went wrong). 164 | 165 | ## Known Limitations 166 | 167 | Some situations are known to cause problems for Versioneer. This details the 168 | most significant ones. More can be found on Github 169 | [issues page](https://github.com/warner/python-versioneer/issues). 170 | 171 | ### Subprojects 172 | 173 | Versioneer has limited support for source trees in which `setup.py` is not in 174 | the root directory (e.g. `setup.py` and `.git/` are *not* siblings). The are 175 | two common reasons why `setup.py` might not be in the root: 176 | 177 | * Source trees which contain multiple subprojects, such as 178 | [Buildbot](https://github.com/buildbot/buildbot), which contains both 179 | "master" and "slave" subprojects, each with their own `setup.py`, 180 | `setup.cfg`, and `tox.ini`. Projects like these produce multiple PyPI 181 | distributions (and upload multiple independently-installable tarballs). 182 | * Source trees whose main purpose is to contain a C library, but which also 183 | provide bindings to Python (and perhaps other langauges) in subdirectories. 184 | 185 | Versioneer will look for `.git` in parent directories, and most operations 186 | should get the right version string. However `pip` and `setuptools` have bugs 187 | and implementation details which frequently cause `pip install .` from a 188 | subproject directory to fail to find a correct version string (so it usually 189 | defaults to `0+unknown`). 190 | 191 | `pip install --editable .` should work correctly. `setup.py install` might 192 | work too. 193 | 194 | Pip-8.1.1 is known to have this problem, but hopefully it will get fixed in 195 | some later version. 196 | 197 | [Bug #38](https://github.com/warner/python-versioneer/issues/38) is tracking 198 | this issue. The discussion in 199 | [PR #61](https://github.com/warner/python-versioneer/pull/61) describes the 200 | issue from the Versioneer side in more detail. 201 | [pip PR#3176](https://github.com/pypa/pip/pull/3176) and 202 | [pip PR#3615](https://github.com/pypa/pip/pull/3615) contain work to improve 203 | pip to let Versioneer work correctly. 204 | 205 | Versioneer-0.16 and earlier only looked for a `.git` directory next to the 206 | `setup.cfg`, so subprojects were completely unsupported with those releases. 207 | 208 | ### Editable installs with setuptools <= 18.5 209 | 210 | `setup.py develop` and `pip install --editable .` allow you to install a 211 | project into a virtualenv once, then continue editing the source code (and 212 | test) without re-installing after every change. 213 | 214 | "Entry-point scripts" (`setup(entry_points={"console_scripts": ..})`) are a 215 | convenient way to specify executable scripts that should be installed along 216 | with the python package. 217 | 218 | These both work as expected when using modern setuptools. When using 219 | setuptools-18.5 or earlier, however, certain operations will cause 220 | `pkg_resources.DistributionNotFound` errors when running the entrypoint 221 | script, which must be resolved by re-installing the package. This happens 222 | when the install happens with one version, then the egg_info data is 223 | regenerated while a different version is checked out. Many setup.py commands 224 | cause egg_info to be rebuilt (including `sdist`, `wheel`, and installing into 225 | a different virtualenv), so this can be surprising. 226 | 227 | [Bug #83](https://github.com/warner/python-versioneer/issues/83) describes 228 | this one, but upgrading to a newer version of setuptools should probably 229 | resolve it. 230 | 231 | ### Unicode version strings 232 | 233 | While Versioneer works (and is continually tested) with both Python 2 and 234 | Python 3, it is not entirely consistent with bytes-vs-unicode distinctions. 235 | Newer releases probably generate unicode version strings on py2. It's not 236 | clear that this is wrong, but it may be surprising for applications when then 237 | write these strings to a network connection or include them in bytes-oriented 238 | APIs like cryptographic checksums. 239 | 240 | [Bug #71](https://github.com/warner/python-versioneer/issues/71) investigates 241 | this question. 242 | 243 | 244 | ## Updating Versioneer 245 | 246 | To upgrade your project to a new release of Versioneer, do the following: 247 | 248 | * install the new Versioneer (`pip install -U versioneer` or equivalent) 249 | * edit `setup.cfg`, if necessary, to include any new configuration settings 250 | indicated by the release notes. See [UPGRADING](./UPGRADING.md) for details. 251 | * re-run `versioneer install` in your source tree, to replace 252 | `SRC/_version.py` 253 | * commit any changed files 254 | 255 | ## Future Directions 256 | 257 | This tool is designed to make it easily extended to other version-control 258 | systems: all VCS-specific components are in separate directories like 259 | src/git/ . The top-level `versioneer.py` script is assembled from these 260 | components by running make-versioneer.py . In the future, make-versioneer.py 261 | will take a VCS name as an argument, and will construct a version of 262 | `versioneer.py` that is specific to the given VCS. It might also take the 263 | configuration arguments that are currently provided manually during 264 | installation by editing setup.py . Alternatively, it might go the other 265 | direction and include code from all supported VCS systems, reducing the 266 | number of intermediate scripts. 267 | 268 | 269 | ## License 270 | 271 | To make Versioneer easier to embed, all its code is dedicated to the public 272 | domain. The `_version.py` that it creates is also in the public domain. 273 | Specifically, both are released under the Creative Commons "Public Domain 274 | Dedication" license (CC0-1.0), as described in 275 | https://creativecommons.org/publicdomain/zero/1.0/ . 276 | 277 | """ 278 | 279 | from __future__ import print_function 280 | try: 281 | import configparser 282 | except ImportError: 283 | import ConfigParser as configparser 284 | import errno 285 | import json 286 | import os 287 | import re 288 | import subprocess 289 | import sys 290 | 291 | 292 | class VersioneerConfig: 293 | """Container for Versioneer configuration parameters.""" 294 | 295 | 296 | def get_root(): 297 | """Get the project root directory. 298 | 299 | We require that all commands are run from the project root, i.e. the 300 | directory that contains setup.py, setup.cfg, and versioneer.py . 301 | """ 302 | root = os.path.realpath(os.path.abspath(os.getcwd())) 303 | setup_py = os.path.join(root, "setup.py") 304 | versioneer_py = os.path.join(root, "versioneer.py") 305 | if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)): 306 | # allow 'python path/to/setup.py COMMAND' 307 | root = os.path.dirname(os.path.realpath(os.path.abspath(sys.argv[0]))) 308 | setup_py = os.path.join(root, "setup.py") 309 | versioneer_py = os.path.join(root, "versioneer.py") 310 | if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)): 311 | err = ("Versioneer was unable to run the project root directory. " 312 | "Versioneer requires setup.py to be executed from " 313 | "its immediate directory (like 'python setup.py COMMAND'), " 314 | "or in a way that lets it use sys.argv[0] to find the root " 315 | "(like 'python path/to/setup.py COMMAND').") 316 | raise VersioneerBadRootError(err) 317 | try: 318 | # Certain runtime workflows (setup.py install/develop in a setuptools 319 | # tree) execute all dependencies in a single python process, so 320 | # "versioneer" may be imported multiple times, and python's shared 321 | # module-import table will cache the first one. So we can't use 322 | # os.path.dirname(__file__), as that will find whichever 323 | # versioneer.py was first imported, even in later projects. 324 | me = os.path.realpath(os.path.abspath(__file__)) 325 | me_dir = os.path.normcase(os.path.splitext(me)[0]) 326 | vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0]) 327 | if me_dir != vsr_dir: 328 | print("Warning: build in %s is using versioneer.py from %s" 329 | % (os.path.dirname(me), versioneer_py)) 330 | except NameError: 331 | pass 332 | return root 333 | 334 | 335 | def get_config_from_root(root): 336 | """Read the project setup.cfg file to determine Versioneer config.""" 337 | # This might raise EnvironmentError (if setup.cfg is missing), or 338 | # configparser.NoSectionError (if it lacks a [versioneer] section), or 339 | # configparser.NoOptionError (if it lacks "VCS="). See the docstring at 340 | # the top of versioneer.py for instructions on writing your setup.cfg . 341 | setup_cfg = os.path.join(root, "setup.cfg") 342 | parser = configparser.SafeConfigParser() 343 | with open(setup_cfg, "r") as f: 344 | parser.readfp(f) 345 | VCS = parser.get("versioneer", "VCS") # mandatory 346 | 347 | def get(parser, name): 348 | if parser.has_option("versioneer", name): 349 | return parser.get("versioneer", name) 350 | return None 351 | cfg = VersioneerConfig() 352 | cfg.VCS = VCS 353 | cfg.style = get(parser, "style") or "" 354 | cfg.versionfile_source = get(parser, "versionfile_source") 355 | cfg.versionfile_build = get(parser, "versionfile_build") 356 | cfg.tag_prefix = get(parser, "tag_prefix") 357 | if cfg.tag_prefix in ("''", '""'): 358 | cfg.tag_prefix = "" 359 | cfg.parentdir_prefix = get(parser, "parentdir_prefix") 360 | cfg.verbose = get(parser, "verbose") 361 | return cfg 362 | 363 | 364 | class NotThisMethod(Exception): 365 | """Exception raised if a method is not valid for the current scenario.""" 366 | 367 | # these dictionaries contain VCS-specific tools 368 | LONG_VERSION_PY = {} 369 | HANDLERS = {} 370 | 371 | 372 | def register_vcs_handler(vcs, method): # decorator 373 | """Decorator to mark a method as the handler for a particular VCS.""" 374 | def decorate(f): 375 | """Store f in HANDLERS[vcs][method].""" 376 | if vcs not in HANDLERS: 377 | HANDLERS[vcs] = {} 378 | HANDLERS[vcs][method] = f 379 | return f 380 | return decorate 381 | 382 | 383 | def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, 384 | env=None): 385 | """Call the given command(s).""" 386 | assert isinstance(commands, list) 387 | p = None 388 | for c in commands: 389 | try: 390 | dispcmd = str([c] + args) 391 | # remember shell=False, so use git.cmd on windows, not just git 392 | p = subprocess.Popen([c] + args, cwd=cwd, env=env, 393 | stdout=subprocess.PIPE, 394 | stderr=(subprocess.PIPE if hide_stderr 395 | else None)) 396 | break 397 | except EnvironmentError: 398 | e = sys.exc_info()[1] 399 | if e.errno == errno.ENOENT: 400 | continue 401 | if verbose: 402 | print("unable to run %s" % dispcmd) 403 | print(e) 404 | return None, None 405 | else: 406 | if verbose: 407 | print("unable to find command, tried %s" % (commands,)) 408 | return None, None 409 | stdout = p.communicate()[0].strip() 410 | if sys.version_info[0] >= 3: 411 | stdout = stdout.decode() 412 | if p.returncode != 0: 413 | if verbose: 414 | print("unable to run %s (error)" % dispcmd) 415 | print("stdout was %s" % stdout) 416 | return None, p.returncode 417 | return stdout, p.returncode 418 | LONG_VERSION_PY['git'] = ''' 419 | # This file helps to compute a version number in source trees obtained from 420 | # git-archive tarball (such as those provided by githubs download-from-tag 421 | # feature). Distribution tarballs (built by setup.py sdist) and build 422 | # directories (produced by setup.py build) will contain a much shorter file 423 | # that just contains the computed version number. 424 | 425 | # This file is released into the public domain. Generated by 426 | # versioneer-0.17 (https://github.com/warner/python-versioneer) 427 | 428 | """Git implementation of _version.py.""" 429 | 430 | import errno 431 | import os 432 | import re 433 | import subprocess 434 | import sys 435 | 436 | 437 | def get_keywords(): 438 | """Get the keywords needed to look up the version information.""" 439 | # these strings will be replaced by git during git-archive. 440 | # setup.py/versioneer.py will grep for the variable names, so they must 441 | # each be defined on a line of their own. _version.py will just call 442 | # get_keywords(). 443 | git_refnames = "%(DOLLAR)sFormat:%%d%(DOLLAR)s" 444 | git_full = "%(DOLLAR)sFormat:%%H%(DOLLAR)s" 445 | git_date = "%(DOLLAR)sFormat:%%ci%(DOLLAR)s" 446 | keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} 447 | return keywords 448 | 449 | 450 | class VersioneerConfig: 451 | """Container for Versioneer configuration parameters.""" 452 | 453 | 454 | def get_config(): 455 | """Create, populate and return the VersioneerConfig() object.""" 456 | # these strings are filled in when 'setup.py versioneer' creates 457 | # _version.py 458 | cfg = VersioneerConfig() 459 | cfg.VCS = "git" 460 | cfg.style = "%(STYLE)s" 461 | cfg.tag_prefix = "%(TAG_PREFIX)s" 462 | cfg.parentdir_prefix = "%(PARENTDIR_PREFIX)s" 463 | cfg.versionfile_source = "%(VERSIONFILE_SOURCE)s" 464 | cfg.verbose = False 465 | return cfg 466 | 467 | 468 | class NotThisMethod(Exception): 469 | """Exception raised if a method is not valid for the current scenario.""" 470 | 471 | 472 | LONG_VERSION_PY = {} 473 | HANDLERS = {} 474 | 475 | 476 | def register_vcs_handler(vcs, method): # decorator 477 | """Decorator to mark a method as the handler for a particular VCS.""" 478 | def decorate(f): 479 | """Store f in HANDLERS[vcs][method].""" 480 | if vcs not in HANDLERS: 481 | HANDLERS[vcs] = {} 482 | HANDLERS[vcs][method] = f 483 | return f 484 | return decorate 485 | 486 | 487 | def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, 488 | env=None): 489 | """Call the given command(s).""" 490 | assert isinstance(commands, list) 491 | p = None 492 | for c in commands: 493 | try: 494 | dispcmd = str([c] + args) 495 | # remember shell=False, so use git.cmd on windows, not just git 496 | p = subprocess.Popen([c] + args, cwd=cwd, env=env, 497 | stdout=subprocess.PIPE, 498 | stderr=(subprocess.PIPE if hide_stderr 499 | else None)) 500 | break 501 | except EnvironmentError: 502 | e = sys.exc_info()[1] 503 | if e.errno == errno.ENOENT: 504 | continue 505 | if verbose: 506 | print("unable to run %%s" %% dispcmd) 507 | print(e) 508 | return None, None 509 | else: 510 | if verbose: 511 | print("unable to find command, tried %%s" %% (commands,)) 512 | return None, None 513 | stdout = p.communicate()[0].strip() 514 | if sys.version_info[0] >= 3: 515 | stdout = stdout.decode() 516 | if p.returncode != 0: 517 | if verbose: 518 | print("unable to run %%s (error)" %% dispcmd) 519 | print("stdout was %%s" %% stdout) 520 | return None, p.returncode 521 | return stdout, p.returncode 522 | 523 | 524 | def versions_from_parentdir(parentdir_prefix, root, verbose): 525 | """Try to determine the version from the parent directory name. 526 | 527 | Source tarballs conventionally unpack into a directory that includes both 528 | the project name and a version string. We will also support searching up 529 | two directory levels for an appropriately named parent directory 530 | """ 531 | rootdirs = [] 532 | 533 | for i in range(3): 534 | dirname = os.path.basename(root) 535 | if dirname.startswith(parentdir_prefix): 536 | return {"version": dirname[len(parentdir_prefix):], 537 | "full-revisionid": None, 538 | "dirty": False, "error": None, "date": None} 539 | else: 540 | rootdirs.append(root) 541 | root = os.path.dirname(root) # up a level 542 | 543 | if verbose: 544 | print("Tried directories %%s but none started with prefix %%s" %% 545 | (str(rootdirs), parentdir_prefix)) 546 | raise NotThisMethod("rootdir doesn't start with parentdir_prefix") 547 | 548 | 549 | @register_vcs_handler("git", "get_keywords") 550 | def git_get_keywords(versionfile_abs): 551 | """Extract version information from the given file.""" 552 | # the code embedded in _version.py can just fetch the value of these 553 | # keywords. When used from setup.py, we don't want to import _version.py, 554 | # so we do it with a regexp instead. This function is not used from 555 | # _version.py. 556 | keywords = {} 557 | try: 558 | f = open(versionfile_abs, "r") 559 | for line in f.readlines(): 560 | if line.strip().startswith("git_refnames ="): 561 | mo = re.search(r'=\s*"(.*)"', line) 562 | if mo: 563 | keywords["refnames"] = mo.group(1) 564 | if line.strip().startswith("git_full ="): 565 | mo = re.search(r'=\s*"(.*)"', line) 566 | if mo: 567 | keywords["full"] = mo.group(1) 568 | if line.strip().startswith("git_date ="): 569 | mo = re.search(r'=\s*"(.*)"', line) 570 | if mo: 571 | keywords["date"] = mo.group(1) 572 | f.close() 573 | except EnvironmentError: 574 | pass 575 | return keywords 576 | 577 | 578 | @register_vcs_handler("git", "keywords") 579 | def git_versions_from_keywords(keywords, tag_prefix, verbose): 580 | """Get version information from git keywords.""" 581 | if not keywords: 582 | raise NotThisMethod("no keywords at all, weird") 583 | date = keywords.get("date") 584 | if date is not None: 585 | # git-2.2.0 added "%%cI", which expands to an ISO-8601 -compliant 586 | # datestamp. However we prefer "%%ci" (which expands to an "ISO-8601 587 | # -like" string, which we must then edit to make compliant), because 588 | # it's been around since git-1.5.3, and it's too difficult to 589 | # discover which version we're using, or to work around using an 590 | # older one. 591 | date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 592 | refnames = keywords["refnames"].strip() 593 | if refnames.startswith("$Format"): 594 | if verbose: 595 | print("keywords are unexpanded, not using") 596 | raise NotThisMethod("unexpanded keywords, not a git-archive tarball") 597 | refs = set([r.strip() for r in refnames.strip("()").split(",")]) 598 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 599 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 600 | TAG = "tag: " 601 | tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) 602 | if not tags: 603 | # Either we're using git < 1.8.3, or there really are no tags. We use 604 | # a heuristic: assume all version tags have a digit. The old git %%d 605 | # expansion behaves like git log --decorate=short and strips out the 606 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 607 | # between branches and tags. By ignoring refnames without digits, we 608 | # filter out many common branch names like "release" and 609 | # "stabilization", as well as "HEAD" and "master". 610 | tags = set([r for r in refs if re.search(r'\d', r)]) 611 | if verbose: 612 | print("discarding '%%s', no digits" %% ",".join(refs - tags)) 613 | if verbose: 614 | print("likely tags: %%s" %% ",".join(sorted(tags))) 615 | for ref in sorted(tags): 616 | # sorting will prefer e.g. "2.0" over "2.0rc1" 617 | if ref.startswith(tag_prefix): 618 | r = ref[len(tag_prefix):] 619 | if verbose: 620 | print("picking %%s" %% r) 621 | return {"version": r, 622 | "full-revisionid": keywords["full"].strip(), 623 | "dirty": False, "error": None, 624 | "date": date} 625 | # no suitable tags, so version is "0+unknown", but full hex is still there 626 | if verbose: 627 | print("no suitable tags, using unknown + full revision id") 628 | return {"version": "0+unknown", 629 | "full-revisionid": keywords["full"].strip(), 630 | "dirty": False, "error": "no suitable tags", "date": None} 631 | 632 | 633 | @register_vcs_handler("git", "pieces_from_vcs") 634 | def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): 635 | """Get version from 'git describe' in the root of the source tree. 636 | 637 | This only gets called if the git-archive 'subst' keywords were *not* 638 | expanded, and _version.py hasn't already been rewritten with a short 639 | version string, meaning we're inside a checked out source tree. 640 | """ 641 | GITS = ["git"] 642 | if sys.platform == "win32": 643 | GITS = ["git.cmd", "git.exe"] 644 | 645 | out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, 646 | hide_stderr=True) 647 | if rc != 0: 648 | if verbose: 649 | print("Directory %%s not under git control" %% root) 650 | raise NotThisMethod("'git rev-parse --git-dir' returned error") 651 | 652 | # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] 653 | # if there isn't one, this yields HEX[-dirty] (no NUM) 654 | describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", 655 | "--always", "--long", 656 | "--match", "%%s*" %% tag_prefix], 657 | cwd=root) 658 | # --long was added in git-1.5.5 659 | if describe_out is None: 660 | raise NotThisMethod("'git describe' failed") 661 | describe_out = describe_out.strip() 662 | full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) 663 | if full_out is None: 664 | raise NotThisMethod("'git rev-parse' failed") 665 | full_out = full_out.strip() 666 | 667 | pieces = {} 668 | pieces["long"] = full_out 669 | pieces["short"] = full_out[:7] # maybe improved later 670 | pieces["error"] = None 671 | 672 | # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] 673 | # TAG might have hyphens. 674 | git_describe = describe_out 675 | 676 | # look for -dirty suffix 677 | dirty = git_describe.endswith("-dirty") 678 | pieces["dirty"] = dirty 679 | if dirty: 680 | git_describe = git_describe[:git_describe.rindex("-dirty")] 681 | 682 | # now we have TAG-NUM-gHEX or HEX 683 | 684 | if "-" in git_describe: 685 | # TAG-NUM-gHEX 686 | mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) 687 | if not mo: 688 | # unparseable. Maybe git-describe is misbehaving? 689 | pieces["error"] = ("unable to parse git-describe output: '%%s'" 690 | %% describe_out) 691 | return pieces 692 | 693 | # tag 694 | full_tag = mo.group(1) 695 | if not full_tag.startswith(tag_prefix): 696 | if verbose: 697 | fmt = "tag '%%s' doesn't start with prefix '%%s'" 698 | print(fmt %% (full_tag, tag_prefix)) 699 | pieces["error"] = ("tag '%%s' doesn't start with prefix '%%s'" 700 | %% (full_tag, tag_prefix)) 701 | return pieces 702 | pieces["closest-tag"] = full_tag[len(tag_prefix):] 703 | 704 | # distance: number of commits since tag 705 | pieces["distance"] = int(mo.group(2)) 706 | 707 | # commit: short hex revision ID 708 | pieces["short"] = mo.group(3) 709 | 710 | else: 711 | # HEX: no tags 712 | pieces["closest-tag"] = None 713 | count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], 714 | cwd=root) 715 | pieces["distance"] = int(count_out) # total number of commits 716 | 717 | # commit date: see ISO-8601 comment in git_versions_from_keywords() 718 | date = run_command(GITS, ["show", "-s", "--format=%%ci", "HEAD"], 719 | cwd=root)[0].strip() 720 | pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 721 | 722 | return pieces 723 | 724 | 725 | def plus_or_dot(pieces): 726 | """Return a + if we don't already have one, else return a .""" 727 | if "+" in pieces.get("closest-tag", ""): 728 | return "." 729 | return "+" 730 | 731 | 732 | def render_pep440(pieces): 733 | """Build up version string, with post-release "local version identifier". 734 | 735 | Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you 736 | get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty 737 | 738 | Exceptions: 739 | 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] 740 | """ 741 | if pieces["closest-tag"]: 742 | rendered = pieces["closest-tag"] 743 | if pieces["distance"] or pieces["dirty"]: 744 | rendered += plus_or_dot(pieces) 745 | rendered += "%%d.g%%s" %% (pieces["distance"], pieces["short"]) 746 | if pieces["dirty"]: 747 | rendered += ".dirty" 748 | else: 749 | # exception #1 750 | rendered = "0+untagged.%%d.g%%s" %% (pieces["distance"], 751 | pieces["short"]) 752 | if pieces["dirty"]: 753 | rendered += ".dirty" 754 | return rendered 755 | 756 | 757 | def render_pep440_pre(pieces): 758 | """TAG[.post.devDISTANCE] -- No -dirty. 759 | 760 | Exceptions: 761 | 1: no tags. 0.post.devDISTANCE 762 | """ 763 | if pieces["closest-tag"]: 764 | rendered = pieces["closest-tag"] 765 | if pieces["distance"]: 766 | rendered += ".post.dev%%d" %% pieces["distance"] 767 | else: 768 | # exception #1 769 | rendered = "0.post.dev%%d" %% pieces["distance"] 770 | return rendered 771 | 772 | 773 | def render_pep440_post(pieces): 774 | """TAG[.postDISTANCE[.dev0]+gHEX] . 775 | 776 | The ".dev0" means dirty. Note that .dev0 sorts backwards 777 | (a dirty tree will appear "older" than the corresponding clean one), 778 | but you shouldn't be releasing software with -dirty anyways. 779 | 780 | Exceptions: 781 | 1: no tags. 0.postDISTANCE[.dev0] 782 | """ 783 | if pieces["closest-tag"]: 784 | rendered = pieces["closest-tag"] 785 | if pieces["distance"] or pieces["dirty"]: 786 | rendered += ".post%%d" %% pieces["distance"] 787 | if pieces["dirty"]: 788 | rendered += ".dev0" 789 | rendered += plus_or_dot(pieces) 790 | rendered += "g%%s" %% pieces["short"] 791 | else: 792 | # exception #1 793 | rendered = "0.post%%d" %% pieces["distance"] 794 | if pieces["dirty"]: 795 | rendered += ".dev0" 796 | rendered += "+g%%s" %% pieces["short"] 797 | return rendered 798 | 799 | 800 | def render_pep440_old(pieces): 801 | """TAG[.postDISTANCE[.dev0]] . 802 | 803 | The ".dev0" means dirty. 804 | 805 | Eexceptions: 806 | 1: no tags. 0.postDISTANCE[.dev0] 807 | """ 808 | if pieces["closest-tag"]: 809 | rendered = pieces["closest-tag"] 810 | if pieces["distance"] or pieces["dirty"]: 811 | rendered += ".post%%d" %% pieces["distance"] 812 | if pieces["dirty"]: 813 | rendered += ".dev0" 814 | else: 815 | # exception #1 816 | rendered = "0.post%%d" %% pieces["distance"] 817 | if pieces["dirty"]: 818 | rendered += ".dev0" 819 | return rendered 820 | 821 | 822 | def render_git_describe(pieces): 823 | """TAG[-DISTANCE-gHEX][-dirty]. 824 | 825 | Like 'git describe --tags --dirty --always'. 826 | 827 | Exceptions: 828 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 829 | """ 830 | if pieces["closest-tag"]: 831 | rendered = pieces["closest-tag"] 832 | if pieces["distance"]: 833 | rendered += "-%%d-g%%s" %% (pieces["distance"], pieces["short"]) 834 | else: 835 | # exception #1 836 | rendered = pieces["short"] 837 | if pieces["dirty"]: 838 | rendered += "-dirty" 839 | return rendered 840 | 841 | 842 | def render_git_describe_long(pieces): 843 | """TAG-DISTANCE-gHEX[-dirty]. 844 | 845 | Like 'git describe --tags --dirty --always -long'. 846 | The distance/hash is unconditional. 847 | 848 | Exceptions: 849 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 850 | """ 851 | if pieces["closest-tag"]: 852 | rendered = pieces["closest-tag"] 853 | rendered += "-%%d-g%%s" %% (pieces["distance"], pieces["short"]) 854 | else: 855 | # exception #1 856 | rendered = pieces["short"] 857 | if pieces["dirty"]: 858 | rendered += "-dirty" 859 | return rendered 860 | 861 | 862 | def render(pieces, style): 863 | """Render the given version pieces into the requested style.""" 864 | if pieces["error"]: 865 | return {"version": "unknown", 866 | "full-revisionid": pieces.get("long"), 867 | "dirty": None, 868 | "error": pieces["error"], 869 | "date": None} 870 | 871 | if not style or style == "default": 872 | style = "pep440" # the default 873 | 874 | if style == "pep440": 875 | rendered = render_pep440(pieces) 876 | elif style == "pep440-pre": 877 | rendered = render_pep440_pre(pieces) 878 | elif style == "pep440-post": 879 | rendered = render_pep440_post(pieces) 880 | elif style == "pep440-old": 881 | rendered = render_pep440_old(pieces) 882 | elif style == "git-describe": 883 | rendered = render_git_describe(pieces) 884 | elif style == "git-describe-long": 885 | rendered = render_git_describe_long(pieces) 886 | else: 887 | raise ValueError("unknown style '%%s'" %% style) 888 | 889 | return {"version": rendered, "full-revisionid": pieces["long"], 890 | "dirty": pieces["dirty"], "error": None, 891 | "date": pieces.get("date")} 892 | 893 | 894 | def get_versions(): 895 | """Get version information or return default if unable to do so.""" 896 | # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have 897 | # __file__, we can work backwards from there to the root. Some 898 | # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which 899 | # case we can only use expanded keywords. 900 | 901 | cfg = get_config() 902 | verbose = cfg.verbose 903 | 904 | try: 905 | return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, 906 | verbose) 907 | except NotThisMethod: 908 | pass 909 | 910 | try: 911 | root = os.path.realpath(__file__) 912 | # versionfile_source is the relative path from the top of the source 913 | # tree (where the .git directory might live) to this file. Invert 914 | # this to find the root from __file__. 915 | for i in cfg.versionfile_source.split('/'): 916 | root = os.path.dirname(root) 917 | except NameError: 918 | return {"version": "0+unknown", "full-revisionid": None, 919 | "dirty": None, 920 | "error": "unable to find root of source tree", 921 | "date": None} 922 | 923 | try: 924 | pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) 925 | return render(pieces, cfg.style) 926 | except NotThisMethod: 927 | pass 928 | 929 | try: 930 | if cfg.parentdir_prefix: 931 | return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) 932 | except NotThisMethod: 933 | pass 934 | 935 | return {"version": "0+unknown", "full-revisionid": None, 936 | "dirty": None, 937 | "error": "unable to compute version", "date": None} 938 | ''' 939 | 940 | 941 | @register_vcs_handler("git", "get_keywords") 942 | def git_get_keywords(versionfile_abs): 943 | """Extract version information from the given file.""" 944 | # the code embedded in _version.py can just fetch the value of these 945 | # keywords. When used from setup.py, we don't want to import _version.py, 946 | # so we do it with a regexp instead. This function is not used from 947 | # _version.py. 948 | keywords = {} 949 | try: 950 | f = open(versionfile_abs, "r") 951 | for line in f.readlines(): 952 | if line.strip().startswith("git_refnames ="): 953 | mo = re.search(r'=\s*"(.*)"', line) 954 | if mo: 955 | keywords["refnames"] = mo.group(1) 956 | if line.strip().startswith("git_full ="): 957 | mo = re.search(r'=\s*"(.*)"', line) 958 | if mo: 959 | keywords["full"] = mo.group(1) 960 | if line.strip().startswith("git_date ="): 961 | mo = re.search(r'=\s*"(.*)"', line) 962 | if mo: 963 | keywords["date"] = mo.group(1) 964 | f.close() 965 | except EnvironmentError: 966 | pass 967 | return keywords 968 | 969 | 970 | @register_vcs_handler("git", "keywords") 971 | def git_versions_from_keywords(keywords, tag_prefix, verbose): 972 | """Get version information from git keywords.""" 973 | if not keywords: 974 | raise NotThisMethod("no keywords at all, weird") 975 | date = keywords.get("date") 976 | if date is not None: 977 | # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant 978 | # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 979 | # -like" string, which we must then edit to make compliant), because 980 | # it's been around since git-1.5.3, and it's too difficult to 981 | # discover which version we're using, or to work around using an 982 | # older one. 983 | date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 984 | refnames = keywords["refnames"].strip() 985 | if refnames.startswith("$Format"): 986 | if verbose: 987 | print("keywords are unexpanded, not using") 988 | raise NotThisMethod("unexpanded keywords, not a git-archive tarball") 989 | refs = set([r.strip() for r in refnames.strip("()").split(",")]) 990 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 991 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 992 | TAG = "tag: " 993 | tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) 994 | if not tags: 995 | # Either we're using git < 1.8.3, or there really are no tags. We use 996 | # a heuristic: assume all version tags have a digit. The old git %d 997 | # expansion behaves like git log --decorate=short and strips out the 998 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 999 | # between branches and tags. By ignoring refnames without digits, we 1000 | # filter out many common branch names like "release" and 1001 | # "stabilization", as well as "HEAD" and "master". 1002 | tags = set([r for r in refs if re.search(r'\d', r)]) 1003 | if verbose: 1004 | print("discarding '%s', no digits" % ",".join(refs - tags)) 1005 | if verbose: 1006 | print("likely tags: %s" % ",".join(sorted(tags))) 1007 | for ref in sorted(tags): 1008 | # sorting will prefer e.g. "2.0" over "2.0rc1" 1009 | if ref.startswith(tag_prefix): 1010 | r = ref[len(tag_prefix):] 1011 | if verbose: 1012 | print("picking %s" % r) 1013 | return {"version": r, 1014 | "full-revisionid": keywords["full"].strip(), 1015 | "dirty": False, "error": None, 1016 | "date": date} 1017 | # no suitable tags, so version is "0+unknown", but full hex is still there 1018 | if verbose: 1019 | print("no suitable tags, using unknown + full revision id") 1020 | return {"version": "0+unknown", 1021 | "full-revisionid": keywords["full"].strip(), 1022 | "dirty": False, "error": "no suitable tags", "date": None} 1023 | 1024 | 1025 | @register_vcs_handler("git", "pieces_from_vcs") 1026 | def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): 1027 | """Get version from 'git describe' in the root of the source tree. 1028 | 1029 | This only gets called if the git-archive 'subst' keywords were *not* 1030 | expanded, and _version.py hasn't already been rewritten with a short 1031 | version string, meaning we're inside a checked out source tree. 1032 | """ 1033 | GITS = ["git"] 1034 | if sys.platform == "win32": 1035 | GITS = ["git.cmd", "git.exe"] 1036 | 1037 | out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, 1038 | hide_stderr=True) 1039 | if rc != 0: 1040 | if verbose: 1041 | print("Directory %s not under git control" % root) 1042 | raise NotThisMethod("'git rev-parse --git-dir' returned error") 1043 | 1044 | # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] 1045 | # if there isn't one, this yields HEX[-dirty] (no NUM) 1046 | describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", 1047 | "--always", "--long", 1048 | "--match", "%s*" % tag_prefix], 1049 | cwd=root) 1050 | # --long was added in git-1.5.5 1051 | if describe_out is None: 1052 | raise NotThisMethod("'git describe' failed") 1053 | describe_out = describe_out.strip() 1054 | full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) 1055 | if full_out is None: 1056 | raise NotThisMethod("'git rev-parse' failed") 1057 | full_out = full_out.strip() 1058 | 1059 | pieces = {} 1060 | pieces["long"] = full_out 1061 | pieces["short"] = full_out[:7] # maybe improved later 1062 | pieces["error"] = None 1063 | 1064 | # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] 1065 | # TAG might have hyphens. 1066 | git_describe = describe_out 1067 | 1068 | # look for -dirty suffix 1069 | dirty = git_describe.endswith("-dirty") 1070 | pieces["dirty"] = dirty 1071 | if dirty: 1072 | git_describe = git_describe[:git_describe.rindex("-dirty")] 1073 | 1074 | # now we have TAG-NUM-gHEX or HEX 1075 | 1076 | if "-" in git_describe: 1077 | # TAG-NUM-gHEX 1078 | mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) 1079 | if not mo: 1080 | # unparseable. Maybe git-describe is misbehaving? 1081 | pieces["error"] = ("unable to parse git-describe output: '%s'" 1082 | % describe_out) 1083 | return pieces 1084 | 1085 | # tag 1086 | full_tag = mo.group(1) 1087 | if not full_tag.startswith(tag_prefix): 1088 | if verbose: 1089 | fmt = "tag '%s' doesn't start with prefix '%s'" 1090 | print(fmt % (full_tag, tag_prefix)) 1091 | pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" 1092 | % (full_tag, tag_prefix)) 1093 | return pieces 1094 | pieces["closest-tag"] = full_tag[len(tag_prefix):] 1095 | 1096 | # distance: number of commits since tag 1097 | pieces["distance"] = int(mo.group(2)) 1098 | 1099 | # commit: short hex revision ID 1100 | pieces["short"] = mo.group(3) 1101 | 1102 | else: 1103 | # HEX: no tags 1104 | pieces["closest-tag"] = None 1105 | count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], 1106 | cwd=root) 1107 | pieces["distance"] = int(count_out) # total number of commits 1108 | 1109 | # commit date: see ISO-8601 comment in git_versions_from_keywords() 1110 | date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], 1111 | cwd=root)[0].strip() 1112 | pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 1113 | 1114 | return pieces 1115 | 1116 | 1117 | def do_vcs_install(manifest_in, versionfile_source, ipy): 1118 | """Git-specific installation logic for Versioneer. 1119 | 1120 | For Git, this means creating/changing .gitattributes to mark _version.py 1121 | for export-subst keyword substitution. 1122 | """ 1123 | GITS = ["git"] 1124 | if sys.platform == "win32": 1125 | GITS = ["git.cmd", "git.exe"] 1126 | files = [manifest_in, versionfile_source] 1127 | if ipy: 1128 | files.append(ipy) 1129 | try: 1130 | me = __file__ 1131 | if me.endswith(".pyc") or me.endswith(".pyo"): 1132 | me = os.path.splitext(me)[0] + ".py" 1133 | versioneer_file = os.path.relpath(me) 1134 | except NameError: 1135 | versioneer_file = "versioneer.py" 1136 | files.append(versioneer_file) 1137 | present = False 1138 | try: 1139 | f = open(".gitattributes", "r") 1140 | for line in f.readlines(): 1141 | if line.strip().startswith(versionfile_source): 1142 | if "export-subst" in line.strip().split()[1:]: 1143 | present = True 1144 | f.close() 1145 | except EnvironmentError: 1146 | pass 1147 | if not present: 1148 | f = open(".gitattributes", "a+") 1149 | f.write("%s export-subst\n" % versionfile_source) 1150 | f.close() 1151 | files.append(".gitattributes") 1152 | run_command(GITS, ["add", "--"] + files) 1153 | 1154 | 1155 | def versions_from_parentdir(parentdir_prefix, root, verbose): 1156 | """Try to determine the version from the parent directory name. 1157 | 1158 | Source tarballs conventionally unpack into a directory that includes both 1159 | the project name and a version string. We will also support searching up 1160 | two directory levels for an appropriately named parent directory 1161 | """ 1162 | rootdirs = [] 1163 | 1164 | for i in range(3): 1165 | dirname = os.path.basename(root) 1166 | if dirname.startswith(parentdir_prefix): 1167 | return {"version": dirname[len(parentdir_prefix):], 1168 | "full-revisionid": None, 1169 | "dirty": False, "error": None, "date": None} 1170 | else: 1171 | rootdirs.append(root) 1172 | root = os.path.dirname(root) # up a level 1173 | 1174 | if verbose: 1175 | print("Tried directories %s but none started with prefix %s" % 1176 | (str(rootdirs), parentdir_prefix)) 1177 | raise NotThisMethod("rootdir doesn't start with parentdir_prefix") 1178 | 1179 | SHORT_VERSION_PY = """ 1180 | # This file was generated by 'versioneer.py' (0.17) from 1181 | # revision-control system data, or from the parent directory name of an 1182 | # unpacked source archive. Distribution tarballs contain a pre-generated copy 1183 | # of this file. 1184 | 1185 | import json 1186 | 1187 | version_json = ''' 1188 | %s 1189 | ''' # END VERSION_JSON 1190 | 1191 | 1192 | def get_versions(): 1193 | return json.loads(version_json) 1194 | """ 1195 | 1196 | 1197 | def versions_from_file(filename): 1198 | """Try to determine the version from _version.py if present.""" 1199 | try: 1200 | with open(filename) as f: 1201 | contents = f.read() 1202 | except EnvironmentError: 1203 | raise NotThisMethod("unable to read _version.py") 1204 | mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON", 1205 | contents, re.M | re.S) 1206 | if not mo: 1207 | mo = re.search(r"version_json = '''\r\n(.*)''' # END VERSION_JSON", 1208 | contents, re.M | re.S) 1209 | if not mo: 1210 | raise NotThisMethod("no version_json in _version.py") 1211 | return json.loads(mo.group(1)) 1212 | 1213 | 1214 | def write_to_version_file(filename, versions): 1215 | """Write the given version number to the given _version.py file.""" 1216 | os.unlink(filename) 1217 | contents = json.dumps(versions, sort_keys=True, 1218 | indent=1, separators=(",", ": ")) 1219 | with open(filename, "w") as f: 1220 | f.write(SHORT_VERSION_PY % contents) 1221 | 1222 | print("set %s to '%s'" % (filename, versions["version"])) 1223 | 1224 | 1225 | def plus_or_dot(pieces): 1226 | """Return a + if we don't already have one, else return a .""" 1227 | if "+" in pieces.get("closest-tag", ""): 1228 | return "." 1229 | return "+" 1230 | 1231 | 1232 | def render_pep440(pieces): 1233 | """Build up version string, with post-release "local version identifier". 1234 | 1235 | Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you 1236 | get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty 1237 | 1238 | Exceptions: 1239 | 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] 1240 | """ 1241 | if pieces["closest-tag"]: 1242 | rendered = pieces["closest-tag"] 1243 | if pieces["distance"] or pieces["dirty"]: 1244 | rendered += plus_or_dot(pieces) 1245 | rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) 1246 | if pieces["dirty"]: 1247 | rendered += ".dirty" 1248 | else: 1249 | # exception #1 1250 | rendered = "0+untagged.%d.g%s" % (pieces["distance"], 1251 | pieces["short"]) 1252 | if pieces["dirty"]: 1253 | rendered += ".dirty" 1254 | return rendered 1255 | 1256 | 1257 | def render_pep440_pre(pieces): 1258 | """TAG[.post.devDISTANCE] -- No -dirty. 1259 | 1260 | Exceptions: 1261 | 1: no tags. 0.post.devDISTANCE 1262 | """ 1263 | if pieces["closest-tag"]: 1264 | rendered = pieces["closest-tag"] 1265 | if pieces["distance"]: 1266 | rendered += ".post.dev%d" % pieces["distance"] 1267 | else: 1268 | # exception #1 1269 | rendered = "0.post.dev%d" % pieces["distance"] 1270 | return rendered 1271 | 1272 | 1273 | def render_pep440_post(pieces): 1274 | """TAG[.postDISTANCE[.dev0]+gHEX] . 1275 | 1276 | The ".dev0" means dirty. Note that .dev0 sorts backwards 1277 | (a dirty tree will appear "older" than the corresponding clean one), 1278 | but you shouldn't be releasing software with -dirty anyways. 1279 | 1280 | Exceptions: 1281 | 1: no tags. 0.postDISTANCE[.dev0] 1282 | """ 1283 | if pieces["closest-tag"]: 1284 | rendered = pieces["closest-tag"] 1285 | if pieces["distance"] or pieces["dirty"]: 1286 | rendered += ".post%d" % pieces["distance"] 1287 | if pieces["dirty"]: 1288 | rendered += ".dev0" 1289 | rendered += plus_or_dot(pieces) 1290 | rendered += "g%s" % pieces["short"] 1291 | else: 1292 | # exception #1 1293 | rendered = "0.post%d" % pieces["distance"] 1294 | if pieces["dirty"]: 1295 | rendered += ".dev0" 1296 | rendered += "+g%s" % pieces["short"] 1297 | return rendered 1298 | 1299 | 1300 | def render_pep440_old(pieces): 1301 | """TAG[.postDISTANCE[.dev0]] . 1302 | 1303 | The ".dev0" means dirty. 1304 | 1305 | Eexceptions: 1306 | 1: no tags. 0.postDISTANCE[.dev0] 1307 | """ 1308 | if pieces["closest-tag"]: 1309 | rendered = pieces["closest-tag"] 1310 | if pieces["distance"] or pieces["dirty"]: 1311 | rendered += ".post%d" % pieces["distance"] 1312 | if pieces["dirty"]: 1313 | rendered += ".dev0" 1314 | else: 1315 | # exception #1 1316 | rendered = "0.post%d" % pieces["distance"] 1317 | if pieces["dirty"]: 1318 | rendered += ".dev0" 1319 | return rendered 1320 | 1321 | 1322 | def render_git_describe(pieces): 1323 | """TAG[-DISTANCE-gHEX][-dirty]. 1324 | 1325 | Like 'git describe --tags --dirty --always'. 1326 | 1327 | Exceptions: 1328 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 1329 | """ 1330 | if pieces["closest-tag"]: 1331 | rendered = pieces["closest-tag"] 1332 | if pieces["distance"]: 1333 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 1334 | else: 1335 | # exception #1 1336 | rendered = pieces["short"] 1337 | if pieces["dirty"]: 1338 | rendered += "-dirty" 1339 | return rendered 1340 | 1341 | 1342 | def render_git_describe_long(pieces): 1343 | """TAG-DISTANCE-gHEX[-dirty]. 1344 | 1345 | Like 'git describe --tags --dirty --always -long'. 1346 | The distance/hash is unconditional. 1347 | 1348 | Exceptions: 1349 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 1350 | """ 1351 | if pieces["closest-tag"]: 1352 | rendered = pieces["closest-tag"] 1353 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 1354 | else: 1355 | # exception #1 1356 | rendered = pieces["short"] 1357 | if pieces["dirty"]: 1358 | rendered += "-dirty" 1359 | return rendered 1360 | 1361 | 1362 | def render(pieces, style): 1363 | """Render the given version pieces into the requested style.""" 1364 | if pieces["error"]: 1365 | return {"version": "unknown", 1366 | "full-revisionid": pieces.get("long"), 1367 | "dirty": None, 1368 | "error": pieces["error"], 1369 | "date": None} 1370 | 1371 | if not style or style == "default": 1372 | style = "pep440" # the default 1373 | 1374 | if style == "pep440": 1375 | rendered = render_pep440(pieces) 1376 | elif style == "pep440-pre": 1377 | rendered = render_pep440_pre(pieces) 1378 | elif style == "pep440-post": 1379 | rendered = render_pep440_post(pieces) 1380 | elif style == "pep440-old": 1381 | rendered = render_pep440_old(pieces) 1382 | elif style == "git-describe": 1383 | rendered = render_git_describe(pieces) 1384 | elif style == "git-describe-long": 1385 | rendered = render_git_describe_long(pieces) 1386 | else: 1387 | raise ValueError("unknown style '%s'" % style) 1388 | 1389 | return {"version": rendered, "full-revisionid": pieces["long"], 1390 | "dirty": pieces["dirty"], "error": None, 1391 | "date": pieces.get("date")} 1392 | 1393 | 1394 | class VersioneerBadRootError(Exception): 1395 | """The project root directory is unknown or missing key files.""" 1396 | 1397 | 1398 | def get_versions(verbose=False): 1399 | """Get the project version from whatever source is available. 1400 | 1401 | Returns dict with two keys: 'version' and 'full'. 1402 | """ 1403 | if "versioneer" in sys.modules: 1404 | # see the discussion in cmdclass.py:get_cmdclass() 1405 | del sys.modules["versioneer"] 1406 | 1407 | root = get_root() 1408 | cfg = get_config_from_root(root) 1409 | 1410 | assert cfg.VCS is not None, "please set [versioneer]VCS= in setup.cfg" 1411 | handlers = HANDLERS.get(cfg.VCS) 1412 | assert handlers, "unrecognized VCS '%s'" % cfg.VCS 1413 | verbose = verbose or cfg.verbose 1414 | assert cfg.versionfile_source is not None, \ 1415 | "please set versioneer.versionfile_source" 1416 | assert cfg.tag_prefix is not None, "please set versioneer.tag_prefix" 1417 | 1418 | versionfile_abs = os.path.join(root, cfg.versionfile_source) 1419 | 1420 | # extract version from first of: _version.py, VCS command (e.g. 'git 1421 | # describe'), parentdir. This is meant to work for developers using a 1422 | # source checkout, for users of a tarball created by 'setup.py sdist', 1423 | # and for users of a tarball/zipball created by 'git archive' or github's 1424 | # download-from-tag feature or the equivalent in other VCSes. 1425 | 1426 | get_keywords_f = handlers.get("get_keywords") 1427 | from_keywords_f = handlers.get("keywords") 1428 | if get_keywords_f and from_keywords_f: 1429 | try: 1430 | keywords = get_keywords_f(versionfile_abs) 1431 | ver = from_keywords_f(keywords, cfg.tag_prefix, verbose) 1432 | if verbose: 1433 | print("got version from expanded keyword %s" % ver) 1434 | return ver 1435 | except NotThisMethod: 1436 | pass 1437 | 1438 | try: 1439 | ver = versions_from_file(versionfile_abs) 1440 | if verbose: 1441 | print("got version from file %s %s" % (versionfile_abs, ver)) 1442 | return ver 1443 | except NotThisMethod: 1444 | pass 1445 | 1446 | from_vcs_f = handlers.get("pieces_from_vcs") 1447 | if from_vcs_f: 1448 | try: 1449 | pieces = from_vcs_f(cfg.tag_prefix, root, verbose) 1450 | ver = render(pieces, cfg.style) 1451 | if verbose: 1452 | print("got version from VCS %s" % ver) 1453 | return ver 1454 | except NotThisMethod: 1455 | pass 1456 | 1457 | try: 1458 | if cfg.parentdir_prefix: 1459 | ver = versions_from_parentdir(cfg.parentdir_prefix, root, verbose) 1460 | if verbose: 1461 | print("got version from parentdir %s" % ver) 1462 | return ver 1463 | except NotThisMethod: 1464 | pass 1465 | 1466 | if verbose: 1467 | print("unable to compute version") 1468 | 1469 | return {"version": "0+unknown", "full-revisionid": None, 1470 | "dirty": None, "error": "unable to compute version", 1471 | "date": None} 1472 | 1473 | 1474 | def get_version(): 1475 | """Get the short version string for this project.""" 1476 | return get_versions()["version"] 1477 | 1478 | 1479 | def get_cmdclass(): 1480 | """Get the custom setuptools/distutils subclasses used by Versioneer.""" 1481 | if "versioneer" in sys.modules: 1482 | del sys.modules["versioneer"] 1483 | # this fixes the "python setup.py develop" case (also 'install' and 1484 | # 'easy_install .'), in which subdependencies of the main project are 1485 | # built (using setup.py bdist_egg) in the same python process. Assume 1486 | # a main project A and a dependency B, which use different versions 1487 | # of Versioneer. A's setup.py imports A's Versioneer, leaving it in 1488 | # sys.modules by the time B's setup.py is executed, causing B to run 1489 | # with the wrong versioneer. Setuptools wraps the sub-dep builds in a 1490 | # sandbox that restores sys.modules to it's pre-build state, so the 1491 | # parent is protected against the child's "import versioneer". By 1492 | # removing ourselves from sys.modules here, before the child build 1493 | # happens, we protect the child from the parent's versioneer too. 1494 | # Also see https://github.com/warner/python-versioneer/issues/52 1495 | 1496 | cmds = {} 1497 | 1498 | # we add "version" to both distutils and setuptools 1499 | from distutils.core import Command 1500 | 1501 | class cmd_version(Command): 1502 | description = "report generated version string" 1503 | user_options = [] 1504 | boolean_options = [] 1505 | 1506 | def initialize_options(self): 1507 | pass 1508 | 1509 | def finalize_options(self): 1510 | pass 1511 | 1512 | def run(self): 1513 | vers = get_versions(verbose=True) 1514 | print("Version: %s" % vers["version"]) 1515 | print(" full-revisionid: %s" % vers.get("full-revisionid")) 1516 | print(" dirty: %s" % vers.get("dirty")) 1517 | print(" date: %s" % vers.get("date")) 1518 | if vers["error"]: 1519 | print(" error: %s" % vers["error"]) 1520 | cmds["version"] = cmd_version 1521 | 1522 | # we override "build_py" in both distutils and setuptools 1523 | # 1524 | # most invocation pathways end up running build_py: 1525 | # distutils/build -> build_py 1526 | # distutils/install -> distutils/build ->.. 1527 | # setuptools/bdist_wheel -> distutils/install ->.. 1528 | # setuptools/bdist_egg -> distutils/install_lib -> build_py 1529 | # setuptools/install -> bdist_egg ->.. 1530 | # setuptools/develop -> ? 1531 | # pip install: 1532 | # copies source tree to a tempdir before running egg_info/etc 1533 | # if .git isn't copied too, 'git describe' will fail 1534 | # then does setup.py bdist_wheel, or sometimes setup.py install 1535 | # setup.py egg_info -> ? 1536 | 1537 | # we override different "build_py" commands for both environments 1538 | if "setuptools" in sys.modules: 1539 | from setuptools.command.build_py import build_py as _build_py 1540 | else: 1541 | from distutils.command.build_py import build_py as _build_py 1542 | 1543 | class cmd_build_py(_build_py): 1544 | def run(self): 1545 | root = get_root() 1546 | cfg = get_config_from_root(root) 1547 | versions = get_versions() 1548 | _build_py.run(self) 1549 | # now locate _version.py in the new build/ directory and replace 1550 | # it with an updated value 1551 | if cfg.versionfile_build: 1552 | target_versionfile = os.path.join(self.build_lib, 1553 | cfg.versionfile_build) 1554 | print("UPDATING %s" % target_versionfile) 1555 | write_to_version_file(target_versionfile, versions) 1556 | cmds["build_py"] = cmd_build_py 1557 | 1558 | if "cx_Freeze" in sys.modules: # cx_freeze enabled? 1559 | from cx_Freeze.dist import build_exe as _build_exe 1560 | # nczeczulin reports that py2exe won't like the pep440-style string 1561 | # as FILEVERSION, but it can be used for PRODUCTVERSION, e.g. 1562 | # setup(console=[{ 1563 | # "version": versioneer.get_version().split("+", 1)[0], # FILEVERSION 1564 | # "product_version": versioneer.get_version(), 1565 | # ... 1566 | 1567 | class cmd_build_exe(_build_exe): 1568 | def run(self): 1569 | root = get_root() 1570 | cfg = get_config_from_root(root) 1571 | versions = get_versions() 1572 | target_versionfile = cfg.versionfile_source 1573 | print("UPDATING %s" % target_versionfile) 1574 | write_to_version_file(target_versionfile, versions) 1575 | 1576 | _build_exe.run(self) 1577 | os.unlink(target_versionfile) 1578 | with open(cfg.versionfile_source, "w") as f: 1579 | LONG = LONG_VERSION_PY[cfg.VCS] 1580 | f.write(LONG % 1581 | {"DOLLAR": "$", 1582 | "STYLE": cfg.style, 1583 | "TAG_PREFIX": cfg.tag_prefix, 1584 | "PARENTDIR_PREFIX": cfg.parentdir_prefix, 1585 | "VERSIONFILE_SOURCE": cfg.versionfile_source, 1586 | }) 1587 | cmds["build_exe"] = cmd_build_exe 1588 | del cmds["build_py"] 1589 | 1590 | if 'py2exe' in sys.modules: # py2exe enabled? 1591 | try: 1592 | from py2exe.distutils_buildexe import py2exe as _py2exe # py3 1593 | except ImportError: 1594 | from py2exe.build_exe import py2exe as _py2exe # py2 1595 | 1596 | class cmd_py2exe(_py2exe): 1597 | def run(self): 1598 | root = get_root() 1599 | cfg = get_config_from_root(root) 1600 | versions = get_versions() 1601 | target_versionfile = cfg.versionfile_source 1602 | print("UPDATING %s" % target_versionfile) 1603 | write_to_version_file(target_versionfile, versions) 1604 | 1605 | _py2exe.run(self) 1606 | os.unlink(target_versionfile) 1607 | with open(cfg.versionfile_source, "w") as f: 1608 | LONG = LONG_VERSION_PY[cfg.VCS] 1609 | f.write(LONG % 1610 | {"DOLLAR": "$", 1611 | "STYLE": cfg.style, 1612 | "TAG_PREFIX": cfg.tag_prefix, 1613 | "PARENTDIR_PREFIX": cfg.parentdir_prefix, 1614 | "VERSIONFILE_SOURCE": cfg.versionfile_source, 1615 | }) 1616 | cmds["py2exe"] = cmd_py2exe 1617 | 1618 | # we override different "sdist" commands for both environments 1619 | if "setuptools" in sys.modules: 1620 | from setuptools.command.sdist import sdist as _sdist 1621 | else: 1622 | from distutils.command.sdist import sdist as _sdist 1623 | 1624 | class cmd_sdist(_sdist): 1625 | def run(self): 1626 | versions = get_versions() 1627 | self._versioneer_generated_versions = versions 1628 | # unless we update this, the command will keep using the old 1629 | # version 1630 | self.distribution.metadata.version = versions["version"] 1631 | return _sdist.run(self) 1632 | 1633 | def make_release_tree(self, base_dir, files): 1634 | root = get_root() 1635 | cfg = get_config_from_root(root) 1636 | _sdist.make_release_tree(self, base_dir, files) 1637 | # now locate _version.py in the new base_dir directory 1638 | # (remembering that it may be a hardlink) and replace it with an 1639 | # updated value 1640 | target_versionfile = os.path.join(base_dir, cfg.versionfile_source) 1641 | print("UPDATING %s" % target_versionfile) 1642 | write_to_version_file(target_versionfile, 1643 | self._versioneer_generated_versions) 1644 | cmds["sdist"] = cmd_sdist 1645 | 1646 | return cmds 1647 | 1648 | 1649 | CONFIG_ERROR = """ 1650 | setup.cfg is missing the necessary Versioneer configuration. You need 1651 | a section like: 1652 | 1653 | [versioneer] 1654 | VCS = git 1655 | style = pep440 1656 | versionfile_source = src/myproject/_version.py 1657 | versionfile_build = myproject/_version.py 1658 | tag_prefix = 1659 | parentdir_prefix = myproject- 1660 | 1661 | You will also need to edit your setup.py to use the results: 1662 | 1663 | import versioneer 1664 | setup(version=versioneer.get_version(), 1665 | cmdclass=versioneer.get_cmdclass(), ...) 1666 | 1667 | Please read the docstring in ./versioneer.py for configuration instructions, 1668 | edit setup.cfg, and re-run the installer or 'python versioneer.py setup'. 1669 | """ 1670 | 1671 | SAMPLE_CONFIG = """ 1672 | # See the docstring in versioneer.py for instructions. Note that you must 1673 | # re-run 'versioneer.py setup' after changing this section, and commit the 1674 | # resulting files. 1675 | 1676 | [versioneer] 1677 | #VCS = git 1678 | #style = pep440 1679 | #versionfile_source = 1680 | #versionfile_build = 1681 | #tag_prefix = 1682 | #parentdir_prefix = 1683 | 1684 | """ 1685 | 1686 | INIT_PY_SNIPPET = """ 1687 | from ._version import get_versions 1688 | __version__ = get_versions()['version'] 1689 | del get_versions 1690 | """ 1691 | 1692 | 1693 | def do_setup(): 1694 | """Main VCS-independent setup function for installing Versioneer.""" 1695 | root = get_root() 1696 | try: 1697 | cfg = get_config_from_root(root) 1698 | except (EnvironmentError, configparser.NoSectionError, 1699 | configparser.NoOptionError) as e: 1700 | if isinstance(e, (EnvironmentError, configparser.NoSectionError)): 1701 | print("Adding sample versioneer config to setup.cfg", 1702 | file=sys.stderr) 1703 | with open(os.path.join(root, "setup.cfg"), "a") as f: 1704 | f.write(SAMPLE_CONFIG) 1705 | print(CONFIG_ERROR, file=sys.stderr) 1706 | return 1 1707 | 1708 | print(" creating %s" % cfg.versionfile_source) 1709 | with open(cfg.versionfile_source, "w") as f: 1710 | LONG = LONG_VERSION_PY[cfg.VCS] 1711 | f.write(LONG % {"DOLLAR": "$", 1712 | "STYLE": cfg.style, 1713 | "TAG_PREFIX": cfg.tag_prefix, 1714 | "PARENTDIR_PREFIX": cfg.parentdir_prefix, 1715 | "VERSIONFILE_SOURCE": cfg.versionfile_source, 1716 | }) 1717 | 1718 | ipy = os.path.join(os.path.dirname(cfg.versionfile_source), 1719 | "__init__.py") 1720 | if os.path.exists(ipy): 1721 | try: 1722 | with open(ipy, "r") as f: 1723 | old = f.read() 1724 | except EnvironmentError: 1725 | old = "" 1726 | if INIT_PY_SNIPPET not in old: 1727 | print(" appending to %s" % ipy) 1728 | with open(ipy, "a") as f: 1729 | f.write(INIT_PY_SNIPPET) 1730 | else: 1731 | print(" %s unmodified" % ipy) 1732 | else: 1733 | print(" %s doesn't exist, ok" % ipy) 1734 | ipy = None 1735 | 1736 | # Make sure both the top-level "versioneer.py" and versionfile_source 1737 | # (PKG/_version.py, used by runtime code) are in MANIFEST.in, so 1738 | # they'll be copied into source distributions. Pip won't be able to 1739 | # install the package without this. 1740 | manifest_in = os.path.join(root, "MANIFEST.in") 1741 | simple_includes = set() 1742 | try: 1743 | with open(manifest_in, "r") as f: 1744 | for line in f: 1745 | if line.startswith("include "): 1746 | for include in line.split()[1:]: 1747 | simple_includes.add(include) 1748 | except EnvironmentError: 1749 | pass 1750 | # That doesn't cover everything MANIFEST.in can do 1751 | # (http://docs.python.org/2/distutils/sourcedist.html#commands), so 1752 | # it might give some false negatives. Appending redundant 'include' 1753 | # lines is safe, though. 1754 | if "versioneer.py" not in simple_includes: 1755 | print(" appending 'versioneer.py' to MANIFEST.in") 1756 | with open(manifest_in, "a") as f: 1757 | f.write("include versioneer.py\n") 1758 | else: 1759 | print(" 'versioneer.py' already in MANIFEST.in") 1760 | if cfg.versionfile_source not in simple_includes: 1761 | print(" appending versionfile_source ('%s') to MANIFEST.in" % 1762 | cfg.versionfile_source) 1763 | with open(manifest_in, "a") as f: 1764 | f.write("include %s\n" % cfg.versionfile_source) 1765 | else: 1766 | print(" versionfile_source already in MANIFEST.in") 1767 | 1768 | # Make VCS-specific changes. For git, this means creating/changing 1769 | # .gitattributes to mark _version.py for export-subst keyword 1770 | # substitution. 1771 | do_vcs_install(manifest_in, cfg.versionfile_source, ipy) 1772 | return 0 1773 | 1774 | 1775 | def scan_setup_py(): 1776 | """Validate the contents of setup.py against Versioneer's expectations.""" 1777 | found = set() 1778 | setters = False 1779 | errors = 0 1780 | with open("setup.py", "r") as f: 1781 | for line in f.readlines(): 1782 | if "import versioneer" in line: 1783 | found.add("import") 1784 | if "versioneer.get_cmdclass()" in line: 1785 | found.add("cmdclass") 1786 | if "versioneer.get_version()" in line: 1787 | found.add("get_version") 1788 | if "versioneer.VCS" in line: 1789 | setters = True 1790 | if "versioneer.versionfile_source" in line: 1791 | setters = True 1792 | if len(found) != 3: 1793 | print("") 1794 | print("Your setup.py appears to be missing some important items") 1795 | print("(but I might be wrong). Please make sure it has something") 1796 | print("roughly like the following:") 1797 | print("") 1798 | print(" import versioneer") 1799 | print(" setup( version=versioneer.get_version(),") 1800 | print(" cmdclass=versioneer.get_cmdclass(), ...)") 1801 | print("") 1802 | errors += 1 1803 | if setters: 1804 | print("You should remove lines like 'versioneer.VCS = ' and") 1805 | print("'versioneer.versionfile_source = ' . This configuration") 1806 | print("now lives in setup.cfg, and should be removed from setup.py") 1807 | print("") 1808 | errors += 1 1809 | return errors 1810 | 1811 | if __name__ == "__main__": 1812 | cmd = sys.argv[1] 1813 | if cmd == "setup": 1814 | errors = do_setup() 1815 | errors += scan_setup_py() 1816 | if errors: 1817 | sys.exit(1) 1818 | --------------------------------------------------------------------------------