diff --git a/owslib/feature/postrequest.py b/owslib/feature/postrequest.py index 83c6b3dd2..c9ca945c5 100644 --- a/owslib/feature/postrequest.py +++ b/owslib/feature/postrequest.py @@ -173,8 +173,12 @@ def set_filter(self, filter): Cannot be used with set_bbox() or set_featureid(). """ - f = etree.fromstring(filter) - sub_elem = f.find(util.nspath("Filter", FES_NAMESPACE)) + if isinstance(filter, str): + f = etree.fromstring(filter) + sub_elem = f.find(util.nspath("Filter", FES_NAMESPACE)) + else: + sub_elem = filter + self._query.append(sub_elem) def set_maxfeatures(self, maxfeatures): diff --git a/owslib/fes2.py b/owslib/fes2.py index f7141cc9e..795104fd4 100644 --- a/owslib/fes2.py +++ b/owslib/fes2.py @@ -18,6 +18,7 @@ from owslib.etree import etree from owslib import util from owslib.namespaces import Namespaces +from abc import ABCMeta, abstractmethod # default variables @@ -391,6 +392,64 @@ def toXML(self): return tmp +class Filter(OgcExpression): + def __init__(self, filter): + self.filter = filter + + def toXML(self): + node = etree.Element(util.nspath_eval("fes:Filter", namespaces)) + node.append(self.filter.toXML()) + return node + + +class TopologicalOpType(OgcExpression, metaclass=ABCMeta): + """Abstract base class for topological operators.""" + @property + @abstractmethod + def operation(self): + """This is a mechanism to ensure this class is subclassed by an actual operation.""" + pass + + def __init__(self, propertyname, geometry): + self.propertyname = propertyname + self.geometry = geometry + + def toXML(self): + node = etree.Element(util.nspath_eval(f"fes:{self.operation}", namespaces)) + etree.SubElement(node, util.nspath_eval("fes:ValueReference", namespaces)).text = self.propertyname + node.append(self.geometry.toXML()) + + return node + + +class Intersects(TopologicalOpType): + operation = "Intersects" + + +class Contains(TopologicalOpType): + operation = "Contains" + + +class Disjoint(TopologicalOpType): + operation = "Disjoint" + + +class Within(TopologicalOpType): + operation = "Within" + + +class Touches(TopologicalOpType): + operation = "Touches" + + +class Overlaps(TopologicalOpType): + operation = "Overlaps" + + +class Equals(TopologicalOpType): + operation = "Equals" + + # BINARY class BinaryLogicOpType(OgcExpression): """ Binary Operators: And / Or """ diff --git a/owslib/gml.py b/owslib/gml.py new file mode 100644 index 000000000..3bea9eb82 --- /dev/null +++ b/owslib/gml.py @@ -0,0 +1,69 @@ +from dataclasses import dataclass +from typing import Sequence +from owslib.etree import etree +from owslib import util +from owslib.namespaces import Namespaces + + +def get_namespaces(): + n = Namespaces() + ns = n.get_namespaces(["gml32", "ogc", "xsd"]) + ns[None] = n.get_namespace("ogc") + return ns + + +namespaces = get_namespaces() + + +def prefix(x): + """Shorthand to insert namespaces.""" + return util.nspath_eval(x, namespaces) + + +@dataclass +class AbstractGMLType(): + id: str + + +@dataclass +class AbstractGeometryType(AbstractGMLType): + srsName: str = None + srsDimension: int = None + # axisLabels: str = None + # uomLabels: str = None + + description: str = None + descriptionReference: str = None + identifier: str = None + name: str = None + + +@dataclass +class _PointBase: + """This is used to avoid issues arising from non-optional attributes defined after optional attributes.""" + pos: Sequence[float] + + +@dataclass +class Point(AbstractGeometryType, _PointBase): + """GML Point object.""" + + def toXML(self): + """Return `lxml.etree.Element` object.""" + node = etree.Element(prefix("gml32:Point")) + for key in ["id", "srsName"]: + if getattr(self, key, None) is not None: + node.set(prefix(f"gml32:{key}"), getattr(self, key)) + + for key in ["description", "descriptionReference", "identifier", "name"]: + content = getattr(self, key) + if content is not None: + etree.SubElement(node, prefix(f"gml32:{key}")).text = content + + coords = etree.SubElement(node, prefix("gml32:pos")) + coords.text = " ".join([str(c) for c in self.pos]) + for key in ["srsDimension"]: + if getattr(self, key, None) is not None: + node.set(prefix(f"gml32:{key}"), getattr(self, key)) + + return node diff --git a/owslib/iso.py b/owslib/iso.py index 7b68ecd71..f29e6312c 100644 --- a/owslib/iso.py +++ b/owslib/iso.py @@ -548,7 +548,8 @@ def __init__(self, md=None, identtype=None): self.status = _testCodeListValue(md.find(util.nspath_eval('gmd:status/gmd:MD_ProgressCode', namespaces))) self.graphicoverview = [] - for val in md.findall(util.nspath_eval('gmd:graphicOverview/gmd:MD_BrowseGraphic/gmd:fileName/gco:CharacterString', namespaces)): + for val in md.findall(util.nspath_eval( + 'gmd:graphicOverview/gmd:MD_BrowseGraphic/gmd:fileName/gco:CharacterString', namespaces)): if val is not None: val2 = util.testXMLValue(val) if val2 is not None: diff --git a/requirements.txt b/requirements.txt index fcca5839b..e508ba74f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ pytz requests>=1.0 pyproj >=2 pyyaml +dataclasses; python_version < '3.7' diff --git a/tests/test_fes2.py b/tests/test_fes2.py new file mode 100644 index 000000000..6bb12be58 --- /dev/null +++ b/tests/test_fes2.py @@ -0,0 +1,46 @@ +import pytest +from owslib.wfs import WebFeatureService +from owslib import fes2 +from owslib.gml import Point +from owslib.namespaces import Namespaces +from owslib import util +import json + +n = Namespaces() +FES_NAMESPACE = n.get_namespace("fes") +GML32_NAMESPACE = n.get_namespace("gml32") + + +SERVICE_URL = "http://soggy.zoology.ubc.ca:8080/geoserver/wfs" + + +def test_raw_filter(): + """Just inspect the filter object (not embedded in a getfeature request).""" + point = Point(id="qc", srsName="http://www.opengis.net/gml/srs/epsg.xml#4326", pos=[-71, 46]) + f = fes2.Filter( + fes2.And([fes2.Intersects(propertyname="the_geom", geometry=point), + fes2.PropertyIsLike("name", "value")] + ) + ) + + xml = f.toXML() + + # Fairly basic test + xml.find(util.nspath("Filter", FES_NAMESPACE)) + xml.find(util.nspath("And", FES_NAMESPACE)) + xml.find(util.nspath("Intersects", FES_NAMESPACE)) + xml.find(util.nspath("Point", GML32_NAMESPACE)) + + +@pytest.mark.online +def test_filter(): + """A request without filtering will yield 600 entries. With filtering we expect only one. + + Note that this type of topological filtering only works (so far) with WFS 2.0.0 and POST requests. + """ + wfs = WebFeatureService(SERVICE_URL, version="2.0.0") + layer = "bcmca:commercialfish_crab" + point = Point(id="random", srsName="http://www.opengis.net/gml/srs/epsg.xml#4326", pos=[-129.8, 55.44]) + f = fes2.Filter(fes2.Contains(propertyname="geom", geometry=point)) + r = wfs.getfeature(layer, outputFormat="application/json", method="POST", filter=f.toXML()) + assert json.load(r)["totalFeatures"] == 1 diff --git a/tests/test_wfs_generic.py b/tests/test_wfs_generic.py index 57af94c61..343c6d059 100644 --- a/tests/test_wfs_generic.py +++ b/tests/test_wfs_generic.py @@ -2,7 +2,7 @@ from owslib.util import ServiceException from urllib.parse import urlparse from tests.utils import resource_file, sorted_url_query, service_ok - +import json import pytest SERVICE_URL = 'https://www.sciencebase.gov/catalogMaps/mapping/ows/53398e51e4b0db25ad10d288' @@ -54,7 +54,6 @@ def test_getfeature(): @pytest.mark.skipif(not service_ok(SERVICE_URL), reason="WFS service is unreachable") def test_outputformat_wfs_100(): - import json wfs = WebFeatureService('https://www.sciencebase.gov/catalogMaps/mapping/ows/53398e51e4b0db25ad10d288', version='1.0.0') feature = wfs.getfeature( @@ -66,7 +65,6 @@ def test_outputformat_wfs_100(): @pytest.mark.skipif(not service_ok(SERVICE_URL), reason="WFS service is unreachable") def test_outputformat_wfs_110(): - import json wfs = WebFeatureService('https://www.sciencebase.gov/catalogMaps/mapping/ows/53398e51e4b0db25ad10d288', version='1.1.0') feature = wfs.getfeature( @@ -78,7 +76,6 @@ def test_outputformat_wfs_110(): @pytest.mark.skipif(not service_ok(SERVICE_URL), reason="WFS service is unreachable") def test_outputformat_wfs_200(): - import json wfs = WebFeatureService('https://www.sciencebase.gov/catalogMaps/mapping/ows/53398e51e4b0db25ad10d288', version='2.0.0') feature = wfs.getfeature( @@ -90,7 +87,6 @@ def test_outputformat_wfs_200(): @pytest.mark.skipif(not service_ok(SERVICE_URL), reason="WFS service is unreachable") def test_srsname_wfs_100(): - import json wfs = WebFeatureService('https://www.sciencebase.gov/catalogMaps/mapping/ows/53398e51e4b0db25ad10d288', version='1.0.0') # ServiceException: Unable to support srsName: EPSG:99999999 @@ -99,7 +95,6 @@ def test_srsname_wfs_100(): typename=['sb:Project_Area'], maxfeatures=1, propertyname=None, outputFormat='application/json', srsname="EPSG:99999999") - import json wfs = WebFeatureService('https://www.sciencebase.gov/catalogMaps/mapping/ows/53398e51e4b0db25ad10d288', version='1.0.0') feature = wfs.getfeature( @@ -112,7 +107,6 @@ def test_srsname_wfs_100(): @pytest.mark.skipif(not service_ok(SERVICE_URL), reason="WFS service is unreachable") def test_srsname_wfs_110(): - import json wfs = WebFeatureService( 'https://www.sciencebase.gov/catalogMaps/mapping/ows/53398e51e4b0db25ad10d288', version='1.1.0') @@ -122,7 +116,6 @@ def test_srsname_wfs_110(): typename=['sb:Project_Area'], maxfeatures=1, propertyname=None, outputFormat='application/json', srsname="EPSG:99999999") - import json wfs = WebFeatureService( 'https://www.sciencebase.gov/catalogMaps/mapping/ows/53398e51e4b0db25ad10d288', version='1.0.0')