diff --git a/boardgamegeek/api.py b/boardgamegeek/api.py
index 888e34c..2747581 100644
--- a/boardgamegeek/api.py
+++ b/boardgamegeek/api.py
@@ -18,6 +18,7 @@
import logging
import sys
import warnings
+import urllib.parse
from .objects.user import User
from .objects.search import SearchResult
@@ -110,6 +111,7 @@ def __init__(self, api_endpoint, cache, timeout, retries, retry_delay, requests_
self._plays_api_url = api_endpoint + "/plays"
self._hot_api_url = api_endpoint + "/hot"
self._collection_api_url = api_endpoint + "/collection"
+ self._login_url = urllib.parse.urljoin(api_endpoint, "/login/api/v1")
try:
self._timeout = float(timeout)
self._retries = int(retries)
@@ -225,6 +227,36 @@ def guild(self, guild_id, progress=None, members=True):
return guild
+ def log_in(self, user_name, password):
+ """
+ Logs in to site.
+
+ :param str user_name: user name to log-in as
+ :param str password: secret password for user
+ :retval str: error message if log-in failed.
+ :retval None: if log-in successful.
+ """
+ body = {'credentials': {'username': user_name, 'password': password}}
+ # will set authentication cookies on self.requests_session
+ response = self.requests_session.post(self._login_url, json=body)
+ if response.status_code >= 400:
+ if 'application/json' == response.headers['Content-Type']:
+ response.cooked = response.json()
+ response.error = response.cooked['errors']['message']
+ else:
+ response.error = response.text
+ if response.status_code >= 500:
+ # server error
+ pass
+ else:
+ # client error; likely invalid credentials
+ pass
+ return response.error
+ else:
+ return None
+
+ sign_in = log_in
+
# TODO: refactor
def user(self, name, progress=None, buddies=True, guilds=True, hot=True, top=True, domain=BGGRestrictDomainTo.BOARD_GAME):
"""
@@ -503,7 +535,7 @@ def collection(self, user_name, subtype=BGGRestrictCollectionTo.BOARD_GAME, excl
version=None, own=None, rated=None, played=None, commented=None, trade=None, want=None, wishlist=None,
wishlist_prio=None, preordered=None, want_to_play=None, want_to_buy=None, prev_owned=None,
has_parts=None, want_parts=None, min_rating=None, rating=None, min_bgg_rating=None, bgg_rating=None,
- min_plays=None, max_plays=None, collection_id=None, modified_since=None):
+ min_plays=None, max_plays=None, private=None, collection_id=None, modified_since=None):
"""
Returns an user's game collection
@@ -533,6 +565,7 @@ def collection(self, user_name, subtype=BGGRestrictCollectionTo.BOARD_GAME, excl
:param double rating: return items rated by the user with a maximum of ``rating``
:param double min_bgg_rating : return items rated on BGG with a minimum of ``min_bgg_rating``
:param double bgg_rating: return items rated on BGG with a maximum of ``bgg_rating``
+ :param bool private: include private game info in results. Only works when viewing your own collection and you are logged in.
:param int collection_id: restrict results to the collection specified by this id
:param str modified_since: restrict results to those whose status (own, want, etc.) has been changed/added since ``modified_since``. Format: ``YY-MM-DD`` or ``YY-MM-DD HH:MM:SS``
@@ -626,6 +659,9 @@ def collection(self, user_name, subtype=BGGRestrictCollectionTo.BOARD_GAME, excl
else:
raise BGGValueError("invalid 'bgg_rating'")
+ if private is not None and private:
+ params["showprivate"] = 1
+
if collection_id is not None:
params["collid"] = collection_id
diff --git a/boardgamegeek/cache.py b/boardgamegeek/cache.py
index e6e9dbc..6158115 100644
--- a/boardgamegeek/cache.py
+++ b/boardgamegeek/cache.py
@@ -20,7 +20,7 @@ def __init__(self, ttl):
int(ttl)
except ValueError:
raise BGGValueError
- self.cache = requests_cache.core.CachedSession(backend="memory", expire_after=ttl, allowable_codes=(200,))
+ self.cache = requests_cache.CachedSession(backend="memory", expire_after=ttl, allowable_codes=(200,))
class CacheBackendSqlite(CacheBackend):
@@ -30,7 +30,7 @@ def __init__(self, path, ttl, fast_save=True):
except ValueError:
raise BGGValueError
- self.cache = requests_cache.core.CachedSession(cache_name=path,
+ self.cache = requests_cache.CachedSession(cache_name=path,
backend="sqlite",
expire_after=ttl,
extension="",
diff --git a/boardgamegeek/loaders/collection.py b/boardgamegeek/loaders/collection.py
index 6a40746..b3a7f60 100644
--- a/boardgamegeek/loaders/collection.py
+++ b/boardgamegeek/loaders/collection.py
@@ -1,7 +1,7 @@
from ..objects.collection import Collection
from ..exceptions import BGGApiError, BGGItemNotFoundError
from ..utils import get_board_game_version_from_element
-from ..utils import xml_subelement_text, xml_subelement_attr
+from ..utils import xml_attr, xml_subelement_text, xml_subelement_attr
def create_collection_from_xml(xml_root, user_name):
@@ -27,7 +27,7 @@ def add_collection_items_from_xml(collection, xml_root, subtype):
"id": int(item.attrib["objectid"]),
"image": xml_subelement_text(item, "image"),
"thumbnail": xml_subelement_text(item, "thumbnail"),
- "yearpublished": xml_subelement_attr(item,
+ "yearpublished": xml_subelement_text(item,
"yearpublished",
default=0,
convert=int,
@@ -35,6 +35,21 @@ def add_collection_items_from_xml(collection, xml_root, subtype):
"numplays": xml_subelement_text(item, "numplays", convert=int, default=0),
"comment": xml_subelement_text(item, "comment", default='')}
+ # Add private game info
+ private = item.find("privateinfo")
+ if private:
+ data["private"] = {
+ "paid": xml_attr(private, "pricepaid", convert=float, quiet=True),
+ "currency": xml_attr(private, "pp_currency"),
+ "cv_currency": xml_attr(private, "cv_currency"),
+ "currvalue": xml_attr(private, "currvalue", convert=float, quiet=True),
+ "quantity": xml_attr(private, "quantity", convert=int, quiet=True),
+ "acquired_on": xml_attr(private, "acquisitiondate"),
+ "acquired_from": xml_attr(private, "acquiredfrom"),
+ "location": xml_attr(private, "inventorylocation"),
+ "comment": xml_subelement_text(private, "privatecomment"),
+ }
+
# Add item statistics
stats = item.find("stats")
if stats is None:
diff --git a/boardgamegeek/objects/games.py b/boardgamegeek/objects/games.py
index e2eedbb..68eaf4c 100644
--- a/boardgamegeek/objects/games.py
+++ b/boardgamegeek/objects/games.py
@@ -58,6 +58,20 @@ def numeric_player_count(self):
return int(self.player_count)
+class BoardGamePrivate(DictObject):
+ """
+ Private user info for a board game
+ """
+ def __getattr__(self, item):
+ # allow accessing user's variables using .attribute
+ try:
+ return self._data[item]
+ except:
+ if item.startswith('_'):
+ raise AttributeError
+ return None
+
+
class BoardGameStats(DictObject):
"""
Statistics about a board game
@@ -596,6 +610,7 @@ class CollectionBoardGame(BaseGame):
def __init__(self, data):
super(CollectionBoardGame, self).__init__(data)
+ self._private = BoardGamePrivate(data.get('private', {}))
def __repr__(self):
return "CollectionBoardGame (id: {})".format(self.id)
@@ -619,6 +634,10 @@ def _format(self, log):
for v in self._versions:
v._format(log)
+ @property
+ def private(self):
+ return self._private
+
@property
def lastmodified(self):
# TODO: deprecate this
@@ -729,6 +748,78 @@ def comment(self):
"""
return self._data.get("comment", "")
+ @property
+ def private_comment(self):
+ """
+ :return: private comment left by user
+ :rtype: str
+ """
+ return self._private.comment
+
+ @property
+ def paid(self):
+ """
+ :return: price paid by user (private)
+ :rtype: str
+ """
+ return self._private.paid
+
+ @property
+ def currency(self):
+ """
+ :return: currency for price paid by user (private)
+ :rtype: str
+ """
+ return self._private.currency
+
+ @property
+ def currvalue(self):
+ """
+ :return: price paid by user (private)
+ :rtype: str
+ """
+ return self._private.currvalue
+
+ @property
+ def cv_currency(self):
+ """
+ :return: currency for price paid by user (private)
+ :rtype: str
+ """
+ return self._private.cv_currency
+
+ @property
+ def quantity(self):
+ """
+ :return: quantity owned by user (private)
+ :rtype: str
+ """
+ return self._private.quantity
+
+ @property
+ def acquired_on(self):
+ """
+ :return: acquisition date (private)
+ :rtype: str
+ """
+ return self._private.acquired_on
+
+ @property
+ def acquired_from(self):
+ """
+ :return: where game was acquired from (private)
+ :rtype: str
+ """
+ return self._private.acquired_from
+
+ @property
+ def location(self):
+ """
+ :return: where game is inventoried (private)
+ :rtype: str
+ """
+ return self._private.location
+
class BoardGame(BaseGame):
"""
diff --git a/boardgamegeek/utils.py b/boardgamegeek/utils.py
index 49bb976..c75036f 100644
--- a/boardgamegeek/utils.py
+++ b/boardgamegeek/utils.py
@@ -118,6 +118,31 @@ def data(self):
"""
return self._data
+def xml_attr(xml_elem, attribute, convert=None, default=None, quiet=False):
+ """
+ Get a (possibly missing) attribute from an element, optionally converting it.
+
+ :param xml_elem: element to get the attribute from
+ :param attribute: name of the attribute to get
+ :param convert: if not ``None``, a callable to perform the conversion of this attribute to a certain object type
+ :param default: default value if the subelement or attribute is not found
+ :param quiet: if ``True``, don't raise exception from conversions; return default instead
+ :return: value of the attribute or ``None`` in error cases
+
+ """
+ if xml_elem is None or not attribute:
+ return None
+
+ value = xml_elem.attrib.get(attribute, default)
+ if value != default and convert:
+ try:
+ value = convert(value)
+ except:
+ if quiet:
+ value = default
+ else:
+ raise
+ return value
def xml_subelement_attr_by_attr(xml_elem, subelement, filter_attr, filter_value, convert=None, attribute="value", default=None, quiet=False):
"""
@@ -149,18 +174,7 @@ def xml_subelement_attr_by_attr(xml_elem, subelement, filter_attr, filter_value,
return None
for subel in xml_elem.findall('.//{}[@{}="{}"]'.format(subelement, filter_attr, filter_value)):
- value = subel.attrib.get(attribute)
- if value is None:
- value = default
- elif convert:
- try:
- value = convert(value)
- except:
- if quiet:
- value = default
- else:
- raise
- return value
+ return xml_attr(subel, attribute, convert=convert, default=default, quiet=quiet)
return default
@@ -195,17 +209,7 @@ def xml_subelement_attr(xml_elem, subelement, convert=None, attribute="value", d
if subel is None:
value = default
else:
- value = subel.attrib.get(attribute)
- if value is None:
- value = default
- elif convert:
- try:
- value = convert(value)
- except:
- if quiet:
- value = default
- else:
- raise
+ value = xml_attr(subel, attribute, convert=convert, default=default, quiet=quiet)
return value
@@ -237,17 +241,7 @@ def xml_subelement_attr_list(xml_elem, subelement, convert=None, attribute="valu
subel = xml_elem.findall(subelement)
res = []
for e in subel:
- value = e.attrib.get(attribute)
- if value is None:
- value = default
- elif convert:
- try:
- value = convert(value)
- except:
- if quiet:
- value = default
- else:
- raise
+ value = xml_attr(e, attribute, convert=convert, default=default, quiet=quiet)
res.append(value)
return res
diff --git a/test/_common.py b/test/_common.py
index 36e2265..8ec1683 100644
--- a/test/_common.py
+++ b/test/_common.py
@@ -6,10 +6,12 @@
import pytest
import sys
import re
+import glob
import xml.etree.ElementTree as ET
from boardgamegeek import BGGClient, BGGClientLegacy, CacheBackendNone
+from boardgamegeek.objects.collection import Collection
# Kinda hard to test without having a "test" user
@@ -65,6 +67,88 @@ def xml():
"""
return ET.fromstring(xml_code)
+def glob_xml_name(filepat, allowAny=False):
+ """
+ Get the name (or raise an exception) of an XML sample in ``XML_PATH`` matching a glob pattern.
+
+ :param str filepat: glob pattern to use for search
+ :param bool allowAny: If multiple files match the pattern, controls whether an exception is thrown (if ``False``) or one of the multiple files is picked & used (if ``True``).
+ :retval str: the pathname of a matching file
+ :raises FileNotFoundError: if no file matches the given pattern
+ :raises FileExistsError: if multiple files match the given pattern and ``allowAny`` is ``False``
+ ::
+ """
+ filenames = glob.glob(os.path.join(XML_PATH, os.path.basename(filepat)))
+ if 1 == len(filenames) or (filenames and allowAny):
+ return filenames[0]
+ elif filenames:
+ raise FileExistsError(f"Found multiple XML samples matching '{filepat}'")
+ else:
+ raise FileNotFoundError(f"No XML samples matching: '{filepat}'")
+
+def glob_xml_contents(filepat, allowAny=False):
+ """
+ Get the content (or raise an exception) of an XML sample in ``XML_PATH`` matching a glob pattern.
+
+ :param str filepat: glob pattern to use for search
+ :param bool allowAny: If multiple files match the pattern, controls whether an exception is thrown (if ``False``) or one of the multiple files is picked & used (if ``True``).
+ :retval str: the contents of a matching file
+ :raises FileNotFoundError: if no file matches the given pattern
+ :raises FileExistsError: if multiple files match the given pattern and ``allowAny`` is ``False``
+ ::
+ """
+ filename = glob_xml_name(filepat, allowAny=allowAny)
+ with open(filename) as xml_file:
+ xml = xml_file.read()
+ return xml
+
+def open_and_parse_xml(which_xml, params=None, allowAny=False):
+ """
+ Get an XML sample and return the parsed result.
+
+ :param str which_xml: partial URL endpoint or glob pattern for the XML sample
+ :param list params: query parameters (only if which_xml is an endpoint)
+ :param bool allowAny: If multiple files match the pattern, controls whether an exception is thrown (if ``False``) or one of the multiple files is picked & used (if ``True``).
+ :retval xml.etree.ElementTree.Element: the contents of a matching file, parsed as XML
+ :raises FileNotFoundError: if no file matches the given pattern
+ :raises FileExistsError: if multiple files match the given pattern and ``allowAny`` is ``False``
+ """
+ if params is not None:
+ response = simulate_bgg("/" + which_xml, params, timeout=-1)
+ xml = response.text
+ else:
+ xml = glob_xml_contents(which_xml, allowAny=allowAny)
+
+ if sys.version_info >= (3,):
+ return ET.fromstring(xml)
+ else:
+ return ET.fromstring(xml.encode("utf-8"))
+
+
+
+@pytest.fixture
+def xml_collection_minimal():
+ return open_and_parse_xml("collection@*&which=minimal")
+
+@pytest.fixture
+def xml_collection_brief():
+ return open_and_parse_xml("collection@*&which=brief")
+
+@pytest.fixture
+def xml_collection_error():
+ return open_and_parse_xml("collection", {
+ "username": TEST_INVALID_USER,
+ "subtype":"boardgame", "stats":1,
+ })
+
+@pytest.fixture
+def xml_collection_full():
+ return open_and_parse_xml("collection@*&which=full")
+
+@pytest.fixture
+def xml_collection_without_stats():
+ return open_and_parse_xml("collection@*&which=nostats", allowAny=True)
+
@pytest.fixture
def bgg():
diff --git a/test/test_collection.py b/test/test_collection.py
index 7befcec..89e48a5 100644
--- a/test/test_collection.py
+++ b/test/test_collection.py
@@ -5,7 +5,7 @@
from _common import *
from boardgamegeek import BGGError, BGGValueError, BGGItemNotFoundError
from boardgamegeek.objects.collection import CollectionBoardGame, Collection
-from boardgamegeek.objects.games import BoardGameVersion
+from boardgamegeek.objects.games import BoardGameVersion, BoardGamePrivate
import time
@@ -63,28 +63,39 @@ def test_creating_collection_out_of_raw_data():
Collection({"items": [{"id": 102}]})
# test that items are added to the collection from the constructor
- c = Collection({"owner": "me",
- "items": [{"id": 100,
- "name": "foobar",
- "image": "",
- "thumbnail": "",
- "yearpublished": 1900,
- "numplays": 32,
- "comment": "This game is great!",
- "minplayers": 1,
- "maxplayers": 5,
- "minplaytime": 60,
- "maxplaytime": 120,
- "playingtime": 100,
- "stats": {
- "usersrated": 123,
- "ranks": [{
- "id": "1", "type": "subtype", "name": "boardgame", "friendlyname": "friendly",
- "value": "10", "bayesaverage": "0.51"
- }]
- }
-
- }]})
+ collection_data = {
+ "owner": "me",
+ "items": [{
+ "id": 100,
+ "name": "foobar",
+ "image": "",
+ "thumbnail": "",
+ "yearpublished": 1900,
+ "numplays": 32,
+ "comment": "This game is great!",
+ "minplayers": 1,
+ "maxplayers": 5,
+ "minplaytime": 60,
+ "maxplaytime": 120,
+ "playingtime": 100,
+ "stats": {
+ "usersrated": 123,
+ "ranks": [{
+ "id": "1", "type": "subtype", "name": "boardgame", "friendlyname": "friendly",
+ "value": "10", "bayesaverage": "0.51"
+ }]
+ },
+ "private": {
+ "comment": "private comment",
+ "paid":42.0, "currency":"USD",
+ "currvalue": 23.0, "cv_currency":"EUR",
+ "quantity":"1",
+ "acquired_on": "2000-01-01",
+ "acquired_from": "store",
+ "location": "home",
+ },
+ }]}
+ c = Collection(collection_data)
assert len(c) == 1
assert c.owner == "me"
@@ -106,6 +117,44 @@ def test_creating_collection_out_of_raw_data():
assert ci.users_rated == 123
assert ci.rating_bayes_average is None
+ private_data = collection_data['items'][0]['private']
+ assert ci.private is not None
+ assert ci.private_comment == private_data['comment']
+ assert ci.paid == private_data['paid']
+ assert ci.currency == private_data['currency']
+ assert ci.currvalue == private_data["currvalue"]
+ assert ci.cv_currency == private_data["cv_currency"]
+ assert ci.quantity == private_data["quantity"]
+ assert ci.acquired_on == private_data["acquired_on"]
+ assert ci.acquired_from == private_data["acquired_from"]
+ assert ci.location == private_data["location"]
+
with pytest.raises(BGGError):
# raises exception on invalid game data
c.add_game({"bla": "bla"})
+
+def test_creating_private_out_of_raw_data():
+ # pre
+ private_data = {
+ "comment": "private comment",
+ "paid": 42.0, "currency": "USD",
+ "currvalue": 23.0, "cv_currency":"EUR",
+ "quantity":"1",
+ "acquired_on": "2000-01-01",
+ "acquired_from": "store",
+ "location": "home",
+ }
+
+ # in
+ prvt = BoardGamePrivate(private_data)
+
+ # post
+ assert prvt.paid == private_data['paid']
+ assert prvt.currency == private_data['currency']
+ assert prvt.comment == private_data['comment']
+ assert prvt.quantity == private_data['quantity']
+ assert prvt.currvalue == private_data["currvalue"]
+ assert prvt.cv_currency == private_data["cv_currency"]
+ assert prvt.acquired_on == private_data["acquired_on"]
+ assert prvt.acquired_from == private_data["acquired_from"]
+ assert prvt.location == private_data["location"]
diff --git a/test/test_loaders_collection.py b/test/test_loaders_collection.py
new file mode 100644
index 0000000..9ac4ec0
--- /dev/null
+++ b/test/test_loaders_collection.py
@@ -0,0 +1,164 @@
+from __future__ import unicode_literals
+
+import pytest
+import mock
+
+from _common import *
+from boardgamegeek.exceptions import BGGApiError, BGGItemNotFoundError
+from boardgamegeek.objects.collection import Collection
+from boardgamegeek.loaders.collection import create_collection_from_xml, add_collection_items_from_xml
+
+
+MISSING_STR_VAL = "missing"
+MISSING_INT_VAL = -1
+MISSING_FLOAT_VAL = -10.
+
+
+def test_create_collection_from_xml_error(xml_collection_error):
+ with pytest.raises(BGGItemNotFoundError, match=re.escape(xml_collection_error.findtext("*/message", default=MISSING_STR_VAL))):
+ create_collection_from_xml(xml_collection_error, TEST_INVALID_USER)
+
+
+def test_create_collection_from_xml_minimal(xml_collection_minimal):
+ # in
+ collection = create_collection_from_xml(xml_collection_minimal, TEST_VALID_USER)
+
+ # post
+ assert collection.owner == TEST_VALID_USER
+
+
+
+def test_add_collection_items_from_xml_without_stats(xml_collection_without_stats, mocker):
+ # pre
+ with pytest.raises(BGGApiError, match="missing 'stats'"):
+ collection = mocker.MagicMock(Collection)
+
+ # in
+ add_collection_items_from_xml(collection, xml_collection_without_stats, "boardgame")
+
+
+def test_add_collection_items_from_xml_minimal(xml_collection_minimal, mocker):
+ # pre
+ collection = mocker.MagicMock(Collection)
+
+ # in
+ add_collection_items_from_xml(collection, xml_collection_minimal, "boardgame")
+
+ # post
+ collection.add_game.assert_called()
+ actual = collection.add_game.call_args.args[0]
+ assert actual is not None
+
+ item = xml_collection_minimal.find("item[@subtype='boardgame']")
+ expected = {
+ "id": int(item.attrib.get("objectid", MISSING_INT_VAL)),
+ "comment": "",
+ "name":None, "image":None, "thumbnail":None, "rating":None,
+ "yearpublished":0, "numplays":0, "minplayers":0, "maxplayers":0,
+ "minplaytime":0, "maxplaytime":0, "playingtime":0,
+ "stats": {
+ "usersrated":None, "average":None, "bayesaverage":None,
+ "stddev":None, "median":None,
+ "ranks":[],
+ }
+ }
+ assert actual == expected
+
+
+def test_add_collection_items_from_xml_brief(xml_collection_brief, mocker):
+ # pre
+ collection = mocker.MagicMock(Collection)
+
+ # in
+ add_collection_items_from_xml(collection, xml_collection_brief, "boardgame")
+
+ # post
+ collection.add_game.assert_called()
+ actual = collection.add_game.call_args.args[0]
+ assert actual is not None
+
+ item = xml_collection_brief.find("item[@subtype='boardgame']")
+ stats = item.find("stats")
+ expected = {
+ "id": int(item.attrib.get("objectid", MISSING_INT_VAL)),
+ "name": item.findtext("name", default=MISSING_STR_VAL),
+ "comment": "",
+ "image":None, "thumbnail":None,
+ "yearpublished":0, "numplays":0,
+ "stats": {
+ "usersrated":None, "average":None, "bayesaverage":None,
+ "stddev":None, "median":None,
+ "ranks":[],
+ },
+ "rating": float(stats.find("rating").attrib.get("value", MISSING_FLOAT_VAL)),
+ }
+
+ del stats.attrib["numowned"]
+ for key, value in stats.attrib.items():
+ expected[key] = int(value)
+ status = item.find("status")
+ for key, value in status.attrib.items():
+ expected[key] = value
+
+ assert actual == expected
+
+
+def test_add_collection_items_from_xml_full(xml_collection_full, mocker):
+ # pre
+ collection = mocker.MagicMock(Collection)
+
+ # in
+ add_collection_items_from_xml(collection, xml_collection_full, "boardgame")
+
+ # post
+ collection.add_game.assert_called()
+ actual = collection.add_game.call_args.args[0]
+ assert actual is not None
+
+ item = xml_collection_full.find("item[@subtype='boardgame']")
+ stats = item.find("stats")
+ private = item.find("privateinfo")
+ expected = {
+ "id": int(item.attrib.get("objectid", MISSING_INT_VAL)),
+ "name": item.findtext("name", default=MISSING_STR_VAL),
+ "comment": item.findtext("comment", default=MISSING_STR_VAL),
+ "image": item.findtext("image", default=MISSING_STR_VAL),
+ "thumbnail": item.findtext("thumbnail", default=MISSING_STR_VAL),
+ "yearpublished": int(item.findtext("yearpublished", default=MISSING_INT_VAL)),
+ "numplays": int(item.findtext("numplays", default=MISSING_INT_VAL)),
+ "stats": {
+ "usersrated":None, "average":None, "bayesaverage":None,
+ "stddev":None, "median":None,
+ "ranks":[],
+ },
+ "rating": float(stats.find("rating").attrib.get("value", MISSING_FLOAT_VAL)),
+ "private": {
+ "comment": private.findtext("privatecomment", default=MISSING_STR_VAL),
+ "paid": float(private.attrib.get("pricepaid", MISSING_FLOAT_VAL)),
+ "currency": private.attrib.get("pp_currency", MISSING_STR_VAL),
+ "currvalue": float(private.attrib.get("currvalue", MISSING_FLOAT_VAL)),
+ "cv_currency": private.attrib.get("cv_currency", MISSING_STR_VAL),
+ "quantity": int(private.attrib.get("quantity", MISSING_INT_VAL)),
+ "acquired_on": private.attrib.get("acquisitiondate", MISSING_STR_VAL),
+ "acquired_from": private.attrib.get("acquiredfrom", MISSING_STR_VAL),
+ "location": private.attrib.get("inventorylocation", MISSING_STR_VAL),
+ }
+ }
+
+ del stats.attrib["numowned"]
+ for key, value in stats.attrib.items():
+ expected[key] = int(value)
+ for rank in stats.findall("ranks/rank"):
+ expected["stats"]["ranks"].append({
+ "type": rank.attrib.get("type", MISSING_STR_VAL),
+ "id": rank.attrib.get("id", MISSING_INT_VAL),
+ "name": rank.attrib.get("name", MISSING_STR_VAL),
+ "friendlyname": rank.attrib.get("friendlyname", MISSING_STR_VAL),
+ "value": rank.attrib.get("value", MISSING_STR_VAL),
+ "bayesaverage": float(rank.attrib.get("bayesaverage", MISSING_FLOAT_VAL)),
+ })
+ status = item.find("status")
+ for key, value in status.attrib.items():
+ expected[key] = value
+
+ assert actual == expected
diff --git a/test/test_utils.py b/test/test_utils.py
index d358c55..9f4f059 100644
--- a/test/test_utils.py
+++ b/test/test_utils.py
@@ -7,6 +7,47 @@
from _common import *
from boardgamegeek.objects.things import Thing
+def test_get_xml_attr(xml):
+
+ node = bggutil.xml_attr(None, "attr")
+ assert node is None
+
+ node = bggutil.xml_attr(None, "attr", default='default')
+ assert node is None
+
+ node = bggutil.xml_attr(xml, None)
+ assert node is None
+
+ node = bggutil.xml_attr(xml, None, default='default')
+ assert node is None
+
+ node = bggutil.xml_attr(xml, "")
+ assert node is None
+
+ node = bggutil.xml_attr(xml.find("node1"), "attr")
+ assert node == "hello1"
+
+ node = bggutil.xml_attr(xml.find("node1"), "int_attr", convert=int)
+ assert node == 1
+
+ # test that default works
+ node = bggutil.xml_attr(xml.find("node_thats_missing"), "attr", default="default")
+ assert node == None
+
+ node = bggutil.xml_attr(xml.find("node1"), "attribute_thats_missing", default=1234)
+ assert node == 1234
+
+ # test quiet
+ with pytest.raises(Exception):
+ # attr can't be converted to int
+ node = bggutil.xml_attr(xml.find("node1"), "attr", convert=int)
+
+ node = bggutil.xml_attr(xml.find("node1"), "attr", convert=int, quiet=True)
+ assert node == None
+
+ node = bggutil.xml_attr(xml.find("node1"), "attr", convert=int, default=999, quiet=True)
+ assert node == 999
+
def test_get_xml_subelement_attr(xml):
node = bggutil.xml_subelement_attr(None, "hello")
diff --git a/test/xml/collection@brief=1&id=34219&stats=1&username=fagentu007&which=brief b/test/xml/collection@brief=1&id=34219&stats=1&username=fagentu007&which=brief
new file mode 100644
index 0000000..25d8d60
--- /dev/null
+++ b/test/xml/collection@brief=1&id=34219&stats=1&username=fagentu007&which=brief
@@ -0,0 +1,13 @@
+
+
+ -
+ Biblios
+
+
+
+
+
+
+
+
+
diff --git a/test/xml/collection@id=34219&private=1&stats=1&username=fagentu007&version=1&which=full b/test/xml/collection@id=34219&private=1&stats=1&username=fagentu007&version=1&which=full
new file mode 100644
index 0000000..c9e69a9
--- /dev/null
+++ b/test/xml/collection@id=34219&private=1&stats=1&username=fagentu007&version=1&which=full
@@ -0,0 +1,28 @@
+
+
+ -
+ Biblios
+ 2007
+ https://cf.geekdo-images.com/images/pic759154.jpg
+ https://cf.geekdo-images.com/images/pic759154_t.jpg
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Private comment.
+
+ Public comment.
+
+ 5
+
+
diff --git a/test/xml/collection@id=34219&stats=0&username=fagentu007&version=1&which=nostats b/test/xml/collection@id=34219&stats=0&username=fagentu007&version=1&which=nostats
new file mode 100644
index 0000000..ae07cde
--- /dev/null
+++ b/test/xml/collection@id=34219&stats=0&username=fagentu007&version=1&which=nostats
@@ -0,0 +1,11 @@
+
+
+ -
+ Biblios
+ 2007
+ https://cf.geekdo-images.com/images/pic759154.jpg
+ https://cf.geekdo-images.com/images/pic759154_t.jpg
+
+ 0
+
+
diff --git a/test/xml/collection@id=34219&stats=1&username=fagentu007&which=minimal b/test/xml/collection@id=34219&stats=1&username=fagentu007&which=minimal
new file mode 100644
index 0000000..bae9a7d
--- /dev/null
+++ b/test/xml/collection@id=34219&stats=1&username=fagentu007&which=minimal
@@ -0,0 +1,7 @@
+
+
+ -
+
+
+
+