2

I am having a problem with encoding to JSON in Python, specifically with decimal.Decimal values. I am using this to output JSON for a Google App Engine application.

To circumvent the exception from the default json module in Python telling me that it can't handle decimal.Decimal objects, I am using this encoder subclass:

class DecimalEncoder(json.JSONEncoder):
    def default(self, o):
        if isinstance(o, decimal.Decimal):
            return float(o)
        return super(DecimalEncoder, self).default(o)

On other applications, this does work. In this case it doesn't. After much frustration I found out that this gives weird results:

print id(decimal.Decimal)
print id(type(o))

One would expect the id's to be identical, because it makes sense for the same class object to reside only once in memory. Because the id's differ, isinstance() doesn't work.

Could it be that decimal.Decimal is already imported somewhere else, for instance in the App Engine and/or webapp2 packages?

The following modul reproduces the error on my system (OSx 10.10, Python 2.7.6, GAE SDK 1.9.20). Just create an GAE application and put this in main.py:

import webapp2, decimal, json, MySQLdb, sys

class DecimalEncoder(json.JSONEncoder):
    def default(self, o):
        print id(decimal.Decimal)
        print id(type(o))
        if isinstance(o, decimal.Decimal):
            return float(o)
        return super(DecimalEncoder, self).default(o)

class MainHandler(webapp2.RequestHandler):
    def get(self):
        db = MySQLdb.connect(unix_socket='/var/mysql/mysql.sock', host='localhost', user='root', db='ssss', charset='utf8')
        cursor = db.cursor(MySQLdb.cursors.DictCursor)
        cursor.execute("SELECT id, price FROM product WHERE id = 1")
        record = cursor.fetchone()

        self.response.headers['Content-Type'] = 'application/json'
        self.response.write(json.dumps(
            record,
            cls=DecimalEncoder,
            indent=4,
            separators=(',', ': ')
        ))

app = webapp2.WSGIApplication([
    ('/', MainHandler)
], debug=True)

Database table:

CREATE TABLE `product` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `price` decimal(10,2) unsigned DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8;
INSERT INTO product VALUES(0, 5.00);
Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
Rebel Designer
  • 235
  • 4
  • 12
  • what is the type of `o`? Where does `o` come from? – Daniel Jun 07 '15 at 14:09
  • So what else can you tell us about `type(o)`? What does `type(o).__module__` say was the source module? Normally, Python modules can be imported more than once but they'd be references to the same `sys.modules['decimal']` objects. – Martijn Pieters Jun 07 '15 at 14:10
  • `type(0)` gives ``. `type(o).__module__` gives `decimal`, as does `decimal.Decimal.__module__`. `o` comes from the JSON encoder, and ultimately from a MySQL database (MySQLdb Python module). – Rebel Designer Jun 07 '15 at 14:18
  • @RebelDesigner: this rings a very vague bell, I have seen something like this reported before. If another module *patches* `decimal.Decimal` then the replacement class may not have been used by whatever imported the class before, and you get this issue. The problem then is finding what patched it, and how to fix this for MySQL. – Martijn Pieters Jun 07 '15 at 14:58
  • Well. I'm using the exact same things in another project. It's based on webapp2, **not** running on GAE, but standalone. It uses MySQLdb, decimal.Decimal, webapp2, Python native JSON encoding with the exact same encoding class. What am I missing? – Rebel Designer Jun 07 '15 at 18:34
  • could you use `numbers.Number` as the `isinstance` type instead? normal `int` and `float` will have already be caught by the encoder so I don't think you should have to worry about encoding them incorrectly... – mgilson Jun 07 '15 at 21:39
  • Same result with `isinstance(0, numbers.Number)` – Rebel Designer Jun 08 '15 at 06:18
  • Not so elegant temporary fix:`if isinstance(o, decimal.Decimal): return float(o) try: return super(DecimalEncoder, self).default(o) except TypeError as e: return float(o)` – Rebel Designer Jun 08 '15 at 09:11
  • @Martijn Pieters: I have spit through most of the GAE development server code. Nothing seems to change anything in `decimal.Decimal`. I'm getting really frustrated here. My whole project is stuck.. – Rebel Designer Jun 09 '15 at 12:39
  • @RebelDesigner: I can imagine that this is rather frustrating. You can reproduce this *locally*, with the SDK, right? Can you create a small reproducible sample that shows the issue? – Martijn Pieters Jun 09 '15 at 12:41
  • @MartijnPieters: yes, see question for updated drop-in code sample. – Rebel Designer Jun 09 '15 at 12:58
  • @RebelDesigner: not quite stand-alone though; what is in the `db` module? As a bonus, can you give us a minimal MySQL setup? – Martijn Pieters Jun 09 '15 at 13:20
  • @MartijnPieters: updated. – Rebel Designer Jun 09 '15 at 14:01
  • Thanks, I can certainly reproduce the issue with that. Not sure how much time I have to go spelunking but I'll write up a work-around. – Martijn Pieters Jun 09 '15 at 20:46

1 Answers1

8

It appears that the decimal.Decimal class is patched somewhere in the Google App Engine SDK (or the module is reloaded), and this is done between the MySQL conversion library importing decimal and you importing the same.

Luckily, we can work around this by updating MySQL conversion table:

from MySQLdb.constants import FIELD_TYPE
from MySQLdb.converters import conversions
import decimal

conversions[FIELD_TYPE.DECIMAL] = conversions[FIELD_TYPE.NEWDECIMAL] = decimal.Decimal

That's it; the above code resets the MySQL class and your JSON encoder type check succeeds.

The alternative would be for you to look for the class MySQLdb is using:

class DecimalEncoder(json.JSONEncoder):
    def default(self, o):
        if isinstance(o, MySQLdb.converters.conversions[MySQLdb.constants.FIELD_TYPE.DECIMAL]):
            return float(o)
        return super(DecimalEncoder, self).default(o)
Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
  • Damn, you are a hero. Both solutions work as expected and solve my problem. Now on to find the underlying problem and fix that. I don't believe I'm the first one to run into this. Though, I can't find anything related on SO or Google. Thank you so much. – Rebel Designer Jun 10 '15 at 09:38
  • I have been playing around with the idea that the problem occurs when files are auto reloaded after a change is detected. I got the same error when I restarted the GAE server and made a request without any reloaded files after first start. I'll test some more. – Rebel Designer Jun 10 '15 at 09:42
  • 1
    @RebelDesigner: Yeah, I didn't have time to do a full scan of what happens. In the debugger it is clean the MySQLdb module is loaded very early on, before the WSGI server is started, so there is plenty of time to sneak in a module reload. – Martijn Pieters Jun 10 '15 at 09:43
  • which debugger? Never worked with a Python debugger. Maybe I can find something. Again, thank you! – Rebel Designer Jun 10 '15 at 09:45
  • The standard library [`pdb` module](https://docs.python.org/2/library/pdb.html); I just set a trace in the MySQL converters module to record the id of the `decimals` module there, but stepping out and move into the WSGI server somewhere didn't sit too well as the SDK does funky stuff with the `sys.std*` streams, I think, so left it for now. – Martijn Pieters Jun 10 '15 at 09:47