# -*- coding: utf-8 -*- """ *************************************************************************** catalog.py --------------------- Date : November 2012 Copyright : (C) 2012 by Victor Olaya Email : volayaf at gmail dot com *************************************************************************** * * * This program is free software; you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation; either version 2 of the License, or * * (at your option) any later version. * * * *************************************************************************** """ __author__ = 'Victor Olaya' __date__ = 'November 2012' __copyright__ = '(C) 2012, Victor Olaya' # This will get replaced with a git SHA1 when you do a git archive __revision__ = '$Format:%H$' from datetime import datetime, timedelta import logging from sextante.admintools.geoserver.layer import Layer from sextante.admintools.geoserver.store import coveragestore_from_index, datastore_from_index, \ UnsavedDataStore, UnsavedCoverageStore from sextante.admintools.geoserver.style import Style from sextante.admintools.geoserver.support import prepare_upload_bundle, url from sextante.admintools.geoserver.layergroup import LayerGroup, UnsavedLayerGroup from sextante.admintools.geoserver.workspace import workspace_from_index, Workspace from os import unlink from xml.etree.ElementTree import XML from xml.parsers.expat import ExpatError from urlparse import urlparse from sextante.admintools import httplib2 logger = logging.getLogger("gsconfig.catalog") class UploadError(Exception): pass class ConflictingDataError(Exception): pass class AmbiguousRequestError(Exception): pass class FailedRequestError(Exception): pass def _name(named): """Get the name out of an object. This varies based on the type of the input: * the "name" of a string is itself * the "name" of None is itself * the "name" of an object with a property named name is that property - as long as it's a string * otherwise, we raise a ValueError """ if isinstance(named, basestring) or named is None: return named elif hasattr(named, 'name') and isinstance(named.name, basestring): return named.name else: raise ValueError("Can't interpret %s as a name or a configuration object" % named) class Catalog(object): """ The GeoServer catalog represents all of the information in the GeoServer configuration. This includes: - Stores of geospatial data - Resources, or individual coherent datasets within stores - Styles for resources - Layers, which combine styles with resources to create a visible map layer - LayerGroups, which alias one or more layers for convenience - Workspaces, which provide logical grouping of Stores - Maps, which provide a set of OWS services with a subset of the server's Layers - Namespaces, which provide unique identifiers for resources """ def __init__(self, service_url, username="admin", password="geoserver", disable_ssl_certificate_validation=False): self.service_url = service_url if self.service_url.endswith("/"): self.service_url = self.service_url.strip("/") self.http = httplib2.Http( disable_ssl_certificate_validation=disable_ssl_certificate_validation) self.username = username self.password = password self.http.add_credentials(self.username, self.password) netloc = urlparse(service_url).netloc self.http.authorizations.append( httplib2.BasicAuthentication( (username, password), netloc, service_url, {}, None, None, self.http )) self._cache = dict() def delete(self, config_object, purge=False, recurse=False): """ send a delete request XXX [more here] """ rest_url = config_object.href #params aren't supported fully in httplib2 yet, so: params = [] # purge deletes the SLD from disk when a style is deleted if purge: params.append("purge=true") # recurse deletes the resource when a layer is deleted. if recurse: params.append("recurse=true") if params: rest_url = rest_url + "?" + "&".join(params) headers = { "Content-type": "application/xml", "Accept": "application/xml" } response, content = self.http.request(rest_url, "DELETE", headers=headers) self._cache.clear() if response.status == 200: return (response, content) else: raise FailedRequestError("Tried to make a DELETE request to %s but got a %d status code: \n%s" % (rest_url, response.status, content)) def get_xml(self, rest_url): logger.debug("GET %s", rest_url) cached_response = self._cache.get(rest_url) def is_valid(cached_response): return cached_response is not None and datetime.now() - cached_response[0] < timedelta(seconds=5) def parse_or_raise(xml): try: return XML(xml) except (ExpatError, SyntaxError), e: msg = "GeoServer gave non-XML response for [GET %s]: %s" msg = msg % (rest_url, xml) raise Exception(msg, e) if is_valid(cached_response): raw_text = cached_response[1] return parse_or_raise(raw_text) else: response, content = self.http.request(rest_url) if response.status == 200: self._cache[rest_url] = (datetime.now(), content) return parse_or_raise(content) else: raise FailedRequestError("Tried to make a GET request to %s but got a %d status code: \n%s" % (url, response.status, content)) def reload(self): reload_url = url(self.service_url, ['reload']) response = self.http.request(reload_url, "POST") self._cache.clear() return response def save(self, obj): """ saves an object to the REST service gets the object's REST location and the XML from the object, then POSTS the request. """ rest_url = obj.href message = obj.message() headers = { "Content-type": "application/xml", "Accept": "application/xml" } logger.debug("%s %s", obj.save_method, obj.href) response = self.http.request(rest_url, obj.save_method, message, headers) headers, body = response self._cache.clear() if 400 <= int(headers['status']) < 600: raise FailedRequestError("Error code (%s) from GeoServer: %s" % (headers['status'], body)) return response def get_store(self, name, workspace=None): #stores = [s for s in self.get_stores(workspace) if s.name == name] if workspace is None: store = None for ws in self.get_workspaces(): found = None try: found = self.get_store(name, ws) except: # don't expect every workspace to contain the named store pass if found: if store: raise AmbiguousRequestError("Multiple stores found named: " + name) else: store = found if not store: raise FailedRequestError("No store found named: " + name) return store else: # workspace is not None if isinstance(workspace, basestring): workspace = self.get_workspace(workspace) if workspace is None: return None logger.debug("datastore url is [%s]", workspace.datastore_url ) ds_list = self.get_xml(workspace.datastore_url) cs_list = self.get_xml(workspace.coveragestore_url) datastores = [n for n in ds_list.findall("dataStore") if n.find("name").text == name] coveragestores = [n for n in cs_list.findall("coverageStore") if n.find("name").text == name] ds_len, cs_len = len(datastores), len(coveragestores) if ds_len == 1 and cs_len == 0: return datastore_from_index(self, workspace, datastores[0]) elif ds_len == 0 and cs_len == 1: return coveragestore_from_index(self, workspace, coveragestores[0]) elif ds_len == 0 and cs_len == 0: raise FailedRequestError("No store found in " + str(workspace) + " named: " + name) else: raise AmbiguousRequestError(str(workspace) + " and name: " + name + " do not uniquely identify a layer") def get_stores(self, workspace=None): if workspace is not None: if isinstance(workspace, basestring): workspace = self.get_workspace(workspace) ds_list = self.get_xml(workspace.datastore_url) cs_list = self.get_xml(workspace.coveragestore_url) datastores = [datastore_from_index(self, workspace, n) for n in ds_list.findall("dataStore")] coveragestores = [coveragestore_from_index(self, workspace, n) for n in cs_list.findall("coverageStore")] return datastores + coveragestores else: stores = [] for ws in self.get_workspaces(): a = self.get_stores(ws) stores.extend(a) return stores def create_datastore(self, name, workspace=None): if isinstance(workspace, basestring): workspace = self.get_workspace(workspace) elif workspace is None: workspace = self.get_default_workspace() return UnsavedDataStore(self, name, workspace) def create_coveragestore2(self, name, workspace = None): """ Hm we already named the method that creates a coverage *resource* create_coveragestore... time for an API break? """ if isinstance(workspace, basestring): workspace = self.get_workspace(workspace) elif workspace is None: workspace = self.get_default_workspace() return UnsavedCoverageStore(self, name, workspace) def add_data_to_store(self, store, name, data, workspace=None, overwrite = False, charset = None): if isinstance(store, basestring): store = self.get_store(store, workspace=workspace) if workspace is not None: workspace = _name(workspace) assert store.workspace.name == workspace, "Specified store (%s) is not in specified workspace (%s)!" % (store, workspace) else: workspace = store.workspace.name store = store.name if isinstance(data, dict): bundle = prepare_upload_bundle(name, data) else: bundle = data params = dict() if overwrite: params["update"] = "overwrite" if charset is not None: params["charset"] = charset message = open(bundle) headers = { 'Content-Type': 'application/zip', 'Accept': 'application/xml' } upload_url = url(self.service_url, ["workspaces", workspace, "datastores", store, "file.shp"], params) try: headers, response = self.http.request(upload_url, "PUT", message, headers) self._cache.clear() if headers.status != 201: raise UploadError(response) finally: unlink(bundle) def create_featurestore(self, name, data, workspace=None, overwrite=False, charset=None): if not overwrite: try: store = self.get_store(name, workspace) msg = "There is already a store named " + name if workspace: msg += " in " + str(workspace) raise ConflictingDataError(msg) except FailedRequestError: # we don't really expect that every layer name will be taken pass if workspace is None: workspace = self.get_default_workspace() workspace = _name(workspace) params = dict() if charset is not None: params['charset'] = charset ds_url = url(self.service_url, ["workspaces", workspace, "datastores", name, "file.shp"], params) # PUT /workspaces//datastores//file.shp headers = { "Content-type": "application/zip", "Accept": "application/xml" } if isinstance(data,dict): logger.debug('Data is NOT a zipfile') archive = prepare_upload_bundle(name, data) else: logger.debug('Data is a zipfile') archive = data message = open(archive) try: headers, response = self.http.request(ds_url, "PUT", message, headers) self._cache.clear() if headers.status != 201: raise UploadError(response) finally: unlink(archive) def create_coveragestore(self, name, data, workspace=None, overwrite=False): if not overwrite: try: store = self.get_store(name, workspace) msg = "There is already a store named " + name if workspace: msg += " in " + str(workspace) raise ConflictingDataError(msg) except FailedRequestError: # we don't really expect that every layer name will be taken pass if workspace is None: workspace = self.get_default_workspace() headers = { "Content-type": "image/tiff", "Accept": "application/xml" } archive = None ext = "geotiff" if isinstance(data, dict): archive = prepare_upload_bundle(name, data) message = open(archive) if "tfw" in data: headers['Content-type'] = 'application/archive' ext = "worldimage" elif isinstance(data, basestring): message = open(data) else: message = data cs_url = url(self.service_url, ["workspaces", workspace.name, "coveragestores", name, "file." + ext]) try: headers, response = self.http.request(cs_url, "PUT", message, headers) self._cache.clear() if headers.status != 201: raise UploadError(response) finally: if archive is not None: unlink(archive) def get_resource(self, name, store=None, workspace=None): if store is not None: candidates = [s for s in self.get_resources(store) if s.name == name] if len(candidates) == 0: return None elif len(candidates) > 1: raise AmbiguousRequestError else: return candidates[0] if workspace is not None: for store in self.get_stores(workspace): resource = self.get_resource(name, store) if resource is not None: return resource return None for ws in self.get_workspaces(): resource = self.get_resource(name, workspace=ws) if resource is not None: return resource return None def get_resources(self, store=None, workspace=None): if isinstance(workspace, basestring): workspace = self.get_workspace(workspace) if isinstance(store, basestring): store = self.get_store(store, workspace) if store is not None: return store.get_resources() if workspace is not None: resources = [] for store in self.get_stores(workspace): resources.extend(self.get_resources(store)) return resources resources = [] for ws in self.get_workspaces(): resources.extend(self.get_resources(workspace=ws)) return resources def get_layer(self, name): try: lyr = Layer(self, name) lyr.fetch() return lyr except FailedRequestError: return None def get_layers(self, resource=None): if isinstance(resource, basestring): resource = self.get_resource(resource) layers_url = url(self.service_url, ["layers.xml"]) description = self.get_xml(layers_url) lyrs = [Layer(self, l.find("name").text) for l in description.findall("layer")] if resource is not None: lyrs = [l for l in lyrs if l.resource.href == resource.href] # TODO: Filter by style return lyrs def get_layergroup(self, name=None): try: group_url = url(self.service_url, ["layergroups", name + ".xml"]) group = self.get_xml(group_url) return LayerGroup(self, group.find("name").text) except FailedRequestError: return None def get_layergroups(self): groups = self.get_xml("%s/layergroups.xml" % self.service_url) return [LayerGroup(self, g.find("name").text) for g in groups.findall("layerGroup")] def create_layergroup(self, name, layers = (), styles = (), bounds = None): if any(g.name == name for g in self.get_layergroups()): raise ConflictingDataError("LayerGroup named %s already exists!" % name) else: return UnsavedLayerGroup(self, name, layers, styles, bounds) def get_style(self, name): try: style_url = url(self.service_url, ["styles", name + ".xml"]) dom = self.get_xml(style_url) return Style(self, dom.find("name").text) except FailedRequestError: return None def get_styles(self): styles_url = url(self.service_url, ["styles.xml"]) description = self.get_xml(styles_url) return [Style(self, s.find('name').text) for s in description.findall("style")] def create_style(self, name, data, overwrite = False): if overwrite == False and self.get_style(name) is not None: raise ConflictingDataError("There is already a style named %s" % name) headers = { "Content-type": "application/vnd.ogc.sld+xml", "Accept": "application/xml" } if overwrite: style_url = url(self.service_url, ["styles", name + ".sld"]) headers, response = self.http.request(style_url, "PUT", data, headers) else: style_url = url(self.service_url, ["styles"], dict(name=name)) headers, response = self.http.request(style_url, "POST", data, headers) self._cache.clear() if headers.status < 200 or headers.status > 299: raise UploadError(response) def create_workspace(self, name, uri): xml = ("" "{name}" "{uri}" "").format(name=name, uri=uri) headers = { "Content-Type": "application/xml" } workspace_url = self.service_url + "/namespaces/" headers, response = self.http.request(workspace_url, "POST", xml, headers) assert 200 <= headers.status < 300, "Tried to create workspace but got " + str(headers.status) + ": " + response self._cache.clear() return self.get_workspace(name) def get_workspaces(self): description = self.get_xml("%s/workspaces.xml" % self.service_url) return [workspace_from_index(self, node) for node in description.findall("workspace")] def get_workspace(self, name): candidates = [w for w in self.get_workspaces() if w.name == name] if len(candidates) == 0: return None elif len(candidates) > 1: raise AmbiguousRequestError() else: return candidates[0] def get_default_workspace(self): return Workspace(self, "default") def set_default_workspace(self): raise NotImplementedError()