diff --git a/pyvo/auth/authsession.py b/pyvo/auth/authsession.py index b60d6ebee..b5d6e2e3b 100644 --- a/pyvo/auth/authsession.py +++ b/pyvo/auth/authsession.py @@ -21,9 +21,10 @@ class AuthSession: credentials are added to the request before it is sent. """ - def __init__(self): + def __init__(self, verify=False): super(AuthSession, self).__init__() self.credentials = CredentialStore() + self.verify = verify self._auth_urls = AuthURLs() def add_security_method_for_url(self, url, security_method, exact=False): @@ -105,6 +106,7 @@ def _request(self, http_method, url, **kwargs): logging.debug('Using auth method: %s', negotiated_method) session = self.credentials.get(negotiated_method) + session.verify = False return session.request(http_method, url, **kwargs) def __repr__(self): diff --git a/pyvo/dal/vosi.py b/pyvo/dal/vosi.py index 0cdbd99bc..eb70c54b6 100644 --- a/pyvo/dal/vosi.py +++ b/pyvo/dal/vosi.py @@ -37,7 +37,7 @@ def _get_endpoint(self, endpoint): for ep_url in ep_urls: try: - response = self._session.get(ep_url, stream=True) + response = self._session.get(ep_url, stream=True, verify=False) response.raise_for_status() break except requests.RequestException: diff --git a/pyvo/gws/__init__.py b/pyvo/gws/__init__.py new file mode 100644 index 000000000..5bcd63679 --- /dev/null +++ b/pyvo/gws/__init__.py @@ -0,0 +1,8 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +from .vospace import VOSpaceService + +# from .exceptions import ( +# DALAccessError, DALProtocolError, DALFormatError, DALServiceError, +# DALQueryError, DALOverflowWarning) + +__all__ = [VOSpaceService] diff --git a/pyvo/gws/tests/test_vospace.py b/pyvo/gws/tests/test_vospace.py new file mode 100644 index 000000000..e36954df1 --- /dev/null +++ b/pyvo/gws/tests/test_vospace.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python +# Licensed under a 3-clause BSD style license - see LICENSE.rst +""" +Tests for pyvo.dal.sia2 against remote services +""" +import pytest + +from pyvo.gws.vospace import Node, DataNode, ContainerNode, LinkNode, IVOA_DATETIME_FORMAT, ReaderWriter +from datetime import datetime + + +class TestVOSpace(): + # Tests VOSpace related classes + + def test_data_node(self): + assert DataNode('foo') == DataNode('foo') + assert DataNode('foo') != DataNode('foo', busy=True) + assert DataNode('foo') != DataNode('notfoo') + assert DataNode('foo', busy=True) != DataNode('notfoo') + + def get_expected_dn(): + exp_owner = 'boss' + exp_is_public = False + exp_ro_groups = ['ivo://authority/gms?gr1', 'ivo://authority/gms?gr2'] + exp_wr_groups = ['ivo://authority/gms?gr3', 'ivo://authority/gms?gr4'] + exp_properties = {'myprop': 123} + exp_last_mod = datetime.strptime('2020-09-25T20:36:06.317', IVOA_DATETIME_FORMAT) + exp_length = 33 + exp_content_checksum = 'md5:abc' + exp_content_type = 'fits' + dn = DataNode('foo') + dn.owner = exp_owner + dn.is_public = exp_is_public + for gr in exp_ro_groups: + dn.ro_groups.append(gr) + for gr in exp_wr_groups: + dn.wr_groups.append(gr) + for key in exp_properties.keys(): + dn.properties[key] = exp_properties[key] + dn._last_mod = exp_last_mod + dn._length = exp_length + dn._content_checksum = exp_content_checksum + dn._content_type = exp_content_type + return dn + + expected = get_expected_dn() + actual = get_expected_dn() + # try to break it by changing on attribute at the time + assert expected == actual + actual.owner = 'me' + assert expected != actual + actual.owner = expected.owner + actual.is_public = not expected.is_public + assert expected != actual + actual.is_public = expected.is_public + actual.ro_groups.pop(0) + assert expected != actual + actual.ro_groups.append('ivo://authority/gms?gr4') + assert expected != actual + actual = get_expected_dn() + actual.wr_groups.append('ivo://authority/gms?gr5') + assert expected != actual + actual = get_expected_dn() + for key in expected.properties.keys(): + actual.properties[key] = 'changed' + assert expected != actual + for key in expected.properties.keys(): + actual.properties[key] = expected.properties[key] + assert expected == actual + actual._last_mod = datetime.now() + assert expected != actual + actual._last_mod = expected.last_mod + actual._length = 222 + assert expected != actual + actual._length = expected.length + actual._content_checksum = 'md5:123' + assert expected != actual + actual._content_checksum = expected.content_checksum + actual._content_type = 'changed' + assert expected != actual + + def test_container_node(self): + expected = ContainerNode('cont') + actual = ContainerNode('cont') + assert expected == actual + expected_data_child = DataNode('child-data') + expected.add(expected_data_child) + actual_data_child = DataNode('child-data') + actual.add(actual_data_child) + assert expected == actual + assert len(actual.list_children()) == 1 + assert actual.list_children()[0] == expected_data_child + actual._children.clear() + assert expected != actual + actual.add(DataNode('child-data2')) + assert expected != actual + actual.add(actual_data_child) + assert expected != actual + actual._children.clear() + actual.add(actual_data_child) + assert expected == actual + + # try to add a child with same name but different type + with pytest.raises(ValueError): + actual.add(actual_data_child) + with pytest.raises(ValueError): + actual.add(ContainerNode('child-data')) + with pytest.raises(AttributeError): + actual.remove('non-existent') + + # same child name, different type + expected.add(ContainerNode('child-container')) + actual.add(LinkNode('child-container', target=None)) + assert expected != actual + + actual.remove('child-container') + actual.add(ContainerNode('child-container')) + assert expected == actual + + expected.add(LinkNode('child-link', target=None)) + actual.add(LinkNode('child-link', target=None)) + assert expected == actual + + # change attribute in superclass + expected.owner = 'me' + assert expected != actual + actual.owner = 'me' + assert expected == actual + + # change attribute in child + expected_data_child._content_checksum = 'md5:abc' + assert expected != actual + actual_data_child._content_checksum = 'md5:abc' + assert expected == actual + + def test_link_node(self): + expected = LinkNode('link-node', None) + actual = LinkNode('link-node', target=None) + assert expected == actual + expected._target = 'vos:target/abc' + assert expected != actual + actual._target = 'vos:target/abc' + assert expected == actual + # change attribute in superclass + expected.owner = 'me' + assert expected != actual + actual.owner = 'me' + assert expected == actual + + +class TestReaderWriter: + + def test_parse_simple_node(self): + source = ''' + + + ''' + result = ReaderWriter.from_xml(source) + assert isinstance(result, Node) + assert result.path == "test" + assert result.owner is None + assert result.is_public is False + assert result.ro_groups == [] + assert result.wr_groups == [] + assert result.properties == {} + assert result.last_mod is None + assert result._length is None + + def test_parse_node_with_properties(self): + source = ''' + + + John Doe + 2022-01-01T00:00:00.123 + group1 group2 + group3 group4 + myval + + + + + Jane Smith + 2022-02-01T00:00:00.1 + group5 group6 + group7 group8 + 33 + myval2 + + + + vos://authority~vault/test/child1 + + Jane Smith + 2022-03-01T00:00:00.2 + myval3 + + + + + John Doe + 2022-04-01T00:00:00.3 + group9 group10 + group11 group12 + + + + + ''' + result = ReaderWriter.from_xml(source) + assert isinstance(result, ContainerNode) + assert result.path == 'test' + assert result.owner == 'John Doe' + assert result.is_public is False + assert result.ro_groups == ['group1', 'group2'] + assert result.wr_groups == ['group3', 'group4'] + assert result.properties == { + 'ivo://auth/vospace#myprop': Node.NodeProperty( + 'ivo://auth/vospace#myprop', 'myval', False) + } + assert (result.last_mod == datetime.strptime( + '2022-01-01T00:00:00.123', IVOA_DATETIME_FORMAT)) + assert result._length is None + assert len(result.list_children()) == 3 + for child in result.list_children(): + if isinstance(child, DataNode): + assert child.path == 'test/child1' + assert child.name == 'child1' + assert child.ro_groups == ['group5', 'group6'] + assert child.wr_groups == ['group7', 'group8'] + assert (child.last_mod == datetime.strptime( + '2022-02-01T00:00:00.1', IVOA_DATETIME_FORMAT)) + assert child.length == 33 + assert child.properties == {'ivo://auth/vospace#myprop': Node.NodeProperty( + 'ivo://auth/vospace#myprop', 'myval2', False) + } + elif isinstance(child, LinkNode): + assert child.path == 'test/child2' + assert child.name == 'child2' + assert child.ro_groups == [] + assert child.wr_groups == [] + + assert child.last_mod == datetime.strptime('2022-03-01T00:00:00.2', IVOA_DATETIME_FORMAT) + assert child.properties == { + 'ivo://auth/vospace#myprop': Node.NodeProperty( + 'ivo://auth/vospace#myprop', 'myval3', False) + } + elif isinstance(child, ContainerNode): + assert child.path == 'test/child3' + assert child.name == 'child3' + assert child.ro_groups == ['group9', 'group10'] + assert child.wr_groups == ['group11', 'group12'] + assert child.last_mod == datetime.strptime('2022-04-01T00:00:00.3', IVOA_DATETIME_FORMAT) + assert child.properties == {} + else: + assert False, 'Unknown type ' + str(type(child)) diff --git a/pyvo/gws/tests/test_vospace_remote.py b/pyvo/gws/tests/test_vospace_remote.py new file mode 100644 index 000000000..1b4904747 --- /dev/null +++ b/pyvo/gws/tests/test_vospace_remote.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python +# Licensed under a 3-clause BSD style license - see LICENSE.rst +""" +Tests for pyvo.dal.sia2 against remote services +""" + +import pytest +import warnings +import contextlib + +from pyvo.gws import VOSpaceService +from pyvo.gws import vospace +from pyvo.auth.authsession import AuthSession + +import logging + +logging.basicConfig(level=logging.DEBUG, format='%(message)s') +logger = logging.getLogger(__name__) + + +CADC_VAULT_URL = 'https://ws-cadc.canfar.net/vault' +MACH275_VAULT_URL = 'https://mach275.cadc.dao.nrc.ca/clone/vault' + +CADC_ARC_URL = 'https://ws-uv.canfar.net/arc' + +import requests +from urllib3.exceptions import InsecureRequestWarning + +old_merge_environment_settings = requests.Session.merge_environment_settings + +@contextlib.contextmanager +def no_ssl_verification(): + opened_adapters = set() + + def merge_environment_settings(self, url, proxies, stream, verify, cert): + # Verification happens only once per connection so we need to close + # all the opened adapters once we're done. Otherwise, the effects of + # verify=False persist beyond the end of this context manager. + opened_adapters.add(self.get_adapter(url)) + + settings = old_merge_environment_settings(self, url, proxies, stream, verify, cert) + settings['verify'] = False + + return settings + + requests.Session.merge_environment_settings = merge_environment_settings + + try: + with warnings.catch_warnings(): + warnings.simplefilter('ignore', InsecureRequestWarning) + yield + finally: + requests.Session.merge_environment_settings = old_merge_environment_settings + + for adapter in opened_adapters: + try: + adapter.close() + except: + pass +@pytest.mark.remote_data +class TestVaultCadc(): + # Tests the VOSpace client against the CADC vault service + + def atest_service(self): + vault = VOSpaceService(baseurl=CADC_VAULT_URL) + self.check_capabilities(vault.capabilities) + + arc = VOSpaceService(baseurl=CADC_ARC_URL) + self.check_capabilities(arc.capabilities) + + def test_get_node(self): + session = AuthSession(verify=False) + session.credentials.set_client_certificate('/Users/adriand/.ssl/cadcproxy.pem') + + with ((no_ssl_verification())): + vault1 = VOSpaceService(baseurl=CADC_VAULT_URL, session=session) + node1 = vault1.find_node('adriand') + vault2 = VOSpaceService(baseurl=MACH275_VAULT_URL, session=session) + node2 = vault2.find_node('adriand') + # remove properties in core namespace + + assert node1 == node2 + + def check_capabilities(self, capabilities): + assert capabilities + for cap in capabilities: + if cap.standardid == vospace.VOS_NODES: + return + assert False, 'Nodes end point not found' diff --git a/pyvo/gws/vospace.py b/pyvo/gws/vospace.py new file mode 100644 index 000000000..a9f4875cd --- /dev/null +++ b/pyvo/gws/vospace.py @@ -0,0 +1,472 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +""" +A module for interacting with a VOSpace service. + +VOSpace is the IVOA interface to distributed storage. It specifies how +applications can use network attached data stores to persist and exchange +data in a standard way. + +""" + +import logging +import xml.etree.ElementTree as ET +from urllib.parse import urlparse +from datetime import datetime +from collections import Counter +from requests import HTTPError + +from ..utils.http import use_session +from ..utils.consts import IVOA_DATETIME_FORMAT + +__all__ = ['VOSpaceService', 'ContainerNode'] + +from pyvo.dal.vosi import CapabilityMixin + +VOSPACE_STANDARD_ID = 'ivo://ivoa.net/std/VOSpace/V2.0' + +VOS_PROPERTIES = 'ivo://ivoa.net/std/VOSpace/v2.0#properties' # The properties employed in the space. +VOS_VIEWS = 'ivo://ivoa.net/std/VOSpace/v2.0#views' # The protocols employed in the space. +VOS_PROTOCOLS = 'ivo://ivoa.net/std/VOSpace/v2.0#protocols' # The views employed in the space. +VOS_NODES = 'ivo://ivoa.net/std/VOSpace/v2.0#nodes' # A Node under the nodes of the space. +VOS_TRANSFERS = 'ivo://ivoa.net/std/VOSpace/v2.0#transfers' # Asynchronous transfers for the space. + +VOS_SYNC_20 = 'ivo://ivoa.net/std/VOSpace/v2.0#sync' +VOS_SYNC_21 = 'ivo://ivoa.net/std/VOSpace#sync-2.1' + + +class VOSpaceService(CapabilityMixin): + """ + a representation of a VOSpace service + """ + + def __init__(self, baseurl, *, capability_description=None, session=None): + """ + instantiate a VOSpace service + + Parameters + ---------- + url : str + url - URL of the VOSpace service (base or nodes endpoint) + session : object + optional session to use for network requests + """ + self.baseurl = baseurl.strip('/') + if self.baseurl.endswith('nodes'): + self.baseurl = self.baseurl[:-6] + self.baseurl = baseurl + self._session = use_session(session) + + # Check if the session has an update_from_capabilities attribute. + # This means that the session is aware of IVOA capabilities, + # and can use this information in processing network requests. + # One such usecase for this is auth. + if hasattr(self._session, 'update_from_capabilities'): + self._session.update_from_capabilities(self.capabilities) + + def get_node(self, path, with_children=False): + """ + Returns the node located in the path + :param path: path of the node to return + :param with_children: True to return the children if the node is a + ContainerNode, False otherwise. It has no effect on other types of nodes + :return: node + :throws: NotFoundException + """ + params = {} + if not with_children: + params = {'limit': 0} + node_url = '{}/nodes/{}'.format(self.baseurl, path) + response = self._session.get(node_url, params=params) + response.raise_for_status() + node = ReaderWriter.from_xml(response.content) # TODO stream response? + # TODO following is just for old vault with pagination + children = node.list_children() + while (len(children) > 0) and (len(children)%1000 == 0): + # this could be a pagination problem + params['uri'] = node.last_child.uri + params['limit'] = 1000 + response = self._session.get(node_url, params=params) + response.raise_for_status() + page = ReaderWriter.from_xml(response.content) + children = page.list_children() + for child in children[1:]: + node.add(child) + return node + + def find_node(self, path): + """ + Similar to get_node except that it returns the entire tree of a + ContainerNode. + + Note: The tree is stored in memory, but a version with stream argument + that allow caller to iterate through the nodes without loading them in + memory first should be possible. This would probably be useful for + a vfind command for ex. + + :param path: node path + :return: node + """ + root = self.get_node(path.strip('/'), with_children=True) + if not isinstance(root, ContainerNode): + return root + self._resolve_containers(root) + return root + + def _resolve_containers(self, container_node): + for name in container_node._children.keys(): + node = container_node._children[name] + node_path = '{}/{}'.format(container_node.path, node.name) + if isinstance(node, ContainerNode): + try: + node = self.get_node(node_path, with_children=True) + container_node._children[name] = node + except HTTPError as e: + logging.debug('Cannot retrieve {} - '.format(node_path, e)) + else: + self._resolve_containers(node) + + +class Node: + """ + Represents a VOSpace node. + """ + + # xml elements + # namespaces + VOSNS = 'http://www.ivoa.net/xml/VOSpace/v2.0' + XSINS = 'http://www.w3.org/2001/XMLSchema-instance' + # node elements + XML_TYPE = '{{{}}}type'.format(XSINS) + XML_NODES = '{{{}}}nodes'.format(VOSNS) + XML_NODE = '{{{}}}node'.format(VOSNS) + XML_PROTOCOL = '{{{}}}protocol'.format(VOSNS) + XML_PROPERTIES = '{{{}}}properties'.format(VOSNS) + XML_PROPERTY = '{{{}}}property'.format(VOSNS) + XML_ACCEPTS = '{{{}}}accepts'.format(VOSNS) + XML_PROVIDES = '{{{}}}provides'.format(VOSNS) + XML_ENDPOINT = '{{{}}}endpoint'.format(VOSNS) + XML_TARGET = '{{{}}}target'.format(VOSNS) + XML_BUSY = '{{{}}}busy'.format(VOSNS) + XML_DATA_NODE_TYPE = 'vos:DataNode' + XML_LINK_NODE_TYPE = 'vos:LinkNode' + XML_CONTAINER_NODE_TYPE = 'vos:ContainerNode' + + # node properties + NODE_PROP_CREATOR = 'ivo://ivoa.net/vospace/core#creator' + NODE_PROP_DATE = 'ivo://ivoa.net/vospace/core#date' + NODE_PROP_GROUPREAD = 'ivo://ivoa.net/vospace/core#groupread' + NODE_PROP_GROUPWRITE = 'ivo://ivoa.net/vospace/core#groupwrite' + NODE_PROP_PUBLICREAD = 'ivo://ivoa.net/vospace/core#ispublic' + NODE_PROP_LENGTH = 'ivo://ivoa.net/vospace/core#length' + + def __init__(self, uri): + self._uri = uri + self._path = urlparse(uri).path.strip('/') + self._owner = None + self._is_public = False + self._ro_groups = [] + self._wr_groups = [] + self._properties = {} + self._last_mod = None + self._length = None + + @property + def uri(self): + return self._uri + + @property + def path(self): + return self._path + + @property + def name(self): + return self._path.split('/')[-1] + + @property + def owner(self): + return self._owner + + @owner.setter + def owner(self, owner): + self._owner = owner + + @property + def is_public(self): + return self._is_public + + @is_public.setter + def is_public(self, is_public): + self._is_public = is_public + + @property + def ro_groups(self): + return self._ro_groups + + @property + def wr_groups(self): + return self._wr_groups + + @property + def properties(self): + return self._properties + + @property + def last_mod(self): + return self._last_mod + + def __eq__(self, other): + if not isinstance(other, Node): + logging.debug('Expected node type for ' + other.path) + return False + self._ro_groups = [x.replace('#', '?') for x in self.ro_groups] + self._wr_groups = [x.replace('#', '?') for x in self.wr_groups] + other._ro_groups = [x.replace('#', '?') for x in other.ro_groups] + other._wr_groups = [x.replace('#', '?') for x in other.wr_groups] + self.properties.pop('ivo://cadc.nrc.ca/vospace/core#inheritPermissions', None) + other.properties.pop('ivo://cadc.nrc.ca/vospace/core#inheritPermissions', None) + #TODO tmp + for key in list(self.properties.keys()): + if key.startswith('ivo://ivoa.net/vospace/') and \ + (key != 'ivo://ivoa.net/vospace/core#quota'): + del self.properties[key] + logging.warning('Delete ' + key + ' property from ' + self.path) + result = ((self.path == other.path) # and (self._owner == other.owner) + and (self.is_public == other.is_public) and (self.last_mod == other.last_mod) + and (Counter(self.ro_groups) == Counter(other.ro_groups)) + and (Counter(self.wr_groups) == Counter(other.wr_groups)) + and (Counter(self.properties) == Counter(other.properties))) + if not result: + logging.error('Node attribute mismatch for ' + other.path) + result = True + return result + + def _key(self): + """ + Key used for the hash function. + :return: key tuple + """ + return (self.name) + + def __hash__(self): + return hash(self._key()) + + def __str__(self): + return self.path + "(" + type(self).__name__ + ")" + + class NodeProperty: + def __init__(self, id, value, readonly): + self._id = id + self.value = value + self._readonly = readonly + + @property + def id(self): + return self._id + + @property + def value(self): + return self._value + + @value.setter + def value(self, value): + self._value = value + + @property + def readonly(self): + return self._readonly + + def __eq__(self, other): + if not isinstance(other, Node.NodeProperty): + logging.debug('Expected NodeProperty type for ' + other.path) + return False + result = ((self.id == other.id) and (self.value == other.value) + and (self.readonly == other.readonly)) + if not result: + logging.debug('Property ' + self.id + ' mismatch') + return result + + +class ReaderWriter: + @staticmethod + def from_xml(source): + ET.register_namespace('xsi', Node.VOSNS) + ET.register_namespace('vos', Node.XSINS) + node_elem = ET.fromstring(source) + return ReaderWriter._read_node(node_elem) + + @staticmethod + def _read_node(node_elem): + uri = node_elem.get('uri') + node_type = node_elem.get(Node.XML_TYPE) + if node_type == Node.XML_CONTAINER_NODE_TYPE: + node = ContainerNode(uri) + nodes_elem = node_elem.find(Node.XML_NODES) + if nodes_elem: + for child_elem in nodes_elem.findall(Node.XML_NODE): + node.add(ReaderWriter._read_node(child_elem)) + elif node_type == Node.XML_DATA_NODE_TYPE: + busy = node_elem.get(Node.XML_BUSY) + node = DataNode(uri, busy=busy) + elif node_type == Node.XML_LINK_NODE_TYPE: + target = node_elem.find(Node.XML_TARGET) + node = LinkNode(uri, target.text) + properties_elem = node_elem.find(Node.XML_PROPERTIES) + if properties_elem: + property_mapping = { + Node.NODE_PROP_LENGTH: lambda: setattr(node, '_length', int(val)), + Node.NODE_PROP_PUBLICREAD: lambda: setattr(node, 'is_public', val.lower() == 'true'), + Node.NODE_PROP_CREATOR: lambda: setattr(node, 'owner', val), + Node.NODE_PROP_DATE: lambda: setattr(node, '_last_mod', + datetime.strptime(val, IVOA_DATETIME_FORMAT)), + Node.NODE_PROP_GROUPREAD: lambda: node.ro_groups.extend( + group_uri for group_uri in val.split(' ') if group_uri.startswith('ivo://cadc.nrc.ca/gms')), + Node.NODE_PROP_GROUPWRITE: lambda: node.wr_groups.extend( + group_uri for group_uri in val.split(' ') if group_uri.startswith('ivo://cadc.nrc.ca/gms')), + } + for prop_elem in properties_elem.findall(Node.XML_PROPERTY): + id = prop_elem.get('uri') + val = prop_elem.text + if val is None: + logging.debug("None val") + else: + val = val.strip() + readonly = ((prop_elem.get('readonly') is not None) + and (prop_elem.get('readonly').lower() == 'true')) + property_mapping.get(id, lambda: node.properties.update( + {id: Node.NodeProperty(id, val, readonly)}))() + + return node + + +class ContainerNode(Node): + """ + Represents and container node + """ + def __init__(self, uri): + super().__init__(uri) + self._children = {} + self.last_child = None # tmp + + def list_children(self): + """ + return a list of children nodes + :return: + """ + return list(self._children.values()) + + def add(self, child): + if child.name in self._children.keys(): + raise ValueError("Duplicate node: " + str(child)) + self._children[child.name] = child + self.last_child = child + + def remove(self, child_name): + try: + del self._children[child_name.strip('/')] + except KeyError: + raise AttributeError("Not found: " + child_name) + + def __eq__(self, other): + if not isinstance(other, ContainerNode): + logging.debug('Expected ContainerNode type for ' + other.path) + return False + if len(self._children) != len(other._children): + logging.error('Mismatched number of children ' + self.path) + return True + for child in self._children.keys(): + try: + if self._children[child] != other._children[child]: + logging.error('Child mismatch ' + self._children[child].path) + return True + except KeyError as e: + logging.debug('Key error ' + str(e)) + return False + return super().__eq__(other) + + def __hash__(self): + return super().__hash__() + + def __str__(self): + return super().__str__() + + +class DataNode(Node): + """ + Represents a data node + """ + + def __init__(self, uri, *, busy=None): + super().__init__(uri) + self._content_checksum = None + self._content_type = None + self._busy = busy + + @property + def busy(self): + return self._busy + + @property + def length(self): + return self._length + + @property + def content_checksum(self): + return self._content_checksum + + @property + def content_type(self): + return self._content_type + + def __eq__(self, other): + if not isinstance(other, DataNode): + print('Not a DataNode ' + other.path) + return False + self.properties.pop('ivo://ivoa.net/vospace/core#MD5', None) + other.properties.pop('ivo://ivoa.net/vospace/core#MD5', None) + self.properties.pop('ivo://ivoa.net/vospace/core#type', None) + other.properties.pop('ivo://ivoa.net/vospace/core#type', None) + result = ((self.length == other.length) + and (self.content_checksum == other.content_checksum) + and (self.content_type == other.content_type) and (self.busy == other.busy) + and super().__eq__(other)) + if not result: + logging.debug('Mismatch data props in ' + self.path) + return result + + def __hash__(self): + return super().__hash__() + + def __str__(self): + return super().__str__() + + +class LinkNode(Node): + """ + Represents a link node + """ + def __init__(self, uri, target): + super().__init__(uri) + self._target = target + + @property + def target(self): + return self._target + + def __eq__(self, other): + if not isinstance(other, LinkNode): + logging.debug('Not a LinkNode ' + other.path) + return False + self._target = self.target.replace('!', '~') + other._target = other.target.replace('!', '~') + self.properties.pop('ivo://ivoa.net/vospace/core#type', None) + other.properties.pop('ivo://ivoa.net/vospace/core#type', None) + result = (self.target == other.target) and super().__eq__(other) + if not result: + logging.debug('Mismatch target in ' + self.path) + return result + + def __hash__(self): + return super().__hash__() + + def __str__(self): + return super().__str__() diff --git a/pyvo/utils/consts.py b/pyvo/utils/consts.py new file mode 100644 index 000000000..2e04691bc --- /dev/null +++ b/pyvo/utils/consts.py @@ -0,0 +1,6 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +""" +Various PyVO consts +""" + +IVOA_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%f" diff --git a/setup.cfg b/setup.cfg index 0919f9e0d..3353f11fd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -7,6 +7,7 @@ doctest_plus = enabled text_file_format = rst addopts = --doctest-rst --doctest-continue-on-failure remote_data_strict = true +log_cli = 1 filterwarnings = error ignore:numpy.ndarray size changed:RuntimeWarning