{ "info": { "author": "Bri Hatch", "author_email": "bri@extrahop.com", "bugtrack_url": null, "classifiers": [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python", "Topic :: System :: Systems Administration" ], "description": "\nDoJobber\n========\n\nDoJobber is a python task orchestration framework based on writing\nsmall single-task idempotent classes (Jobs), defining\ninterdependencies, and letting python do all the work of running\nthem in the \"right order\".\n\nDoJobber builds an internal graph of Jobs. It will run\nJobs that have no unmet dependencies, working up the chain\nuntil it either reaches the root or cannot go further due to\nJob failures.\n\nEach Job serves a single purpose, and must be idempotent,\ni.e. it will produce the same results if executed once or\nmultiple times, without causing any unintended side effects.\nBecause of this you can run your python script multiple times\nand it will get closer and closer to completion as any\npreviously-failed Jobs succeed.\n\nHere's an example of how one might break down the overall\ngoal of inviting friends over to watch a movie - this\nis the result of the ``tests/dojobber_example.py`` script.\n\n .. image:: https://raw.githubusercontent.com/ExtraHop/DoJobber/master/example.png\n :alt: DoJobber example graph\n :width: 90%\n :align: center\n\nRather than a yaml-based syntax with many plugins, DoJobber\nlets you write in native python, so anything you can code\nyou can plumb into the DoJobber framework.\n\nDoJobber is conceptually based on a Google program known as\nMasher that was built for automating service and datacenter\nspinups, but shares no code with it.\n\n\nJob Structure\n=============\n\nEach Job is is own class. Here's an example::\n\n class FriendsArrive(Job):\n DEPS = (InviteFriends,)\n\n def Check(self, *dummy_args, **dummy_kwargs):\n # Do something to verify that everyone has arrived.\n pass\n\n def Run(self, *dummy_args, **dummy_kwargs):\n pass\n\nEach Job has a DEPS attribute, ``Check`` method, and ``Run`` method.\n\nDEPS\n----\n\nDEPS defines which other Jobs it is dependent on. This is used\nfor generating the internal graph.\n\n\nCheck\n-----\n\n\n``Check`` executes and, if it does not raise an Exception, is considered\nto have passed. If it passes then the Job passed and the next Job will\nrun. It's purpose is to verify that we are in the desired state for\nthis Job. For example if the job was to create a user, this may\nlook up the user in /etc/passwd.\n\nRun\n---\n\n``Run`` executes if ``Check`` failed. Its job is to do something to achieve\nour goal. DoJobber doesn't care if it returns anything, throws an\nexception, or exits - all this is ignored.\n\nAn example might be creating a user account, or adding a database\nentry, or launching an ansible playbook.\n\nRecheck\n-------\n\nThe Recheck phase simply executes the ``Check`` method again. Hopefully\nthe ``Run`` method did the work that was necessary, so ``Check`` will verify\nall is now well. If so (i.e. ``Check`` does not raise an Exception) then\nwe consider this Job a success, and any dependent Jobs are not blocked\nfrom running.\n\nJob Features\n============\n\nJob Arguments\n-------------\n\nJobs can take both positional and keyword arguments. These are set via the\nset_args method::\n\n dojob = dojobber.DoJobber()\n dojob.configure(RootJob, ......)\n dojob.set_args('arg1', 'arg2', foo='foo', bar='bar', ...)\n\nBecause of this it is best to accept both in your ``Check`` and ``Run``\nmethods::\n\n def Check(self, *args, **kwargs):\n ....\n\n def Run(self, *args, **kwargs):\n ....\n\nIf you're generating your keyword arguments from argparse or optparse,\nthen you can be even lazier - send it in as a dict::\n\n myparser = argparse.ArgumentParser()\n myparser.add_argument('--movie', dest='movie', help='Movie to watch.')\n ...\n args = myparser.parse_args()\n dojob.set_args(**args.__dict__)\n\nAn then in your ``Check``/``Run`` you can use them by name::\n\n def Check(self, *args, **kwargs):\n if kwargs['movie'] == 'Zardoz':\n raise Error('Really?')\n\n\nLocal Job Storage\n-----------------\n\nLocal Storage allows you to share information between\na Job's ``Check`` and ``Run`` methods. For example a ``Check``\nmay do an expensive lookup or initialization which\nthe ``Run`` may then use to speed up its work.\n\nTo use Local Job Storage, simply use the\n``self.storage`` dictionary from your ``Check`` and/or\n``Run`` methods.\n\nLocal Storage is not available to any other Jobs. See\nGlobal Job Storage for how you can share information\nbetween Jobs.\n\nExample::\n\n\tclass UselessExample(Job):\n def Check(self, \\*dummy_args, **dummy_kwargs):\n if not self.storage.get('sql_username'):\n self.storage['sql_username'] = (some expensive API call)\n (check something)\n\n def Run(self, *dummy_args, **kwargs):\n subprocess.call(COMMAND + [self.storage['sql_username']])\n\n\nGlobal Job Storage\n------------------\n\nGlobal Storage allows you to share information between\nJobs. Naturally it is up to you to assure any\nJob that requires Global Storage is defined as\ndependent on the Job(s) that set Global Storage.\n\nTo use Global Job Storage, simply use the\n``self.global_storage`` dictionary from your\n``Check`` and/or ``Run`` methods.\n\nGlobal Storage is available to all Jobs. It is up to\nyou to avoid naming collisions.\n\n\nExample::\n\n # Store the number of CPUs on this machine for later\n # Jobs to use for nefarious purposes.\n class CountCPUs(Job):\n def Check(self, *dummy_args, **dummy_kwargs):\n self.global_storage['num_cpus'] = len(\n [x\n for x in open('/proc/cpuinfo').readlines()\n if 'vendor_id' in x])\n\n # FixFanSpeed is dependent on CountCPUs\n class FixFanSpeed(Job):\n DEPS = (CountCPUs,)\n\n def Check(self, *args, **kwargs):\n for cpu in range(self.global_storage['num_cpus']):\n ....\n\nCleanup\n-------\n\nJobs can have a Cleanup method. After checknrun is complete,\nthe Cleanup method of each Job that ran (i.e. ``Run`` was executed)\nwill be excuted. They are run in LIFO order, so Cleanups 'unwind'\neverything.\n\nYou can pass the cleanup=False option to DoJobber() to prevent\nCleanup from happening and run it manually if you prefer::\n\n dojob = dojobber.DoJobber()\n dojob.configure(RootJob, cleanup=False, ......)\n dojob.checknrun()\n dojob.cleanup()\n\nCreating Jobs Dynamically\n-------------------------\n\nYou can dynamically create Jobs by making new Job classes\nand adding them to the DEPS of an existing class. This is\nuseful if you need to create new Jobs based on commandline\noptions. Dynamically creating many small single-purpose jobs\nis a better pattern than creating one large monolithic\njob that dynamically determines what it needs to do and check.\n\nHere's an example of how you could create a new Job dynamically.\nWe start with a base Job, ``SendInvite``, which has uninitialized\nclass valiables ``EMAIL`` and ``NAME``::\n\n # Base Job\n class SendInvite(Job):\n EMAIL = None\n NAME = None\n\n def Check(self, *args, **kwargs):\n r = requests.get(\n 'https://api.example.com/invited/' + self.EMAIL)\n assert(r.status_code == 200)\n\n def Run(self, *args, **kwargs):\n requests.post(\n 'https://api.example.com/invite/' + self.EMAIL)\n\n\nThis example Job has ``Check``/``Run`` methods which use class\nattribute ``EMAIL`` and ``NAME`` for their configuration.\n\nSo to get new Jobs based on this class, you create them and them\nto the ``DEPS`` of an existing Job such that they appear in the graph::\n\n class InviteFriends(DummyJob):\n \"\"\"Job that will become dynamically dependent on other Jobs.\"\"\"\n DEPS = []\n\n\n def invite_friends(people):\n \"\"\"Add Invite Jobs for these people.\n\n People is a list of dictionaries with keys email and name.\n \"\"\"\n for person in people:\n job = type('Invite {}'.format(person['name']),\n (SendInvite,), {})\n job.EMAIL = person['email']\n job.NAME = person['name']\n InviteFriends.DEPS.append(job)\n\n def main():\n # do a bunch of stuff\n ...\n\n # Dynamically add new Jobs to the InviteFriends\n invite_friends([\n {'name': 'Wendell Bagg', 'email': 'bagg@example.com'},\n {'name': 'Lawyer Cat', 'email': 'lawyercat@example.com'}\n ])\n\n\nRetry Logic\n===========\n\nDoJobber is meant to be able to be retried over and over until\nyou achieve success. You may be tempted to write something like\nthis::\n\n\n\t...\n retry = 5\n while retry:\n dojob.checknrun()\n if dojob.success():\n break\n print('Trying again...')\n retry -= 1\n\nHowever this is not necessary, and in fact is a waste of computing\ncycles. The above code would cause us to check even the already\nsuccessful nodes unnecessarily, slowing everything down.\n\nInstead, you can use two class attribute to configure retry\nparameters. ``TRIES`` specifies how many times your Job can\nerun before we give up, and ``RETRY_DELAY`` specifies the\nminimum amount of time between retries.\n\nRetries are useful for those cases where an action in ``Run``\nfails due to a temporary condition (maybe the remote server\nis unavailable briefly), or where the activities triggered\nin the ``Run`` take time to complete (maybe an API call\nreturns immediately, but background fullfillment takes 30\nseconds).\n\nBy relying on retry logic, instead of adding in arbirtary\n``sleep`` cycles in your code, you can have a more robust\nJob graph.\n\nStorage Considerations\n----------------------\n\nWhen a Job is retried, it will be created from scratch. This means\nthat ``storage`` **is not available between runs**, however ``global_storage``\nis. This is done to keep things as pristine as possible between\nJob executions.\n\nTRIES Attribute\n--------------\nTRIES defines the number of tries (check/run/recheck cycles)\nthat the Job is allowed to do before giving up. It must be >= 1.\n\nThe TRIES default if unspecified is 3, which can be changed\nin ``configure()`` via the ``default_tries=###`` argument, for\nexample::\n\n class Foo(Job):\n TRIES = 10\n ...\n\n class Bar(Job):\n DEPS = (Foo,)\n ... # No TRIES attribute\n\n ...\n\n dojob = dojobber.DoJobber()\n dojob.configure(Foo, default_tries=1)\n\nIn the above case, Foo can be tried 10 times, while Bar can only be\ntried 1 time, since it has no ``TRIES`` specified and ``default_tries``\nin configure is 1.\n\nRETRY_DELAY\n-----------\n\nRETRY_DELAY defines the minimum amount of time to wait between\ntries (check/run/recheck cycles) of **this** Job before giving\nup with permanent failure. It is measured in seconds, and may\nbe any non-negative numeric value, including 0 and fractional\nseconds like 0.02.\n\n\nThe RETRY_DELAY default if unspecified is 1 , which can be\nchanged in ``configure()`` via the ``default_retry-delay=###`` argument,\nfor example::\n\n class Foo(Job):\n RETRY_DELAY = 10.5 # A long but strangely precise value...\n ...\n\n class Bar(Job):\n DEPS = (Foo,)\n ... # No RETRY_DELAY attribute\n\n ...\n\n dojob = dojobber.DoJobber()\n dojob.configure(Foo, default_retry_delay=0.5)\n\nIn the above case, Foo will never start unless at least 10.5 seconds\nhave passed since the previous Foo attempt, while Bar only required\n0.5 seconds have passed since it has no ``RETRY_DELAY`` specified\nand ``default_retry_delay`` in configure is 0.5.\n\nDelay minimization\n------------------\n\nWhen a Job has a failure it is not immediately retried.\nInstead we will hit all Jobs in the graph that are still\nawaiting check/run/recheck. Once every reachable Job has\nbeen hit we will 'start over' on the Jobs that failed.\n\nIn practice this means that you aren't wasting the full\nRETRY_DELAY because other Jobs were likely doing work\nbetween retries of this Job. (Unless your graph is\nhighly linear and there are no unblocked Jobs.)\n\nYou can see how Job retries are interleaved by looking\nat the example code::\n\n $ tests/dojobber_example.py -v | grep 'recheck: fail'\n TurnOnTV.recheck: fail \"Remote batteries are dead.\"\n SitOnCouch.recheck: fail \"No space on couch.\"\n PopcornBowl.recheck: fail \"Dishwasher cycle not done yet.\"\n Pizza.recheck: fail \"Giordano's did not arrive yet.\"\n TurnOnTV.recheck: fail \"Remote batteries are dead.\"\n SitOnCouch.recheck: fail \"No space on couch.\"\n PopcornBowl.recheck: fail \"Dishwasher cycle not done yet.\"\n Pizza.recheck: fail \"Giordano's did not arrive yet.\"\n TurnOnTV.recheck: fail \"Remote batteries are dead.\"\n SitOnCouch.recheck: fail \"No space on couch.\"\n PopcornBowl.recheck: fail \"Dishwasher cycle not done yet.\"\n PopcornBowl.recheck: fail \"Dishwasher cycle not done yet.\"\n PopcornBowl.recheck: fail \"Dishwasher cycle not done yet.\"\n Popcorn.recheck: fail \"Still popping...\"\n Popcorn.recheck: fail \"Still popping...\"\n\nNote initially we have several Jobs that fail on\ndistinct branches, and these can be retried in a round-robin\nsort of fashion. Only once we end up at strict dependencies\nof PopcornBowl and Popcorn do we see single Jobs being retried\nwithout others getting their time.\n\nJob Types\n=========\n\nThere are several DoJobber Job types:\n\nJob\n---\n\nJob requires a ``Check``, ``Run``, and may have optional Cleanup::\n\n class CreateUser(Job):\n \"\"\"Create our user's account.\"\"\"\n\n def Check(self, *_, **kwargs):\n \"\"\"Verify the user exists\"\"\"\n import pwd\n pwd.getpwnam(kwargs['username'])\n\n def Run(self, *_, **kwargs):\n \"\"\"Create user given the commandline username/gecos arguments\"\"\"\n import subprocess\n subprocess.call([\n 'sudo', '/usr/sbin/adduser',\n '--shell', '/bin/tcsh',\n '--gecos', kwargs['gecos'],\n kwargs['username'])\n\n ### Optional Cleanup method\n #def Cleanup(self):\n # \"\"\"Do something to clean up.\"\"\"\n # pass\n\nDummyJob\n--------\n\nDummyJob has no ``Check``, ``Run``, nor Cleanup. It is used simply to\nhave a Job for grouping dependent or dynamically-created Jobs.\n\n\nSo a DummyJob may look as simple as this::\n\n class PlaceHolder(DummyJob):\n DEPS = (Dependency1, Dependency2, ...)\n\n\nRunonlyJob\n----------\n\nA ``RunonlyJob`` has no check, just a ``Run``, which will run every time.\n\nIf ``Run`` raises an exception then the Job is considered failed.\n\nThey cannot succeed in no_act mode, because\nin this mode the ``Run`` is never executed.\n\nSo an example ``Run`` may look like this::\n\n class RemoveDangerously(RunonlyJob):\n DEPS = (UserAcceptedTheConsequences,)\n\n def Run(...):\n os.system('rm -rf /')\n\nIn general, avoid ``RunonlyJobs`` - it's better if you can understand if\na change even needs making.\n\nDebugging and Logging\n=====================\n\nThere are two types of logging for DoJobber: runtime information\nabout Job success/failure for anyone wanting more details\nabout the processing of your Jobs, and developer DoJobber\ndebugging which is useful when writing your DoJobber code.\n\nRuntime Debugging\n-----------------\n\nTo increase verbosity of Job success and failures you\npass `verbose` or `debug` keyword arguments to `configure`::\n\n dojob = dojobber.DoJobber()\n dojob.configure(RootJob, verbose=True, ....)\n # or\n dojob.configure(RootJob, debug=True, ....)\n\nSetting `verbose` will show a line of check/run/recheck status\nto stderr, as well as any failure output from rechecks, such as::\n\n FindTVRemote.check: fail\n FindTVRemote.run: pass\n FindTVRemote.recheck: pass\n TurnOnTV.check: fail\n TurnOnTV.run: pass\n ...\n\nUsing `debug` will additionally show a full stacktrace of\nany failure of check/run/recheck phases.\n\nDevelopment Debugging\n---------------------\n\nWhen writing your DoJobber code you may want to turn on\nthe developer debugging capabilities. This is enabled\nwhen DoJobber is initialized by passing the `dojobber_loglevel`\nkeyword argument::\n\n import logging\n dojob = DoJobber(dojobber_loglevel=logging.DEBUG)\n\nDoJobber's default is to show `CRITICAL` errors only.\nAcceptable levels are those defined in the logging module.\n\nThis can help identify problems when writing your code,\nsuch as passing a non-iterable as a `DEPS` variable,\nwatching as your Job graph is created from the\nclasses, etc.\n\n\nExamples\n========\n\nThe ``tests/dojobber_example.py`` script in the source directory is\nfully-functioning suite of tests with numerous comments strewn\nthroughout.\n\n\nSee Also\n========\n\n`Bri Hatch `_ gave a talk\nabout DoJobber at LinuxFestNorthwest in 2018. You can find his\n`presentation `_\non his website, and the\n`presentation video `_ is\navailable on YouTube.\n\n\n", "description_content_type": "", "docs_url": null, "download_url": "", "downloads": { "last_day": -1, "last_month": -1, "last_week": -1 }, "home_page": "https://github.com/ExtraHop/DoJobber", "keywords": "", "license": "MIT", "maintainer": "", "maintainer_email": "", "name": "dojobber", "package_url": "https://pypi.org/project/dojobber/", "platform": "", "project_url": "https://pypi.org/project/dojobber/", "project_urls": { "Homepage": "https://github.com/ExtraHop/DoJobber" }, "release_url": "https://pypi.org/project/dojobber/0.6.2/", "requires_dist": [ "python-graph-core", "python-graph-dot" ], "requires_python": ">=2.7", "summary": "Job orchestration framework based on individual idempotent python classes and dependencies.", "version": "0.6.2" }, "last_serial": 5229442, "releases": { "0.5.0": [ { "comment_text": "", "digests": { "md5": "e76909492500175892b906939956d4b3", "sha256": "03ca46a1f84096bb09fa447e8ef49cb1a20e21e58545fdfe816a1e667116c9e2" }, "downloads": -1, "filename": "dojobber-0.5.0-py2.py3-none-any.whl", "has_sig": false, "md5_digest": "e76909492500175892b906939956d4b3", "packagetype": "bdist_wheel", "python_version": "py2.py3", "requires_python": ">=2.7", "size": 12008, "upload_time": "2018-04-23T08:34:30", "url": "https://files.pythonhosted.org/packages/d8/3d/515a650c543658ccdc965fb0ccc24a5d51794127604b49ef95dbb8569157/dojobber-0.5.0-py2.py3-none-any.whl" }, { "comment_text": "", "digests": { "md5": "7d65261da5e2b01d0a7850903ddfc57b", "sha256": "ac796a04532a21660338aff8178af2b4b00f30e53222781be104f8fd14771927" }, "downloads": -1, "filename": "dojobber-0.5.0.tar.gz", "has_sig": false, "md5_digest": "7d65261da5e2b01d0a7850903ddfc57b", "packagetype": "sdist", "python_version": "source", "requires_python": ">=2.7", "size": 10869, "upload_time": "2018-04-23T08:34:21", "url": "https://files.pythonhosted.org/packages/9d/f2/fbccc935f4d8ed43ef00b5f350a477063fe55434928667eb1ac56d5497d8/dojobber-0.5.0.tar.gz" } ], "0.5.1": [ { "comment_text": "", "digests": { "md5": "7ef7c18cd05275fd276a46fe2b5f99da", "sha256": "05415efb0fd24d5b9f4b54f558eee9eea3ab6196c811c9e480fd79d9f335c305" }, "downloads": -1, "filename": "dojobber-0.5.1-py2.py3-none-any.whl", "has_sig": false, "md5_digest": "7ef7c18cd05275fd276a46fe2b5f99da", "packagetype": "bdist_wheel", "python_version": "py2.py3", "requires_python": ">=2.7", "size": 12648, "upload_time": "2018-04-23T23:00:35", "url": "https://files.pythonhosted.org/packages/f0/28/2bd0bfb47bddb2f09a1e99ce7e529dd3d40d8dbeecc7eb2b25e6cee97a89/dojobber-0.5.1-py2.py3-none-any.whl" }, { "comment_text": "", "digests": { "md5": "9332cc02e4d27e7f6d4ee7d922a31ae5", "sha256": "c46c530d6fb158ab25ad1d09a4a7d9256c8386c29760b6b44c9cdc455534aa5a" }, "downloads": -1, "filename": "dojobber-0.5.1.tar.gz", "has_sig": false, "md5_digest": "9332cc02e4d27e7f6d4ee7d922a31ae5", "packagetype": "sdist", "python_version": "source", "requires_python": ">=2.7", "size": 11529, "upload_time": "2018-04-23T23:00:36", "url": "https://files.pythonhosted.org/packages/9b/d7/b7ef9d1c3ae5d3d293acde4ba88d2c87146a2800d4bb711802d69ee5a0b7/dojobber-0.5.1.tar.gz" } ], "0.5.2": [ { "comment_text": "", "digests": { "md5": "9f76efefe062427db5dde9e0bb467d82", "sha256": "fcae2d3c783e2b05e16d5cc5d93e980f4db909ee5de04c76a26e3de9233d01a7" }, "downloads": -1, "filename": "dojobber-0.5.2-py2.py3-none-any.whl", "has_sig": false, "md5_digest": "9f76efefe062427db5dde9e0bb467d82", "packagetype": "bdist_wheel", "python_version": "py2.py3", "requires_python": ">=2.7", "size": 15443, "upload_time": "2018-06-11T00:30:36", "url": "https://files.pythonhosted.org/packages/b0/41/04a946b0b20287d5d6513dbcdcb07d327c93d8e5462e766606a6ed54613d/dojobber-0.5.2-py2.py3-none-any.whl" }, { "comment_text": "", "digests": { "md5": "08be05b0b9fed5fe076c9a409e5b6b72", "sha256": "f7eb2532ac2b6a0e98e72f3b4745c9e2a53dbae0e399a54791b92455894843cb" }, "downloads": -1, "filename": "dojobber-0.5.2.tar.gz", "has_sig": false, "md5_digest": "08be05b0b9fed5fe076c9a409e5b6b72", "packagetype": "sdist", "python_version": "source", "requires_python": ">=2.7", "size": 13263, "upload_time": "2018-06-11T00:30:37", "url": "https://files.pythonhosted.org/packages/f1/e2/91c1765006225e722114684095ad2d3c9c442af457ab76f66c03574c0262/dojobber-0.5.2.tar.gz" } ], "0.6.0": [ { "comment_text": "", "digests": { "md5": "c07fe6be2efd8cb2e27bf7c9bd89d084", "sha256": "7f13fa817c4f498291ed7bed8a0a54bdd4af622fa85ac7a3427e3ba217aba3e5" }, "downloads": -1, "filename": "dojobber-0.6.0-py2.py3-none-any.whl", "has_sig": false, "md5_digest": "c07fe6be2efd8cb2e27bf7c9bd89d084", "packagetype": "bdist_wheel", "python_version": "py2.py3", "requires_python": ">=2.7", "size": 19547, "upload_time": "2018-10-15T04:18:06", "url": "https://files.pythonhosted.org/packages/80/d4/4da015b35f2c10bdda015c96374a98a5971eba5c9123a49d623a8c85b700/dojobber-0.6.0-py2.py3-none-any.whl" }, { "comment_text": "", "digests": { "md5": "fd7bd31159a2c790f1fb9a60716936c7", "sha256": "a6ca941e2c3c0f10d967be9dc10c234c666868b05a769b398b2721f420b563e9" }, "downloads": -1, "filename": "dojobber-0.6.0.tar.gz", "has_sig": false, "md5_digest": "fd7bd31159a2c790f1fb9a60716936c7", "packagetype": "sdist", "python_version": "source", "requires_python": ">=2.7", "size": 15968, "upload_time": "2018-10-15T04:18:08", "url": "https://files.pythonhosted.org/packages/a3/59/58de4072b620435b2f0a335fc31bcab042cb95e39cc26c5ca71e1e2fdada/dojobber-0.6.0.tar.gz" } ], "0.6.1": [ { "comment_text": "", "digests": { "md5": "7446e6c0229d129435565b45a6997011", "sha256": "f89268e018efad60892909042d316a8b55ab149a12a6b472db34a133830ecef5" }, "downloads": -1, "filename": "dojobber-0.6.1-py2.py3-none-any.whl", "has_sig": false, "md5_digest": "7446e6c0229d129435565b45a6997011", "packagetype": "bdist_wheel", "python_version": "py2.py3", "requires_python": ">=2.7", "size": 21020, "upload_time": "2019-05-05T16:37:48", "url": "https://files.pythonhosted.org/packages/7f/9f/ae99f2eeab864d23262f6e4c93932ccb8d5b3308b7d2bbd1ca9edef2465d/dojobber-0.6.1-py2.py3-none-any.whl" }, { "comment_text": "", "digests": { "md5": "22364256cc14e7686971d5b8344bafc5", "sha256": "65473b29f2027089ce1a7d99c439702586f4973f8e4dab5f92d46ef733d223e2" }, "downloads": -1, "filename": "dojobber-0.6.1.tar.gz", "has_sig": false, "md5_digest": "22364256cc14e7686971d5b8344bafc5", "packagetype": "sdist", "python_version": "source", "requires_python": ">=2.7", "size": 17108, "upload_time": "2019-05-05T16:37:50", "url": "https://files.pythonhosted.org/packages/f7/85/928a48888395756a50776d01cc54520fe236f9db0a448dc62fe8082c4e17/dojobber-0.6.1.tar.gz" } ], "0.6.2": [ { "comment_text": "", "digests": { "md5": "52b2bee2803615f2ebfe3fb440ca2dfe", "sha256": "c67e8d43b3dd55e88a5abcc7a82a2da0589f9a79519accd8bd56592724c4e5cc" }, "downloads": -1, "filename": "dojobber-0.6.2-py2.py3-none-any.whl", "has_sig": false, "md5_digest": "52b2bee2803615f2ebfe3fb440ca2dfe", "packagetype": "bdist_wheel", "python_version": "py2.py3", "requires_python": ">=2.7", "size": 21100, "upload_time": "2019-05-05T19:56:43", "url": "https://files.pythonhosted.org/packages/c1/44/ac5d5791a2abe9ba23858600f701a859b73d659f85055c4f113430b92f02/dojobber-0.6.2-py2.py3-none-any.whl" }, { "comment_text": "", "digests": { "md5": "255a38811a8c4d67aa305d5266adcfb3", "sha256": "795b603186fa15cc59de54af539b3d2b1bfc8789c6290a51dc9693684fc333f2" }, "downloads": -1, "filename": "dojobber-0.6.2.tar.gz", "has_sig": false, "md5_digest": "255a38811a8c4d67aa305d5266adcfb3", "packagetype": "sdist", "python_version": "source", "requires_python": ">=2.7", "size": 17186, "upload_time": "2019-05-05T19:56:44", "url": "https://files.pythonhosted.org/packages/a8/99/cde412b62edfac5ade239365a62461b1eee419c9a4de48b5169d037b580b/dojobber-0.6.2.tar.gz" } ] }, "urls": [ { "comment_text": "", "digests": { "md5": "52b2bee2803615f2ebfe3fb440ca2dfe", "sha256": "c67e8d43b3dd55e88a5abcc7a82a2da0589f9a79519accd8bd56592724c4e5cc" }, "downloads": -1, "filename": "dojobber-0.6.2-py2.py3-none-any.whl", "has_sig": false, "md5_digest": "52b2bee2803615f2ebfe3fb440ca2dfe", "packagetype": "bdist_wheel", "python_version": "py2.py3", "requires_python": ">=2.7", "size": 21100, "upload_time": "2019-05-05T19:56:43", "url": "https://files.pythonhosted.org/packages/c1/44/ac5d5791a2abe9ba23858600f701a859b73d659f85055c4f113430b92f02/dojobber-0.6.2-py2.py3-none-any.whl" }, { "comment_text": "", "digests": { "md5": "255a38811a8c4d67aa305d5266adcfb3", "sha256": "795b603186fa15cc59de54af539b3d2b1bfc8789c6290a51dc9693684fc333f2" }, "downloads": -1, "filename": "dojobber-0.6.2.tar.gz", "has_sig": false, "md5_digest": "255a38811a8c4d67aa305d5266adcfb3", "packagetype": "sdist", "python_version": "source", "requires_python": ">=2.7", "size": 17186, "upload_time": "2019-05-05T19:56:44", "url": "https://files.pythonhosted.org/packages/a8/99/cde412b62edfac5ade239365a62461b1eee419c9a4de48b5169d037b580b/dojobber-0.6.2.tar.gz" } ] }