diff --git a/SConstruct b/SConstruct index 0a5053b5a..ec5f0cac9 100644 --- a/SConstruct +++ b/SConstruct @@ -106,6 +106,7 @@ PLUGINS = { # plugins with external dependencies 'raster': {'default':True,'path':None,'inc':None,'lib':None,'lang':'C++'}, 'geojson': {'default':True,'path':None,'inc':None,'lib':None,'lang':'C++'}, 'kismet': {'default':False,'path':None,'inc':None,'lib':None,'lang':'C++'}, + 'python': {'default':True,'path':None,'inc':None,'lib':None,'lang':'C++'}, } diff --git a/bindings/python/mapnik/__init__.py b/bindings/python/mapnik/__init__.py index 78b618827..c55a95385 100644 --- a/bindings/python/mapnik/__init__.py +++ b/bindings/python/mapnik/__init__.py @@ -39,6 +39,7 @@ Several things happen when you do: """ +import itertools import os import sys import warnings @@ -594,6 +595,75 @@ def Geos(**keywords): keywords['type'] = 'geos' return CreateDatasource(keywords) +def Python(**keywords): + """Create a Python Datasource. + + >>> from mapnik import Python, PythonDatasource + >>> datasource = Python('PythonDataSource') + >>> lyr = Layer('Python datasource') + >>> lyr.datasource = datasource + """ + keywords['type'] = 'python' + return CreateDatasource(keywords) + +class PythonDatasource(object): + """A base class for a Python data source. + + Optional arguments: + envelope -- a mapnik.Box2d (minx, miny, maxx, maxy) envelope of the data source, default (-180,-90,180,90) + geometry_type -- one of the DataGeometryType enumeration values, default Point + data_type -- one of the DataType enumerations, default Vector + """ + def __init__(self, envelope=None, geometry_type=None, data_type=None): + self.envelope = envelope or Box2d(-180, -90, 180, 90) + self.geometry_type = geometry_type or DataGeometryType.Point + self.data_type = data_type or DataType.Vector + + def features(self, query): + """Return an iterable which yields instances of Feature for features within the passed query. + + Required arguments: + query -- a Query instance specifying the region for which features should be returned + """ + return None + + def features_at_point(self, point): + """Rarely uses. Return an iterable which yields instances of Feature for the specified point.""" + return None + + @classmethod + def wkb_features(cls, keys, features): + """A convenience function to wrap an iterator yielding pairs of WKB format geometry and dictionaries of + key-value pairs into mapnik features. Return this from PythonDatasource.features() passing it a sequence of keys + to appear in the output and an iterator yielding features. + + For example. One might have a features() method in a derived class like the following: + + def features(self, query): + # ... create WKB features feat1 and feat2 + + return mapnik.PythonDatasource.wkb_features( + keys = ( 'name', 'author' ), + features = [ + (feat1, { 'name': 'feat1', 'author': 'alice' }), + (feat2, { 'name': 'feat2', 'author': 'bob' }), + ] + ) + + """ + ctx = Context() + [ctx.push(x) for x in keys] + + def make_it(feat, idx): + f = Feature(ctx, idx) + geom, attrs = feat + f.add_geometries_from_wkb(geom) + for k, v in attrs.iteritems(): + f[k] = v + return f + + return itertools.imap(make_it, features, itertools.count(1)) + class _TextSymbolizer(TextSymbolizer,_injector): @property def text_size(self): diff --git a/plugins/input/python/Makefile b/plugins/input/python/Makefile new file mode 100644 index 000000000..4d4641eee --- /dev/null +++ b/plugins/input/python/Makefile @@ -0,0 +1,34 @@ +CXX = g++ + +CXXFLAGS = $(shell mapnik-config --cflags) -I/usr/include/python2.7 -fPIC + +LIBS = $(shell mapnik-config --libs --ldflags --dep-libs) -lboost_python + +SRC = $(wildcard *.cpp) + +OBJ = $(SRC:.cpp=.o) + +BIN = python.input + +all : $(SRC) $(BIN) + +$(BIN) : $(OBJ) + $(CXX) -shared $(OBJ) $(LIBS) -o $@ + +.cpp.o : + $(CXX) -c $(CXXFLAGS) $< -o $@ + +.PHONY : clean test + +clean: + rm -f $(OBJ) + rm -f $(BIN) + +deploy : all + cp python.input $(shell mapnik-config --input-plugins) + +install: clean all deploy + +test: deploy + python test.py + diff --git a/plugins/input/python/README.md b/plugins/input/python/README.md new file mode 100644 index 000000000..575e0d7c5 --- /dev/null +++ b/plugins/input/python/README.md @@ -0,0 +1,241 @@ +# Python plugin + +This plugin allows you to write data sources in the Python programming language. +This is useful if you want to rapidly prototype a plugin, perform some custom +manipulation on data or if you want to bind mapnik to a datasource which is most +conveniently accessed through Python. + +The plugin may be used from the existing mapnik Python bindings or it can embed +the Python interpreter directly allowing it to be used from C++, XML or even +JavaScript. + +## Rationale + +Mapnik already has excellent Python bindings but they only directly support +calling *into* mapnik *from* Python. This forces mapnik and its input plugins to +be the lowest layer of the stack. The role of this plugin is to allow mapnik to +call *into* Python itself. This allows mapnik to sit as rendering middleware +between a custom Python frontend and a custom Python datasource. This increases +the utility of mapnik as a component in a larger system. + +There already exists MemoryDatasource which can be used to dynamically create +geometry in Python. It suffers from the problem that it does not allow +generating only the geometry which is seen by a particular query. Similarly the +entire geometry must exist in memory before rendering can progress. By using a +custom iterator object or by using generator expressions this plugin allows +geometry to be created on demand and to be destroyed after use. This can have a +great impact on memory efficiency. Since geometry is generated on-demand as +rendering progresses there can be arbitrarily complex 'cleverness' optimising +the geometry generated for a particular query. Obvious examples of this would +be generating only geometry within the query bounding box and generating +geometry with an appropriate level of detail for the output resolution. + +## Initialization + +Only the `factory` parameter is required. This is of the form +`[module:]callable`. If `module` is present then `module` will be imported and +its attribute named `callable` will be used as a factory callable. If `module` +is omitted, then `__main__` is used. Any other parameter aside from `factory` or +`type` will be passed directly to the callable as keyword arguments. Note that +these will always be passed as strings even if the parameter can be parsed as an +integer of floating point value. + +The callable should return an object with the following required attributes: + +* `envelope` - a 4-tuple giving the (minx, miny, maxx, maxy) extent of the + datasource; + +* `data_type` - a `mapnik.DataType` instance giving the type of data stored in + this datasource. This will usually be one of `mapnik.DataType.Vector` or + `mapnik.DataType.Raster`. + +The following attributes are optional: + +* `geometry_type` - if the dataset is a vector dataset, this is an instance of + `mapnik.DataGeometryType` giving the type of geometry returned by the + datasource. + +The following methods must be present: + +* `features(query)` - takes a single argument which is an instance of + `mapnik.Query` and returns an iterable of `mapnik.Feature` instances for that + query. + +* `features_at_point(point)` - almost never used. Takes a single argument which + is an instance of `mapnik.Point` (I think) and returns an iterable of + features associated with that point. + +## Convenience classes + +The standard `mapnik` module provides a convenience class called +`mapnik.PythonDatasource` which has default implementations for the required +methods and accepts the geometry type, data type and envelope as constructor +arguments. It also provides some convenience class methods which take care of +constructing features for you: + +* `mapnik.PythonDatasource.wkb_features` - constructs features from + well-known-binary (WKB) format geometry. Takes two keyword arguments: `keys` + which is a sequence of keys associated with each feature and `features` which + is a sequence of pairs. The first element in each pair is the WKB + representation of the feature and the second element is a dictionary mapping + keys to values. + +# Caveats + +* If used directly from C++, `Py_Initialize()` must have been called before the + plugin is loaded to initialise the interpreter correctly. + +* When inside the interpreter the global interpreter lock is held each time a + feature is fetched and so multi-threaded rendering performance may suffer. You + can mitigate this by making sure that the feature iterator yields its value as + quickly as possible, potentially from an in-memory buffer filled fom another + process over IPC. + +# Examples + +In XML: + +```xml + + + + + style + + python + test:TestDatasource + + + +``` + +In Python using the shapely geometry library: + +```python +import mapnik +from shapely.geometry import * + +class TestDatasource(mapnik.PythonDatasource): + def __init__(self): + super(TestDatasource, self).__init__() + + def features(self, query): + return mapnik.PythonDatasource.wkb_features( + keys = ('label',), + features = ( + ( Point(5,6).wkb, { 'label': 'foo-bar'} ), + ( Point(100,60).wkb, { 'label': 'buzz-quux'} ), + ) + ) + +if __name__ == '__main__': + m = mapnik.Map(1280,1024) + m.background = mapnik.Color('white') + s = mapnik.Style() + r = mapnik.Rule() + r.symbols.append(mapnik.PointSymbolizer()) + t = mapnik.TextSymbolizer(mapnik.Expression("[label]"),"DejaVu Sans Book",10,mapnik.Color('black')) + t.displacement = (5,5) + r.symbols.append(t) + s.rules.append(r) + m.append_style('point_style',s) + ds = mapnik.Python(factory='TestDatasource') + layer = mapnik.Layer('python') + layer.datasource = ds + layer.styles.append('point_style') + m.layers.append(layer) + m.zoom_all() + mapnik.render_to_file(m,'map.png', 'png') +``` + +A more complex Python example which makes use of iterators to generate geometry +dynamically: + +```python +"""A more complex example which renders an infinite series of concentric +circles centred on a point. + +The circles are represented by a Python iterator which will yield only the +circles which intersect the query's bounding box. The advantage of this +approach over a MemoryDatasource is that a) only those circles which intersect +the viewport are actually generated and b) only the memory for the largest +circle need be available since each circle is created on demand and destroyed +when finished with. +""" +import math +import mapnik +from shapely.geometry import * + +def box2d_to_shapely(box): + import shapely.geometry + return shapely.geometry.box(box.minx, box.miny, box.maxx, box.maxy) + +class ConcentricCircles(object): + def __init__(self, centre, bounds, step=1): + self.centre = centre + self.bounds = bounds + self.step = step + + class Iterator(object): + def __init__(self, container): + self.container = container + + centre = self.container.centre + bounds = self.container.bounds + step = self.container.step + + if centre.within(bounds): + self.radius = 0 + else: + self.radius = math.ceil(centre.distance(bounds) / float(step)) * step + + def next(self): + circle = self.container.centre.buffer(self.radius) + self.radius += self.container.step + + # has the circle grown so large that the boundary is entirely within it? + if circle.contains(self.container.bounds): + raise StopIteration() + + return ( circle.wkb, { } ) + + def __iter__(self): + return ConcentricCircles.Iterator(self) + +class TestDatasource(mapnik.PythonDatasource): + def __init__(self): + super(TestDatasource, self).__init__(geometry_type=mapnik.DataGeometryType.Polygon) + + def features(self, query): + # Get the query bounding-box as a shapely bounding box + bounding_box = box2d_to_shapely(query.bbox) + centre = Point(-20, 0) + + return mapnik.PythonDatasource.wkb_features( + keys = (), + features = ConcentricCircles(centre, bounding_box, 0.5) + ) + +if __name__ == '__main__': + m = mapnik.Map(640, 320) + + m.background = mapnik.Color('white') + s = mapnik.Style() + r = mapnik.Rule() + r.symbols.append(mapnik.LineSymbolizer()) + s.rules.append(r) + m.append_style('point_style',s) + ds = mapnik.Python(factory='TestDatasource') + layer = mapnik.Layer('python') + layer.datasource = ds + layer.styles.append('point_style') + m.layers.append(layer) + box = mapnik.Box2d(-60, -60, 0, -30) + m.zoom_to_box(box) + mapnik.render_to_file(m,'map.png', 'png') +``` diff --git a/plugins/input/python/build.py b/plugins/input/python/build.py new file mode 100644 index 000000000..4a8481eb3 --- /dev/null +++ b/plugins/input/python/build.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python + +# Mapnik uses the build tool SCons. + +# This python file is run to compile a plugin +# It must be called from the main 'SConstruct' file like: + +# SConscript('path/to/this/file.py') + +# see docs at: http://www.scons.org/wiki/SConscript() + +import os + +# Give this plugin a name +# here this happens to be the same as the directory +PLUGIN_NAME = 'python' + +# Here we pull from the SCons environment exported from the main instance +Import ('plugin_base') +Import ('env') + +# the below install details are also pulled from the +# main SConstruct file where configuration happens + +# plugins can go anywhere, and be registered in custom locations by Mapnik +# but the standard location is '/usr/local/lib/mapnik/input' +install_dest = env['MAPNIK_INPUT_PLUGINS_DEST'] + +# clone the environment here +# so that if we modify the env it in this file +# those changes to not pollute other builds later on... +plugin_env = plugin_base.Clone() + +# Add the cpp files that need to be compiled +plugin_sources = Split( + """ + %(PLUGIN_NAME)s_datasource.cpp + %(PLUGIN_NAME)s_featureset.cpp + """ % locals() + ) + +# Add any external libraries this plugin should +# directly link to +libraries = [ '' ] # eg 'libfoo' + +libraries.append('mapnik') +libraries.append('boost_system%s' % env['BOOST_APPEND']) +# link libicuuc, but ICU_LIB_NAME is used custom builds of icu can +# have different library names like osx which offers /usr/lib/libicucore.dylib +libraries.append(env['ICU_LIB_NAME']) + +libraries.append(env['BOOST_PYTHON_LIB']) + +# TODO - do solaris/fedora need direct linking too? +if env['PLATFORM'] == 'Darwin': + if not env['PYTHON_DYNAMIC_LOOKUP']: + libraries.append('png') + if env['JPEG']: + libraries.append('jpeg') + libraries.append(env['ICU_LIB_NAME']) + libraries.append('boost_regex%s' % env['BOOST_APPEND']) + if env['THREADING'] == 'multi': + libraries.append('boost_thread%s' % env['BOOST_APPEND']) + + ##### Python linking on OS X is tricky ### + # Confounding problems are: + # 1) likelyhood of multiple python installs of the same major.minor version + # because apple supplies python built-in and many users may have installed + # further versions using macports + # 2) boost python directly links to a python version + # 3) the below will directly link _mapnik.so to a python version + # 4) _mapnik.so must link to the same python lib as boost_python.dylib otherwise + # python will Abort with a Version Mismatch error. + # See http://trac.mapnik.org/ticket/453 for the seeds of a better approach + # for now we offer control over method of direct linking... + # The default below is to link against the python dylib in the form of + #/path/to/Python.framework/Python instead of -lpython + + # http://developer.apple.com/mac/library/DOCUMENTATION/Darwin/Reference/ManPages/man1/ld.1.html + + if env['PYTHON_DYNAMIC_LOOKUP']: + python_link_flag = '-undefined dynamic_lookup' + elif env['FRAMEWORK_PYTHON']: + if env['FRAMEWORK_SEARCH_PATH']: + # if the user has supplied a custom root path to search for + # a given Python framework, then use that to direct the linker + python_link_flag = '-F%s -framework Python -Z' % env['FRAMEWORK_SEARCH_PATH'] + else: + # otherwise be as explicit as possible for linking to the same Framework + # as the executable we are building with (or is pointed to by the PYTHON variable) + # otherwise we may accidentally link against either: + # /System/Library/Frameworks/Python.framework/Python/Versions/ + # or + # /Library/Frameworks/Python.framework/Python/Versions/ + # See: http://trac.mapnik.org/ticket/380 + link_prefix = env['PYTHON_SYS_PREFIX'] + if '.framework' in link_prefix: + python_link_flag = '-F%s -framework Python -Z' % os.path.dirname(link_prefix.split('.')[0]) + elif '/System' in link_prefix: + python_link_flag = '-F/System/Library/Frameworks/ -framework Python -Z' + else: + # should we fall back to -lpython here? + python_link_flag = '-F/ -framework Python' + + # if we are not linking to a framework then use the *nix standard approach + else: + # TODO - do we need to pass -L/? + python_link_flag = '-lpython%s' % env['PYTHON_VERSION'] + +elif env['PLATFORM'] == 'SunOS': + # make sure to explicitly link mapnik.so against + # libmapnik in its installed location + python_link_flag = '-R%s' % env['MAPNIK_LIB_BASE'] +else: + # all other platforms we don't directly link python + python_link_flag = '' + +if env['CUSTOM_LDFLAGS']: + linkflags = '%s %s' % (env['CUSTOM_LDFLAGS'], python_link_flag) +else: + linkflags = python_link_flag + +plugin_env.Append(CPPPATH = env['PYTHON_INCLUDES']) + +TARGET = plugin_env.SharedLibrary( + # the name of the target to build, eg 'sqlite.input' + '../%s' % PLUGIN_NAME, + # prefix - normally none used + SHLIBPREFIX='', + # extension, mapnik expects '.input' + SHLIBSUFFIX='.input', + # list of source files to compile + source=plugin_sources, + # libraries to link to + LIBS=libraries, + # any custom linkflags, eg. LDFLAGS + # in this case CUSTOM_LDFLAGS comes + # from Mapnik's main SConstruct file + # and can be removed here if you do + # not need it + LINKFLAGS=env.get('CUSTOM_LDFLAGS') + ) + +# if 'uninstall' is not passed on the command line +# then we actually create the install targets that +# scons will install if 'install' is passed as an arg +if 'uninstall' not in COMMAND_LINE_TARGETS: + env.Install(install_dest, TARGET) + env.Alias('install', install_dest) diff --git a/plugins/input/python/examples/concentric_circles.py b/plugins/input/python/examples/concentric_circles.py new file mode 100644 index 000000000..b80531251 --- /dev/null +++ b/plugins/input/python/examples/concentric_circles.py @@ -0,0 +1,83 @@ +"""A more complex example which renders an infinite series of concentric +circles centred on a point. + +The circles are represented by a Python iterator which will yield only the +circles which intersect the query's bounding box. The advantage of this +approach over a MemoryDatasource is that a) only those circles which intersect +the viewport are actually generated and b) only the memory for the largest +circle need be available since each circle is created on demand and destroyed +when finished with. +""" +import math +import mapnik +from shapely.geometry import * + +def box2d_to_shapely(box): + import shapely.geometry + return shapely.geometry.box(box.minx, box.miny, box.maxx, box.maxy) + +class ConcentricCircles(object): + def __init__(self, centre, bounds, step=1): + self.centre = centre + self.bounds = bounds + self.step = step + + class Iterator(object): + def __init__(self, container): + self.container = container + + centre = self.container.centre + bounds = self.container.bounds + step = self.container.step + + if centre.within(bounds): + self.radius = 0 + else: + self.radius = math.ceil(centre.distance(bounds) / float(step)) * step + + def next(self): + circle = self.container.centre.buffer(self.radius) + self.radius += self.container.step + + # has the circle grown so large that the boundary is entirely within it? + if circle.contains(self.container.bounds): + raise StopIteration() + + return ( circle.wkb, { } ) + + def __iter__(self): + return ConcentricCircles.Iterator(self) + +class TestDatasource(mapnik.PythonDatasource): + def __init__(self): + super(TestDatasource, self).__init__( + geometry_type=mapnik.DataGeometryType.Polygon + ) + + def features(self, query): + # Get the query bounding-box as a shapely bounding box + bounding_box = box2d_to_shapely(query.bbox) + centre = Point(-20, 0) + + return mapnik.PythonDatasource.wkb_features( + keys = (), + features = ConcentricCircles(centre, bounding_box, 0.5) + ) + +if __name__ == '__main__': + m = mapnik.Map(640, 320) + + m.background = mapnik.Color('white') + s = mapnik.Style() + r = mapnik.Rule() + r.symbols.append(mapnik.LineSymbolizer()) + s.rules.append(r) + m.append_style('point_style',s) + ds = mapnik.Python(factory='TestDatasource') + layer = mapnik.Layer('python') + layer.datasource = ds + layer.styles.append('point_style') + m.layers.append(layer) + box = mapnik.Box2d(-60, -60, 0, -30) + m.zoom_to_box(box) + mapnik.render_to_file(m,'map.png', 'png') diff --git a/plugins/input/python/examples/simple_points.py b/plugins/input/python/examples/simple_points.py new file mode 100644 index 000000000..1a51fc3cd --- /dev/null +++ b/plugins/input/python/examples/simple_points.py @@ -0,0 +1,34 @@ +import mapnik +from shapely.geometry import * + +class TestDatasource(mapnik.PythonDatasource): + def __init__(self): + super(TestDatasource, self).__init__() + + def features(self, query): + return mapnik.PythonDatasource.wkb_features( + keys = ('label',), + features = ( + ( Point(5,6).wkb, { 'label': 'foo-bar'} ), + ( Point(100,60).wkb, { 'label': 'buzz-quux'} ), + ) + ) + +if __name__ == '__main__': + m = mapnik.Map(1280,1024) + m.background = mapnik.Color('white') + s = mapnik.Style() + r = mapnik.Rule() + r.symbols.append(mapnik.PointSymbolizer()) + t = mapnik.TextSymbolizer(mapnik.Expression("[label]"),"DejaVu Sans Book",10,mapnik.Color('black')) + t.displacement = (5,5) + r.symbols.append(t) + s.rules.append(r) + m.append_style('point_style',s) + ds = mapnik.Python(factory='TestDatasource') + layer = mapnik.Layer('python') + layer.datasource = ds + layer.styles.append('point_style') + m.layers.append(layer) + m.zoom_all() + mapnik.render_to_file(m,'map.png', 'png') diff --git a/plugins/input/python/examples/simple_xml.py b/plugins/input/python/examples/simple_xml.py new file mode 100644 index 000000000..8dbe3261b --- /dev/null +++ b/plugins/input/python/examples/simple_xml.py @@ -0,0 +1,8 @@ +import mapnik +stylesheet = 'simple_xml.xml' +image = 'simple_xml.png' +m = mapnik.Map(600, 300) +mapnik.load_map(m, stylesheet) +m.zoom_all() +mapnik.render_to_file(m, image) +print "rendered image to '%s'" % image diff --git a/plugins/input/python/examples/simple_xml.xml b/plugins/input/python/examples/simple_xml.xml new file mode 100644 index 000000000..d7e3c3b8e --- /dev/null +++ b/plugins/input/python/examples/simple_xml.xml @@ -0,0 +1,16 @@ + + + + + style + + python + test:TestDatasource + + + diff --git a/plugins/input/python/python_datasource.cpp b/plugins/input/python/python_datasource.cpp new file mode 100644 index 000000000..8f5c48ec7 --- /dev/null +++ b/plugins/input/python/python_datasource.cpp @@ -0,0 +1,208 @@ +// file plugin +#include "python_datasource.hpp" +#include "python_featureset.hpp" + +// stl +#include +#include + +// boost +#include +#include +#include +#include +#include + +#include "python_utils.hpp" + +using mapnik::datasource; +using mapnik::parameters; + +DATASOURCE_PLUGIN(python_datasource) + +python_datasource::python_datasource(parameters const& params, bool bind) + : datasource(params), + desc_(*params_.get("type"), *params_.get("encoding","utf-8")), + factory_(*params_.get("factory", "")) +{ + // extract any remaining parameters as keyword args for the factory + BOOST_FOREACH(const mapnik::parameters::value_type& kv, params_) + { + if((kv.first != "type") && (kv.first != "factory")) + { + kwargs_.insert(std::make_pair(kv.first, *params_.get(kv.first))); + } + } + + if (bind) + { + this->bind(); + } +} + +python_datasource::~python_datasource() { } + +// This name must match the plugin filename, eg 'python.input' +const char* python_datasource::name_="python"; + +const char* python_datasource::name() +{ + return name_; +} + +mapnik::layer_descriptor python_datasource::get_descriptor() const +{ + if (!is_bound_) bind(); + + return desc_; +} + +// The following methods call into the Python interpreter and hence require, unfortunately, that the GIL be held. + +void python_datasource::bind() const +{ + using namespace boost; + using namespace boost::python; + + if (is_bound_) return; + + // if no factory callable is defined, bind is a nop + if (factory_.empty()) return; + + // split factory at ':' to parse out module and callable + std::vector factory_split; + split(factory_split, factory_, is_any_of(":")); + if ((factory_split.size() < 1) || (factory_split.size() > 2)) + { + // FIMXE: is this appropriate error reporting? + std::cerr << "python: factory string must be of the form '[module:]callable' when parsing \"" + << factory_ << '"' << std::endl; + return; + } + + // extract the module and the callable + str module_name("__main__"), callable_name; + if (factory_split.size() == 1) + { + callable_name = str(factory_split[0]); + } + else + { + module_name = str(factory_split[0]); + callable_name = str(factory_split[1]); + } + + { + ensure_gil lock; + + // import the main module from Python (in case we're embedding the + // interpreter directly) and also import the callable. + object main_module = import("__main__"); + object callable_module = import(module_name); + object callable = callable_module.attr(callable_name); + + // prepare the arguments + dict kwargs; + typedef std::map::value_type kv_type; + BOOST_FOREACH(const kv_type& kv, kwargs_) + { + kwargs[str(kv.first)] = str(kv.second); + } + + // get our wrapped data source + datasource_ = callable(*boost::python::make_tuple(), **kwargs); + } + + is_bound_ = true; +} + +mapnik::datasource::datasource_t python_datasource::type() const +{ + using namespace boost::python; + + typedef boost::optional return_type; + + if (!is_bound_) bind(); + + ensure_gil lock; + + object data_type = datasource_.attr("data_type"); + long data_type_integer = extract(data_type); + return mapnik::datasource::datasource_t(data_type_integer); +} + +mapnik::box2d python_datasource::envelope() const +{ + using namespace boost::python; + + if (!is_bound_) bind(); + + ensure_gil lock; + return extract >(datasource_.attr("envelope")); +} + +boost::optional python_datasource::get_geometry_type() const +{ + using namespace boost::python; + + typedef boost::optional return_type; + + if (!is_bound_) bind(); + + ensure_gil lock; + + // if the datasource object has no geometry_type attribute, return a 'none' value + if (!PyObject_HasAttrString(datasource_.ptr(), "geometry_type")) + return return_type(); + + object py_geometry_type = datasource_.attr("geometry_type"); + + // if the attribute value is 'None', return a 'none' value + if (py_geometry_type.ptr() == object().ptr()) + return return_type(); + + long geom_type_integer = extract(py_geometry_type); + return mapnik::datasource::geometry_t(geom_type_integer); +} + +mapnik::featureset_ptr python_datasource::features(mapnik::query const& q) const +{ + using namespace boost::python; + + if (!is_bound_) bind(); + + // if the query box intersects our world extent then query for features + if (envelope().intersects(q.get_bbox())) + { + ensure_gil lock; + + object features(datasource_.attr("features")(q)); + + // if 'None' was returned, return an empty feature set + if(features.ptr() == object().ptr()) + return mapnik::featureset_ptr(); + + return boost::make_shared(features); + } + + // otherwise return an empty featureset pointer + return mapnik::featureset_ptr(); +} + +mapnik::featureset_ptr python_datasource::features_at_point(mapnik::coord2d const& pt) const +{ + using namespace boost::python; + + if (!is_bound_) bind(); + + ensure_gil lock; + + object features(datasource_.attr("features_at_point")(pt)); + + // if we returned none, return an empty set + if(features.ptr() == object().ptr()) + return mapnik::featureset_ptr(); + + // otherwise, return a feature set which can iterate over the iterator + return boost::make_shared(features); +} diff --git a/plugins/input/python/python_datasource.hpp b/plugins/input/python/python_datasource.hpp new file mode 100644 index 000000000..6f23fcfc8 --- /dev/null +++ b/plugins/input/python/python_datasource.hpp @@ -0,0 +1,56 @@ +#ifndef PYTHON_DATASOURCE_HPP +#define PYTHON_DATASOURCE_HPP + +// mapnik +#include + +// boost +#include + +class python_datasource : public mapnik::datasource +{ +public: + // constructor + // arguments must not change + python_datasource(mapnik::parameters const& params, bool bind=true); + + // destructor + virtual ~python_datasource (); + + // mandatory: type of the plugin, used to match at runtime + mapnik::datasource::datasource_t type() const; + + // mandatory: name of the plugin + static const char* name(); + + // mandatory: function to query features by box2d + // this is called when rendering, specifically in feature_style_processor.hpp + mapnik::featureset_ptr features(mapnik::query const& q) const; + + // mandatory: function to query features by point (coord2d) + // not used by rendering, but available to calling applications + mapnik::featureset_ptr features_at_point(mapnik::coord2d const& pt) const; + + // mandatory: return the box2d of the datasource + // called during rendering to determine if the layer should be processed + mapnik::box2d envelope() const; + + // mandatory: optionally return the overal geometry type of the datasource + boost::optional get_geometry_type() const; + + // mandatory: return the layer descriptor + mapnik::layer_descriptor get_descriptor() const; + + // mandatory: will bind the datasource given params + void bind() const; + +private: + static const char* name_; + mutable mapnik::layer_descriptor desc_; + const std::string factory_; + std::map kwargs_; + mutable boost::python::object datasource_; +}; + + +#endif // PYTHON_DATASOURCE_HPP diff --git a/plugins/input/python/python_featureset.cpp b/plugins/input/python/python_featureset.cpp new file mode 100644 index 000000000..f8a3edb8f --- /dev/null +++ b/plugins/input/python/python_featureset.cpp @@ -0,0 +1,30 @@ +// boost +#include + +#include "python_featureset.hpp" +#include "python_utils.hpp" + +python_featureset::python_featureset(boost::python::object iterator) +{ + ensure_gil lock; + begin_ = boost::python::stl_input_iterator(iterator); +} + +python_featureset::~python_featureset() +{ + ensure_gil lock; + begin_ = end_; +} + +mapnik::feature_ptr python_featureset::next() +{ + // checking to see if we've reached the end does not require the GIL. + if(begin_ == end_) + return mapnik::feature_ptr(); + + // getting the next feature might call into the interpreter and so the GIL must be held. + ensure_gil lock; + + return *(begin_++); +} + diff --git a/plugins/input/python/python_featureset.hpp b/plugins/input/python/python_featureset.hpp new file mode 100644 index 000000000..ae7525af3 --- /dev/null +++ b/plugins/input/python/python_featureset.hpp @@ -0,0 +1,30 @@ +#ifndef PYTHON_FEATURESET_HPP +#define PYTHON_FEATURESET_HPP + +// boost +#include +#include + +// mapnik +#include + +// extend the mapnik::Featureset defined in include/mapnik/datasource.hpp +class python_featureset : public mapnik::Featureset +{ +public: + // this constructor can have any arguments you need + python_featureset(boost::python::object iterator); + + // desctructor + virtual ~python_featureset(); + + // mandatory: you must expose a next() method, called when rendering + mapnik::feature_ptr next(); + +private: + typedef boost::python::stl_input_iterator feature_iter; + + feature_iter begin_, end_; +}; + +#endif // PYTHON_FEATURESET_HPP diff --git a/plugins/input/python/python_utils.hpp b/plugins/input/python/python_utils.hpp new file mode 100644 index 000000000..2fbed1201 --- /dev/null +++ b/plugins/input/python/python_utils.hpp @@ -0,0 +1,16 @@ +#ifndef PYTHON_UTILS_HPP +#define PYTHON_UTILS_HPP + +#include + +// Use RAII to acquire and release the GIL as needed. +class ensure_gil +{ + public: + ensure_gil() : gil_state_(PyGILState_Ensure()) {} + ~ensure_gil() { PyGILState_Release( gil_state_ ); } + protected: + PyGILState_STATE gil_state_; +}; + +#endif // PYTHON_UTILS_HPP diff --git a/tests/data/good_maps/python_circle_datasource.xml b/tests/data/good_maps/python_circle_datasource.xml new file mode 100644 index 000000000..0d19d45c2 --- /dev/null +++ b/tests/data/good_maps/python_circle_datasource.xml @@ -0,0 +1,50 @@ + + + + + + + + + + 1 + + python + python_plugin_test:CirclesDatasource + + + + + 2 + + python + python_plugin_test:CirclesDatasource + -20 + 50 + + + + + 3 + + python + python_plugin_test:CirclesDatasource + 60 + 140 + 20 + + + + diff --git a/tests/data/good_maps/python_point_datasource.xml b/tests/data/good_maps/python_point_datasource.xml new file mode 100644 index 000000000..3f311d746 --- /dev/null +++ b/tests/data/good_maps/python_point_datasource.xml @@ -0,0 +1,18 @@ + + + + + + 1 + + python + python_plugin_test:PointDatasource + + + + diff --git a/tests/python_tests/images/support/mapnik-python-circle-render1.png b/tests/python_tests/images/support/mapnik-python-circle-render1.png new file mode 100644 index 000000000..d4c7b946b Binary files /dev/null and b/tests/python_tests/images/support/mapnik-python-circle-render1.png differ diff --git a/tests/python_tests/images/support/mapnik-python-point-render1.png b/tests/python_tests/images/support/mapnik-python-point-render1.png new file mode 100644 index 000000000..b131d8644 Binary files /dev/null and b/tests/python_tests/images/support/mapnik-python-point-render1.png differ diff --git a/tests/python_tests/python_plugin_test.py b/tests/python_tests/python_plugin_test.py new file mode 100644 index 000000000..6b180e2fc --- /dev/null +++ b/tests/python_tests/python_plugin_test.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import os +import math +import mapnik +import sys +from utilities import execution_path +from nose.tools import * + +try: + from shapely.geometry import Point + have_shapely = True +except ImportError: + print('Shapely is required for python data source test.') + have_shapely = False + +def setup(): + # All of the paths used are relative, if we run the tests + # from another directory we need to chdir() + os.chdir(execution_path('.')) + +class PointDatasource(mapnik.PythonDatasource): + def __init__(self): + super(PointDatasource, self).__init__( + envelope = mapnik.Box2d(0,-10,100,110) + ) + + def features(self, query): + return mapnik.PythonDatasource.wkb_features( + keys = ('label',), + features = ( + ( Point(5,6).wkb, { 'label': 'foo-bar'} ), + ( Point(60,50).wkb, { 'label': 'buzz-quux'} ), + ) + ) + +def box2d_to_shapely(box): + import shapely.geometry + return shapely.geometry.box(box.minx, box.miny, box.maxx, box.maxy) + +class ConcentricCircles(object): + def __init__(self, centre, bounds, step=1): + self.centre = centre + self.bounds = bounds + self.step = step + + class Iterator(object): + def __init__(self, container): + self.container = container + + centre = self.container.centre + bounds = self.container.bounds + step = self.container.step + + if centre.within(bounds): + self.radius = step + else: + self.radius = math.ceil(centre.distance(bounds) / float(step)) * step + + def next(self): + circle = self.container.centre.buffer(self.radius) + self.radius += self.container.step + + # has the circle grown so large that the boundary is entirely within it? + if circle.contains(self.container.bounds): + raise StopIteration() + + return ( circle.wkb, { } ) + + def __iter__(self): + return ConcentricCircles.Iterator(self) + +class CirclesDatasource(mapnik.PythonDatasource): + def __init__(self, centre_x=-20, centre_y=0, step=10): + super(CirclesDatasource, self).__init__( + geometry_type=mapnik.DataGeometryType.Polygon + ) + + # note that the plugin loader will set all arguments to strings and will not try to parse them + centre_x = int(centre_x) + centre_y = int(centre_y) + step = int(step) + + self.centre_x = centre_x + self.centre_y = centre_y + self.step = step + + def features(self, query): + # Get the query bounding-box as a shapely bounding box + bounding_box = box2d_to_shapely(query.bbox) + centre = Point(self.centre_x, self.centre_y) + + return mapnik.PythonDatasource.wkb_features( + keys = (), + features = ConcentricCircles(centre, bounding_box, self.step) + ) + +if 'python' in mapnik.DatasourceCache.instance().plugin_names() and have_shapely: + # make sure we can load from ourself as a module + sys.path.append(execution_path('.')) + + def test_python_point_init(): + ds = mapnik.Python(factory='python_plugin_test:PointDatasource') + e = ds.envelope() + + assert_almost_equal(e.minx, 0, places=7) + assert_almost_equal(e.miny, -10, places=7) + assert_almost_equal(e.maxx, 100, places=7) + assert_almost_equal(e.maxy, 110, places=7) + + def test_python_circle_init(): + ds = mapnik.Python(factory='python_plugin_test:CirclesDatasource') + e = ds.envelope() + + assert_almost_equal(e.minx, -180, places=7) + assert_almost_equal(e.miny, -90, places=7) + assert_almost_equal(e.maxx, 180, places=7) + assert_almost_equal(e.maxy, 90, places=7) + + def test_python_circle_init_with_args(): + ds = mapnik.Python(factory='python_plugin_test:CirclesDatasource', centre_x=40, centre_y=7) + e = ds.envelope() + + assert_almost_equal(e.minx, -180, places=7) + assert_almost_equal(e.miny, -90, places=7) + assert_almost_equal(e.maxx, 180, places=7) + assert_almost_equal(e.maxy, 90, places=7) + + def test_python_point_rendering(): + m = mapnik.Map(512,512) + mapnik.load_map(m,'../data/good_maps/python_point_datasource.xml') + m.zoom_all() + im = mapnik.Image(512,512) + mapnik.render(m,im) + actual = '/tmp/mapnik-python-point-render1.png' + expected = 'images/support/mapnik-python-point-render1.png' + im.save(actual) + expected_im = mapnik.Image.open(expected) + eq_(im.tostring(),expected_im.tostring(), + 'failed comparing actual (%s) and expected (%s)' % (actual,'tests/python_tests/'+ expected)) + + def test_python_circle_rendering(): + m = mapnik.Map(512,512) + mapnik.load_map(m,'../data/good_maps/python_circle_datasource.xml') + m.zoom_all() + im = mapnik.Image(512,512) + mapnik.render(m,im) + actual = '/tmp/mapnik-python-circle-render1.png' + expected = 'images/support/mapnik-python-circle-render1.png' + im.save(actual) + expected_im = mapnik.Image.open(expected) + eq_(im.tostring(),expected_im.tostring(), + 'failed comparing actual (%s) and expected (%s)' % (actual,'tests/python_tests/'+ expected)) + +if __name__ == "__main__": + setup() + [eval(run)() for run in dir() if 'test_' in run]