diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..f30b319 --- /dev/null +++ b/.flake8 @@ -0,0 +1,36 @@ +[flake8] +exclude = + .git, + .direnv, +# flake8 has extend-ignore, but autopep8 does not support it +ignore = + # python3 -m flake8 --help (3.9.2, with no .flake8 config) + # gives these defaults: + # E704 multiple statements on one line (def) + E704, + # E24 multiple spaces after ‘,’ or tab after ‘,’ + E24, + # E226 missing whitespace around arithmetic operator + E226, + # E126 continuation line over-indented for hanging indent + E126, + # W503 line break before binary operator + W503, + # W504 line break after binary operator + W504, + # E121 continuation line under-indented for hanging indent + E121, + # E123 closing bracket does not match indentation of opening bracket’s line + E123, + + # too many changes for now + # E501 line too long + E501, + # fixing style is too many changes for now + # https://pycodestyle.pycqa.org/en/latest/intro.html#error-codes + # E1 Indentation + E1, + # E2 Whitespace + E2, + # E3 Blank line + E3, diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..9e6057b --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,64 @@ +name: Tests + +on: [push, pull_request] + +permissions: + contents: read + +jobs: + build: + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "pypy-3.8", "pypy-3.9"] + os: [ubuntu-20.04, ubuntu-22.04, macOS-latest, windows-latest] + include: + - python-version: "2.7" + os: "ubuntu-20.04" + - python-version: "2.7" + os: "ubuntu-22.04" + steps: + - uses: actions/checkout@v4.1.0 + - name: Set up Python ${{ matrix.python-version }} (github action) + if: matrix.python-version != '2.7' + uses: actions/setup-python@v4.7.0 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + - name: Set up Python ${{ matrix.python-version }} (apt-get) + if: matrix.python-version == '2.7' + # since actions/python-versions removed 2.7 from versions-manifest.json + # and also deleted all the python 2.7 binary artifacts, + # we have to apt-get install python2 + # https://github.com/actions/setup-python/issues/672 + run: | + set -eux + sudo apt-get update + sudo apt-get install -y python2 python3-virtualenv + virtualenv -p python2 "${{ runner.temp }}/venv" + # Fix for error in ubuntu-20.04 pip (fixed in ubuntu-22.04) + # can't find '__main__' module in '/usr/share/python-wheels/pep517-0.8.2-py2.py3-none-any.whl/pep517/_in_process.py + # https://github.com/pypa/pip/issues/7874#issuecomment-605520503 + "${{ runner.temp }}/venv/bin/pip" install --force-reinstall --upgrade pip + echo "${{ runner.temp }}/venv/bin" >> $GITHUB_PATH + - name: Install prod dependencies + run: python -m pip install -r requirements.txt + - name: Check that all recursive dependencies are in requirements.txt + run: python -m unittest tests.test_requirements_txt + env: + TEST_REQUIREMENTS: prod + - name: Install test dependencies + run: python -m pip install -r requirements-test.txt + - name: Run tests + run: python -m unittest discover + - name: Check that all recursive dependencies are in requirements.txt and requirements-test.txt + run: python -m unittest tests.test_requirements_txt + env: + TEST_REQUIREMENTS: test + - name: Run flake8 + # need to upgrade flake8 to 6.1.0 for python 3.12 + # https://flake8.pycqa.org/en/latest/release-notes/6.1.0.html + if: matrix.python-version != '3.12' + run: python -m flake8 diff --git a/doc/conf.py b/doc/conf.py index f01a717..2e958b9 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -11,7 +11,8 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys, os +import sys +import os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the diff --git a/doc/exts/document_init_methods.py b/doc/exts/document_init_methods.py index 7b4db46..dffbe63 100644 --- a/doc/exts/document_init_methods.py +++ b/doc/exts/document_init_methods.py @@ -34,8 +34,6 @@ """ -import logging - def document_init_methods(app, what, name, obj, skip, options): if not skip: return diff --git a/doc/readme_from_docstring.py b/doc/readme_from_docstring.py index 6b57524..3d4086e 100755 --- a/doc/readme_from_docstring.py +++ b/doc/readme_from_docstring.py @@ -31,7 +31,7 @@ import remoteobjects -readme = file('README.rst', 'w') +readme = open('README.rst', 'w') readme.write(remoteobjects.__doc__.strip()) readme.write("\n") readme.close() diff --git a/examples/couchdb.py b/examples/couchdb.py index c7f1421..6e2c0ba 100755 --- a/examples/couchdb.py +++ b/examples/couchdb.py @@ -34,17 +34,18 @@ A generic example CouchDB client, implemented using remoteobjects. """ +from __future__ import print_function __version__ = '1.0' __date__ = '24 August 2009' __author__ = 'Mark Paschal' -import httplib from optparse import OptionParser import simplejson as json import sys -from urlparse import urljoin, urlparse +from six.moves import http_client +from six.moves.urllib.parse import urljoin from remoteobjects import RemoteObject, fields, ListObject @@ -53,10 +54,10 @@ class CouchObject(RemoteObject): # CouchDB omits Locations for Created responses, so don't expect them. location_headers = dict(RemoteObject.location_headers) - location_headers[httplib.CREATED] = None + location_headers[http_client.CREATED] = None def update_from_response(self, url, response, content): - if response.status == httplib.CREATED: + if response.status == http_client.CREATED: # CouchDB CREATED responses don't contain full content, so only # unpack the ID and revision. data = json.loads(content) @@ -103,7 +104,7 @@ class ViewResult(CouchObject, ListObject): entries = fields.List(fields.Object(ListItem), api_name='rows') def filter(self, **kwargs): - for k, v in kwargs.iteritems(): + for k, v in kwargs.items(): if isinstance(v, list) or isinstance(v, dict) or isinstance(v, bool): kwargs[k] = json.dumps(v) return super(ViewResult, self).filter(**kwargs) @@ -127,16 +128,16 @@ def create_db(url): db.deliver() except Database.NotFound: db.put() - print "Database %s created" % url + print("Database %s created" % url) else: - print "Database %s already exists" % url + print("Database %s already exists" % url) def create_view(dburl): viewset = Viewset.get(urljoin(dburl, '_design/profiles')) try: viewset.deliver() - print "Retrieved existing 'profiles' views" + print("Retrieved existing 'profiles' views") except Viewset.NotFound: # Start with an empty set of views. viewset.views = {} @@ -150,7 +151,7 @@ def create_view(dburl): viewset.views['url'] = View(mapfn=profiles_url_code) viewset.put() - print "Updated 'profiles' views for %s" % dburl + print("Updated 'profiles' views for %s" % dburl) def main(argv=None): @@ -164,7 +165,7 @@ def main(argv=None): db = opts.database if db is None: - print >>sys.stderr, "Option --database is required" + print("Option --database is required", file=sys.stderr) return 1 # Create the database, if necessary. diff --git a/examples/giantbomb.py b/examples/giantbomb.py index 6b39bec..ca82423 100644 --- a/examples/giantbomb.py +++ b/examples/giantbomb.py @@ -34,19 +34,18 @@ An example Giant Bomb API client, implemented using remoteobjects. """ +from __future__ import print_function __version__ = '1.0' __date__ = '24 August 2009' __author__ = 'Mark Paschal' -from cgi import parse_qs -from datetime import datetime from optparse import OptionParser import sys -import time -from urllib import urlencode -from urlparse import urljoin, urlparse, urlunparse +from six.moves.urllib.parse import ( + urlencode, parse_qs, urljoin, urlparse, urlunparse +) from remoteobjects import RemoteObject, fields @@ -69,9 +68,9 @@ def filter(self, **kwargs): url = self._location parts = list(urlparse(url)) query = parse_qs(parts[4]) - query = dict([(k, v[0]) for k, v in query.iteritems()]) + query = dict([(k, v[0]) for k, v in query.items()]) - for k, v in kwargs.iteritems(): + for k, v in kwargs.items(): if v is None and k in query: del query[k] else: @@ -144,7 +143,7 @@ def main(argv=None): opts, args = parser.parse_args() if opts.key is None: - print >>sys.stderr, "Option --key is required" + print("Option --key is required", file=sys.stderr) return 1 query = ' '.join(args) @@ -155,16 +154,16 @@ def main(argv=None): search = search.filter(query=query) if len(search.results) == 0: - print "No results for %r" % query + print("No results for %r" % query) elif len(search.results) == 1: (game,) = search.results - print "## %s ##" % game.name - print - print game.summary + print("## %s ##" % game.name) + print() + print(game.summary) else: - print "## Search results for %r ##" % query + print("## Search results for %r ##" % query) for game in search.results: - print game.name + print(game.name) return 0 diff --git a/examples/netflix.py b/examples/netflix.py index 68351e6..b713cb8 100644 --- a/examples/netflix.py +++ b/examples/netflix.py @@ -34,23 +34,21 @@ An example Netflix API client, implemented using remoteobjects. """ +from __future__ import print_function __version__ = '1.0' __date__ = '25 August 2009' __author__ = 'Mark Paschal' -import cgi from optparse import OptionParser import sys -from urllib import urlencode -import urlparse +from six.moves.urllib.parse import parse_qs, urlparse from xml.etree import ElementTree -import httplib2 from oauth.oauth import OAuthConsumer, OAuthRequest, OAuthSignatureMethod_HMAC_SHA1 -from remoteobjects import RemoteObject, fields, PageObject +from remoteobjects import RemoteObject, fields class Flixject(RemoteObject): @@ -68,9 +66,9 @@ def get_request(self, headers=None, **kwargs): http_url=request['uri']) # OAuthRequest will strip our query parameters, so add them back in. - parts = list(urlparse.urlparse(self._location)) - queryargs = cgi.parse_qs(parts[4], keep_blank_values=True) - for key, value in queryargs.iteritems(): + parts = list(urlparse(self._location)) + queryargs = parse_qs(parts[4], keep_blank_values=True) + for key, value in queryargs.items(): orq.set_parameter(key, value[0]) # Sign the request. @@ -86,7 +84,7 @@ def get_request(self, headers=None, **kwargs): return request def update_from_tree(self, tree): - data = dict((k, v(tree)) for k, v in self.decoder_ring.items()) + data = dict((k, v(tree)) for k, v in list(self.decoder_ring.items())) self.update_from_dict(data) return self @@ -135,18 +133,18 @@ def do_search(opts, args): search.deliver() if len(search.results) == 0: - print "No results for %r" % query + print("No results for %r" % query) elif len(search.results) == 1: result = search.results[0] - print "## %s ##" % result.title + print("## %s ##" % result.title) else: - print "## Results for %r ##" % query - print + print("## Results for %r ##" % query) + print() for title in search.results: if title is None: - print "(oops, none)" + print("(oops, none)") else: - print title.title + print(title.title) return 0 @@ -166,7 +164,7 @@ def main(argv=None): opts, args = parser.parse_args() if opts.key is None or opts.secret is None: - print >>sys.stderr, "Options --key and --secret are required" + print("Options --key and --secret are required", file=sys.stderr) return 1 Flixject.api_token = (opts.key, opts.secret) diff --git a/examples/twitter.py b/examples/twitter.py index fdb66ac..e7d829d 100644 --- a/examples/twitter.py +++ b/examples/twitter.py @@ -34,17 +34,21 @@ A Twitter API client, implemented using remoteobjects. """ +from __future__ import print_function __version__ = '1.1' __date__ = '17 April 2009' __author__ = 'Brad Choate' -import httplib from optparse import OptionParser import sys -from urllib import urlencode, quote_plus -from urlparse import urljoin, urlunsplit +from six import text_type +from six.moves import input +from six.moves import http_client # python3: http.client +from six.moves.urllib.parse import ( + urlencode, quote_plus, urljoin, urlunsplit +) from httplib2 import Http @@ -77,7 +81,7 @@ def get_user(cls, http=None, **kwargs): url += '/%s.json' % quote_plus(kwargs['id']) else: url += '.json' - query = urlencode(filter(lambda x: x in ('screen_name', 'user_id'), kwargs)) + query = urlencode([(key, value) for key, value in kwargs.items() if key in ('screen_name', 'user_id')]) url = urlunsplit((None, None, url, query, None)) return cls.get(urljoin(Twitter.endpoint, url), http=http) @@ -143,14 +147,14 @@ def __getitem__(self, key): @classmethod def get_messages(cls, http=None, **kwargs): url = '/direct_messages.json' - query = urlencode(filter(lambda x: x in ('since_id', 'page'), kwargs)) + query = urlencode([(key, value) for key, value in kwargs.items() if key in ('since_id', 'page')]) url = urlunsplit((None, None, url, query, None)) return cls.get(urljoin(Twitter.endpoint, url), http=http) @classmethod def get_sent_messages(cls, http=None, **kwargs): url = '/direct_messages/sent.json' - query = urlencode(filter(lambda x: x in ('since_id', 'page'), kwargs)) + query = urlencode([(key, value) for key, value in kwargs.items() if key in ('since_id', 'page')]) url = urlunsplit((None, None, url, query, None)) return cls.get(urljoin(Twitter.endpoint, url), http=http) @@ -177,7 +181,7 @@ def get_related(cls, relation, http=None, **kwargs): url += '/%s.json' % quote_plus(kwargs['id']) else: url += '.json' - query = urlencode(filter(lambda x: x in ('screen_name', 'user_id', 'page'), kwargs)) + query = urlencode([(key, value) for key, value in kwargs.items() if key in ('screen_name', 'user_id', 'page')]) url = urlunsplit((None, None, url, query, None)) return cls.get(urljoin(Twitter.endpoint, url), http=http) @@ -195,7 +199,7 @@ def public(cls, http=None): @classmethod def friends(cls, http=None, **kwargs): - query = urlencode(filter(lambda x: x in ('since_id', 'max_id', 'count', 'page'), kwargs)) + query = urlencode([(key, value) for key, value in kwargs.items() if key in ('since_id', 'max_id', 'count', 'page')]) url = urlunsplit((None, None, '/statuses/friends_timeline.json', query, None)) return cls.get(urljoin(Twitter.endpoint, url), http=http) @@ -206,13 +210,13 @@ def user(cls, http=None, **kwargs): url += '/%s.json' % quote_plus(kwargs['id']) else: url += '.json' - query = urlencode(filter(lambda x: x in ('screen_name', 'user_id', 'since_id', 'max_id', 'page'), kwargs)) + query = urlencode([(key, value) for key, value in kwargs.items() if key in ('screen_name', 'user_id', 'since_id', 'max_id', 'page')]) url = urlunsplit((None, None, url, query, None)) return cls.get(urljoin(Twitter.endpoint, url), http=http) @classmethod def mentions(cls, http=None, **kwargs): - query = urlencode(filter(lambda x: x in ('since_id', 'max_id', 'page'), kwargs)) + query = urlencode([(key, value) for key, value in kwargs.items() if key in ('since_id', 'max_id', 'page')]) url = urlunsplit((None, None, '/statuses/mentions.json', query, None)) return cls.get(urljoin(Twitter.endpoint, url), http=http) @@ -258,21 +262,21 @@ def direct_messages_sent(self, **kwargs): def show_public(twitter): - print "## Public timeline ##" + print("## Public timeline ##") for tweet in twitter.public_timeline(): - print unicode(tweet) + print(text_type(tweet)) def show_dms(twitter): - print "## Direct messages sent to me ##" + print("## Direct messages sent to me ##") for dm in twitter.direct_messages_received(): - print unicode(dm) + print(text_type(dm)) def show_friends(twitter): - print "## Tweets from my friends ##" + print("## Tweets from my friends ##") for tweet in twitter.friends_timeline(): - print unicode(tweet) + print(text_type(tweet)) def main(argv=None): @@ -296,18 +300,18 @@ def main(argv=None): # We'll use regular HTTP authentication, so ask for a password and add # it in the regular httplib2 way. if opts.username is not None: - password = raw_input("Password (will echo): ") + password = input("Password (will echo): ") twitter.add_credentials(opts.username, password) try: - print + print() opts.action(twitter) - print - except httplib.HTTPException, exc: + print() + except http_client.HTTPException as exc: # The API could be down, or the credentials on an auth-only request # could be wrong, so show the error to the end user. - print >>sys.stderr, "Error making request: %s: %s" \ - % (type(exc).__name__, str(exc)) + print("Error making request: %s: %s" + % (type(exc).__name__, text_type(exc)), file=sys.stderr) return 1 return 0 diff --git a/remoteobjects/dataobject.py b/remoteobjects/dataobject.py index 6c9d764..1d7fb65 100644 --- a/remoteobjects/dataobject.py +++ b/remoteobjects/dataobject.py @@ -41,7 +41,7 @@ from copy import deepcopy -import logging +from six import with_metaclass import remoteobjects.fields @@ -119,7 +119,7 @@ def add_to_class(cls, name, value): setattr(cls, name, value) -class DataObject(object): +class DataObject(with_metaclass(DataObjectMetaclass)): """An object that can be decoded from or encoded as a dictionary. @@ -139,8 +139,6 @@ class DataObject(object): """ - __metaclass__ = DataObjectMetaclass - def __init__(self, **kwargs): """Initializes a new `DataObject` with the given field values.""" self.api_data = {} @@ -153,9 +151,9 @@ def __eq__(self, other): same data in all their fields, the objects are equivalent. """ - if type(self) != type(other): - return False - for k, v in self.fields.iteritems(): + if not isinstance(other, type(self)): + return NotImplemented + for k, v in self.fields.items(): if isinstance(v, remoteobjects.fields.Field): if getattr(self, k) != getattr(other, k): return False @@ -172,7 +170,7 @@ def __ne__(self, other): @classmethod def statefields(cls): - return cls.fields.keys() + ['api_data'] + return list(cls.fields.keys()) + ['api_data'] def __getstate__(self): return dict((k, self.__dict__[k]) for k in self.statefields() @@ -182,7 +180,7 @@ def get(self, attr, *args): return getattr(self, attr, *args) def __iter__(self): - for key in self.fields.keys(): + for key in list(self.fields.keys()): yield key def to_dict(self): @@ -191,7 +189,7 @@ def to_dict(self): data = deepcopy(self.api_data) # Now replace the data with what's actually in our object - for field_name, field in self.fields.iteritems(): + for field_name, field in self.fields.items(): value = getattr(self, field.attrname, None) if value is not None: data[field.api_name] = field.encode(value) @@ -200,7 +198,7 @@ def to_dict(self): # Now delete any fields that ended up being None # since we should exclude them in the resulting dict. - for k in data.keys(): + for k in list(data.keys()): if data[k] is None: del data[k] @@ -229,7 +227,7 @@ def update_from_dict(self, data): if not isinstance(data, dict): raise TypeError # Clear any local instance field data - for k in self.fields.iterkeys(): + for k in self.fields.keys(): if k in self.__dict__: del self.__dict__[k] self.api_data = data diff --git a/remoteobjects/fields.py b/remoteobjects/fields.py index 69af15a..519ae6f 100644 --- a/remoteobjects/fields.py +++ b/remoteobjects/fields.py @@ -40,9 +40,7 @@ from datetime import datetime, tzinfo, timedelta import dateutil.parser -import logging -import time -import urlparse +from six.moves.urllib.parse import urljoin import remoteobjects.dataobject @@ -233,7 +231,7 @@ def __get__(self, obj, cls): return self # Since it's a constant, always return the same value. return self.value - + def __set__(self, obj, value): # If it's the correct value, do nothing. Else, raise an exception. if value != self.value: @@ -309,13 +307,13 @@ def decode(self, value): if callable(self.default): return self.default() return self.default or None - return dict((k, self.fld.decode(v)) for k, v in value.iteritems()) + return dict((k, self.fld.decode(v)) for k, v in value.items()) def encode(self, value): """Encodes a `DataObject` attribute (a dictionary with decoded `DataObject` attribute values for values) into a dictionary value (a dictionary with encoded dictionary values for values).""" - return dict((k, self.fld.encode(v)) for k, v in value.iteritems()) + return dict((k, self.fld.encode(v)) for k, v in value.items()) class AcceptsStringCls(object): @@ -439,11 +437,17 @@ class Link(AcceptsStringCls, Property): For example: + >>> from remoteobjects import RemoteObject + >>> from remoteobjects.fields import Link + >>> class Event(RemoteObject): pass + ... >>> class Item(RemoteObject): ... feed = Link(Event) ... >>> i = Item.get('http://example.com/item/') - >>> f = i.feed # f's URL: http://example.com/item/feed + >>> f = i.feed + >>> f._location + 'http://example.com/item/feed' Override the `__get__` method of a `Link` subclass to customize how the URLs to linked objects are constructed. @@ -482,5 +486,5 @@ def __get__(self, instance, owner): """ if instance._location is None: raise AttributeError('Cannot find URL of %s relative to URL-less %s' % (self.cls.__name__, owner.__name__)) - newurl = urlparse.urljoin(instance._location, self.api_name) + newurl = urljoin(instance._location, self.api_name) return self.cls.get(newurl) diff --git a/remoteobjects/http.py b/remoteobjects/http.py index 6bab3d4..43400c8 100644 --- a/remoteobjects/http.py +++ b/remoteobjects/http.py @@ -31,11 +31,11 @@ from remoteobjects.json import ForgivingDecoder import httplib2 -import httplib +from six import PY3, text_type +from six.moves import http_client # python3: http.client import logging -from remoteobjects.dataobject import DataObject, DataObjectMetaclass -from remoteobjects import fields +from remoteobjects.dataobject import DataObject userAgent = httplib2.Http() @@ -48,7 +48,7 @@ def omit_nulls(data): if not hasattr(data, '__dict__'): return str(data) data = dict(data.__dict__) - for key in data.keys(): + for key in list(data.keys()): if data[key] is None: del data[key] return data @@ -60,36 +60,36 @@ class HttpObject(DataObject): JSON API.""" response_has_content = { - httplib.OK: True, - httplib.ACCEPTED: False, - httplib.CREATED: True, - httplib.NO_CONTENT: False, - httplib.MOVED_PERMANENTLY: True, - httplib.FOUND: True, - httplib.NOT_MODIFIED: True, + http_client.OK: True, + http_client.ACCEPTED: False, + http_client.CREATED: True, + http_client.NO_CONTENT: False, + http_client.MOVED_PERMANENTLY: True, + http_client.FOUND: True, + http_client.NOT_MODIFIED: True, } location_headers = { - httplib.OK: 'Content-Location', - httplib.CREATED: 'Location', - httplib.MOVED_PERMANENTLY: 'Location', - httplib.FOUND: 'Location', + http_client.OK: 'Content-Location', + http_client.CREATED: 'Location', + http_client.MOVED_PERMANENTLY: 'Location', + http_client.FOUND: 'Location', } location_header_required = { - httplib.CREATED: True, - httplib.MOVED_PERMANENTLY: True, - httplib.FOUND: True, + http_client.CREATED: True, + http_client.MOVED_PERMANENTLY: True, + http_client.FOUND: True, } content_types = ('application/json',) - class NotFound(httplib.HTTPException): + class NotFound(http_client.HTTPException): """An HTTPException thrown when the server reports that the requested resource was not found.""" pass - class Unauthorized(httplib.HTTPException): + class Unauthorized(http_client.HTTPException): """An HTTPException thrown when the server reports that the requested resource is not available through an unauthenticated request. @@ -100,7 +100,7 @@ class Unauthorized(httplib.HTTPException): """ pass - class Forbidden(httplib.HTTPException): + class Forbidden(http_client.HTTPException): """An HTTPException thrown when the server reports that the client, as authenticated, is not authorized to request the requested resource. @@ -111,7 +111,7 @@ class Forbidden(httplib.HTTPException): """ pass - class PreconditionFailed(httplib.HTTPException): + class PreconditionFailed(http_client.HTTPException): """An HTTPException thrown when the server reports that some of the conditions in a conditional request were not true. @@ -122,16 +122,16 @@ class PreconditionFailed(httplib.HTTPException): """ pass - class RequestError(httplib.HTTPException): + class RequestError(http_client.HTTPException): """An HTTPException thrown when the server reports an error in the client's request. This exception corresponds to the HTTP status code 400. - + """ pass - class ServerError(httplib.HTTPException): + class ServerError(http_client.HTTPException): """An HTTPException thrown when the server reports an unexpected error. This exception corresponds to the HTTP status code 500. @@ -139,7 +139,7 @@ class ServerError(httplib.HTTPException): """ pass - class BadResponse(httplib.HTTPException): + class BadResponse(http_client.HTTPException): """An HTTPException thrown when the client receives some other non-success HTTP response.""" pass @@ -187,17 +187,17 @@ def raise_for_response(cls, url, response, content): """ # Turn exceptional httplib2 responses into exceptions. classname = cls.__name__ - if response.status == httplib.NOT_FOUND: + if response.status == http_client.NOT_FOUND: raise cls.NotFound('No such %s %s' % (classname, url)) - if response.status == httplib.UNAUTHORIZED: + if response.status == http_client.UNAUTHORIZED: raise cls.Unauthorized('Not authorized to fetch %s %s' % (classname, url)) - if response.status == httplib.FORBIDDEN: + if response.status == http_client.FORBIDDEN: raise cls.Forbidden('Forbidden from fetching %s %s' % (classname, url)) - if response.status == httplib.PRECONDITION_FAILED: + if response.status == http_client.PRECONDITION_FAILED: raise cls.PreconditionFailed('Precondition failed for %s request to %s' % (classname, url)) - if response.status in (httplib.INTERNAL_SERVER_ERROR, httplib.BAD_REQUEST): - if response.status == httplib.BAD_REQUEST: + if response.status in (http_client.INTERNAL_SERVER_ERROR, http_client.BAD_REQUEST): + if response.status == http_client.BAD_REQUEST: err_cls = cls.RequestError else: err_cls = cls.ServerError @@ -263,10 +263,22 @@ def update_from_response(self, url, response, content): self.raise_for_response(url, response, content) if self.response_has_content.get(response.status): - try: + if PY3: + # Both simplejson and the built-in json module decode the bytes + # before passing it to the JSONDecoder in python3, + # so we might as well do the same thing + # instead of passing bytes to a modified JSONDecoder + # https://github.com/simplejson/simplejson/blob/v3.17.5/simplejson/decoder.py#L368-L369 + # https://github.com/python/cpython/blob/v3.9.7/Lib/json/__init__.py#L341 + if not isinstance(content, text_type): + # use a forgiving decode + content = content.decode('utf-8', errors='replace') data = json.loads(content) - except UnicodeDecodeError: - data = json.loads(content, cls=ForgivingDecoder) + else: + try: + data = json.loads(content) + except UnicodeDecodeError: + data = json.loads(content, cls=ForgivingDecoder) self.update_from_dict(data) diff --git a/remoteobjects/json.py b/remoteobjects/json.py index 7ef953f..111cb45 100644 --- a/remoteobjects/json.py +++ b/remoteobjects/json.py @@ -28,10 +28,19 @@ # POSSIBILITY OF SUCH DAMAGE. from simplejson import JSONDecoder -from simplejson.decoder import FLAGS, BACKSLASH, STRINGCHUNK, DEFAULT_ENCODING +from simplejson.decoder import BACKSLASH, STRINGCHUNK, DEFAULT_ENCODING +try: + # simplejson >=3.12 + from simplejson.errors import errmsg +except ImportError: + # simplejson >=3.1.0, <3.12, since this commit: + # https://github.com/simplejson/simplejson/commit/104b40fcf6aa39d9ba7b240c3c528d1f85e86ef2 + # and before this commit + # https://github.com/simplejson/simplejson/commit/0d36c5cd16055d55e6eceaf252f072a9339e0746 + from simplejson.scanner import errmsg from simplejson.scanner import py_make_scanner -import re - +from six import unichr, text_type +import sys # Truly heinous... we are going to the trouble of reproducing this # entire routine, because we need to supply an errors="replace" @@ -42,7 +51,8 @@ def forgiving_scanstring(s, end, encoding=None, strict=True, _b=BACKSLASH, _m=ST Unescapes all valid JSON string escape sequences and raises ValueError on attempt to decode an invalid string. If strict is False then literal control characters are allowed in the string. - + + Returns a tuple of the decoded string and the index of the character in s after the end quote.""" if encoding is None: @@ -59,8 +69,8 @@ def forgiving_scanstring(s, end, encoding=None, strict=True, _b=BACKSLASH, _m=ST content, terminator = chunk.groups() # Content is contains zero or more unescaped string characters if content: - if not isinstance(content, unicode): - content = unicode(content, encoding, errors="replace") + if not isinstance(content, text_type): + content = text_type(content, encoding, errors="replace") _append(content) # Terminator is the end of string, a literal control character, # or a backslash denoting that an escape sequence follows diff --git a/remoteobjects/listobject.py b/remoteobjects/listobject.py index bcf1582..c41fca6 100644 --- a/remoteobjects/listobject.py +++ b/remoteobjects/listobject.py @@ -27,15 +27,11 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from urlparse import urljoin, urlparse, urlunparse -import cgi -import inspect import sys -import urllib +from six import with_metaclass import remoteobjects.fields as fields -from remoteobjects.dataobject import find_by_name -from remoteobjects.promise import PromiseObject, PromiseError +from remoteobjects.promise import PromiseObject class SequenceProxy(object): @@ -83,7 +79,7 @@ def __new__(cls, name, bases, attr): return type.__new__(cls, name, bases, attr) -class PageOf(PromiseObject.__metaclass__): +class PageOf(with_metaclass(OfOf, type(PromiseObject))): """Metaclass defining a `PageObject` containing a set of some other class's instances. @@ -92,6 +88,10 @@ class PageOf(PromiseObject.__metaclass__): new `PageObject` classes that contain objects of a specified other class, like so: + >>> from remoteobjects import RemoteObject + >>> from remoteobjects.listobject import PageObject, PageOf + >>> class Entry(RemoteObject): pass + ... >>> PageOfEntry = PageOf(Entry) This is equivalent to defining ``PageOfEntry`` yourself: @@ -103,8 +103,6 @@ class PageOf(PromiseObject.__metaclass__): """ - __metaclass__ = OfOf - _modulename = 'remoteobjects.listobject._pages' def __new__(cls, name, bases=None, attr=None): @@ -151,7 +149,7 @@ def __new__(cls, name, bases=None, attr=None): return newcls -class PageObject(SequenceProxy, PromiseObject): +class PageObject(with_metaclass(PageOf, SequenceProxy, PromiseObject)): """A `RemoteObject` representing a set of other `RemoteObject` instances. @@ -173,6 +171,9 @@ class PageObject(SequenceProxy, PromiseObject): `PageOf`, with the class reference you would use to construct an `Object` field. That is, these declarations are equivalent: + >>> from remoteobjects import fields, RemoteObject + >>> from remoteobjects.listobject import PageObject, PageOf + >>> class Entry(RemoteObject): pass >>> PageOfEntry = PageOf(Entry) >>> class PageOfEntry(PageObject): @@ -184,8 +185,6 @@ class PageObject(SequenceProxy, PromiseObject): """ - __metaclass__ = PageOf - entries = fields.List(fields.Field()) def __getitem__(self, key): @@ -194,9 +193,9 @@ def __getitem__(self, key): if isinstance(key, slice): args = dict() if key.start is not None: - args['offset'] = key.start if key.stop is not None: args['limit'] = key.stop - key.start + args['offset'] = key.start elif key.stop is not None: args['limit'] = key.stop return self.filter(**args) @@ -215,9 +214,7 @@ class ListOf(PageOf): _modulename = 'remoteobjects.listobject._lists' -class ListObject(PageObject): - - __metaclass__ = ListOf +class ListObject(with_metaclass(ListOf, PageObject)): def update_from_dict(self, data): super(ListObject, self).update_from_dict({ 'entries': data }) diff --git a/remoteobjects/promise.py b/remoteobjects/promise.py index 8ff03ea..4696cbd 100644 --- a/remoteobjects/promise.py +++ b/remoteobjects/promise.py @@ -27,15 +27,10 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -import urlparse -import urllib -import cgi - -import httplib import httplib2 +from six.moves.urllib.parse import parse_qs, urlencode, urlparse, urlunparse import remoteobjects.http -from remoteobjects.fields import Property class PromiseError(Exception): @@ -184,12 +179,12 @@ def options(self, http=None, **kwargs): return resp def __setattr__(self, name, value): - if name is not '_delivered' and not self._delivered and name in self.fields: + if name != '_delivered' and not self._delivered and name in self.fields: self.deliver() return super(PromiseObject, self).__setattr__(name, value) def __delattr__(self, name): - if name is not '_delivered' and not self._delivered and name in self.fields: + if name != '_delivered' and not self._delivered and name in self.fields: self.deliver() return super(PromiseObject, self).__delattr__(name) @@ -220,7 +215,7 @@ def update_from_dict(self, data): raise TypeError("Cannot update %r from non-dictionary data source %r" % (self, data)) # Clear any local instance field data - for k in self.fields.iterkeys(): + for k in self.fields.keys(): if k in self.__dict__: del self.__dict__[k] # Update directly to avoid triggering delivery. @@ -247,11 +242,11 @@ def filter(self, **kwargs): you require. """ - parts = list(urlparse.urlparse(self._location)) - queryargs = cgi.parse_qs(parts[4], keep_blank_values=True) - queryargs = dict([(k, v[0]) for k, v in queryargs.iteritems()]) + parts = list(urlparse(self._location)) + queryargs = parse_qs(parts[4], keep_blank_values=True) + queryargs = dict([(k, v[0]) for k, v in queryargs.items()]) queryargs.update(kwargs) - parts[4] = urllib.urlencode(queryargs) - newurl = urlparse.urlunparse(parts) + parts[4] = urlencode(queryargs) + newurl = urlunparse(parts) return self.get(newurl, http=self._http) diff --git a/requirements-test.txt b/requirements-test.txt index 10d6203..2a2609d 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,2 +1,15 @@ -nose -mox +configparser==4.0.2 +contextlib2==0.6.0.post1 +enum34==1.1.10 +flake8==3.9.2 +funcsigs==1.0.2 +functools32==3.2.3.post2; python_version < '3.0' +importlib-metadata==2.1.3 +mccabe==0.6.1 +mock==3.0.5 +pathlib2==2.3.7.post1 +pycodestyle==2.7.0 +pyflakes==2.3.1 +scandir==1.10.0 +typing==3.10.0.0; python_version < '3.0' +zipp==1.2.0 diff --git a/requirements.txt b/requirements.txt index 45927a6..d8e5fcc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,19 @@ -simplejson -httplib2>=0.5.0 -python-dateutil +httplib2==0.22.0 +pyparsing==2.4.7 +python-dateutil==2.8.2 +simplejson==3.19.2 +six==1.16.0 + +cffi==1.15.1; platform_python_implementation == 'PyPy' +greenlet==0.4.13; platform_python_implementation == 'PyPy' +hpy==0.0.4.dev179+g9b5d200; platform_python_implementation == 'PyPy' and python_version < '3.9' +readline==6.2.4.1; platform_python_implementation == 'PyPy' + +argcomplete==3.1.2; python_version == '3.9' and sys_platform == 'win32' +click==8.1.7; python_version == '3.9' and sys_platform == 'win32' +colorama==0.4.6; python_version == '3.9' and sys_platform == 'win32' +packaging==23.2; python_version == '3.9' and sys_platform == 'win32' +pipx==1.2.0; python_version == '3.9' and sys_platform == 'win32' +userpath==1.9.1; python_version == '3.9' and sys_platform == 'win32' + +certifi==2023.7.22; python_version == '3.11' and sys_platform == 'darwin' diff --git a/setup.py b/setup.py index eb8a692..c9660d0 100644 --- a/setup.py +++ b/setup.py @@ -39,7 +39,7 @@ setup( name='remoteobjects', - version='1.2.2', + version='1.3.dev0', description='an Object RESTational Model', author='SAY Media Ltd.', author_email='python@saymedia.com', @@ -61,8 +61,21 @@ packages=['remoteobjects'], provides=['remoteobjects'], - requires=['simplejson(>=2.0.0)', 'httplib2(>=0.5.0)', - 'dateutil(>=2.1)'], - install_requires=['simplejson>=2.0.0', 'httplib2>=0.5.0', - 'python-dateutil>=2.1'], + python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*', + install_requires=[ + 'simplejson>=3.3.0', + 'httplib2>=0.5.0', + 'python-dateutil>=2.1', + 'six~=1.16.0', + ], + extras_require={ + 'test': [ + 'flake8~=3.9', + # according to https://mock.readthedocs.io/en/latest/ + # version 3.0.5 is the last version supporting Python <= 3.5. + # Once we migrate to python 3.3+, we can switch from mock to + # unittest.mock + 'mock~=3.0.5', + ] + }, ) diff --git a/tests/__init__.py b/tests/__init__.py index 121920c..ecd4135 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -26,4 +26,3 @@ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. - diff --git a/tests/performance/benchmark_decoding.py b/tests/performance/benchmark_decoding.py index c3b520b..b413b9c 100755 --- a/tests/performance/benchmark_decoding.py +++ b/tests/performance/benchmark_decoding.py @@ -35,11 +35,11 @@ the second argument. The decode process is run as many times as you specify (via the -n flag). The raw times to decode the JSON data will be dumped to stdout. """ +from __future__ import print_function import optparse +from six.moves import range import time -import remoteobjects -import mox from tests import utils @@ -55,7 +55,7 @@ def test_decoding(object_class, json, count): o = object_class.get('http://example.com/ohhai', http=h) o.deliver() - for _ in xrange(count): + for _ in range(count): h = utils.mock_http(request, json) t = time.time() @@ -78,7 +78,7 @@ def test_decoding(object_class, json, count): try: fd = open(args[0]) json = fd.read() - except: + except Exception: parser.error("Unable to read file: '%s'" % args[1]) finally: fd.close() @@ -86,13 +86,13 @@ def test_decoding(object_class, json, count): module_name, _, class_name = args[1].rpartition('.') try: module = __import__(module_name) - except ImportError, e: + except ImportError as e: parser.error(e.message) try: RemoteObject = getattr(module, class_name) - except AttributeError, e: + except AttributeError as e: parser.error(e.message) for t in test_decoding(RemoteObject, json, options.num_runs): - print t + print(t) diff --git a/tests/requirements.txt b/tests/requirements.txt deleted file mode 100644 index 9308e8d..0000000 --- a/tests/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ --r ../requirements.txt -nose -mox diff --git a/tests/test_dataobject.py b/tests/test_dataobject.py index 5b51d2f..aab4b16 100644 --- a/tests/test_dataobject.py +++ b/tests/test_dataobject.py @@ -28,12 +28,11 @@ # POSSIBILITY OF SUCH DAMAGE. from datetime import datetime, timedelta, tzinfo -import logging import pickle import sys import unittest -import mox +import mock from remoteobjects import fields, dataobject from tests import utils @@ -50,27 +49,27 @@ class BasicMost(self.cls): value = fields.Field() b = BasicMost.from_dict({ 'name': 'foo', 'value': '4' }) - self.assert_(b, 'from_dict() returned something True') - self.assertEquals(b.name, 'foo', 'from_dict() result has correct name') - self.assertEquals(b.value, '4', 'from_dict() result has correct value') + self.assertTrue(b, 'from_dict() returned something True') + self.assertEqual(b.name, 'foo', 'from_dict() result has correct name') + self.assertEqual(b.value, '4', 'from_dict() result has correct value') b = BasicMost(name='bar', value='47').to_dict() - self.assert_(b, 'to_dict() returned something True') - self.assertEquals({ 'name': 'bar', 'value': '47' }, b, 'Basic dict has proper contents') + self.assertTrue(b, 'to_dict() returned something True') + self.assertEqual({ 'name': 'bar', 'value': '47' }, b, 'Basic dict has proper contents') - self.assertEquals(BasicMost.__name__, 'BasicMost', + self.assertEqual(BasicMost.__name__, 'BasicMost', "metaclass magic didn't break our class's name") bm = BasicMost(name='fred', value=2) bm.api_data = {"name": "fred", "value": 2} bm_dict = bm.to_dict() - self.assertEquals({ 'name': 'fred', 'value': 2 }, bm_dict, 'First go-round has proper contents') + self.assertEqual({ 'name': 'fred', 'value': 2 }, bm_dict, 'First go-round has proper contents') bm.name = 'tom' bm_dict = bm.to_dict() - self.assertEquals({ 'name': 'tom', 'value': 2 }, bm_dict, 'Setting name to another string works') + self.assertEqual({ 'name': 'tom', 'value': 2 }, bm_dict, 'Setting name to another string works') bm.name = None bm_dict = bm.to_dict() - self.assertEquals({ 'value': 2 }, bm_dict, 'Setting name to None works, and name is omitted in the dict') + self.assertEqual({ 'value': 2 }, bm_dict, 'Setting name to None works, and name is omitted in the dict') def test_descriptorwise(self): @@ -80,10 +79,10 @@ class BasicMost(self.cls): b = BasicMost() b.name = 'hi' - self.assertEquals(b.name, 'hi') + self.assertEqual(b.name, 'hi') del b.name - self.assert_(b.name is None) + self.assertTrue(b.name is None) def test_types(self): @@ -97,10 +96,10 @@ class WithTypes(self.cls): 'value': 4, 'when': '2008-12-31T04:00:01Z', }) - self.assert_(w, 'from_dict returned something True') - self.assertEquals(w.name, 'foo', 'Typething got the right name') - self.assertEquals(w.value, 4, 'Typething got the right value') - self.assertEquals(w.when, datetime(2008, 12, 31, 4, 0, 1, + self.assertTrue(w, 'from_dict returned something True') + self.assertEqual(w.name, 'foo', 'Typething got the right name') + self.assertEqual(w.value, 4, 'Typething got the right value') + self.assertEqual(w.when, datetime(2008, 12, 31, 4, 0, 1, tzinfo=fields.Datetime.utc), 'Typething got something like the right when') @@ -110,7 +109,7 @@ class WithTypes(self.cls): 'when': '2012-08-17T14:49:50-05:00' }) - self.assertEquals(w.when, datetime(2012, 8, 17, 19, 49, 50, + self.assertEqual(w.when, datetime(2012, 8, 17, 19, 49, 50, tzinfo=fields.Datetime.utc), 'Non-UTC timezone was parsed and converted to UTC') @@ -139,8 +138,8 @@ class WithTypes(self.cls): self.fail('No TypeError parsing malformatted timestamp') w = WithTypes(name='hi', value=99, when=datetime(2009, 2, 3, 10, 44, 0, tzinfo=None)).to_dict() - self.assert_(w, 'to_dict() returned something True') - self.assertEquals(w, { 'name': 'hi', 'value': 99, 'when': '2009-02-03T10:44:00Z' }, + self.assertTrue(w, 'to_dict() returned something True') + self.assertEqual(w, { 'name': 'hi', 'value': 99, 'when': '2009-02-03T10:44:00Z' }, 'Typething dict has proper contents') def test_must_ignore(self): @@ -155,34 +154,34 @@ class BasicMost(self.cls): 'secret': 'codes', }) - self.assert_(b) - self.assert_(b.name) + self.assertTrue(b) + self.assertTrue(b.name) self.assertRaises(AttributeError, lambda: b.secret) d = b.to_dict() - self.assert_('name' in d) - self.assert_('secret' in d) - self.assertEquals(d['secret'], 'codes') + self.assertTrue('name' in d) + self.assertTrue('secret' in d) + self.assertEqual(d['secret'], 'codes') d['blah'] = 'meh' d = b.to_dict() - self.assert_('blah' not in d) + self.assertTrue('blah' not in d) x = BasicMost.from_dict({ 'name': 'foo', 'value': '4', }) self.assertNotEqual(id(b), id(x)) - self.assert_(x) - self.assert_(x.name) + self.assertTrue(x) + self.assertTrue(x.name) x.update_from_dict({ 'secret': 'codes' }) self.assertRaises(AttributeError, lambda: x.secret) d = x.to_dict() - self.assert_('name' not in d) - self.assert_('secret' in d) - self.assertEquals(d['secret'], 'codes') + self.assertTrue('name' not in d) + self.assertTrue('secret' in d) + self.assertEqual(d['secret'], 'codes') def test_spooky_action(self): """Tests that an instance's content can't be changed through the data @@ -203,23 +202,23 @@ class BasicMost(self.cls): x = BasicMost.from_dict(initial) initial['name'] = 'bar' - self.assertEquals(x.name, 'bar', + self.assertEqual(x.name, 'bar', "Changing initial data does change instance's " "internal data") initial['secret']['code'] = 'steak' d = x.to_dict() - self.assertEquals(d['secret']['code'], 'steak', + self.assertEqual(d['secret']['code'], 'steak', "Changing deep hidden initial data *does* change instance's " "original data for export") d['name'] = 'baz' - self.assertEquals(x.name, 'bar', + self.assertEqual(x.name, 'bar', "Changing shallow exported data doesn't change instance's " "internal data retroactively") d['secret']['code'] = 'walt sent me' - self.assertEquals(x.to_dict()['secret']['code'], 'steak', + self.assertEqual(x.to_dict()['secret']['code'], 'steak', "Changing deep exported data doesn't change instance's " "internal data retroactively") @@ -242,7 +241,7 @@ class WithTypes(self.cls): }) self.assertRaises(TypeError, lambda: testobj.when) - self.assert_(testobj.bleh, 'Accessing properly formatted subobject raises no exceptions') + self.assertTrue(testobj.bleh, 'Accessing properly formatted subobject raises no exceptions') testobj = WithTypes.from_dict({ 'name': 'foo', @@ -251,7 +250,7 @@ class WithTypes(self.cls): 'bleh': True, }) - self.assert_(testobj.when, 'Accessing properly formatted datetime attribute raises no exceptions') + self.assertTrue(testobj.when, 'Accessing properly formatted datetime attribute raises no exceptions') self.assertRaises(TypeError, lambda: testobj.bleh) def test_complex(self): @@ -272,23 +271,23 @@ class Parentish(self.cls): ], }) - self.assert_(p, 'from_dict() returned something True for a parent') - self.assertEquals(p.name, 'the parent', 'parent has correct name') - self.assert_(p.children, 'parent has some children') - self.assert_(isinstance(p.children, list), 'children set is a Python list') - self.assertEquals(len(p.children), 3, 'parent has 3 children') + self.assertTrue(p, 'from_dict() returned something True for a parent') + self.assertEqual(p.name, 'the parent', 'parent has correct name') + self.assertTrue(p.children, 'parent has some children') + self.assertIsInstance(p.children, list, 'children set is a Python list') + self.assertEqual(len(p.children), 3, 'parent has 3 children') f, b, w = p.children - self.assert_(isinstance(f, Childer), "parent's first child is a Childer") - self.assert_(isinstance(b, Childer), "parent's twoth child is a Childer") - self.assert_(isinstance(w, Childer), "parent's third child is a Childer") - self.assertEquals(f.name, 'fredina', "parent's first child is named fredina") - self.assertEquals(b.name, 'billzebub', "parent's twoth child is named billzebub") - self.assertEquals(w.name, 'wurfledurf', "parent's third child is named wurfledurf") + self.assertIsInstance(f, Childer, "parent's first child is a Childer") + self.assertIsInstance(b, Childer, "parent's twoth child is a Childer") + self.assertIsInstance(w, Childer, "parent's third child is a Childer") + self.assertEqual(f.name, 'fredina', "parent's first child is named fredina") + self.assertEqual(b.name, 'billzebub', "parent's twoth child is named billzebub") + self.assertEqual(w.name, 'wurfledurf', "parent's third child is named wurfledurf") childs = Childer(name='jeff'), Childer(name='lisa'), Childer(name='conway') p = Parentish(name='molly', children=childs).to_dict() - self.assert_(p, 'to_dict() returned something True') - self.assertEquals(p, { + self.assertTrue(p, 'to_dict() returned something True') + self.assertEqual(p, { 'name': 'molly', 'children': [ { 'name': 'jeff' }, @@ -302,7 +301,7 @@ class Parentish(self.cls): 'children': None }) - self.assertEquals(p.children, None) + self.assertEqual(p.children, None) def test_complex_dict(self): @@ -315,7 +314,7 @@ class Thing(self.cls): 'attributes': None, }) - self.assertEquals(t.attributes, None) + self.assertEqual(t.attributes, None) def test_self_reference(self): @@ -329,10 +328,10 @@ class Reflexive(self.cls): 'themselves': [ {}, {}, {} ], }) - self.assert_(r) - self.assert_(isinstance(r, Reflexive)) - self.assert_(isinstance(r.itself, Reflexive)) - self.assert_(isinstance(r.themselves[0], Reflexive)) + self.assertTrue(r) + self.assertIsInstance(r, Reflexive) + self.assertIsInstance(r.itself, Reflexive) + self.assertIsInstance(r.themselves[0], Reflexive) def test_post_reference(self): @@ -349,15 +348,15 @@ class NotRelated(extra_dataobject.OtherRelated): r = Referencive.from_dict({ 'related': {}, 'other': {} }) - self.assert_(isinstance(r, Referencive)) - self.assert_(isinstance(r.related, Related)) # not extra_dataobject.Related - self.assert_(isinstance(r.other, extra_dataobject.OtherRelated)) # not NotRelated + self.assertIsInstance(r, Referencive) + self.assertIsInstance(r.related, Related) # not extra_dataobject.Related + self.assertIsInstance(r.other, extra_dataobject.OtherRelated) # not NotRelated r = extra_dataobject.Referencive.from_dict({ 'related': {}, 'other': {} }) - self.assert_(isinstance(r, extra_dataobject.Referencive)) - self.assert_(isinstance(r.related, Related)) # not extra_dataobject.Related - self.assert_(isinstance(r.other, extra_dataobject.OtherRelated)) # not NotRelated + self.assertIsInstance(r, extra_dataobject.Referencive) + self.assertIsInstance(r.related, Related) # not extra_dataobject.Related + self.assertIsInstance(r.other, extra_dataobject.OtherRelated) # not NotRelated def set_up_pickling_class(self): class BasicMost(self.cls): @@ -366,12 +365,14 @@ class BasicMost(self.cls): # Simulate a special module for this BasicMost, so pickle can find # the class for it. - pickletest_module = mox.MockAnything() + pickletest_module = mock.Mock() pickletest_module.BasicMost = BasicMost # Note this pseudomodule has no file, so coverage doesn't get a mock # method by mistake. pickletest_module.__file__ = None BasicMost.__module__ = 'remoteobjects._pickletest' + # In python3, we have to set __qualname__ too + BasicMost.__qualname__ = BasicMost.__name__ sys.modules['remoteobjects._pickletest'] = pickletest_module return BasicMost @@ -383,16 +384,16 @@ def test_pickling(self): obj = BasicMost(name='fred', value=7) pickled_obj = pickle.dumps(obj) - self.assert_(pickled_obj) + self.assertTrue(pickled_obj) unpickled_obj = pickle.loads(pickled_obj) - self.assertEquals(unpickled_obj, obj) + self.assertEqual(unpickled_obj, obj) obj = BasicMost.from_dict({'name': 'fred', 'value': 7}) cloned_obj = pickle.loads(pickle.dumps(obj)) - self.assert_(cloned_obj) - self.assert_(hasattr(cloned_obj, 'api_data'), "unpickled instance has api_data too") - self.assertEquals(cloned_obj.api_data, obj.api_data, + self.assertTrue(cloned_obj) + self.assertTrue(hasattr(cloned_obj, 'api_data'), "unpickled instance has api_data too") + self.assertEqual(cloned_obj.api_data, obj.api_data, "unpickled instance kept original's api_data") def test_field_override(self): @@ -404,9 +405,9 @@ class Parent(dataobject.DataObject): class Child(Parent): ted = fields.Datetime() - self.assert_('fred' in Child.fields, 'Child class inherited the fred field') - self.assert_('ted' in Child.fields, 'Child class has a ted field (from somewhere') - self.assert_(isinstance(Child.fields['ted'], fields.Datetime), + self.assertTrue('fred' in Child.fields, 'Child class inherited the fred field') + self.assertTrue('ted' in Child.fields, 'Child class has a ted field (from somewhere') + self.assertIsInstance(Child.fields['ted'], fields.Datetime, 'Child class has overridden ted field, yay') def test_field_api_name(self): @@ -422,16 +423,16 @@ class WeirdNames(dataobject.DataObject): 'plugh': 'http://en.wikipedia.org/wiki/Xyzzy#Poor_password_choice', }) - self.assertEquals(w.normal, 'asfdasf', 'normal value carried through') - self.assertEquals(w.fooBarBaz, 'wurfledurf', 'fbb value carried through') - self.assertEquals(w.xyzzy, 'http://en.wikipedia.org/wiki/Xyzzy#Poor_password_choice', + self.assertEqual(w.normal, 'asfdasf', 'normal value carried through') + self.assertEqual(w.fooBarBaz, 'wurfledurf', 'fbb value carried through') + self.assertEqual(w.xyzzy, 'http://en.wikipedia.org/wiki/Xyzzy#Poor_password_choice', 'xyzzy value carried through') w = WeirdNames(normal='gloing', fooBarBaz='grumdabble', xyzzy='slartibartfast') d = w.to_dict() - self.assert_(d, 'api_named to_dict() returned something True') - self.assertEquals(d, { + self.assertTrue(d, 'api_named to_dict() returned something True') + self.assertEqual(d, { 'normal': 'gloing', 'foo-bar-baz': 'grumdabble', 'plugh': 'slartibartfast', @@ -443,7 +444,7 @@ def test_field_default(self): cheezCalled = False def cheezburgh(obj): - self.assert_(isinstance(obj, WithDefaults)) + self.assertIsInstance(obj, WithDefaults) global cheezCalled cheezCalled = True return 'CHEEZBURGH' @@ -459,21 +460,21 @@ class WithDefaults(dataobject.DataObject): 'itsUsuallySomething': 'omg hi', }) - self.assertEquals(w.plain, 'awesome') - self.assertEquals(w.itsAlwaysSomething, 'haptics') - self.assertEquals(w.itsUsuallySomething, 'omg hi') - self.failIf(cheezCalled) + self.assertEqual(w.plain, 'awesome') + self.assertEqual(w.itsAlwaysSomething, 'haptics') + self.assertEqual(w.itsUsuallySomething, 'omg hi') + self.assertFalse(cheezCalled) for x in (WithDefaults.from_dict({}), WithDefaults()): - self.assert_(x.plain is None) - self.assertEquals(x.itsAlwaysSomething, 7) - self.assertEquals(x.itsUsuallySomething, 'CHEEZBURGH') - self.assert_(cheezCalled) + self.assertTrue(x.plain is None) + self.assertEqual(x.itsAlwaysSomething, 7) + self.assertEqual(x.itsUsuallySomething, 'CHEEZBURGH') + self.assertTrue(cheezCalled) d = WithDefaults().to_dict() - self.assert_('plain' not in d) - self.assertEquals(d['itsAlwaysSomething'], 7) - self.assertEquals(d['itsUsuallySomething'], 'CHEEZBURGH') + self.assertTrue('plain' not in d) + self.assertEqual(d['itsAlwaysSomething'], 7) + self.assertEqual(d['itsUsuallySomething'], 'CHEEZBURGH') def test_field_constant(self): @@ -483,10 +484,10 @@ class WithConstant(dataobject.DataObject): alwaysTheSame = fields.Constant(noninconstant) d = WithConstant().to_dict() - self.assertEquals(d['alwaysTheSame'], noninconstant) + self.assertEqual(d['alwaysTheSame'], noninconstant) x = WithConstant() - self.assertEquals(x.alwaysTheSame, noninconstant) + self.assertEqual(x.alwaysTheSame, noninconstant) try: x.alwaysTheSame = 'snarf' @@ -497,7 +498,7 @@ class WithConstant(dataobject.DataObject): x.alwaysTheSame = noninconstant # Just to make sure - self.assertEquals(x.alwaysTheSame, noninconstant) + self.assertEqual(x.alwaysTheSame, noninconstant) def test_field_link(self): @@ -510,7 +511,7 @@ class WithLink(dataobject.DataObject): x = WithLink() x.link = Frob() # Links don't serialize... for now anyways. - self.assertEquals(x.to_dict(), {}) + self.assertEqual(x.to_dict(), {}) def test_forwards_link(self): class Foo(dataobject.DataObject): @@ -520,7 +521,7 @@ class Bar(dataobject.DataObject): thing = fields.Field() # The string class name should be converted to the class - self.assertEquals(Foo.__dict__["link"].cls, Bar) + self.assertEqual(Foo.__dict__["link"].cls, Bar) def test_field_datetime(self): @@ -531,24 +532,24 @@ class Timely(dataobject.DataObject): 'when': '2008-12-31T04:00:01Z', }) - self.assert_(isinstance(t, Timely), 'Datetime class decoded properly') - self.assert_(isinstance(t.when, datetime), 'Datetime data decoded into a datetime') + self.assertIsInstance(t, Timely, 'Datetime class decoded properly') + self.assertIsInstance(t.when, datetime, 'Datetime data decoded into a datetime') when = datetime(year=2008, month=12, day=31, hour=4, minute=0, second=1, tzinfo=fields.Datetime.utc) - self.assertEquals(t.when, when, 'Datetime data decoded into the expected datetime') - self.assert_(t.when.tzinfo is fields.Datetime.utc, + self.assertEqual(t.when, when, 'Datetime data decoded into the expected datetime') + self.assertTrue(t.when.tzinfo is fields.Datetime.utc, 'Datetime data decoded with utc timezone info') when = datetime(year=2010, month=2, day=11, hour=4, minute=37, second=44) t_data = Timely(when=when).to_dict() - self.assert_(isinstance(t_data, dict), 'Datetime dict encoded properly') - self.assertEquals(t_data['when'], '2010-02-11T04:37:44Z', 'Datetime dict encoded with expected timestamp') + self.assertIsInstance(t_data, dict, 'Datetime dict encoded properly') + self.assertEqual(t_data['when'], '2010-02-11T04:37:44Z', 'Datetime dict encoded with expected timestamp') when = datetime(year=2010, month=2, day=11, hour=4, minute=37, second=44, tzinfo=fields.Datetime.utc) t_data = Timely(when=when).to_dict() - self.assert_(isinstance(t_data, dict), 'Datetime dict with UTC tzinfo encoded properly') - self.assertEquals(t_data['when'], '2010-02-11T04:37:44Z', 'Datetime dict encoded with expected timestamp') + self.assertIsInstance(t_data, dict, 'Datetime dict with UTC tzinfo encoded properly') + self.assertEqual(t_data['when'], '2010-02-11T04:37:44Z', 'Datetime dict encoded with expected timestamp') class EST(tzinfo): @@ -564,18 +565,18 @@ def dst(self, dt): when = datetime(year=2010, month=2, day=10, hour=23, minute=37, second=44, tzinfo=EST()) t_data = Timely(when=when).to_dict() - self.assert_(isinstance(t_data, dict), 'Datetime dict with non-UTC tzinfo encoded properly') - self.assertEquals(t_data['when'], '2010-02-11T04:37:44Z', 'Datetime dict encoded with expected timestamp') + self.assertIsInstance(t_data, dict, 'Datetime dict with non-UTC tzinfo encoded properly') + self.assertEqual(t_data['when'], '2010-02-11T04:37:44Z', 'Datetime dict encoded with expected timestamp') t = Timely.from_dict({ 'when': None, }) - self.assert_(isinstance(t, Timely), 'Datetime with None data decoded properly') - self.assert_(t.when is None, 'Datetime with None data decoded to None timestamp') + self.assertIsInstance(t, Timely, 'Datetime with None data decoded properly') + self.assertTrue(t.when is None, 'Datetime with None data decoded to None timestamp') t = Timely.from_dict({}) - self.assert_(isinstance(t, Timely), 'Datetime with missing data decoded properly') - self.assert_(t.when is None, 'Datetime with missing data decoded to None timestamp') + self.assertIsInstance(t, Timely, 'Datetime with missing data decoded properly') + self.assertTrue(t.when is None, 'Datetime with missing data decoded to None timestamp') if __name__ == '__main__': diff --git a/tests/test_http.py b/tests/test_http.py index 32e8f33..13e0e81 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -27,14 +27,10 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from datetime import datetime -import logging -import sys import unittest -import mox - from remoteobjects import fields, http +from six import PY2 from tests import test_dataobject from tests import utils @@ -64,9 +60,9 @@ class BasicMost(self.cls): h = utils.mock_http(request, content) b = BasicMost.get('http://example.com/ohhai', http=h, headers={"x-test": "boo"}) - self.assertEquals(b.name, 'Fred') - self.assertEquals(b.value, 7) - mox.Verify(h) + self.assertEqual(b.name, 'Fred') + self.assertEqual(b.value, 7) + h.request.assert_called_once_with(**request) def test_get_bad_encoding(self): @@ -78,14 +74,48 @@ class BasicMost(self.cls): 'uri': 'http://example.com/ohhai', 'headers': {'accept': 'application/json'}, } - content = """{"name": "Fred\xf1", "value": "image by \xefrew Example"}""" + content = b"""{"name": "Fred\xf1", "value": "image by \xefrew Example"}""" h = utils.mock_http(request, content) b = BasicMost.get('http://example.com/ohhai', http=h) - self.assertEquals(b.name, u"Fred\ufffd") + self.assertEqual(b.name, u"Fred\ufffd") # Bad characters are replaced with the unicode Replacement Character 0xFFFD. - self.assertEquals(b.value, u"image by \ufffdrew Example") - mox.Verify(h) + self.assertEqual(b.value, u"image by \ufffdrew Example") + h.request.assert_called_once_with(**request) + h.reset_mock() + + # since simplejson 3.3.0, lone surrogates are passed through + # https://github.com/simplejson/simplejson/commit/35816bfe2d0ddeb5ddcc68239683cbb35b7e3ff2 + content = """{"name": "lone surrogate \\ud800", "value": "\\udc00 lone surrogate"}""" + h = utils.mock_http(request, content) + b = BasicMost.get('http://example.com/ohhai', http=h) + # Lone surrogates are passed through as lone surrogates in the python unicode value + self.assertEqual(b.name, u"lone surrogate \ud800") + self.assertEqual(b.value, u"\udc00 lone surrogate") + h.request.assert_called_once_with(**request) + + content = u"""{"name": "100 \u20AC", "value": "13000 \u00A5"}""".encode('utf-8') + h = utils.mock_http(request, content) + b = BasicMost.get('http://example.com/ohhai', http=h) + # JSON containing non-ascii UTF-8 should be decoded to unicode strings + self.assertEqual(b.name, u"100 \u20AC") + self.assertEqual(b.value, u"13000 \u00A5") + h.request.assert_called_once_with(**request) + + content = b"""{"name": "lone surrogate \xed\xa0\x80", "value": "\xed\xb0\x80 lone surrogate"}""" + h = utils.mock_http(request, content) + b = BasicMost.get('http://example.com/ohhai', http=h) + # Lone surrogates are passed through as lone surrogates in the python unicode value + if PY2: + # in python2, our JSONDecoder does not detect naked lone surrogates + self.assertEqual(b.name, u"lone surrogate \ud800") + self.assertEqual(b.value, u"\udc00 lone surrogate") + else: + # in python3, bytes.decode replaces lone surrogates with replacement char + self.assertEqual(b.name, u"lone surrogate \ufffd\ufffd\ufffd") + self.assertEqual(b.value, u"\ufffd\ufffd\ufffd lone surrogate") + + h.request.assert_called_once_with(**request) def test_post(self): @@ -103,8 +133,8 @@ class ContainerMost(self.cls): content = """{"name": "CBS"}""" h = utils.mock_http(request, content) c = ContainerMost.get('http://example.com/asfdasf', http=h) - self.assertEquals(c.name, 'CBS') - mox.Verify(h) + self.assertEqual(c.name, 'CBS') + h.request.assert_called_once_with(**request) b = BasicMost(name='Fred Friendly', value=True) @@ -119,10 +149,10 @@ class ContainerMost(self.cls): location='http://example.com/fred') h = utils.mock_http(request, response) c.post(b, http=h) - mox.Verify(h) + h.request.assert_called_once_with(**request) - self.assertEquals(b._location, 'http://example.com/fred') - self.assertEquals(b._etag, 'xyz') + self.assertEqual(b._location, 'http://example.com/fred') + self.assertEqual(b._etag, 'xyz') def test_put(self): @@ -140,8 +170,8 @@ class BasicMost(self.cls): content = """{"name": "Molly", "value": 80}""" h = utils.mock_http(request, content) b = BasicMost.get('http://example.com/bwuh', http=h) - self.assertEquals(b.name, 'Molly') - mox.Verify(h) + self.assertEqual(b.name, 'Molly') + h.request.assert_called_once_with(**request) headers = { 'accept': 'application/json', @@ -152,9 +182,9 @@ class BasicMost(self.cls): response = dict(content=content, etag='xyz') h = utils.mock_http(request, response) b.put(http=h) - mox.Verify(h) + h.request.assert_called_once_with(**request) - self.assertEquals(b._etag, 'xyz') + self.assertEqual(b._etag, 'xyz') def test_put_no_content(self): """ @@ -173,8 +203,8 @@ class BasicMost(self.cls): content = """{"name": "Molly", "value": 80}""" h = utils.mock_http(request, content) b = BasicMost.get('http://example.com/bwuh', http=h) - self.assertEquals(b.name, 'Molly') - mox.Verify(h) + self.assertEqual(b.name, 'Molly') + h.request.assert_called_once_with(**request) headers = { 'accept': 'application/json', @@ -185,9 +215,9 @@ class BasicMost(self.cls): response = dict(content="", status=204) h = utils.mock_http(request, response) b.put(http=h) - mox.Verify(h) + h.request.assert_called_once_with(**request) - self.assertEquals(b.name, 'Molly') + self.assertEqual(b.name, 'Molly') def test_put_failure(self): @@ -202,8 +232,8 @@ class BasicMost(self.cls): content = """{"name": "Molly", "value": 80}""" h = utils.mock_http(request, content) b = BasicMost.get('http://example.com/bwuh', http=h) - self.assertEquals(b.value, 80) - mox.Verify(h) + self.assertEqual(b.value, 80) + h.request.assert_called_once_with(**request) b.value = 'superluminal' @@ -219,7 +249,7 @@ class BasicMost(self.cls): response = dict(status=412) h = utils.mock_http(request, response) self.assertRaises(BasicMost.PreconditionFailed, lambda: b.put(http=h)) - mox.Verify(h) + h.request.assert_called_once_with(**request) def test_delete(self): @@ -237,8 +267,8 @@ class BasicMost(self.cls): content = """{"name": "Molly", "value": 80}""" h = utils.mock_http(request, content) b = BasicMost.get('http://example.com/bwuh', http=h) - self.assertEquals(b.value, 80) - mox.Verify(h) + self.assertEqual(b.value, 80) + h.request.assert_called_once_with(**request) headers = { 'accept': 'application/json', @@ -248,10 +278,10 @@ class BasicMost(self.cls): response = dict(status=204) h = utils.mock_http(request, response) b.delete(http=h) - mox.Verify(h) + h.request.assert_called_once_with(**request) - self.failIf(b._location is not None) - self.failIf(hasattr(b, '_etag')) + self.assertFalse(b._location is not None) + self.assertFalse(hasattr(b, '_etag')) def test_delete_failure(self): @@ -272,15 +302,15 @@ class BasicMost(self.cls): h = utils.mock_http(request, response) self.assertRaises(BasicMost.PreconditionFailed, lambda: b.delete(http=h)) - mox.Verify(h) + h.request.assert_called_once_with(**request) def test_not_found(self): - self.assert_(self.cls.NotFound) + self.assertTrue(self.cls.NotFound) class Huh(self.cls): name = fields.Field() - self.assert_(Huh.NotFound) + self.assertTrue(Huh.NotFound) request = { 'uri': 'http://example.com/bwuh', @@ -289,7 +319,7 @@ class Huh(self.cls): response = {'content': '', 'status': 404} http = utils.mock_http(request, response) self.assertRaises(Huh.NotFound, lambda: Huh.get('http://example.com/bwuh', http=http).name) - mox.Verify(http) + http.request.assert_called_once_with(**request) @utils.todo def test_not_found_discrete(self): @@ -327,9 +357,9 @@ def try_that(http): 'headers': {'accept': 'application/json'}, } response = dict(status=404) - http = utils.MockedHttp(request, response) + http = utils.mock_http(request, response) self.assertRaises(What.NotFound, lambda: try_that(http)) - mox.Verify(http) + http.request.assert_called_once_with(**request) if __name__ == '__main__': diff --git a/tests/test_listobject.py b/tests/test_listobject.py index 1f25d32..3c9bc7c 100644 --- a/tests/test_listobject.py +++ b/tests/test_listobject.py @@ -30,10 +30,9 @@ import unittest import httplib2 -import mox +import mock -from remoteobjects import fields, http, promise, listobject -from tests import test_dataobject, test_http +from remoteobjects import fields, listobject, promise from tests import utils @@ -46,30 +45,29 @@ def test_slice_filter(self): class Toybox(self.cls): pass - h = mox.MockObject(httplib2.Http) - mox.Replay(h) + h = mock.NonCallableMock(spec_set=httplib2.Http) b = Toybox.get('http://example.com/foo', http=h) - self.assertEquals(b._location, 'http://example.com/foo') + self.assertEqual(b._location, 'http://example.com/foo') j = b[0:10] - self.assert_(isinstance(j, Toybox)) - self.assertEquals(j._location, 'http://example.com/foo?limit=10&offset=0') + self.assertIsInstance(j, Toybox) + self.assertEqual(j._location, 'http://example.com/foo?limit=10&offset=0') j = b[300:370] - self.assert_(isinstance(j, Toybox)) - self.assertEquals(j._location, 'http://example.com/foo?limit=70&offset=300') + self.assertIsInstance(j, Toybox) + self.assertEqual(j._location, 'http://example.com/foo?limit=70&offset=300') j = b[1:] - self.assert_(isinstance(j, Toybox)) - self.assertEquals(j._location, 'http://example.com/foo?offset=1') + self.assertIsInstance(j, Toybox) + self.assertEqual(j._location, 'http://example.com/foo?offset=1') j = b[:10] - self.assert_(isinstance(j, Toybox)) - self.assertEquals(j._location, 'http://example.com/foo?limit=10') + self.assertIsInstance(j, Toybox) + self.assertEqual(j._location, 'http://example.com/foo?limit=10') # Nobody did any HTTP, right? - mox.Verify(h) + self.assertEqual([], h.method_calls) def test_index(self): @@ -81,9 +79,60 @@ class Toybox(self.cls): request = dict(uri=url, headers=headers) content = """{"entries":[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]}""" h = utils.mock_http(request, content) - mox.Replay(h) b = Toybox.get('http://example.com/whahay', http=h) self.assertEqual(b[7], 7) - mox.Verify(h) + h.request.assert_called_once_with(**request) + + +class TestPageOf(unittest.TestCase): + + def test_basemodule(self): + # When creating PageOf(myclass), it should use PageObject as superclass + self.assertEqual(listobject.PageOf._basemodule, listobject.PageObject) + + def test_to_dict(self): + class MyObj(promise.PromiseObject): + myfield = fields.Field() + MyObjPage = listobject.PageOf(MyObj) + obj = MyObjPage(entries=[MyObj(myfield="myval")]) + # When creating PageOf(myclass), it should use PageObject as superclass + self.assertIsInstance(obj, listobject.PageObject) + self.assertEqual({"entries": [{"myfield": "myval"}]}, obj.to_dict()) + + def test_from_dict(self): + class MyObj(promise.PromiseObject): + myfield = fields.Field() + MyObjPage = listobject.PageOf(MyObj) + actual = MyObjPage.from_dict({"entries": [{"myfield": "myval"}]}) + self.assertIsInstance(actual, listobject.PageObject) + self.assertEqual("myval", actual.entries[0].myfield) + expected = MyObjPage(entries=[MyObj(myfield="myval")]) + self.assertEqual(actual, expected) + + +class TestListOf(unittest.TestCase): + + def test_basemodule(self): + # When creating ListOf(myclass), it should use ListObject as superclass + self.assertEqual(listobject.ListOf._basemodule, listobject.ListObject) + + def test_to_dict(self): + class MyObj(promise.PromiseObject): + myfield = fields.Field() + MyObjList = listobject.ListOf(MyObj) + obj = MyObjList(entries=[MyObj(myfield="myval")]) + # When creating ListOf(myclass), it should use ListObject as superclass + self.assertIsInstance(obj, listobject.ListObject) + self.assertEqual([{"myfield": "myval"}], obj.to_dict()) + + def test_from_dict(self): + class MyObj(promise.PromiseObject): + myfield = fields.Field() + MyObjList = listobject.ListOf(MyObj) + actual = MyObjList.from_dict([{"myfield": "myval"}]) + expected = MyObjList(entries=[MyObj(myfield="myval")]) + self.assertEqual("myval", actual.entries[0].myfield) + self.assertIsInstance(actual, listobject.ListObject) + self.assertEqual(actual, expected) diff --git a/tests/test_promise.py b/tests/test_promise.py index 765f83b..818f367 100644 --- a/tests/test_promise.py +++ b/tests/test_promise.py @@ -30,9 +30,9 @@ import unittest import httplib2 -import mox +import mock -from remoteobjects import fields, http, promise +from remoteobjects import fields, promise from tests import test_dataobject, test_http from tests import utils @@ -56,46 +56,61 @@ def test_basic(self): class Tiny(self.cls): name = fields.Field() - h = mox.MockObject(httplib2.Http) - mox.Replay(h) + h = mock.NonCallableMock(spec_set=httplib2.Http) url = 'http://example.com/whahay' t = Tiny.get(url, http=h) # Check that we didn't do anything. - mox.Verify(h) + self.assertEqual([], h.method_calls) headers = {"accept": "application/json"} request = dict(uri=url, headers=headers) content = """{"name": "Mollifred"}""" h = utils.mock_http(request, content) - t._http = h # inject, oops - self.assertEquals(t.name, 'Mollifred') - mox.Verify(h) + t._http = h + + self.assertEqual(t.name, 'Mollifred') + h.request.assert_called_once_with(**request) def test_filter(self): class Toy(self.cls): name = fields.Field() - h = mox.MockObject(httplib2.Http) - mox.Replay(h) + h = mock.NonCallableMock(spec_set=httplib2.Http) b = Toy.get('http://example.com/foo', http=h) - self.assertEquals(b._location, 'http://example.com/foo') + self.assertEqual(b._location, 'http://example.com/foo') x = b.filter(limit=10, offset=7) - self.assert_(x is not b) - self.assertEquals(b._location, 'http://example.com/foo') - self.assertEquals(x._location, 'http://example.com/foo?limit=10&offset=7') + self.assertTrue(x is not b) + self.assertEqual(b._location, 'http://example.com/foo') + self.assertEqual(x._location, 'http://example.com/foo?limit=10&offset=7') y = b.filter(awesome='yes') - self.assertEquals(y._location, 'http://example.com/foo?awesome=yes') + self.assertEqual(y._location, 'http://example.com/foo?awesome=yes') y = y.filter(awesome='no') - self.assertEquals(y._location, 'http://example.com/foo?awesome=no') + self.assertEqual(y._location, 'http://example.com/foo?awesome=no') # Nobody did any HTTP, right? - mox.Verify(h) + self.assertEqual([], h.method_calls) + + def test_filter_mix_str_unicode(self): + """On python2, test that filter accepts both unicode and str""" + class Toy(self.cls): + name = fields.Field() + + h = mock.NonCallableMock(spec_set=httplib2.Http) + + b = Toy.get('http://example.com/foo', http=h) + self.assertEqual(b._location, 'http://example.com/foo') + + y = b.filter(a='a', b=u'b') + self.assertEqual(y._location, 'http://example.com/foo?a=a&b=b') + y = b.filter(**{'a': 'a', u'b': u'b'}) + self.assertEqual(y._location, 'http://example.com/foo?a=a&b=b') + def test_awesome(self): @@ -107,8 +122,8 @@ class Room(self.cls): r = Room.get('http://example.com/bwuh/') b = r.toybox - self.assert_(isinstance(b, Toy)) - self.assertEquals(b._location, 'http://example.com/bwuh/toybox') + self.assertIsInstance(b, Toy) + self.assertEqual(b._location, 'http://example.com/bwuh/toybox') def test_set_before_delivery(self): @@ -127,9 +142,9 @@ class Toy(self.cls): t.names = ["New name"] d = t.to_dict() # this delivers the object - # self.assertEquals(t.foo, "something") - self.assertEquals(d['names'][0], "New name") - self.assertEquals(t.names[0], "New name") + # self.assertEqual(t.foo, "something") + self.assertEqual(d['names'][0], "New name") + self.assertEqual(t.names[0], "New name") h = utils.mock_http(request, content) # test case where we update_from_dict explictly after setting attributes @@ -137,4 +152,4 @@ class Toy(self.cls): t.foo = "local change" t.update_from_dict({"names": ["local update"]}) - self.assertEquals(t.foo, None) + self.assertEqual(t.foo, None) diff --git a/tests/test_requirements_txt.py b/tests/test_requirements_txt.py new file mode 100644 index 0000000..f8c74e3 --- /dev/null +++ b/tests/test_requirements_txt.py @@ -0,0 +1,111 @@ +from __future__ import print_function +import os +import subprocess +import sys +import unittest + + +class TestRequirementsTxt(unittest.TestCase): + def check_pip_freeze_empty(self, flags): + # Run the pip freeze command and capture its output + args = [sys.executable, '-m', 'pip', 'freeze', '--exclude-editable'] + args.extend(flags) + with open(os.devnull, 'w') as fnull: + # python 3.3: stder=subprocess.DEVNULL + result = subprocess.check_output(args, stderr=fnull) + lines = result.decode('utf-8').splitlines() + + # raises ValueError if not present + added_index = lines.index( + '## The following requirements were added by pip freeze:') + added_dependencies = [ + line for line in lines[added_index + 1:] + if len(line) > 0 + # workaround: as of ubuntu 20.04 (fixed in 22.04), + # pip freeze within virtualenv gave extraneous line + # that cannot be installed + # https://bugs.launchpad.net/ubuntu/+source/python-pip/+bug/1635463 + and line != 'pkg-resources==0.0.0' + ] + if len(added_dependencies) > 0: + self.fail( + 'Error: {} were missing recursive dependencies:\n{}'.format( + ' '.join(flags), '\n'.join(added_dependencies))) + + @unittest.skipUnless( + os.environ.get('TEST_REQUIREMENTS') == 'prod', + 'requirements test must be explicitly enabled with TEST_REQUIREMENTS' + ) + def test_requirements_txt_is_complete(self): + '''Ensure that requirements.txt has all recursive dependencies''' + self.check_pip_freeze_empty(['-r', 'requirements.txt']) + + @unittest.skipUnless( + os.environ.get('TEST_REQUIREMENTS') == 'test', + 'requirements test must be explicitly enabled with TEST_REQUIREMENTS' + ) + def test_requirements_test_txt_is_complete(self): + '''Ensure that requirements-test.txt has all recursive dependencies''' + self.check_pip_freeze_empty( + ['-r', 'requirements.txt', '-r', 'requirements-test.txt']) + + def check_pip_install_empty(self, pkg, freeze_flags): + args = [sys.executable, '-m', 'pip', 'install', '--dry-run', pkg] + live_args = [arg for arg in args if arg != '--dry-run'] + freeze_args = [sys.executable, '-m', 'pip', 'freeze', + '--exclude-editable'] + freeze_args.extend(freeze_args) + # TODO: use the --report= arg and parse json instead + with open(os.devnull, 'w') as fnull: + # python 3.3: stder=subprocess.DEVNULL + result = subprocess.check_output(args, stderr=fnull) + lines = result.decode('utf-8').splitlines() + WOULD_INSTALL = 'Would install ' + would_install_lines = [line for line in lines + if line.startswith(WOULD_INSTALL)] + self.assertGreater( + len(would_install_lines), 0, + 'Could not find Would install line after {}'.format( + ' '.join(args))) + would_install_str = would_install_lines[0][len(WOULD_INSTALL):] + would_install_packages = would_install_str.split(' ') + would_install_packages = [ + p for p in would_install_packages + if not p.startswith('remoteobjects-') + ] + self.assertEqual( + would_install_packages, [], + 'requirements missing {}; re-run {} && {}'.format( + ' '.join(would_install_packages), + ' '.join(live_args), + ' '.join(freeze_args), + ) + ) + + # skip on python2 since --dry-run was added in pip 22.2, + # but pip stopped supporting python2 in pip 21 + # https://pip.pypa.io/en/stable/news/#v22-2 + # https://pip.pypa.io/en/stable/news/#v21-0 + @unittest.skipUnless( + sys.version_info[0] >= 3, + 'setup.py prod requirements test requires pip 21' + ) + def test_setup_dependencies_are_installed(self): + '''Ensure that setup.py install_requires are all installed''' + self.check_pip_install_empty('.', ['-r', 'requirements.txt']) + + # skip on python2 since --dry-run was added in pip 22.2, + # but pip stopped supporting python2 in pip 21 + # https://pip.pypa.io/en/stable/news/#v22-2 + # https://pip.pypa.io/en/stable/news/#v21-0 + @unittest.skipUnless( + sys.version_info[0] >= 3 and + os.environ.get('TEST_REQUIREMENTS') == 'test', + 'setup.py test requirements test requires pip 21 and ' + 'must be explicitly enabled with TEST_REQUIREMENTS' + ) + def test_setup_test_dependencies_are_installed(self): + '''Ensure that setup.py extra_require[test] are all installed''' + self.check_pip_install_empty( + '.[test]', + ['-r', 'requirements.txt', '-r', 'requirements-test.txt']) diff --git a/tests/utils.py b/tests/utils.py index 888a5f7..5b665e0 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -27,21 +27,19 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. +from functools import wraps import httplib2 import logging -import os -import mox -import nose -import nose.tools +import mock def todo(fn): - @nose.tools.make_decorator(fn) + @wraps(fn) def test_reverse(*args, **kwargs): try: fn(*args, **kwargs) - except: + except Exception: pass else: raise AssertionError('test %s unexpectedly succeeded' % fn.__name__) @@ -49,7 +47,7 @@ def test_reverse(*args, **kwargs): def mock_http(req, resp_or_content): - mock = mox.MockObject(httplib2.Http) + m = mock.Mock(spec_set=httplib2.Http) if not isinstance(req, dict): req = dict(uri=req) @@ -83,9 +81,8 @@ def make_response(response, url): return httplib2.Response(response_info), content resp, content = make_response(resp_or_content, req['uri']) - mock.request(**req).AndReturn((resp, content)) - mox.Replay(mock) - return mock + m.request.return_value = (resp, content) + return m def log():