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