{
"info": {
"author": "Mike Duskis",
"author_email": "mike.duskis@cybergrx.com",
"bugtrack_url": null,
"classifiers": [],
"description": "> Twin Sister:\n## A Unit Testing Toolkit with Pure Python Dependency Injection\n\n> No, I am Zoot's identical twin sister, Dingo.\n\n## How Twin Sister can help you\n\nWhether or not you accept Michael Feathers's definition of \"legacy code\" as\n\"code without tests,\" you know that you should write unit tests and that it\nwould be a Good Thing if those tests were legible enough to show what your code\ndoes and effective enough to tell you when you've broken something. On the\nother hand, writing good unit tests can be _hard_ -- especially when they need\nto cover the unit's interactions with external components.\n\nEnter Twin Sister. Initially an internal project at ProtectWise in 2016, it\nwas released as open source in 2017 and has been in continuous and expanding\nuse ever since. Its goal is to make unit tests easier to write and easier to\nread without doing violence to the system-under-test. It consists of a small\nlibrary of test doubles and a pure Python dependency injector to deliver them\n(or anything else that suits your fancy).\n\n## What it looks like in action ##\n\n`test_post_something.py`\n```\nfrom unittest import TestCase\n\nfrom expects import expect, equal\nimport requests\nfrom twin_sister import open_dependency_context\nfrom twin_sister.fakes import EndlessFake, FunctionSpy\n\nfrom post_something import post_something\n\nclass TestPostSomething(TestCase):\n\n def setUp(self):\n self.context = open_dependency_context()\n self.post_spy = FunctionSpy()\n requests_stub = EndlessFake(pattern_obj=requests)\n requests_stub.post = self.post_spy\n self.context.inject(requests, requests_stub)\n\n def tearDown(self):\n self.context.close()\n\n def test_uses_post_method(self):\n post_something('yadda')\n self.post_spy.assert_was_called()\n\n def test_sends_specified_content(self):\n content = 'yadda yadda yadda'\n post_something(content)\n expect(self.post_spy['data']).to(equal(content)) \n```\n\n`post_something.py`\n```\nimport requests\nfrom twin_sister import dependency\n\ndef post_something(content):\n post = dependency(requests).post\n post('http://example.com/some-api', data=content)\n```\n\n## Learning More ##\n- ### Dependency injection mechanism\n - #### Dependency injection techniques\n - #### Injecting a dependency with Twin Sister\n - Generic technique to inject any object\n - Injecting a class that always produces the same object\n - Support for the xUnit test pattern\n - Support for multi-threaded tests\n- ### The dependency context and built-in fakery\n - #### Fake environment variables\n - #### Fake logging\n - #### Fake filesystem\n - #### Fake time\n- ### Test doubles\n - #### MutableObject\n - #### EndlessFake\n - #### empty_context_manager\n - #### FakeDateTime\n - #### FunctionSpy\n - #### MasterSpy\n- ### Expects matchers\n - #### complain\n - #### contain_all_items_in\n\n\n\n\n# Dependency injection mechanism #\n\n## What is dependency injection and why should I care? ##\n\nIf you write tests for non-trivial units, you have encountered\nsituations where the unit you are testing depends on some component outside of\nitself. For example, a unit that retrieves data from an HTTP API depends on\nan HTTP client. By definition, a unit test does not include systems outside\nthe unit, so does not make real network requests. Instead, it configures the\nunit to make fake requests using a component with the same interface as the real\nHTTP client. The mechanism that replaces the real HTTP client with a fake one\nis a kind of dependency injection.\n\n\n\n## Dependency injection techniques ##\n\n### Most simple: specify initializer arguments ###\n\n```\nclass Knight:\n\n def __init__(self, *, http_client=None):\n if not http_client:\n http_client = HttpClient()\n```\n\nIn the example above, new knight objects will ordinarily construct a real HTTP\nclient for themselves, but the code that creates them has the opportunity to\ninject an alternative client like this:\n\n```\nfake = FakeHttpClient()\nsir_lancelot = Knight(http_client=fake)\n```\n\nThis approach has the advantage of being simple and straightforward and can\nbe more than adequate if the problem space is small and well-contained.\nIt begins to break down, however, as the system under test becomes more\ncomplex. One manifestation of this breakdown is the appearance of \"hobo arguments.\" The initializer must specify each dependency that can be injected\nand the target bears responsibility for maintaining each injected object and\npassing it to sub-components as they are created.\n\nFor example\n\n\n```\nclass Horse:\n\n def __init__(self, *, tail=None):\n self.tail = tail or HorseTail()\n\n\nclass Knight:\n\n def __init__(self, *, tail_for_horse=None):\n self.horse = Horse(tail=tail_for_horse)\n```\n\n`tail_for_horse` is a hobo. The _only_ reason `Knight.__init__` has for accepting it is to\npass it through to `Horse.__init__`. This is awkward, aside from its damage\nto separation of concerns.\n\n\n### Most thorough: subvert the global symbol table\n\nIn theory, it would be possible to make _all_ HTTP clients fake by redirecting\nHttpClient in the global symbol table to FakeHttpClient. This approach has\nthe advantage of not requiring the targeted code to be aware of the injection\nand is likely to be highly effective if successful. It suffers from major drawbacks,\nhowever. The symtable module (sensibly) does not permit write access, so redirection would\nneed to be performed at a lower level which would break compatibility across\nPython implementations. It's also an extreme hack with potentially serious side\neffects.\n\n### Middle ground: request dependencies explicitly\n\nTwin Sister takes a middle approach. It maintains a registry of symbols\nthat have been injected and then handles requests for dependencies. In this way,\nonly code that requests a dependency explicity is affected by injection:\n\n```\nfrom twin_sister import dependency\n\nclass Horse:\n\n def __init__(self):\n self.tail = dependency(Tail)()\n\n\nclass Knight:\n\n def __init__(self):\n self.horse = dependency(Horse)()\n```\n\n`dependency` returns the injected replacement if one exists. Otherwise, it\nreturns the real thing. In this way, the system will behave sensibly whether\ninjection has occurred or not.\n\n\n\n\n# Injecting a dependency with Twin Sister\n\n\n## Installation from pip\n\n```\npip install twin-sister\n```\n\n## Installation from source\n\n```\npython setup.py install\n```\n\n\n\n## Generic technique to inject any object ##\n\n```\nfrom twin_sister import dependency, dependency_context\n\nclass Knight:\n\n def __init__(self):\n self.horse = dependency(Horse)()\n self.start_month = dependency(current_month)()\n self.guess = dependency(VELOCITY_OF_SOUTH_AFRICAN_SWALLOW)\n\n\nwith dependency_context() as context:\n context.inject(Horse, FakeHorse)\n context.inject(current_month, lambda: 'February')\n context.inject(VELOCITY_OF_SOUTH_AFRICAN_SWALLOW, 42)\n lancelot = Knight()\n lancelot.visit_castle()\n expect(lancelot.strength).to(equal(0))\n```\n\nInjection is effective only inside the dependency context. Inside the context,\nrequests for `Horse` will return `FakeHorse`. Outside the context\n(after the `with` statement), requests for `Horse` will return `Horse`.\n\n\n\n\n## Injecting a class that always produces the same object ##\n\n```\nwith dependency_context() as context:\n eric_the_horse = FakeHorse()\n context.inject_as_class(Horse, eric_the_horse)\n lancelot = Knight()\n lancelot.visit_castle()\n expect(eric_the_horse.hunger).to(equal(42))\n```\n\nEach time the system under test executes code like this\n\n```\nfresh_horse = dependency(Horse)()\n```\n\nfresh_horse will be the same old eric_the_horse.\n\n\n\n\n## Support for xUnit test pattern\n\nInstead of using a context manager, a test can open and close its\ndependency context explicitly:\n\n\n```\nfrom pw_dependency_injector import open_dependency_context\n\n\nclass MyTest(TestCase):\n\n def setUp(self):\n self.dependencies = open_dependency_context()\n\n def tearDown(self):\n self.dependencies.close()\n\n def test_something(self):\n self.dependencies.inject(Horse, FakeHorse)\n outcome = visit_anthrax(spams=37)\n expect(outcome).to(equal('Cardinal Ximinez'))\n\n```\n\n\n\n## Support for multi-threaded tests\n\nBy default, Twin Sister maintains a separate dependency context for each thread.\nThis allows test cases with different dependency schemes to run in parallel\nwithout affecting each other.\n\nHowever, it also provides a mechanism to attach a dependency context to a running\nthread:\n\n```\nmy_thread = Thread(target=spam)\nmy_thread.start()\n\nwith dependency_context() as context:\n context.attach_to_thread(my_thread)\n ...\n```\n\nThe usual rules about context scope apply. Even if the thread continues to run,\nthe context will disappear after the `with` statement ends.\n\n\n\n# The dependency context and built-in fakery #\n\nThe dependency context is essentially a dictionary that maps real objects to their injected fakes, but it also knows how to fake some commonly-used components from the Python standard library.\n\n\n\n## Fake environment variables\n\nMost of the time, we don't want our unit tests to inherit real environment variables because that would introduce an implicit dependency on system configuration. Instead, we create a dependency context with `supply_env=True`. This creates a fake set of environment variables, initially empty. We can then add environment variables as expected by our system under test:\n\n```\nwith dependency_context(supply_env=True) as context:\n context.set_env(PATH='/bin', SPAM='eggs')\n```\n\nThe fake environment is just a dictionary in an injected `os`, so the system-under-test must request it explicitly as a dependency:\n\n```\npath = dependency(os).environ['PATH']\n```\n\nThe injected `os` is mostly a passthrough to the real thing.\n\n\n\n## Fake logging\n\nMost of the time, we don't want our unit tests to use the real Python logging system -- especially if it writes messages to standard output (as it usually does). This makes tests fill standard output with noise from useless logging messages. Some of the time, we want our tests to see the log messages produced by the system-under-test. The fake log system meets both needs.\n\n```\nmessage = 'This goes only to the fake log'\nwith dependency_context(supply_logging=True) as context:\n log = dependency(logging).getLogger(__name__)\n log.error(message)\n # logging.stored_records is a list of logging.LogRecord objects\n assert context.logging.stored_records[0].msg == message\n```\n\n\n\n## Fake filesystem\n\nMost of the time, we don't want our unit tests to use the real filesystem. That would introduce an implicit dependency on actual system state and potentially leave a mess behind. To solve this problem, the dependency context can leverage [pyfakefs](https://pypi.org/project/pyfakefs/) to supply a fake filesystem.\n\n```\nwith dependency_context(supply_fs=True):\n filename = 'favorites.txt'\n open = dependency(open)\n with open(filename, 'w') as f:\n f.write('some of my favorite things')\n with open(filename, 'r') as f:\n print('From the fake file: %s' % f.read())\n assert dependency(os).path.exists(filename)\nassert not os.path.exists(filename)\n```\n\n\n\n## Fake time\n\nSometimes it is useful -- or even necessary -- for a test case to\ncontrol time as its perceived by the system-under-test. The classic example\nis a routine that times out after a specified duration has elapsed. Thorough\ntesting should cover both sides of the boundary, but it is usually undesirable\nor impractical to wait for the duration to elapse. That is where TimeController\ncomes in. It's a self-contained way to inject a fake datetime.datetime:\n\n```\nfrom expects import expect, be_a, be_none\nfrom twin_sister import TimeController\n\n# Verify that the function times out after 24 hours\ntime_travel = TimeController(target=some_function_i_want_to_test)\ntime_travel.start()\ntime_travel.advance(hours=24)\nsleep(0.05) # Give target a chance to cycle\nexpect(time_travel.exception_caught).to(be_a(TimeoutError))\n\n# Verify that the function does not time out before 24 hours\ntime_travel = TimeController(target=some_function_i_want_to_test)\ntime_travel.start()\ntime_travel.advance(hours=24 - 0.0001)\nsleep(0.05) # Give target a chance to cycle\nexpect(time_travel.exception_caught).to(be_none)\n```\n\nThe example above checks for the presence or absence of an exception, but it\nis possible to check _any_ state. For example, let's check the impact of\na long-running bound method on its object:\n\n```\ntime_travel = TimeController(target=thing.monitor_age)\ntime_travel.start()\ntime_travel.advance(days=30)\nsleep(0.05)\nexpect(thing.age_in_days).to(equal(30))\ntime_travel.advance(days=30)\nsleep(0.05)\nexpect(thing.age_in_days).to(equal(60))\n```\n\nWe can also check the return value of the target function:\n\n```\nexpected = 42\ntime_travel = TimeController(target=lambda: expected)\ntime_travel_start()\ntime_travel.join()\nexpect(time_travel.value_returned).to(equal(expected))\n```\n\nBy default, TimeController has its own dependency context, but it can\ninherit a specified one instead:\n\n```\nwith open_dependency_context() as context:\n tc = context.create_time_controller(target=some_function)\n```\n\nThere are limitations. The fake datetime affects only .now() and .utcnow()\nat present. This may change in a future release as needs arise.\n\n\n\n\n# Test Doubles #\n\nClassically, test doubles fall into three general categories:\n\n#### Stubs ####\nA stub faces the unit-under-test and mimics the behavior of some external component.\n\n#### Spies ####\nA spy faces the test and reports on the behavior of the unit-under-test.\n\n#### Mocks ####\nA mock is a stub that contains assertions. Twin Sister's `fakes` module has none of these but most of the supplied fakes are so generic that mock behavior can be added.\n\n\n## Supplied Stubs ##\n\n\n\n### MutableObject ###\n\nEmbarrassingly simple, but frequently useful for creating stubs on the fly:\n\n```\nfrom twin_sister.fakes import MutableObject\n\nstub = MutableObject()\nstub.say_hello = lambda: 'hello, world'\n```\n\n\n\n### EndlessFake ###\n\nAn extremely generic stub that aims to be a substitute for absolutely anything. Its attributes are EndlessFake objects. When it's called like a function, it returns another EndlessFake.\n\nWhen invoked with no arguments, EndlessFake creates the most flexible fake possible:\n\n```\nfrom twin_sister.fakes import EndlessFake\n\nanything = EndlessFake()\nanother_endless_fake = anything.spam\nyet_another_endless_fake = another_endless_fake(biggles=12)\n```\n\nIt's possible to restrict an EndlessFake to attributes defined by some other object:\n\n```\nstub_path = EndlessFake(pattern_obj=os.path)\n# The next line returns an EndlessFake because there is an os.path.join:\nan_endless_fake = stub_path.join\n# The next line will raise AttributeError because there is no os.path.spam:\nstub_path.spam\n```\n\nIt's also possible to restrict an EndlessFake to attributes declared by a class:\n\n```\nfake_string = EndlessFake(pattern_class=str)\n# The next line returns an EndlessFake because strings have attributes called \"split\"\nan_endless_fake = fake_string.split\n# The next will raise AttributeError because normal strings lack beans:\nfake_string.beans\n```\n\nImportant limitation: \"declared by a class\" means that the attribute appears in\nthe class declaration. If the attribute gets created by the initializer instead,\nthen it's not declared by the class and EndlessFake will insist that the attribute\ndoes not exist. If you need an attribute that gets created by the initializer,\nyou're better off instantiating an object to use as a `pattern_obj`.\n\n\n\n### empty_context_manager ###\n\nA context manager that does nothing and yields an EndlessFake, useful for preventing unwanted behavior like opening network connections.\n\n```\nfrom twin_sister.fakes import empty_context_manager\n\nfrom my_stuff import network_connection\n\nwith dependency_context() as context:\n context.inject(network_connection, empty_context_manager)\n with dependency(network_connection)() as conn:\n conn.send(\"I'm singing into an EndlessFake\")\n```\n\nA generic EndlessFake object will also serve as a context manager without complaints.\n\n\n\n\n### FakeDateTime ###\n\nA datetime.datetime stub that reports a fixed time.\n\n```\nfrom twin_sister.fakes import FakeDateTime\n\nt = FakeDateTime(fixed_time=datetime.now())\n# Returns the time when t was instantiated\nt.now()\nt.fixed_time = now()\n# Returns a slightly later time\nt.now()\n```\n\n\n## Supplied Spies ##\n\n\n\n### FunctionSpy ###\n\nPretends to be a real function and tracks calls to itself.\n\n```\nfrom twin_sister.fakes import FunctionSpy\n\nfixed_return_value = 4\nspy = FunctionSpy(return_value=fixed_return_value)\nreturned = spy(6, 37, expected='biggles')\nspy.assert_was_called()\nassert returned == fixed_return_value\nassert spy.args_from_last_call() == (6, 37)\nassert spy.kwargs_from_last_call() == {'expected': biggles}\nassert spy[0] == 6\nassert spy[1] == 37\nassert spy['expected'] == biggles\n\nspy('spam', 'eggs', volume=12)\nassert spy[1] == 'eggs'\n\nargs, kwargs = spy.call_history[0]\nassert args == (6, 37)\nassert kwargs == {'expected': 'biggles'}\n```\n\n\n\n\n### MasterSpy ###\n\nThe spy equivalent of EndlessFake, MasterSpy tracks every interaction and spawns\nmore spies to track interactions with its attributes.\n\n```\nfrom twin_sister.fakes import MasterSpy, MutableObject\n\ntarget = MutableObject()\ntarget.foo = 42\ntarget.bar = 'soap'\ntarget.sing = lambda thing: f'lovely {thing}'\nmaster = MasterSpy(target=target, affect_only_functions=False)\n\nassert master.foo == target.foo\nmaster.bar.replace('a', 'u')\nbar_spy = master.attribute_spies['bar']\nargs, kwargs = bar_spy.last_call_to('replace')\nassert args == ('a', 'u')\n\nmaster.sing(thing='SPAM')\nsing_spy = master.attribute_spies('sing')\nargs, kwargs = sing_spy.call_history[0]\nassert kwargs['thing'] == 'SPAM'\n```\n\nBy default MasterSpy spawns spies only for attributes that are functions.\n\n\n\n\n# Expects Matchers #\n\nCustom matchers for [expects](https://pypi.org/project/expects/), an\nalternative way to assert.\n\n\n\n## complain ##\n\n`expects.raise_error` will quietly return False if an unexpected exception is raised.\n`twin_sister.expects_matchers.complain`, by contrast, will re-raise the exception.\nOtherwise, the matchers are essentially equivalent.\n\n```\nfrom expects import expect, raise_error\nfrom twin_sister.expects_matchers import complain\n\nclass SpamException(RuntimeError):\n pass\n\nclass EggsException(RuntimeError):\n pass\n\ndef raise_spam():\n raise SpamException()\n\ndef raise_eggs():\n raise EggsException()\n\n# both exit quietly because the expectation is met\nexpect(raise_spam).to(raise_error(SpamException))\nexpect(raise_spam).to(complain(SpamException))\n\n# exits quietly because a different exception was raised\nexpect(raise_eggs).not_to(raise_error(SpamException))\n\n# re-raises the exception because it differs from the expectation\nexpect(raise_eggs).not_to(complain(SpamException))\n```\n\n\n\n\n## contain_all_items_in ##\n\nReturns true if one dictionary contains all of the items in another.\n\n```\nfrom expects import expect\nfrom twin_sister.expects_matchers import contain_all_items_in\n\nexpect({'foo': 1, 'bar': 2}).to(contain_all_items_in({'foo': 1}))\nexpect({'foo': 1}).not_to(contain_all_items_in({'foo': 1, 'bar': 2}))\n```",
"description_content_type": "text/markdown",
"docs_url": null,
"download_url": "",
"downloads": {
"last_day": -1,
"last_month": -1,
"last_week": -1
},
"home_page": "https://github.com/CyberGRX/twin-sister",
"keywords": "",
"license": "",
"maintainer": "",
"maintainer_email": "",
"name": "twin-sister",
"package_url": "https://pypi.org/project/twin-sister/",
"platform": "",
"project_url": "https://pypi.org/project/twin-sister/",
"project_urls": {
"Homepage": "https://github.com/CyberGRX/twin-sister"
},
"release_url": "https://pypi.org/project/twin-sister/4.5.1.0/",
"requires_dist": null,
"requires_python": "",
"summary": "Unit test toolkit",
"version": "4.5.1.0"
},
"last_serial": 5851774,
"releases": {
"1.1.0.0": [
{
"comment_text": "",
"digests": {
"md5": "a2bb4c9493b6fc058bdc2ff67324e226",
"sha256": "1d473a75bd4592080a64c960d04b12eefb96b8a5c1925b5ef931299a99e6270f"
},
"downloads": -1,
"filename": "twin_sister-1.1.0.0-py3-none-any.whl",
"has_sig": true,
"md5_digest": "a2bb4c9493b6fc058bdc2ff67324e226",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": null,
"size": 10342,
"upload_time": "2017-11-01T18:06:37",
"url": "https://files.pythonhosted.org/packages/a4/e0/4f8354f757acca5ea91db54abd3478abda22e13703e570f9c6797bcc8665/twin_sister-1.1.0.0-py3-none-any.whl"
}
],
"2.0.2.0": [
{
"comment_text": "",
"digests": {
"md5": "5639c9f43ebe8fbb315a2b6d2d171fc4",
"sha256": "1d95c5e87cab2a962fc1aac67c2c4b0bd84ef65e541d3e2ecddcd2f34ee439cd"
},
"downloads": -1,
"filename": "twin_sister-2.0.2.0-py3-none-any.whl",
"has_sig": false,
"md5_digest": "5639c9f43ebe8fbb315a2b6d2d171fc4",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": null,
"size": 13488,
"upload_time": "2019-02-11T16:18:41",
"url": "https://files.pythonhosted.org/packages/46/18/56b87cb352bc761ce540b345d83869db5b515081f6da29a9db3ddc7eaed6/twin_sister-2.0.2.0-py3-none-any.whl"
},
{
"comment_text": "",
"digests": {
"md5": "b8d942a6d937d8bfee07c701c4dcf08d",
"sha256": "42e9c74a974379133c7e749c4819951dd7b97aa2e3a7bd1422505b2d35ba8cab"
},
"downloads": -1,
"filename": "twin_sister-2.0.2.0.tar.gz",
"has_sig": false,
"md5_digest": "b8d942a6d937d8bfee07c701c4dcf08d",
"packagetype": "sdist",
"python_version": "source",
"requires_python": null,
"size": 8810,
"upload_time": "2019-02-11T16:18:42",
"url": "https://files.pythonhosted.org/packages/8c/82/b9b177d322fa5203722e527cc68f2907bb51fa1ec6bfd3335d0feea1768d/twin_sister-2.0.2.0.tar.gz"
}
],
"2.0.6.0": [
{
"comment_text": "",
"digests": {
"md5": "a435c0b19f39020c21172cdba9b1760b",
"sha256": "9c4767ff9b68cb3ee9dd9de6783400d95ed1bfde6645640d790618db78187293"
},
"downloads": -1,
"filename": "twin_sister-2.0.6.0-py3-none-any.whl",
"has_sig": false,
"md5_digest": "a435c0b19f39020c21172cdba9b1760b",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": null,
"size": 22447,
"upload_time": "2019-05-26T15:00:34",
"url": "https://files.pythonhosted.org/packages/6c/d7/e64947eff34181ffc26774f0ffb0d76be50f4f4cb972d632f6f825b8d29b/twin_sister-2.0.6.0-py3-none-any.whl"
},
{
"comment_text": "",
"digests": {
"md5": "ef4d6e2fdeb4e7c6502d598b2afd55d3",
"sha256": "f24cb2b24d459437efa37e642a1503b25da2451a7a3fdb6c8af6ccb3c54dab30"
},
"downloads": -1,
"filename": "twin_sister-2.0.6.0.tar.gz",
"has_sig": false,
"md5_digest": "ef4d6e2fdeb4e7c6502d598b2afd55d3",
"packagetype": "sdist",
"python_version": "source",
"requires_python": null,
"size": 17843,
"upload_time": "2019-05-26T15:00:36",
"url": "https://files.pythonhosted.org/packages/65/8a/f49e6bfa4c1bb630413f393cb11ff0ab79fa6f224ea0e41d9889a6331a05/twin_sister-2.0.6.0.tar.gz"
}
],
"3.0.1.0": [
{
"comment_text": "",
"digests": {
"md5": "4aa56eb93692fba174d030c5948bfa36",
"sha256": "b6c5f7a8497cfce8ebd48f0a769429d40fca639eddba8e2ef3aaf0c2915e9b02"
},
"downloads": -1,
"filename": "twin_sister-3.0.1.0-py3-none-any.whl",
"has_sig": false,
"md5_digest": "4aa56eb93692fba174d030c5948bfa36",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": null,
"size": 22763,
"upload_time": "2019-05-26T22:49:16",
"url": "https://files.pythonhosted.org/packages/70/f3/3eba16bfba3205748ff4a51fee30e2faba775315f41f3b5fca41f997c6de/twin_sister-3.0.1.0-py3-none-any.whl"
},
{
"comment_text": "",
"digests": {
"md5": "185a21b7597e56a08b50bf7510c93930",
"sha256": "c549e8f619235cf46a7444d2213ceb294927c5392130baf08bcedcdf44bcdd4b"
},
"downloads": -1,
"filename": "twin_sister-3.0.1.0.tar.gz",
"has_sig": false,
"md5_digest": "185a21b7597e56a08b50bf7510c93930",
"packagetype": "sdist",
"python_version": "source",
"requires_python": null,
"size": 17834,
"upload_time": "2019-05-26T22:49:17",
"url": "https://files.pythonhosted.org/packages/3f/54/11e66b3f4b0c39acb13317be9d96ba83ecb24fc6591ff9584803dd16e540/twin_sister-3.0.1.0.tar.gz"
}
],
"4.0.0.0": [
{
"comment_text": "",
"digests": {
"md5": "fed24d8969fbb090c61c66062797015a",
"sha256": "9bd3081050bda478fd52f1c33af41759be384b64555c660c2bf25548ca8ecdc8"
},
"downloads": -1,
"filename": "twin_sister-4.0.0.0.macosx-10.14-x86_64.tar.gz",
"has_sig": false,
"md5_digest": "fed24d8969fbb090c61c66062797015a",
"packagetype": "sdist",
"python_version": "source",
"requires_python": null,
"size": 53129,
"upload_time": "2019-05-26T22:24:57",
"url": "https://files.pythonhosted.org/packages/9d/cb/fd4b920a25c1a28e0d4a55326bfa66364884663f670d404fcda8969d6dff/twin_sister-4.0.0.0.macosx-10.14-x86_64.tar.gz"
},
{
"comment_text": "",
"digests": {
"md5": "bf61540f1f1b86be46079718106394d9",
"sha256": "6a62fc672fce78ce8b586dafabf8e8fd40fc8154c6603972094bd3e51bae482e"
},
"downloads": -1,
"filename": "twin_sister-4.0.0.0-py3-none-any.whl",
"has_sig": false,
"md5_digest": "bf61540f1f1b86be46079718106394d9",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": null,
"size": 36617,
"upload_time": "2019-05-26T22:26:55",
"url": "https://files.pythonhosted.org/packages/e2/68/68a18bfaef422c3f98374bfff06145a65fa22c73ca352d3474ba30b56a52/twin_sister-4.0.0.0-py3-none-any.whl"
}
],
"4.1.0.0": [
{
"comment_text": "",
"digests": {
"md5": "ce474d9bb06a991de4d419de1ac2e79b",
"sha256": "eb101f31afe9a44ebcc2ec149d745e1fb8ba9ba03327d1c7801bd5630a2e55b1"
},
"downloads": -1,
"filename": "twin_sister-4.1.0.0-py3-none-any.whl",
"has_sig": false,
"md5_digest": "ce474d9bb06a991de4d419de1ac2e79b",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": null,
"size": 52984,
"upload_time": "2019-05-27T15:25:44",
"url": "https://files.pythonhosted.org/packages/68/c6/ca7430a9f1a363fe4af5184491a49db6dcbc506d5e02fa8a61cbc597c037/twin_sister-4.1.0.0-py3-none-any.whl"
},
{
"comment_text": "",
"digests": {
"md5": "71c8d1d68f305c56989dc7656decb60b",
"sha256": "f6cc957f85caa0e38978f2be66fc91ef40452ae99533be1e400f2a8e20e067b7"
},
"downloads": -1,
"filename": "twin_sister-4.1.0.0.tar.gz",
"has_sig": false,
"md5_digest": "71c8d1d68f305c56989dc7656decb60b",
"packagetype": "sdist",
"python_version": "source",
"requires_python": null,
"size": 25209,
"upload_time": "2019-05-27T15:25:45",
"url": "https://files.pythonhosted.org/packages/dd/5a/4ea9bb0e91d755360e07526a89b4396e9250df1f4baeb94b7456ae395291/twin_sister-4.1.0.0.tar.gz"
}
],
"4.2.0.0": [
{
"comment_text": "",
"digests": {
"md5": "e3f209f15e760090d3ad34e7ee600905",
"sha256": "15a74714884e258be48b6e579c18459668c997c82b10c4920fb53c28a541b006"
},
"downloads": -1,
"filename": "twin_sister-4.2.0.0-py3-none-any.whl",
"has_sig": false,
"md5_digest": "e3f209f15e760090d3ad34e7ee600905",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": null,
"size": 40955,
"upload_time": "2019-05-27T20:41:26",
"url": "https://files.pythonhosted.org/packages/cd/d2/172ec1eb849f137606f496ae0efdf7ec8aa196960670285fb57e2c1a158a/twin_sister-4.2.0.0-py3-none-any.whl"
},
{
"comment_text": "",
"digests": {
"md5": "c9f590deddf979c362c183c0b326c124",
"sha256": "4c31c85886ac64fe9ed0380ba02518e8f7e3ba7750badee9213434fad187e76e"
},
"downloads": -1,
"filename": "twin_sister-4.2.0.0.tar.gz",
"has_sig": false,
"md5_digest": "c9f590deddf979c362c183c0b326c124",
"packagetype": "sdist",
"python_version": "source",
"requires_python": null,
"size": 32963,
"upload_time": "2019-05-27T20:41:28",
"url": "https://files.pythonhosted.org/packages/26/0f/df497470e808d73a2b969dc8600e4d8bee4f5ecf51820d7d656b25c6ad5d/twin_sister-4.2.0.0.tar.gz"
}
],
"4.2.1.0": [
{
"comment_text": "",
"digests": {
"md5": "b453625297b3d514173758d258718ac6",
"sha256": "5e65a6220484a7b1ebe8f86fce1f84da5383862548756398af6f4f21dd28713c"
},
"downloads": -1,
"filename": "twin_sister-4.2.1.0-py3-none-any.whl",
"has_sig": false,
"md5_digest": "b453625297b3d514173758d258718ac6",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": null,
"size": 40956,
"upload_time": "2019-05-27T20:45:12",
"url": "https://files.pythonhosted.org/packages/0f/ff/7d34836f63dbdc0e426d870ea84214d2cea2fc59161796bd35038d27d770/twin_sister-4.2.1.0-py3-none-any.whl"
},
{
"comment_text": "",
"digests": {
"md5": "e893165f05d86e3a40a8e4f1e0a2911e",
"sha256": "6d99e0f668b5a077c364ba7b7fb6ddd7c787543ef5ccc80cb78729dd502838e8"
},
"downloads": -1,
"filename": "twin_sister-4.2.1.0.tar.gz",
"has_sig": false,
"md5_digest": "e893165f05d86e3a40a8e4f1e0a2911e",
"packagetype": "sdist",
"python_version": "source",
"requires_python": null,
"size": 32961,
"upload_time": "2019-05-27T20:45:13",
"url": "https://files.pythonhosted.org/packages/56/34/122f294a243d0b337dcba1c8c21cb875674aa1fb168083f1500922564d89/twin_sister-4.2.1.0.tar.gz"
}
],
"4.2.3.0": [
{
"comment_text": "",
"digests": {
"md5": "17060d3c9930ccccdd46822d84f13cdd",
"sha256": "cc6926d51a432322f57ca196eca09c7e0408be2dee5cddde6cb11ec40c232e97"
},
"downloads": -1,
"filename": "twin_sister-4.2.3.0-py3-none-any.whl",
"has_sig": false,
"md5_digest": "17060d3c9930ccccdd46822d84f13cdd",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": null,
"size": 40957,
"upload_time": "2019-05-27T21:02:25",
"url": "https://files.pythonhosted.org/packages/9d/36/4f4352415a92e31aaf31edb5b528771fb194f3abd17770ebd8e2cfb383c9/twin_sister-4.2.3.0-py3-none-any.whl"
},
{
"comment_text": "",
"digests": {
"md5": "541800c7687feec23623cc7c10a7d9ea",
"sha256": "4929ba53cdaff0ae925d9ed9c88e78a333a682d60fbc0e297a51acc905cd62d0"
},
"downloads": -1,
"filename": "twin_sister-4.2.3.0.tar.gz",
"has_sig": false,
"md5_digest": "541800c7687feec23623cc7c10a7d9ea",
"packagetype": "sdist",
"python_version": "source",
"requires_python": null,
"size": 32968,
"upload_time": "2019-05-27T21:02:27",
"url": "https://files.pythonhosted.org/packages/46/0c/2c048074c46f927e0221ab6f06a4a7108ce768e71a0b885ba2eacbecb32d/twin_sister-4.2.3.0.tar.gz"
}
],
"4.2.6.0": [
{
"comment_text": "",
"digests": {
"md5": "051bc452ae84285aaf3f443b27b301fb",
"sha256": "492f009eeed4fe108e40e3aea5a03ef69f3c15b8ef0c25c634514ced7e99cad4"
},
"downloads": -1,
"filename": "twin_sister-4.2.6.0-py3-none-any.whl",
"has_sig": false,
"md5_digest": "051bc452ae84285aaf3f443b27b301fb",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": null,
"size": 41280,
"upload_time": "2019-05-28T18:11:53",
"url": "https://files.pythonhosted.org/packages/ab/7b/dabc88ca1e817c7f5bbc5d0f6f246ecaeec24d95fbacabbc1dd387fe6482/twin_sister-4.2.6.0-py3-none-any.whl"
},
{
"comment_text": "",
"digests": {
"md5": "e02bf0702e4e81643e8084f9e6e86d36",
"sha256": "cca92257cf0720f45a54df44cc06e7cad7201dc6b09b2bcf876bc64e77dba9b2"
},
"downloads": -1,
"filename": "twin_sister-4.2.6.0.tar.gz",
"has_sig": false,
"md5_digest": "e02bf0702e4e81643e8084f9e6e86d36",
"packagetype": "sdist",
"python_version": "source",
"requires_python": null,
"size": 33283,
"upload_time": "2019-05-28T18:11:55",
"url": "https://files.pythonhosted.org/packages/4a/0c/3fbb213f6e0708921b565f7a9debebbba4ce07dc53556314edbcb7dd63a5/twin_sister-4.2.6.0.tar.gz"
}
],
"4.2.8.0": [
{
"comment_text": "",
"digests": {
"md5": "e9e92d599795343bbefcfd70d6e8e2c0",
"sha256": "b8d0ae92246f3be36ae4dc86d681dc050f5c302715d37f6133296a62f44c3fc3"
},
"downloads": -1,
"filename": "twin_sister-4.2.8.0-py3-none-any.whl",
"has_sig": false,
"md5_digest": "e9e92d599795343bbefcfd70d6e8e2c0",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": null,
"size": 41407,
"upload_time": "2019-05-30T17:24:31",
"url": "https://files.pythonhosted.org/packages/93/e2/73e1838d075871df3629a4c42e333367c4cd6b4f845fed92f23860ea326e/twin_sister-4.2.8.0-py3-none-any.whl"
},
{
"comment_text": "",
"digests": {
"md5": "ed4541fa47cd1eef78dee9d3eaecb72e",
"sha256": "6fac5b3f3f98abdfdf78e26d5cacc1147ea73a6ff0a1f26e86b72ae060b9e24d"
},
"downloads": -1,
"filename": "twin_sister-4.2.8.0.tar.gz",
"has_sig": false,
"md5_digest": "ed4541fa47cd1eef78dee9d3eaecb72e",
"packagetype": "sdist",
"python_version": "source",
"requires_python": null,
"size": 33550,
"upload_time": "2019-05-30T17:24:33",
"url": "https://files.pythonhosted.org/packages/c1/5a/19120830475697212a1eeae4c39d1af61b8bb610409046bc897d2d74f177/twin_sister-4.2.8.0.tar.gz"
}
],
"4.3.0.0": [
{
"comment_text": "",
"digests": {
"md5": "4ee5461bc9521682f4e11639c16856ed",
"sha256": "e91bff6f92f2d7b4d52268ca1d6791e3b81afc5a0ea3ea59e1abbe4d44a9b436"
},
"downloads": -1,
"filename": "twin_sister-4.3.0.0-py3-none-any.whl",
"has_sig": false,
"md5_digest": "4ee5461bc9521682f4e11639c16856ed",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": null,
"size": 44574,
"upload_time": "2019-06-07T22:18:33",
"url": "https://files.pythonhosted.org/packages/a8/76/ed5dfc1e5a7b28dd83aedcc3ce56d8773a1dbe4f397b8c3bd30124b51b97/twin_sister-4.3.0.0-py3-none-any.whl"
},
{
"comment_text": "",
"digests": {
"md5": "4ee70e0c6bd0e5b297875105b027b3d0",
"sha256": "cb3839d3db17f41bd16aae32ee5fd5b4e01705c4dab4016a46a2ad09f816e451"
},
"downloads": -1,
"filename": "twin_sister-4.3.0.0.tar.gz",
"has_sig": false,
"md5_digest": "4ee70e0c6bd0e5b297875105b027b3d0",
"packagetype": "sdist",
"python_version": "source",
"requires_python": null,
"size": 34445,
"upload_time": "2019-06-07T22:18:35",
"url": "https://files.pythonhosted.org/packages/56/b5/872535617d5a4a0877464e7ad7e6442b9f42c60f33b78788f4a615a114c2/twin_sister-4.3.0.0.tar.gz"
}
],
"4.3.4.0": [
{
"comment_text": "",
"digests": {
"md5": "55376c511e36e314fcdd1b09d1605761",
"sha256": "dbd9230aba26fa8116ea630bbbe9cd9e85e643e0e1e85b4994f7e463dfd6dbc5"
},
"downloads": -1,
"filename": "twin_sister-4.3.4.0-py3-none-any.whl",
"has_sig": false,
"md5_digest": "55376c511e36e314fcdd1b09d1605761",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": null,
"size": 44573,
"upload_time": "2019-06-23T18:28:00",
"url": "https://files.pythonhosted.org/packages/11/3f/8ca8e12b27f3cf3163083821c3c147526a7e757f2393f2d2b50da337e7e9/twin_sister-4.3.4.0-py3-none-any.whl"
},
{
"comment_text": "",
"digests": {
"md5": "0d506101d92db4a075f5b366f1cbeadb",
"sha256": "bac6a0af17ff596c1377dd002aca724615a8aea1d0be3b5eba94e538d50c05c2"
},
"downloads": -1,
"filename": "twin_sister-4.3.4.0.tar.gz",
"has_sig": false,
"md5_digest": "0d506101d92db4a075f5b366f1cbeadb",
"packagetype": "sdist",
"python_version": "source",
"requires_python": null,
"size": 34471,
"upload_time": "2019-06-23T18:28:02",
"url": "https://files.pythonhosted.org/packages/0d/2b/23635148deea9c48967030e725a11a7cb24669e3ff9469784e7588018726/twin_sister-4.3.4.0.tar.gz"
}
],
"4.3.8.0": [
{
"comment_text": "",
"digests": {
"md5": "e30cd71a6468f3366b51791e6a7a0b1f",
"sha256": "109ee33d8a4796bbd938c6361bcd43d234b8960b5a166ace2bb19141764ce007"
},
"downloads": -1,
"filename": "twin_sister-4.3.8.0.tar.gz",
"has_sig": false,
"md5_digest": "e30cd71a6468f3366b51791e6a7a0b1f",
"packagetype": "sdist",
"python_version": "source",
"requires_python": null,
"size": 34368,
"upload_time": "2019-06-23T18:41:54",
"url": "https://files.pythonhosted.org/packages/ed/a3/28a284eb840735950bff3362b2c8c1fda685f79d8d3811eec4cd3f98b576/twin_sister-4.3.8.0.tar.gz"
}
],
"4.4.0.0": [
{
"comment_text": "",
"digests": {
"md5": "339db4ab890831c368ab2c40ebc8c81f",
"sha256": "e8992c1295e6b5ccf7ac8af0110ad3ccb515668ae1bb4b37bcfe51accd0c9df5"
},
"downloads": -1,
"filename": "twin_sister-4.4.0.0.tar.gz",
"has_sig": false,
"md5_digest": "339db4ab890831c368ab2c40ebc8c81f",
"packagetype": "sdist",
"python_version": "source",
"requires_python": null,
"size": 35664,
"upload_time": "2019-08-07T21:44:46",
"url": "https://files.pythonhosted.org/packages/cf/16/271677df01e7d62f755e1ca90917fd32bdfaa82eb88f1ffcbefd4e36db27/twin_sister-4.4.0.0.tar.gz"
}
],
"4.5.0.0": [
{
"comment_text": "",
"digests": {
"md5": "93e9523fe59edfca075cb8edaecdac2e",
"sha256": "37617a92b1a2f9e602f0c8483be74c1ee8bf22ed805d0f4a60f8cd355d1b13d1"
},
"downloads": -1,
"filename": "twin_sister-4.5.0.0.tar.gz",
"has_sig": false,
"md5_digest": "93e9523fe59edfca075cb8edaecdac2e",
"packagetype": "sdist",
"python_version": "source",
"requires_python": null,
"size": 35833,
"upload_time": "2019-09-18T16:38:08",
"url": "https://files.pythonhosted.org/packages/b2/04/52e3a99e11db8c46bf5c1e899e92d98b67a4df76011b327ee4e0ce444391/twin_sister-4.5.0.0.tar.gz"
}
],
"4.5.1.0": [
{
"comment_text": "",
"digests": {
"md5": "471d8d857121830014831d9321e6345e",
"sha256": "4d72e9b3081b39e7a1a48812d73ec87c4a5c494a1cb1902163fbd0c4312a0389"
},
"downloads": -1,
"filename": "twin_sister-4.5.1.0.tar.gz",
"has_sig": false,
"md5_digest": "471d8d857121830014831d9321e6345e",
"packagetype": "sdist",
"python_version": "source",
"requires_python": null,
"size": 35834,
"upload_time": "2019-09-18T18:08:58",
"url": "https://files.pythonhosted.org/packages/51/4a/6ec5a886ef3ca4f5f9010f882ddb2231985bb784129c94da0192faf09553/twin_sister-4.5.1.0.tar.gz"
}
]
},
"urls": [
{
"comment_text": "",
"digests": {
"md5": "471d8d857121830014831d9321e6345e",
"sha256": "4d72e9b3081b39e7a1a48812d73ec87c4a5c494a1cb1902163fbd0c4312a0389"
},
"downloads": -1,
"filename": "twin_sister-4.5.1.0.tar.gz",
"has_sig": false,
"md5_digest": "471d8d857121830014831d9321e6345e",
"packagetype": "sdist",
"python_version": "source",
"requires_python": null,
"size": 35834,
"upload_time": "2019-09-18T18:08:58",
"url": "https://files.pythonhosted.org/packages/51/4a/6ec5a886ef3ca4f5f9010f882ddb2231985bb784129c94da0192faf09553/twin_sister-4.5.1.0.tar.gz"
}
]
}