{ "info": { "author": "Tom Grek", "author_email": "tom.grek@gmail.com", "bugtrack_url": null, "classifiers": [], "description": "# MLQ, a queue for ML jobs\n\nMLQ is a job queueing system, and framework for workers to process queued jobs, providing an easy way to offload long running jobs to other computers.\n\nYou've got an ML model and want to deploy it. Meaning that, you have a web app and want users to be able to re-train the model, and that takes a long time. Or perhaps even inference takes a long time. Long, relative to the responsiveness users expect from webapps, meaning, not immediate.\n\nYou can't do this stuff direct from your Flask app, because it would lock up the app and not scale beyond a couple of users.\n\nThe solution is to enqueue the user's request, and until then, show the user some loading screen, or tell them to check back in a few minutes. The ML stuff happens in the background, in a separate process, or perhaps on a different machine. When it's done, the user is notified (maybe via websockets; maybe their browser is polling at intervals).\n\nOr perhaps your company has a limited resource, such as GPUs, and you need a solution for employees to access them from Jupyter one-by-one.\n\nMLQ is designed to provide a performant, reliable, and most of all easy to use, queue and workers to solve the above common problems.\n\nIt's in Python 3.6+, is built on asyncio, and uses Redis as a queue backend.\n\n## Usage\n\n`pip install mlq`\n\n## Requirements\n\nYou need access to a running Redis instance, for example `apt install redis-server` will get you one at localhost:6379, otherwise there is AWS's Elasticache and many other options.\n\n## Quick Start\n\nThis assumes: you have a web app with a Python backend. For a complete example, see [here](https://github.com/tomgrek/mlq/tree/master/examples/full_workflow). In brief:\n\n```\nimport time\nfrom mlq.queue import MLQ\n\n# Create MLQ: namespace, redis host, redis port, redis db\nmlq = MLQ('example_app', 'localhost', 6379, 0)\n\njob_id = mlq.post({'my_data': 1234})\nresult = None\nwhile not result:\n time.sleep(0.1)\n job = mlq.get_job(job_id)\n result = job['result']\n```\n\nThen, of course you need a worker, or many workers, processing the job. Somewhere else (another terminal, a screen session, another machine, etc):\n\n```\nimport asyncio\nfrom mlq.queue import MLQ\n\nmlq = MLQ('example_app', 'localhost', 6379, 0)\n\ndef simple_multiply(params_dict, *args):\n return params_dict['my_data'] * 2\n\nasync def main():\n print(\"Running, waiting for messages.\")\n mlq.create_listener(simple_multiply)\n\nif __name__ == '__main__':\n asyncio.run(main())\n```\n\n## Job Lifecycle\n\n1. Submit a job with MLQ. Optionally, specify a callback URL that'll be hit, with some useful query params, once the job has been processed.\n2. Job goes into a queue managed by MLQ. Jobs are processed first-in, first-out (FIFO).\n3. Create a worker (or maybe a worker already exists). Optimally, create many workers. They all connect to a shared Redis instance.\n4. A worker (sorry, sometimes I call it a consumer), or many workers in parallel, will pick jobs out of the queue and process them by feeding them into listener functions that you define.\n5. As soon as a worker takes on the processing of some message/data, that message/data is moved into a processing queue. So, if the worker fails midway through, the message is not lost.\n6. Worker stores the output of its listener functions and hits the callback with a result. Optionally, a larger result -- perhaps binary -- is stored in Redis, waiting to be picked up and served by a backend API.\n7. Ask MLQ for the job result, if the callback was not enough for you.\n\nAlternatively, the worker might fail to process the job before it gets to step 6. Maybe the input data was invalid, maybe it was a bad listener function; whatever happened, there was an exception:\n\n8. MLQ will move failed jobs into a dead letter queue - not lost, but waiting for you to fix the problem.\n\nIn another case, maybe the worker dies midway through processing a message. When that happens, also the job is not lost. It might just be a case of, for example, the spot instance price just jumped and your worker shut down. MLQ provides a [reaper](https://github.com/tomgrek/mlq#the-reaper) thread that can be run at regular intervals to requeue jobs whose processing has stalled. If the job is requeued enough times that it exceeds some threshold you've specified, something is wrong - it'll be moved to the dead letter queue.\n\n## Listener functions: arguments and return values\n\nWhen creating a worker, you (probably should) give it a listener function to execute on items pulled from the queue. In the example above, that function is called `simple_multiply`. Its signature is `simple_multiply(params_dict, *args)`. What is that?\n\n* `params_dict` is the original message that was pushed to the queue; in the example it'd be this dictionary: `{'my_data': 1234}`. It doesn't have to be a dict, rather, it's whatever you `post`ed to the queue, so it could be an `int` or a `string`; any serializable type (it needs to be serializable because in the Redis queue it has to be stored as a string). Speaking of which, internally, MLQ does its serialization with [MessagePack](https://msgpack.org/index.html), which supports binary and is faster than JSON.\n\n* `args` is a dict with several useful things in. Most of them are concerned with distributed computing utilities (documented below) and so can safely be ignored by most users. But, you also get access to the dict `args['full_message']` and the function `args['update_progress']`. The full message provides the queued message as MLQ sees it, including some possibly useful things:\n\n```\n{\n 'id': msg_id,\n 'timestamp': timestamp, # when the message was enqueued\n 'worker': None, # uuid of the worker it's being processed on (if any)\n 'processing_started': None, # when processing started on the worker\n 'processing_finished': None,\n 'progress': None, # an integer from -1 (failed), 0 (not started), to 100 (complete)\n 'short_result': None, # the stored short result (if any)\n 'result': None, # the stored full result (if any)\n 'callback': callback, # URL to callback, if any, that user specified when posting the message\n 'retries': 0, # how many times this job has attempted to be processed by workers and requeued\n 'functions': functions, # which listener function names should be called, default all of them\n 'msg': msg # the actual message\n}\n```\n\nAfter a worker completes processing, this same object (a serialized version of it, anyway) remains stored in Redis and (the de-serialized version of it) accessible using `mlq.get_result(job_id)`.\n\nYou can update the progress of a message from within your listener function like so: `args['update_progress'](50)` where `50` represents 50% (but you can update with any stringify-able type). This is useful say if processing a job has two time consuming steps. So if you have a UI that's polling the backend for updates, after completing one of them, set progress to 50% - then the responsible backend endpoint can just call `mlq.get_progress(job_id)` (where `job_id` is the UUID that was returned from `mlq.post`) and it will get that 50% complete value which you can report to the client.\n\n#### The return value\n\nRemember that if you write a listener function that imports other libraries, they need to be importable on whatever machines/Python environments the consumers are running on, too.\n\n## Usage over HTTP\n\nMLQ uses [gevent](http://www.gevent.org/index.html) (similar to gunicorn if you know that better) for a WSGI server.\n\nIf you want to launch many servers -- which may not be necessary, you probably want one server but many workers -- there's no need for any special `gevent` magic.\n\n1. Launch one (or more) consumers:\n\n`python3 controller/app.py consumer --server`\n\nThere may be little benefit to running more than one server unless you are\ngoing to put some load balancer in front of them _and_ are expecting seriously\nhigh traffic. To run workers that don't have an HTTP interface -- they will\nstill get messages that are sent to the first `--server` worker -- just leave\noff the `--server` parameter.\n\nOptional params:\n```\n--server_address 0.0.0.0 (if you want it to accept connections from outside localhost)\n--server_port 5000\n```\n\nNote that if you are expanding `server_address` beyond 127.0.0.1, MLQ does not come with any authentication. It'd be open to the world (or the local reachable cluster/network).\n\nFor multiple servers, you'll need to specify each with a different `server_port`. Nothing\nis automagically spun off into different threads. One server, one process, one port. Load\nbalance it via nginx or your cloud provider, if you really need this. Hint: you probably don't.\n\n2. Give the consumer something to do\n\nThe consumer sits around waiting for messages, and when it receives one, runs the\nlistener functions that you've specified on that message. The output of the last\nlistener function is then stored as the result. So, you need to specify at least\none listener function (unless that is you just want a queue for, say, logging purposes).\n\nDefine a function:\n```\ndef multiply(msg, *args):\n print(\"I got a message: \" + str(msg))\n return msg['arg1'] * msg['arg2']\n```\n\n`msg` should be a string or dictionary type. The `args` dictionary gives\naccess to the full message as-enqueued, including its id, the UUID of the worker that's\nprocessing it, the timestamp of when it was enqueued, as well as a bunch of utility functions\nuseful for distributed processing. See later in this document.\n\nWhen you have the function defined, you need to `cloudpickle` then `jsonpickle` it to send it\nto the consumer as a HTTP POST. In Python, the code would be something like:\n\n```\nimport cloudpickle\nimport jsonpickle\nimport urllib3\nhttp = urllib3.PoolManager()\nhttp.request('POST', 'localhost:5001/consumer',\n headers={'Content-Type':'application/json'},\n body=jsonpickle.encode(cloudpickle.dumps(multiply)))\n```\n\nFunctions sent to workers should be unique by name. If you later decide you don't need that function any more, just make the same request but instead of POST, use DELETE.\n\n3. Send a message to the server\n\nIf you're using the app tool, it's very easy:\n\n```\npython controller/app.py post '{\"arg1\": 6, \"arg2\": 3}'\n```\n\nIf you're doing it from `curl` or direct from Python, you have to bear in mind that you need to POST a string which is a JSON object with at least a `msg` key. So, for example:\n\n```\nr = http.request('POST', 'localhost:5001/jobs',\n headers={'Content-Type':'application/json'},\n body=json.dumps({'msg':{'arg1':6, 'arg2':3}}))\n```\n\nFrom curl:\n```\ncurl localhost:5001/jobs -X POST -d '{\"msg\": {\"arg1\": 6, \"arg2\": 3}}' -H 'Content-Type: application/json'\n```\n\nYou'll get back the unique id of the enqueued request, which is an integer.\n\nOnce processing is complete, if you passed a \"callback\" key in the POST body, that callback will be called with the (short) result and a success flag. But we didn't do that yet, so ...\n\n4. ... at some point in the future, get the result.\n\nResults are stored indefinitely. Retrieve it by curl with something like:\n\n```\ncurl localhost:5001/jobs/53/result\n```\n\nMLQ supports binary results, but to see them with curl you'll need to add ` --output -`. Because\nresults are binary, they are returned just as bytes, so beware string comparisons and suchlike. Alternatively,\nyou can hit the `/short_result` endpoint; `short_result` is always a string.\n\nTo get the result within Python, it's just:\n\n```\nr = http.request('GET', 'localhost:5001/jobs/53/result')\nprint(r.data)\n```\n\nAdditionally, say for example your job's result was a generated image or waveform. Binary data,\nbut you want it to be interpreted by the browser as say a JPG or WAV. You can add any\nfile extension to the above URL so a user's browser would interpret it correctly. For example,\nthe following are equivalent:\n```\ncurl localhost:5001/jobs/53/result\ncurl localhost:5001/jobs/53/result.mp3\n```\n\n5. Add a callback\n\nWhen you enqueue a job, you can optionally pass a callback which will be called when\nthe job is complete. Here, callback means a URL which will be HTTP GET'd. The URL will be\nattempted up to 3 times before MLQ gives up.\n\nThe user submits a job via your backend (Node, Flask, whatever), you return immediately --\nbut create within your backend a callback URL that when it's hit, knows to signal\nsomething to the user.\n\nYou pass the callback URL when you enqueue a job. It will receive the following arguments\nas a query string: `?success=[success]&job_id=[job_id]&short_result=[short_result]`\n\n* Success is 0 or 1 depending whether the listener functions errored (threw exceptions) or timeout'd, or if everything went smoothly.\n\n* Job ID is the ID of the job you received when you first submitted it. This makes it\npossible to have a single callback URL defined in your app, and handle callbacks\ndependent on the job id.\n\n* Short result is a string that can become part of a URL, returned from your listener\nfunction. For example, if you do some image processing and determine that the picture\nshows a 'widget', `short_result` might be `widget`.\n\nThe end result is that you'll get a callback to something like\n[whatever URL you passed]?success=1&job_id=53&short_result=widget.\n\nIt's possible three ways, depending if you're using the client app, Python directly, or HTTP calls:\n```\nmlq.post('message', callback='http://localhost:5001/some_callback')\npython3 controller/app.py post message --callback 'http://localhost:5001/some_callback'\ncurl localhost:5001/jobs -X POST -H 'Content-Type: application/json' -d '{\"msg\":\"message\", \"callback\":'http://localhost:5001/some_callback'}'\n```\n\n## The reaper\n\nIf a worker goes offline mid-job, that job would get stuck in limbo: it looks like someone picked it up, but it never finished. MLQ comes with a reaper that detects jobs that have been running for longer than a threshold, assumes the worker probably died, and requeues the job. It keeps a tally\nof how many times the job was requeued; if that's higher than some specified number, it'll move the job to the dead letter queue instead of requeuing it.\n\nThe reaper runs at a specified interval. To create a reaper that runs once every 60s, checks for jobs running for longer than 300s (five minutes), and if they've been retried fewer than 5 times puts them back in the job queue:\n\n```\nIf you're using MLQ directly from Python:\nmlq.create_reaper(call_how_often=60, job_timeout=300, max_retries=5)\n\nIf you're using MLQ from the command line:\npython3 controller/app.py --reaper --reaper_interval 60 --reaper_timeout 300 --reaper_retries 5\n```\n\n## Distributed Computing\n\nTo aid in distributed computing, there are a number of additional conveniences in MLQ.\n\n* Specify what listener functions are called for a job.\n\nNormally, you add listener functions to a list, and they are all called any time a message\ncomes in. In fact, 99% of the time you'll probably just add a single listener function to\neach consumer.\n\nThat's not always desirable though: maybe from one listener function, you want to enqueue a partial result that's handled in another worker or two. (Workers share all listener functions; at least the ones that were added since they came into existence.)\n\nTo this end, you can specify a list of functions that are called for a particular message. Only _those_ functions will be called when the message is dequeued into a listener function.\n\nAs always, there are 3 possible ways to do it: pure Python, with the controller app, and via http.\n```\nmlq.post('message', functions=['my_func'])\npython3 controller/app.py post message --functions my_func\ncurl localhost:5001/jobs -X POST -H 'Content-Type: application/json' -d '{\"msg\":\"message\", \"functions\":[\"my_func\"]}'\n```\n\n* Store data and fetch data\n\nListener functions can store and fetch data using Redis as the backend. This way, they can pass data between different workers perhaps on different machines: say you have a graph where one computation generates a single interim result that can then be simultaneously worked on by two other workers.\n\nThese functions are passed as arguments to the listener function:\n\n`key = store_data(data, key=None, expiry=None)`: Data must be a string type. Key is the key\nin Redis that the data is stored at, leave at None to have MLQ generate a UUID for you. Expiry\nof the key (in seconds) is recommended. Returns the key at which data was stored.\n\n`data = fetch_data(data_key)`: Retrieve data stored in Redis at `data_key`.\n\n* Post a message to the queue from within a listener function\n\nThe API is the same from within a listener function as it is for using the MLQ library directly: `job_id = post(msg, callback=None, functions=None)`\n\n* Block a listener function until the output from another listener function is available\n\n`result = block_until_result(job_id)`. Be careful using this if you have only one worker because all of the worker's execution will be blocked: it won't suspend, process the other job, and return.\n\n### Can messages be lost?\n\nMLQ is designed with atomic transactions such that queued messages should not be lost (of course this cannot be guaranteed).\n\nThere is always the possibility that the backend Redis instance will go down, if you are concerned about this, I recommend looking into Redis AOF persistence.\n\n### Binary job results\n\nAre supported. Results are available at e.g. `localhost:5001/jobs/53/result` but\nalso at `localhost:5001/jobs/53/result.jpg`, `localhost:5001/jobs/53/result.mp3`,\netc, so you can get a user's browser to interpret the binary in whatever way.\n\n### Why not Kafka, Celery, Dask, 0MQ, RabbitMQ, etc\n\nPrimarily, simplicity and ease of use. While MLQ absolutely is suitable for a production system, it's easy and intuitive to get started with on a side project.\n\nDask and Celery are excellent, use them in preference to MLQ if you want to invest the time.\n\n### Developing\n\n```\ngit clone https://github.com/tomgrek/mlq.git\npip install -r requirements.txt\nsource ./run_tests.sh\npython setup.py sdist upload\n```", "description_content_type": "", "docs_url": null, "download_url": "", "downloads": { "last_day": -1, "last_month": -1, "last_week": -1 }, "home_page": "https://github.com/tomgrek/mlq", "keywords": "", "license": "", "maintainer": "", "maintainer_email": "", "name": "mlq", "package_url": "https://pypi.org/project/mlq/", "platform": "", "project_url": "https://pypi.org/project/mlq/", "project_urls": { "Homepage": "https://github.com/tomgrek/mlq" }, "release_url": "https://pypi.org/project/mlq/0.2.2/", "requires_dist": null, "requires_python": "", "summary": "", "version": "0.2.2" }, "last_serial": 4817841, "releases": { "0.1.0": [ { "comment_text": "", "digests": { "md5": "d9061d1f25fb0fe3bb7c5f63bb4cf153", "sha256": "42583ef39f4113b9ba3d8e11e127b22f01273414baa0958fd5cd41c51f8190ff" }, "downloads": -1, "filename": "mlq-0.1.0.tar.gz", "has_sig": false, "md5_digest": "d9061d1f25fb0fe3bb7c5f63bb4cf153", "packagetype": "sdist", "python_version": "source", "requires_python": null, "size": 8407, "upload_time": "2018-11-11T05:51:05", "url": "https://files.pythonhosted.org/packages/ec/88/3fa84f162e756a54ed8282a2000e6b8d7e279fa1fba14cf83b6423259a90/mlq-0.1.0.tar.gz" } ], "0.1.1": [ { "comment_text": "", "digests": { "md5": "0e1baeee1996d39c1c3a9c856fd3a39f", "sha256": "ab36d33418bb093b4a99f0eda9d116a4449d324fb08e7863d356ef410f6b10aa" }, "downloads": -1, "filename": "mlq-0.1.1.tar.gz", "has_sig": false, "md5_digest": "0e1baeee1996d39c1c3a9c856fd3a39f", "packagetype": "sdist", "python_version": "source", "requires_python": null, "size": 8927, "upload_time": "2018-11-11T05:58:54", "url": "https://files.pythonhosted.org/packages/e1/b9/21d0f9b97e03065136c0c973f8e4a57d9d549bdc995bf1b818d30631c141/mlq-0.1.1.tar.gz" } ], "0.1.2": [ { "comment_text": "", "digests": { "md5": "f17354dce9ba1c1ac67eafbe5b56240d", "sha256": "03ddda952c77a13f50e7cf75882cdbd4fba832249ff085a8e3e33dd17e75bf19" }, "downloads": -1, "filename": "mlq-0.1.2.tar.gz", "has_sig": false, "md5_digest": "f17354dce9ba1c1ac67eafbe5b56240d", "packagetype": "sdist", "python_version": "source", "requires_python": null, "size": 8960, "upload_time": "2018-11-11T06:12:29", "url": "https://files.pythonhosted.org/packages/b9/49/ce4ce6441d08fb7f81abe7b608ddfb534769b171433dfe613d1eb5ef52eb/mlq-0.1.2.tar.gz" } ], "0.1.3": [ { "comment_text": "", "digests": { "md5": "7cd0988cdf4d8e14e3db25995511458b", "sha256": "eac3e0cabc7af7474dd8ca27ea10c1328ccae68426bbb137672896eb7ff52809" }, "downloads": -1, "filename": "mlq-0.1.3.tar.gz", "has_sig": false, "md5_digest": "7cd0988cdf4d8e14e3db25995511458b", "packagetype": "sdist", "python_version": "source", "requires_python": null, "size": 8963, "upload_time": "2018-11-11T07:06:28", "url": "https://files.pythonhosted.org/packages/f8/a1/532559d7b7bd879fa71f8d12aa156aadf04e1bb3616d311841485c732886/mlq-0.1.3.tar.gz" } ], "0.1.4": [ { "comment_text": "", "digests": { "md5": "88e39e492a033669ee1e985342b16ab1", "sha256": "70e30de9d717c3b5702c9d648b73fdc91e549aed3cb74e83ba798aa6c8f4b8be" }, "downloads": -1, "filename": "mlq-0.1.4.tar.gz", "has_sig": false, "md5_digest": "88e39e492a033669ee1e985342b16ab1", "packagetype": "sdist", "python_version": "source", "requires_python": null, "size": 10509, "upload_time": "2018-11-11T07:11:39", "url": "https://files.pythonhosted.org/packages/dd/b0/1df8c2597a1b60f0f29d1eaa9f97fd0e239fe929c4035f406054c3f94f18/mlq-0.1.4.tar.gz" } ], "0.1.5": [ { "comment_text": "", "digests": { "md5": "79a496865817f12d0315b29f3ea58c1e", "sha256": "ec06ad9753784fc02f7568b708644bb5f3c5e7a99be4df1a98a1b4dc8afa4050" }, "downloads": -1, "filename": "mlq-0.1.5.tar.gz", "has_sig": false, "md5_digest": "79a496865817f12d0315b29f3ea58c1e", "packagetype": "sdist", "python_version": "source", "requires_python": null, "size": 13836, "upload_time": "2018-11-14T03:21:31", "url": "https://files.pythonhosted.org/packages/15/a7/2a4ac808bef2b1bb483c337b2ee1333bb8327619a43268f1224dc8ca6014/mlq-0.1.5.tar.gz" } ], "0.1.6": [ { "comment_text": "", "digests": { "md5": "ceb58f1d55dc7309e2960e0c4d0d6110", "sha256": "cacc4bcb1568d205174fb1462c79b4d4ec6a7d7b5b69cbdec8e94382e42aa7f3" }, "downloads": -1, "filename": "mlq-0.1.6.tar.gz", "has_sig": false, "md5_digest": "ceb58f1d55dc7309e2960e0c4d0d6110", "packagetype": "sdist", "python_version": "source", "requires_python": null, "size": 13858, "upload_time": "2018-11-22T22:16:30", "url": "https://files.pythonhosted.org/packages/70/a2/22bb5f4b0df25cce58d3bead9d194eb5dca9279c1f9fa3f52e71dce9b74c/mlq-0.1.6.tar.gz" } ], "0.1.7": [ { "comment_text": "", "digests": { "md5": "88fdbfca729d129b3396f689187834a1", "sha256": "707a2e214108c09a0d1c56971738a51ad391b956e4f1f07bfc7a94d1c6898ad0" }, "downloads": -1, "filename": "mlq-0.1.7.tar.gz", "has_sig": false, "md5_digest": "88fdbfca729d129b3396f689187834a1", "packagetype": "sdist", "python_version": "source", "requires_python": null, "size": 13868, "upload_time": "2018-11-22T22:40:10", "url": "https://files.pythonhosted.org/packages/b7/e8/06a6626622dba100ddb3c0b6075693a54b526360350c6d3e5aaee799e295/mlq-0.1.7.tar.gz" } ], "0.1.8": [ { "comment_text": "", "digests": { "md5": "90f5a8be9320d58401c17eb498ca3824", "sha256": "3f0a16c281f3e6d288cefb1231e7dda1ffe55b034fed92905c024be28c19616e" }, "downloads": -1, "filename": "mlq-0.1.8.tar.gz", "has_sig": false, "md5_digest": "90f5a8be9320d58401c17eb498ca3824", "packagetype": "sdist", "python_version": "source", "requires_python": null, "size": 13928, "upload_time": "2018-11-22T22:40:52", "url": "https://files.pythonhosted.org/packages/60/4a/bf8288853b4622c54b0ed3025150a6e7d2d400f50a1088ed8500708ea123/mlq-0.1.8.tar.gz" } ], "0.2.0": [ { "comment_text": "", "digests": { "md5": "8ddc488cf3c8848a13dd1305249b81a6", "sha256": "91b81dc828f24ae4928b235e3219d472ea3f48bcaba3cf267410e2ec90b183d1" }, "downloads": -1, "filename": "mlq-0.2.0.tar.gz", "has_sig": false, "md5_digest": "8ddc488cf3c8848a13dd1305249b81a6", "packagetype": "sdist", "python_version": "source", "requires_python": null, "size": 14043, "upload_time": "2018-11-23T00:28:34", "url": "https://files.pythonhosted.org/packages/48/ca/eec68eb3d6bc66e7e60af9a91ec45dcc9357a2e9bf152266433cda9e6b68/mlq-0.2.0.tar.gz" } ], "0.2.1": [ { "comment_text": "", "digests": { "md5": "0f722c563d1e7ae562f00eb2c4c8dc15", "sha256": "f7b39d809bfebb0d87f0a4be6362498318165666c58f9d27593609681a68970f" }, "downloads": -1, "filename": "mlq-0.2.1.tar.gz", "has_sig": false, "md5_digest": "0f722c563d1e7ae562f00eb2c4c8dc15", "packagetype": "sdist", "python_version": "source", "requires_python": null, "size": 14038, "upload_time": "2018-11-23T02:14:06", "url": "https://files.pythonhosted.org/packages/9b/8f/147d82fc95c45d58153de8dc58100c87cc3ea4ed1a15e98983c6e8ea8582/mlq-0.2.1.tar.gz" } ], "0.2.2": [ { "comment_text": "", "digests": { "md5": "3b9d7ebaa0b5fa63d40be3670e362b5a", "sha256": "7624cbe564efd44633369259cd6d46c5d65efce4ddf8f162fdda5bf960aa4671" }, "downloads": -1, "filename": "mlq-0.2.2.tar.gz", "has_sig": false, "md5_digest": "3b9d7ebaa0b5fa63d40be3670e362b5a", "packagetype": "sdist", "python_version": "source", "requires_python": null, "size": 13998, "upload_time": "2019-02-13T22:43:49", "url": "https://files.pythonhosted.org/packages/1c/5d/9b9e384804744f3fdd60ac142a94a7d9f6dc48dfb23eb53aacfb68567def/mlq-0.2.2.tar.gz" } ] }, "urls": [ { "comment_text": "", "digests": { "md5": "3b9d7ebaa0b5fa63d40be3670e362b5a", "sha256": "7624cbe564efd44633369259cd6d46c5d65efce4ddf8f162fdda5bf960aa4671" }, "downloads": -1, "filename": "mlq-0.2.2.tar.gz", "has_sig": false, "md5_digest": "3b9d7ebaa0b5fa63d40be3670e362b5a", "packagetype": "sdist", "python_version": "source", "requires_python": null, "size": 13998, "upload_time": "2019-02-13T22:43:49", "url": "https://files.pythonhosted.org/packages/1c/5d/9b9e384804744f3fdd60ac142a94a7d9f6dc48dfb23eb53aacfb68567def/mlq-0.2.2.tar.gz" } ] }