diff --git a/.gitignore b/.gitignore index 894a44c..8a9b319 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,4 @@ venv.bak/ # mypy .mypy_cache/ +.idea/ diff --git a/README.md b/README.md index 0fd8ba6..f28d626 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,22 @@ -# pytiled_parser -Python Library for parsing Tiled Map Editor maps. +# PyTiled Parser -NOT READY FOR USE +PyTiled Parser is a Python Library for parsing +[Tiled Map Editor](https://www.mapeditor.org/) (`.tmx`) files used to generate +maps and levels for 2D top-down or side-scrolling games. + +PyTiled Parser is not tied to any particular graphics library, and can be used +with [Arcade](http://arcade.academy), +[Pyglet](https://pyglet.readthedocs.io/en/pyglet-1.3-maintenance/), +[Pygame](https://www.pygame.org/news), etc. + +* Documentation available at: https://pytiled-parser.readthedocs.io/ +* GitHub project at: https://github.com/pvcraven/pytiled_parser +* PiPy: https://pypi.org/project/pytiled-parser/ + +The [Arcade](http://arcade.academy) library has +[supporting code](http://arcade.academy/arcade.html#module-arcade.tilemap) to +integrate PyTiled with that 2D libary, and +[example code](http://arcade.academy/examples/index.html#tmx-files-tiled-map-editor) showing its use. + +Original module by [Beefy-Swain](https://github.com/Beefy-Swain). +Contributions from [pvcraven](https://github.com/pvcraven). diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..9534b01 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/10_ladders_and_more.png b/docs/source/10_ladders_and_more.png new file mode 100644 index 0000000..115e0c4 Binary files /dev/null and b/docs/source/10_ladders_and_more.png differ diff --git a/docs/source/api.rst b/docs/source/api.rst new file mode 100644 index 0000000..5621700 --- /dev/null +++ b/docs/source/api.rst @@ -0,0 +1,23 @@ +.. _pytiled-api: + +PyTiled Parser API +================== + +This page documents the Application Programming Interface (API) +for the PyTiled Parser library. + +.. automodule:: pytiled_parser.xml_parser + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: pytiled_parser.objects + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: pytiled_parser.utilities + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..ddeec51 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,68 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# http://www.sphinx-doc.org/en/master/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- Project information ----------------------------------------------------- + +import os +import sys + +project = 'PyTiled Parser' +copyright = '2019, Beefy-Swain' +author = 'Beefy-Swain' + +sys.path.insert(0, os.path.abspath('../..')) + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.intersphinx', + 'sphinx.ext.todo', + 'sphinx.ext.coverage', + 'sphinx.ext.ifconfig', + 'sphinx.ext.viewcode', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +source_suffix = '.rst' + +master_doc = 'index' + +pygments_style = 'sphinx' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..8b5271b --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,36 @@ +PyTiled Parser +============== + +.. image:: 10_ladders_and_more.png + :width: 50% + +PyTiled Parser is a Python Library for parsing +`Tiled Map Editor`_ (`.tmx`) files used to generate +maps and levels for 2D top-down or side-scrolling games. + +PyTiled Parser is not tied to any particular graphics library, and can be used +with Arcade_, Pyglet_, Pygame_, and more. + +API Documentation +----------------- + +.. toctree:: + :maxdepth: 2 + + api + +Examples +-------- + +* `Games using the Arcade library `_ + +.. _Tiled Map Editor: https://www.mapeditor.org/ +.. _Arcade: http://arcade.academy +.. _Pyglet: https://pyglet.readthedocs.io/en/pyglet-1.3-maintenance/ +.. _Pygame: https://www.pygame.org/news + +For More Info +------------- + +* `PyTiled Parser on GitHub `_ +* `PyTiled Parser on PyPi `_ diff --git a/make.bat b/make.bat new file mode 100644 index 0000000..1f857e6 --- /dev/null +++ b/make.bat @@ -0,0 +1,130 @@ +@echo off + +rem Build script for Windows + +IF "%~1"=="" GOTO printdoc +IF "%~1"=="full" GOTO makefull +IF "%~1"=="dist" GOTO makedist +IF "%~1"=="test" GOTO test +IF "%~1"=="testcov" GOTO test +IF "%~1"=="fast" GOTO makefast +IF "%~1"=="docs" GOTO makedoc +IF "%~1"=="spelling" GOTO spelling +IF "%~1"=="deploy_pypi" GOTO deploy_pypi +IF "%~1"=="clean" GOTO clean +GOTO printdoc + +:clean + +rmdir /S /Q pytiled_parser.egg-info +rmdir /S /Q build +rmdir /S /Q dist +rmdir /S /Q .pytest_cache +rmdir /S /Q doc\build + +GOTO end + +:test + +pytest +GOTO end + +:testcov + +pytest --cov=arcade +GOTO end + +:makedist + +rem Clean out old builds +del /q dist\*.* +python setup.py clean + +rem Build the python +python setup.py build +python setup.py bdist_wheel + +GOTO end + +:makefull +rem -- This builds the project, installs it, and runs unit tests + +rem Clean out old builds +rmdir /s /q "doc\build" +del /q dist\*.* +python setup.py clean + +rem Build the python +python setup.py build +python setup.py bdist_wheel + +rem Install the packages +pip uninstall -y arcade +for /r %%i in (dist\*) do pip install "%%i" + +rem Build the documentation +sphinx-build -b html doc doc/build/html + +rem Run tests and do code coverage +coverage run --source arcade setup.py test +coverage report --omit=arcade/examples/* -m + +GOTO end + +rem -- Make the documentation + +:makedoc + +rmdir /s /q "doc\build" +sphinx-build -n -b html doc doc/build/html + +GOTO end + +rem -- Make the documentation + +:spelling + +rmdir /s /q "doc\build" +sphinx-build -n -b spelling doc doc/build/html + +GOTO end + + +rem == This does a fast build and install, but no unit tests + +:makefast + +python setup.py build +python setup.py bdist_wheel +pip uninstall -y pytiled_parser +for /r %%i in (dist\*) do pip install "%%i" + +GOTO end + +rem -- Deploy + +:deploy_pypi +rem Do a "pip install twine" and set environment variables before running. + +twine upload -u %PYPI_USER% -p %PYPI_PASSWORD% -r pypi dist/* + +GOTO end + + + +rem -- Print documentation + +:printdoc + +echo make test - Runs the tests +echo make testcov - Runs the tests with coverage +echo make dist - Make the distributables +echo make full - Builds the project, installs it, builds +echo documentation, runs unit tests. +echo make docs Builds the documentation. Documentation +echo will be in doc/build/html +echo make fast - Builds and installs the library WITHOUT unit +echo tests. +echo make deploy_pypi - Deploy to PyPi (if you have environment +echo variables set up correctly.) +:end \ No newline at end of file diff --git a/pytiled_parser/objects.py b/pytiled_parser/objects.py index 42daa6c..3af237b 100644 --- a/pytiled_parser/objects.py +++ b/pytiled_parser/objects.py @@ -10,7 +10,8 @@ import attr class Color(NamedTuple): - """Color object. + """ + Color object. Attributes: red (int): Red, between 1 and 255. @@ -68,7 +69,8 @@ class Template: @attr.s(auto_attribs=True) class Chunk: - """Chunk object for infinite maps. + """ + Chunk object for infinite maps. See: https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#chunk @@ -76,8 +78,7 @@ class Chunk: location (OrderedPair): Location of chunk in tiles. width (int): The width of the chunk in tiles. height (int): The height of the chunk in tiles. - layer_data (List[List(int)]): The global tile IDs in chunky - according to row. + layer_data (List[List(int)]): The global tile IDs in chunky according to row. """ location: OrderedPair @@ -88,17 +89,15 @@ class Chunk: @attr.s(auto_attribs=True) class Image: - """Image object. + """ + Image object. This module does not support embedded data in image elements. Attributes: - source (Optional[str]): The reference to the tileset image file. - Not that this is a relative path compared to FIXME - trans (Optional[Color]): Defines a specific color that is treated - as transparent. - width (Optional[str]): The image width in pixels - (optional, used for tile index correction when the image changes). + source (Optional[str]): The reference to the tileset image file. Note that this is a relative path compared to FIXME + trans (Optional[Color]): Defines a specific color that is treated as transparent. + width (Optional[str]): The image width in pixels (optional, used for tile index correction when the image changes). height (Optional[str]): The image height in pixels (optional). """ @@ -128,8 +127,7 @@ class Terrain(NamedTuple): Args: name (str): The name of the terrain type. - tile (int): The local tile-id of the tile that represents the - terrain visually. + tile (int): The local tile-id of the tile that represents the terrain visually. """ name: str @@ -174,25 +172,26 @@ class TileTerrain: @attr.s(auto_attribs=True, kw_only=True) class Layer: - """Class that all layers inherret from. + """ + Class that all layers inherit from. Args: - id: Unique ID of the layer. Each layer that added to a map gets a - unique id. Even if a layer is deleted, no layer ever gets the same + id: Unique ID of the layer. Each layer that added to a map gets a \ + unique id. Even if a layer is deleted, no layer ever gets the same \ ID. name: The name of the layer object. tiled_objects: List of tiled_objects in the layer. offset: Rendering offset of the layer object in pixels. - opacity: Decimal value between 0 and 1 to determine opacity. 1 is - completely opaque, 0 is completely transparent. + opacity: Decimal value between 0 and 1 to determine opacity. 1 is \ + completely opaque, 0 is completely transparent. properties: Properties for the layer. color: The color used to display the objects in this group. - FIXME: editor only? - draworder: Whether the objects are drawn according to the order of the - object elements in the object group element ('manual'), or sorted - by their y-coordinate ('topdown'). Defaults to 'topdown'. See: - https://doc.mapeditor.org/en/stable/manual/objects/#changing-stacking-order - for more info. + draworder: Whether the objects are drawn according to the order of the \ + object elements in the object group element ('manual'), or sorted \ + by their y-coordinate ('topdown'). Defaults to 'topdown'. See: \ + https://doc.mapeditor.org/en/stable/manual/objects/#changing-stacking-order \ + for more info. + """ id_: int @@ -237,15 +236,13 @@ class TiledObject: https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#object Args: - id_ (int): Unique ID of the object. Each object that is placed on a - map gets a unique id. Even if an object was deleted, no object - gets the same ID. + id (int): Unique ID of the object. Each object that is placed on a \ + map gets a unique id. Even if an object was deleted, no object \ + gets the same ID. gid (Optional[int]): Global tiled object ID location (OrderedPair): The location of the object in pixels. - size (Size): The width of the object in pixels - (default: (0, 0)). - rotation (int): The rotation of the object in degrees clockwise - (default: 0). + size (Size): The width of the object in pixels (default: (0, 0)). + rotation (int): The rotation of the object in degrees clockwise (default: 0). opacity (int): The opacity of the object. (default: 255) name (Optional[str]): The name of the object. type (Optional[str]): The type of the object. @@ -374,7 +371,8 @@ class TextObject(TiledObject): @attr.s(auto_attribs=True, kw_only=True) class ObjectLayer(Layer): - """TiledObject Group Object. + """ + TiledObject Group Object. The object group is in fact a map layer, and is hence called \ "object layer" in Tiled. @@ -385,13 +383,13 @@ class ObjectLayer(Layer): Args: tiled_objects: List of tiled_objects in the layer. offset: Rendering offset of the layer object in pixels. - color: The color used to display the objects in this group. - FIXME: editor only? - draworder: Whether the objects are drawn according to the order of the - object elements in the object group element ('manual'), or sorted - by their y-coordinate ('topdown'). Defaults to 'topdown'. See: - https://doc.mapeditor.org/en/stable/manual/objects/#changing-stacking-order - for more info. + color: The color used to display the objects in this group. FIXME: editor only? + draworder: Whether the objects are drawn according to the order of the \ + object elements in the object group element ('manual'), or sorted \ + by their y-coordinate ('topdown'). Defaults to 'topdown'. See: \ + https://doc.mapeditor.org/en/stable/manual/objects/#changing-stacking-order \ + for more info. + """ tiled_objects: List[TiledObject] @@ -425,26 +423,25 @@ class TileSet: Args: name (str): The name of this tileset. - max_tile_size (Size): The maximum size of a tile in this - tile set in pixels. - spacing (int): The spacing in pixels between the tiles in this + max_tile_size (Size): The maximum size of a tile in this tile set in pixels. + spacing (int): The spacing in pixels between the tiles in this \ tileset (applies to the tileset image). - margin (int): The margin around the tiles in this tileset + margin (int): The margin around the tiles in this tileset \ (applies to the tileset image). tile_count (int): The number of tiles in this tileset. - columns (int): The number of tile columns in the tileset. - For image collection tilesets it is editable and is used when + columns (int): The number of tile columns in the tileset. \ + For image collection tilesets it is editable and is used when \ displaying the tileset. - grid (Grid): Only used in case of isometric orientation, and - determines how tile overlays for terrain and collision information + grid (Grid): Only used in case of isometric orientation, and \ + determines how tile overlays for terrain and collision information \ are rendered. - tileoffset (Optional[OrderedPair]): Used to specify an offset in - pixels when drawing a tile from the tileset. When not present, no + tileoffset (Optional[OrderedPair]): Used to specify an offset in \ + pixels when drawing a tile from the tileset. When not present, no \ offset is applied. image (Image): Used for spritesheet tile sets. - terrain_types (Dict[str, int]): List of of terrain types which - can be referenced from the terrain attribute of the tile object. - Ordered according to the terrain element's appearance in the TSX + terrain_types (Dict[str, int]): List of of terrain types which \ + can be referenced from the terrain attribute of the tile object. \ + Ordered according to the terrain element's appearance in the TSX \ file. tiles (Optional[Dict[int, Tile]]): Dict of Tile objects by Tile.id. """ @@ -484,9 +481,13 @@ class Tile: type_: Optional[str] = None terrain: Optional[TileTerrain] = None animation: Optional[List[Frame]] = None + objectgroup: Optional[List[TiledObject]] = None image: Optional[Image] = None properties: Optional[List[Property]] = None tileset: Optional[TileSet] = None + flipped_horizontally: bool = False + flipped_diagonally: bool = False + flipped_vertically: bool = False @attr.s(auto_attribs=True) @@ -528,6 +529,7 @@ class TileMap: """ parent_dir: Path + tmx_file: Union[str, Path] version: str tiled_version: str @@ -545,6 +547,6 @@ class TileMap: hex_side_length: Optional[int] = None stagger_axis: Optional[int] = None stagger_index: Optional[int] = None - background_color: Optional[str] = None + background_color: Optional[Color] = None properties: Optional[Properties] = None diff --git a/pytiled_parser/utilities.py b/pytiled_parser/utilities.py index b43c367..60e2252 100644 --- a/pytiled_parser/utilities.py +++ b/pytiled_parser/utilities.py @@ -60,8 +60,7 @@ def get_tile_by_gid( Returns: objects.Tile: The Tile object reffered to by the global tile ID. - None: If there is no objects.Tile object in the tile_set.tiles dict - for the associated gid. + None: If there is no objects.Tile object in the tile_set.tiles dict for the associated gid. """ tile_set_key = _get_tile_set_key(gid, list(tile_sets.keys())) tile_set = tile_sets[tile_set_key] diff --git a/pytiled_parser/xml_parser.py b/pytiled_parser/xml_parser.py index 81a13f7..1874c97 100644 --- a/pytiled_parser/xml_parser.py +++ b/pytiled_parser/xml_parser.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import Callable, Dict, List, Optional, Tuple, Union import pytiled_parser.objects as objects - +from pytiled_parser.utilities import parse_color def _decode_base64_data( data_text: str, layer_width: int, compression: Optional[str] = None @@ -192,7 +192,10 @@ def _parse_layer( Returns: FIXME """ - id_ = int(layer_element.attrib["id"]) + if "id" in layer_element: + id_ = int(layer_element.attrib["id"]) + else: + id_ = None name = layer_element.attrib["name"] @@ -464,6 +467,19 @@ def _parse_external_tile_set( return _parse_tile_set(tile_set_tree) +def _parse_points(point_string: str) -> List[objects.OrderedPair]: + str_pairs = point_string.split(" ") + + points = [] + for str_pair in str_pairs: + xys = str_pair.split(",") + x = float(xys[0]) + y = float(xys[1]) + points.append((x, y)) + + return points + + def _parse_tiles( tile_element_list: List[etree.Element] ) -> Dict[int, objects.Tile]: @@ -488,6 +504,7 @@ def _parse_tiles( except KeyError: pass + terrain = None try: tile_terrain_attrib = tile_element.attrib["terrain"] except KeyError: @@ -529,11 +546,70 @@ def _parse_tiles( frames = tile_animation_element.findall("./frame") for frame in frames: # tileid refers to the Tile.id of the animation frame - id_ = int(frame.attrib["tileid"]) + animated_id = int(frame.attrib["tileid"]) # duration is in MS. Should perhaps be converted to seconds. # FIXME: make decision duration = int(frame.attrib["duration"]) - animation.append(objects.Frame(id_, duration)) + animation.append(objects.Frame(animated_id, duration)) + + # tile element optional sub-elements + objectgroup: Optional[List[objects.TiledObject]] = None + objectgroup_element = tile_element.find("./objectgroup") + if objectgroup_element: + objectgroup = [] + object_list = objectgroup_element.findall("./object") + for object in object_list: + my_id = object.attrib["id"] + my_x = float(object.attrib["x"]) + my_y = float(object.attrib["y"]) + if "width" in object.attrib: + my_width = float(object.attrib["width"]) + else: + my_width = None + if "height" in object.attrib: + my_height = float(object.attrib["height"]) + else: + my_height = None + + # This is where it would be nice if we could assume a walrus + # operator was part of our Python distribution. + + my_object = None + + polygon = object.findall("./polygon") + + if polygon and len(polygon) > 0: + points = _parse_points(polygon[0].attrib["points"]) + my_object = objects.PolygonObject(id_=my_id, + location=(my_x, my_y), + size=(my_width, my_height), + points=points) + + if my_object is None: + polyline = object.findall("./polyline") + + if polyline and len(polyline) > 0: + points = _parse_points(polyline[0].attrib["points"]) + my_object = objects.PolylineObject(id_=my_id, + location=(my_x, my_y), + size=(my_width, my_height), + points=points) + + if my_object is None: + ellipse = object.findall("./ellipse") + + if ellipse and len(ellipse): + my_object = objects.ElipseObject(id_=my_id, + location=(my_x, my_y), + size=(my_width, my_height)) + + + if my_object is None: + my_object = objects.RectangleObject(id_=my_id, + location=(my_x, my_y), + size=(my_width, my_height)) + + objectgroup.append(my_object) # if this is None, then the Tile is part of a spritesheet image = None @@ -541,6 +617,8 @@ def _parse_tiles( if image_element is not None: image = _parse_image_element(image_element) + # print(f"Adding '{id_}', {image}, {objectgroup}") + tiles[id_] = objects.Tile( id_=id_, type_=_type, @@ -549,6 +627,7 @@ def _parse_tiles( image=image, properties=properties, tileset=None, + objectgroup=objectgroup ) return tiles @@ -804,8 +883,15 @@ def parse_tile_map(tmx_file: Union[str, Path]) -> objects.TileMap: infinite_attribute = map_element.attrib["infinite"] infinite = bool(infinite_attribute == "true") - next_layer_id = int(map_element.attrib["nextlayerid"]) - next_object_id = int(map_element.attrib["nextobjectid"]) + if "nextlayerid" in map_element.attrib: + next_layer_id = int(map_element.attrib["nextlayerid"]) + else: + next_layer_id = None + + if "nextobjectid" in map_element.attrib: + next_object_id = int(map_element.attrib["nextobjectid"]) + else: + next_object_id = None tile_sets = _get_tile_sets(map_element, parent_dir) @@ -813,6 +899,7 @@ def parse_tile_map(tmx_file: Union[str, Path]) -> objects.TileMap: tile_map = objects.TileMap( parent_dir, + tmx_file, version, tiled_version, orientation, @@ -842,7 +929,8 @@ def parse_tile_map(tmx_file: Union[str, Path]) -> objects.TileMap: pass try: - tile_map.background_color = map_element.attrib["backgroundcolor"] + color = parse_color(map_element.attrib["backgroundcolor"]) + tile_map.background_color = (color.red, color.green, color.blue) except KeyError: pass diff --git a/setup.py b/setup.py index dd337da..b71ea0b 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from os import path from setuptools import setup # type: ignore BUILD = 0 -VERSION = "0.0.1" +VERSION = "0.9.0" RELEASE = VERSION if __name__ == "__main__":