Repository: viralogic/py-enumerable (language: python)

Issue:
Retain Enumerable ordering
Current implementation of the `Enumerable` results in inconsistent results when calling methods such as `first()`. For example:

```Python
e = Enumerable((x for x in range(0, 5)))
e.first() # 0
e.first() # 1
```
As you can see, both the calls to `first()` should give 0.

This is because of how the `repeatable` iterator is implemented for the `Enumerable` does not keep track of the start of the iterator when it is exhausted/used. Thus, subsequent calls of any methods that require iterating over the `Enumerable` could start from a non-zero start position and possibly give different results for subsequent calls.
Retain Enumerable ordering
Current implementation of the `Enumerable` results in inconsistent results when calling methods such as `first()`. For example:

```Python
e = Enumerable((x for x in range(0, 5)))
e.first() # 0
e.first() # 1
```
As you can see, both the calls to `first()` should give 0.

This is because of how the `repeatable` iterator is implemented for the `Enumerable` does not keep track of the start of the iterator when it is exhausted/used. Thus, subsequent calls of any methods that require iterating over the `Enumerable` could start from a non-zero start position and possibly give different results for subsequent calls.


Discussion / hints:

Fix patch:
diff --git a/.flake8 b/.flake8
index 434d682..ee6baf3 100644
--- a/.flake8
+++ b/.flake8
@@ -1,4 +1,11 @@
 [flake8]
-max-line-length = 127
+max-line-length = 88
 max-complexity = 10
-select = E9,F63,F7,F82
\ No newline at end of file
+ignore = W503,E712,E231,E203,E501,E741,F401,F811,F841
+exclude =
+    .git,
+    __pycache__,
+    .venv
+count = True
+show-source = True
+statistics = True
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index df980a2..f80a4d9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,7 +17,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [3.6, 3.7, 3.8, 3.9]
+        python-version: ["3.7", "3.8", "3.9", "3.10"]
     steps:
     - uses: actions/checkout@v2
     - name: Set up Python ${{ matrix.python-version }}
@@ -32,6 +32,8 @@ jobs:
       env:
         POETRY_VIRTUALENVS_CREATE: false
     - name: Lint with flake8
-      run: poetry run flake8 . --ignore=W503,E712,E231,E203 --count --max-complexity=10 --max-line-length=88 --show-source --statistics  --exclude=.git,__pycache__,.venv
+      run: poetry run flake8 .
     - name: Test with pytest
-      run: poetry run pytest tests
+      run: poetry run pytest tests --cov=py_linq --cov-branch --cov-report term
+    - name: Upload coverage reports to Codecov
+      uses: codecov/codecov-action@v3
diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml
index 025f48e..6e3e2d1 100644
--- a/.github/workflows/prod-cd.yml
+++ b/.github/workflows/prod-cd.yml
@@ -9,7 +9,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [3.6, 3.7, 3.8, 3.9]
+        python-version: ["3.7", "3.8", "3.9", "3.10"]
     steps:
     - uses: actions/checkout@v2
     - name: Set up Python ${{ matrix.python-version }}
@@ -24,15 +24,17 @@ jobs:
       env:
         POETRY_VIRTUALENVS_CREATE: false
     - name: Lint with flake8
-      run: poetry run flake8 . --ignore=W503,E712,E231,E203 --count --max-complexity=10 --max-line-length=88 --show-source --statistics  --exclude=.git,__pycache__,.venv
+      run: poetry run flake8 .
     - name: Test with pytest
-      run: poetry run pytest tests
+      run: poetry run pytest tests --cov=py_linq --cov-branch --cov-report term
+    - name: Upload coverage reports to Codecov
+      uses: codecov/codecov-action@v3
   deploy:
     needs: test
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [3.6]
+        python-version: [3.7]
     steps:
     - uses: actions/checkout@v2
     - name: Set up Python ${{ matrix.python-version }}
diff --git a/.gitignore b/.gitignore
index a6f5ce2..19782e2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -57,3 +57,8 @@ target/
 .pytest_cache/
 .python-version
 coverage/lcov.info
+cov.xml
+lcov.info
+.benchmarks/
+.vscode/
+
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index e0c9b68..23779ae 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,10 +1,10 @@
 repos:
     -   repo: https://github.com/ambv/black
-        rev: 20.8b1
+        rev: 23.1.0
         hooks:
         - id: black
           language_version: python3.7
-    -   repo: https://gitlab.com/pycqa/flake8
-        rev: 3.8.4
+    -   repo: https://github.com/pycqa/flake8
+        rev: 6.0.0
         hooks:
         - id: flake8
\ No newline at end of file
diff --git a/README.md b/README.md
index ab7df1f..c44a621 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-![Python package](https://github.com/viralogic/py-enumerable/workflows/Python%20package/badge.svg)
+[![codecov](https://codecov.io/gh/viralogic/py-enumerable/branch/master/graph/badge.svg?token=u72ojmMvEn)](https://codecov.io/gh/viralogic/py-enumerable)
 
 # py-linq #
 
diff --git a/docs/except.md b/docs/except.md
index d14717d..e576d49 100644
--- a/docs/except.md
+++ b/docs/except.md
@@ -1,6 +1,6 @@
 ## except
 
-`except_(enumerable, key=lambda x: x)`
+`except_(enumerable, key)`
 
 Returns an `Enumerable` that contains elements not found in the given `enumerable` argument. This is also known as a set difference. This is not an executing function.
 
@@ -23,6 +23,6 @@ e1 = Enumerable([
     {'value': 2},
     {'value': 3}
 ])
-diff = e1.except_(Enumerable([{'value': 1}])
+diff = e1.except_(Enumerable([{'value': 1}], key=lambda x: x['value'])
 # [{'value': 2}, {'value': 3}]
 </code></pre>
\ No newline at end of file
diff --git a/docs/intersect.md b/docs/intersect.md
index fcf0fa1..98eaf3d 100644
--- a/docs/intersect.md
+++ b/docs/intersect.md
@@ -1,6 +1,6 @@
 ## intersect
 
-`intersect(enumerable, key=lambda x: x)`
+`intersect(enumerable, key)`
 
 Returns an `Enumerable` that is the result of an intersection between two `Enumerable` instance based on the value of the key given. This is not an executing function.
 
diff --git a/docs/union.md b/docs/union.md
index 7f0630c..c20f0ca 100644
--- a/docs/union.md
+++ b/docs/union.md
@@ -1,6 +1,6 @@
 ## union
 
-`union(enumerable, key=lambda x: x)`
+`union(enumerable, key)`
 
 Returns `Enumerable` that is a union between two `Enumerable` collections. Note that the key selector `lambda` function needs to map to comparable values in both `Enumerable` collections. This is not an executing function.
 
diff --git a/poetry.lock b/poetry.lock
index da807e7..8e5e54e 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,45 +1,45 @@
 [[package]]
-category = "dev"
-description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
 name = "appdirs"
+version = "1.4.4"
+description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
+category = "dev"
 optional = false
 python-versions = "*"
-version = "1.4.4"
 
 [[package]]
-category = "dev"
-description = "Atomic file writes."
-marker = "sys_platform == \"win32\""
 name = "atomicwrites"
+version = "1.4.0"
+description = "Atomic file writes."
+category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-version = "1.4.0"
 
 [[package]]
-category = "dev"
-description = "Classes Without Boilerplate"
 name = "attrs"
+version = "20.3.0"
+description = "Classes Without Boilerplate"
+category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-version = "20.3.0"
 
 [package.extras]
-dev = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"]
+dev = ["coverage[toml] (>=5.0.2)", "furo", "hypothesis", "pre-commit", "pympler", "pytest (>=4.3.0)", "six", "sphinx", "zope.interface"]
 docs = ["furo", "sphinx", "zope.interface"]
-tests = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
-tests_no_zope = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"]
+tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
+tests-no-zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"]
 
 [[package]]
-category = "dev"
-description = "The uncompromising code formatter."
 name = "black"
+version = "20.8b1"
+description = "The uncompromising code formatter."
+category = "dev"
 optional = false
 python-versions = ">=3.6"
-version = "20.8b1"
 
 [package.dependencies]
 appdirs = "*"
 click = ">=7.1.2"
+dataclasses = {version = ">=0.6", markers = "python_version < \"3.7\""}
 mypy-extensions = ">=0.4.3"
 pathspec = ">=0.6,<1"
 regex = ">=2020.1.8"
@@ -47,116 +47,109 @@ toml = ">=0.10.1"
 typed-ast = ">=1.4.0"
 typing-extensions = ">=3.7.4"
 
-[package.dependencies.dataclasses]
-python = "<3.7"
-version = ">=0.6"
-
 [package.extras]
 colorama = ["colorama (>=0.4.3)"]
 d = ["aiohttp (>=3.3.2)", "aiohttp-cors"]
 
 [[package]]
-category = "dev"
-description = "Validate configuration and produce human readable error messages."
 name = "cfgv"
+version = "3.2.0"
+description = "Validate configuration and produce human readable error messages."
+category = "dev"
 optional = false
 python-versions = ">=3.6.1"
-version = "3.2.0"
 
 [[package]]
-category = "dev"
-description = "Composable command line interface toolkit"
 name = "click"
+version = "7.1.2"
+description = "Composable command line interface toolkit"
+category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
-version = "7.1.2"
 
 [[package]]
-category = "dev"
-description = "Cross-platform colored terminal text."
-marker = "sys_platform == \"win32\""
 name = "colorama"
+version = "0.4.4"
+description = "Cross-platform colored terminal text."
+category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
-version = "0.4.4"
 
 [[package]]
-category = "dev"
-description = "Code coverage measurement for Python"
 name = "coverage"
+version = "6.2"
+description = "Code coverage measurement for Python"
+category = "dev"
 optional = false
-python-versions = "*"
-version = "4.4.2"
+python-versions = ">=3.6"
+
+[package.extras]
+toml = ["tomli"]
 
 [[package]]
-category = "dev"
-description = "A backport of the dataclasses module for Python 3.6"
-marker = "python_version < \"3.7\""
 name = "dataclasses"
+version = "0.6"
+description = "A backport of the dataclasses module for Python 3.6"
+category = "dev"
 optional = false
 python-versions = "*"
-version = "0.6"
 
 [[package]]
-category = "dev"
-description = "Distribution utilities"
 name = "distlib"
+version = "0.3.1"
+description = "Distribution utilities"
+category = "dev"
 optional = false
 python-versions = "*"
-version = "0.3.1"
 
 [[package]]
-category = "dev"
-description = "A platform independent file lock."
 name = "filelock"
+version = "3.0.12"
+description = "A platform independent file lock."
+category = "dev"
 optional = false
 python-versions = "*"
-version = "3.0.12"
 
 [[package]]
-category = "dev"
-description = "the modular source code checker: pep8 pyflakes and co"
 name = "flake8"
+version = "3.9.1"
+description = "the modular source code checker: pep8 pyflakes and co"
+category = "dev"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
-version = "3.9.1"
 
 [package.dependencies]
+importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
 mccabe = ">=0.6.0,<0.7.0"
 pycodestyle = ">=2.7.0,<2.8.0"
 pyflakes = ">=2.3.0,<2.4.0"
 
-[package.dependencies.importlib-metadata]
-python = "<3.8"
-version = "*"
-
 [[package]]
-category = "main"
-description = "Clean single-source support for Python 3 and 2"
 name = "future"
+version = "0.18.2"
+description = "Clean single-source support for Python 3 and 2"
+category = "main"
 optional = false
 python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
-version = "0.18.2"
 
 [[package]]
-category = "dev"
-description = "File identification library for Python"
 name = "identify"
+version = "2.2.4"
+description = "File identification library for Python"
+category = "dev"
 optional = false
 python-versions = ">=3.6.1"
-version = "2.2.4"
 
 [package.extras]
 license = ["editdistance-s"]
 
 [[package]]
-category = "dev"
-description = "Read metadata from Python packages"
-marker = "python_version < \"3.8\""
 name = "importlib-metadata"
+version = "4.0.1"
+description = "Read metadata from Python packages"
+category = "dev"
 optional = false
 python-versions = ">=3.6"
-version = "4.0.1"
 
 [package.dependencies]
 zipp = ">=0.5"
@@ -166,281 +159,261 @@ python = "<3.8"
 version = ">=3.6.4"
 
 [package.extras]
-docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
-testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"]
+docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"]
+testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pep517", "pyfakefs", "pytest (>=4.6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy"]
 
 [[package]]
-category = "dev"
-description = "Read resources from Python packages"
-marker = "python_version < \"3.7\""
 name = "importlib-resources"
+version = "5.1.2"
+description = "Read resources from Python packages"
+category = "dev"
 optional = false
 python-versions = ">=3.6"
-version = "5.1.2"
 
 [package.dependencies]
-[package.dependencies.zipp]
-python = "<3.8"
-version = ">=0.4"
+zipp = {version = ">=0.4", markers = "python_version < \"3.8\""}
 
 [package.extras]
-docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
-testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "pytest-black (>=0.3.7)", "pytest-mypy"]
+docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"]
+testing = ["pytest (>=4.6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=1.2.3)", "pytest-cov", "pytest-enabler", "pytest-flake8", "pytest-mypy"]
 
 [[package]]
-category = "dev"
-description = "iniconfig: brain-dead simple config-ini parsing"
 name = "iniconfig"
+version = "1.1.1"
+description = "iniconfig: brain-dead simple config-ini parsing"
+category = "dev"
 optional = false
 python-versions = "*"
-version = "1.1.1"
 
 [[package]]
-category = "dev"
-description = "McCabe checker, plugin for flake8"
 name = "mccabe"
+version = "0.6.1"
+description = "McCabe checker, plugin for flake8"
+category = "dev"
 optional = false
 python-versions = "*"
-version = "0.6.1"
 
 [[package]]
-category = "dev"
-description = "Experimental type system extensions for programs checked with the mypy typechecker."
 name = "mypy-extensions"
+version = "0.4.3"
+description = "Experimental type system extensions for programs checked with the mypy typechecker."
+category = "dev"
 optional = false
 python-versions = "*"
-version = "0.4.3"
 
 [[package]]
-category = "dev"
-description = "Node.js virtual environment builder"
 name = "nodeenv"
+version = "1.6.0"
+description = "Node.js virtual environment builder"
+category = "dev"
 optional = false
 python-versions = "*"
-version = "1.6.0"
 
 [[package]]
-category = "dev"
-description = "Core utilities for Python packages"
 name = "packaging"
+version = "20.9"
+description = "Core utilities for Python packages"
+category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-version = "20.9"
 
 [package.dependencies]
 pyparsing = ">=2.0.2"
 
 [[package]]
-category = "dev"
-description = "Utility library for gitignore style pattern matching of file paths."
 name = "pathspec"
+version = "0.8.1"
+description = "Utility library for gitignore style pattern matching of file paths."
+category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
-version = "0.8.1"
 
 [[package]]
-category = "dev"
-description = "plugin and hook calling mechanisms for python"
 name = "pluggy"
+version = "0.13.1"
+description = "plugin and hook calling mechanisms for python"
+category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-version = "0.13.1"
 
 [package.dependencies]
-[package.dependencies.importlib-metadata]
-python = "<3.8"
-version = ">=0.12"
+importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
 
 [package.extras]
 dev = ["pre-commit", "tox"]
 
 [[package]]
-category = "dev"
-description = "A framework for managing and maintaining multi-language pre-commit hooks."
 name = "pre-commit"
+version = "2.12.1"
+description = "A framework for managing and maintaining multi-language pre-commit hooks."
+category = "dev"
 optional = false
 python-versions = ">=3.6.1"
-version = "2.12.1"
 
 [package.dependencies]
 cfgv = ">=2.0.0"
 identify = ">=1.0.0"
+importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
+importlib-resources = {version = "*", markers = "python_version < \"3.7\""}
 nodeenv = ">=0.11.1"
 pyyaml = ">=5.1"
 toml = "*"
 virtualenv = ">=20.0.8"
 
-[package.dependencies.importlib-metadata]
-python = "<3.8"
-version = "*"
-
-[package.dependencies.importlib-resources]
-python = "<3.7"
-version = "*"
-
 [[package]]
-category = "dev"
-description = "library with cross-python path, ini-parsing, io, code, log facilities"
 name = "py"
+version = "1.10.0"
+description = "library with cross-python path, ini-parsing, io, code, log facilities"
+category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-version = "1.10.0"
 
 [[package]]
-category = "dev"
-description = "Python style guide checker"
 name = "pycodestyle"
+version = "2.7.0"
+description = "Python style guide checker"
+category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-version = "2.7.0"
 
 [[package]]
-category = "dev"
-description = "passive checker of Python programs"
 name = "pyflakes"
+version = "2.3.1"
+description = "passive checker of Python programs"
+category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-version = "2.3.1"
 
 [[package]]
-category = "dev"
-description = "Python parsing module"
 name = "pyparsing"
+version = "2.4.7"
+description = "Python parsing module"
+category = "dev"
 optional = false
 python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
-version = "2.4.7"
 
 [[package]]
-category = "dev"
-description = "pytest: simple powerful testing with Python"
 name = "pytest"
+version = "6.2.5"
+description = "pytest: simple powerful testing with Python"
+category = "dev"
 optional = false
 python-versions = ">=3.6"
-version = "6.2.3"
 
 [package.dependencies]
-atomicwrites = ">=1.0"
+atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
 attrs = ">=19.2.0"
-colorama = "*"
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
 iniconfig = "*"
 packaging = "*"
-pluggy = ">=0.12,<1.0.0a1"
+pluggy = ">=0.12,<2.0"
 py = ">=1.8.2"
 toml = "*"
 
-[package.dependencies.importlib-metadata]
-python = "<3.8"
-version = ">=0.12"
-
 [package.extras]
 testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
 
 [[package]]
-category = "dev"
-description = "Pytest plugin for measuring coverage."
 name = "pytest-cov"
+version = "2.12.1"
+description = "Pytest plugin for measuring coverage."
+category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
-version = "2.10.1"
 
 [package.dependencies]
-coverage = ">=4.4"
+coverage = ">=5.2.1"
 pytest = ">=4.6"
+toml = "*"
 
 [package.extras]
-testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "pytest-xdist", "virtualenv"]
+testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"]
 
 [[package]]
-category = "dev"
-description = "YAML parser and emitter for Python"
 name = "pyyaml"
+version = "5.4.1"
+description = "YAML parser and emitter for Python"
+category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
-version = "5.4.1"
 
 [[package]]
-category = "dev"
-description = "Alternative regular expression module, to replace re."
 name = "regex"
+version = "2021.4.4"
+description = "Alternative regular expression module, to replace re."
+category = "dev"
 optional = false
 python-versions = "*"
-version = "2021.4.4"
 
 [[package]]
-category = "dev"
-description = "Python 2 and 3 compatibility utilities"
 name = "six"
+version = "1.16.0"
+description = "Python 2 and 3 compatibility utilities"
+category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
-version = "1.15.0"
 
 [[package]]
-category = "dev"
-description = "Python Library for Tom's Obvious, Minimal Language"
 name = "toml"
+version = "0.10.2"
+description = "Python Library for Tom's Obvious, Minimal Language"
+category = "dev"
 optional = false
 python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
-version = "0.10.2"
 
 [[package]]
-category = "dev"
-description = "a fork of Python 2 and 3 ast modules with type comment support"
 name = "typed-ast"
+version = "1.5.4"
+description = "a fork of Python 2 and 3 ast modules with type comment support"
+category = "dev"
 optional = false
-python-versions = "*"
-version = "1.4.3"
+python-versions = ">=3.6"
 
 [[package]]
-category = "dev"
-description = "Backported and Experimental Type Hints for Python 3.5+"
 name = "typing-extensions"
+version = "3.7.4.3"
+description = "Backported and Experimental Type Hints for Python 3.5+"
+category = "dev"
 optional = false
 python-versions = "*"
-version = "3.7.4.3"
 
 [[package]]
-category = "dev"
-description = "Virtual Python Environment builder"
 name = "virtualenv"
+version = "20.4.4"
+description = "Virtual Python Environment builder"
+category = "dev"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
-version = "20.4.4"
 
 [package.dependencies]
 appdirs = ">=1.4.3,<2"
 distlib = ">=0.3.1,<1"
 filelock = ">=3.0.0,<4"
+importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
+importlib-resources = {version = ">=1.0", markers = "python_version < \"3.7\""}
 six = ">=1.9.0,<2"
 
-[package.dependencies.importlib-metadata]
-python = "<3.8"
-version = ">=0.12"
-
-[package.dependencies.importlib-resources]
-python = "<3.7"
-version = ">=1.0"
-
 [package.extras]
 docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"]
-testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)", "xonsh (>=0.9.16)"]
+testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "packaging (>=20.0)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "xonsh (>=0.9.16)"]
 
 [[package]]
-category = "dev"
-description = "Backport of pathlib-compatible object wrapper for zip files"
-marker = "python_version < \"3.8\""
 name = "zipp"
+version = "3.4.1"
+description = "Backport of pathlib-compatible object wrapper for zip files"
+category = "dev"
 optional = false
 python-versions = ">=3.6"
-version = "3.4.1"
 
 [package.extras]
-docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
-testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"]
+docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"]
+testing = ["func-timeout", "jaraco.itertools", "pytest (>=4.6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=1.2.3)", "pytest-cov", "pytest-enabler", "pytest-flake8", "pytest-mypy"]
 
 [metadata]
-content-hash = "f184eda8cf12fd70ecdbeceea571197b1bd29e2af662b6073679ca17170787c8"
-lock-version = "1.0"
+lock-version = "1.1"
 python-versions = ">=3.6.1"
+content-hash = "fcd69b811d12d78bd2326882f0dae399981a5f99d88740f0ca88e430098c9733"
 
 [metadata.files]
 appdirs = [
@@ -456,7 +429,6 @@ attrs = [
     {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"},
 ]
 black = [
-    {file = "black-20.8b1-py3-none-any.whl", hash = "sha256:70b62ef1527c950db59062cda342ea224d772abdf6adc58b86a45421bab20a6b"},
     {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"},
 ]
 cfgv = [
@@ -472,46 +444,53 @@ colorama = [
     {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
 ]
 coverage = [
-    {file = "coverage-4.4.2-cp26-cp26m-macosx_10_10_x86_64.whl", hash = "sha256:d1ee76f560c3c3e8faada866a07a32485445e16ed2206ac8378bd90dadffb9f0"},
-    {file = "coverage-4.4.2-cp26-cp26m-manylinux1_i686.whl", hash = "sha256:007eeef7e23f9473622f7d94a3e029a45d55a92a1f083f0f3512f5ab9a669b05"},
-    {file = "coverage-4.4.2-cp26-cp26m-manylinux1_x86_64.whl", hash = "sha256:17307429935f96c986a1b1674f78079528833410750321d22b5fb35d1883828e"},
-    {file = "coverage-4.4.2-cp26-cp26mu-manylinux1_i686.whl", hash = "sha256:845fddf89dca1e94abe168760a38271abfc2e31863fbb4ada7f9a99337d7c3dc"},
-    {file = "coverage-4.4.2-cp26-cp26mu-manylinux1_x86_64.whl", hash = "sha256:3f4d0b3403d3e110d2588c275540649b1841725f5a11a7162620224155d00ba2"},
-    {file = "coverage-4.4.2-cp27-cp27m-macosx_10_12_intel.whl", hash = "sha256:4c4f368ffe1c2e7602359c2c50233269f3abe1c48ca6b288dcd0fb1d1c679733"},
-    {file = "coverage-4.4.2-cp27-cp27m-macosx_10_12_x86_64.whl", hash = "sha256:f8c55dd0f56d3d618dfacf129e010cbe5d5f94b6951c1b2f13ab1a2f79c284da"},
-    {file = "coverage-4.4.2-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:cdd92dd9471e624cd1d8c1a2703d25f114b59b736b0f1f659a98414e535ffb3d"},
-    {file = "coverage-4.4.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:2ad357d12971e77360034c1596011a03f50c0f9e1ecd12e081342b8d1aee2236"},
-    {file = "coverage-4.4.2-cp27-cp27m-win32.whl", hash = "sha256:700d7579995044dc724847560b78ac786f0ca292867447afda7727a6fbaa082e"},
-    {file = "coverage-4.4.2-cp27-cp27m-win_amd64.whl", hash = "sha256:66f393e10dd866be267deb3feca39babba08ae13763e0fc7a1063cbe1f8e49f6"},
-    {file = "coverage-4.4.2-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:e9a0e1caed2a52f15c96507ab78a48f346c05681a49c5b003172f8073da6aa6b"},
-    {file = "coverage-4.4.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:eea9135432428d3ca7ee9be86af27cb8e56243f73764a9b6c3e0bda1394916be"},
-    {file = "coverage-4.4.2-cp33-cp33m-macosx_10_10_x86_64.whl", hash = "sha256:5ff16548492e8a12e65ff3d55857ccd818584ed587a6c2898a9ebbe09a880674"},
-    {file = "coverage-4.4.2-cp33-cp33m-manylinux1_i686.whl", hash = "sha256:d00e29b78ff610d300b2c37049a41234d48ea4f2d2581759ebcf67caaf731c31"},
-    {file = "coverage-4.4.2-cp33-cp33m-manylinux1_x86_64.whl", hash = "sha256:87d942863fe74b1c3be83a045996addf1639218c2cb89c5da18c06c0fe3917ea"},
-    {file = "coverage-4.4.2-cp34-cp34m-macosx_10_10_x86_64.whl", hash = "sha256:358d635b1fc22a425444d52f26287ae5aea9e96e254ff3c59c407426f44574f4"},
-    {file = "coverage-4.4.2-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:81912cfe276e0069dca99e1e4e6be7b06b5fc8342641c6b472cb2fed7de7ae18"},
-    {file = "coverage-4.4.2-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:079248312838c4c8f3494934ab7382a42d42d5f365f0cf7516f938dbb3f53f3f"},
-    {file = "coverage-4.4.2-cp34-cp34m-win32.whl", hash = "sha256:b0059630ca5c6b297690a6bf57bf2fdac1395c24b7935fd73ee64190276b743b"},
-    {file = "coverage-4.4.2-cp34-cp34m-win_amd64.whl", hash = "sha256:493082f104b5ca920e97a485913de254cbe351900deed72d4264571c73464cd0"},
-    {file = "coverage-4.4.2-cp35-cp35m-macosx_10_10_x86_64.whl", hash = "sha256:e3ba9b14607c23623cf38f90b23f5bed4a3be87cbfa96e2e9f4eabb975d1e98b"},
-    {file = "coverage-4.4.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:82cbd3317320aa63c65555aa4894bf33a13fb3a77f079059eb5935eea415938d"},
-    {file = "coverage-4.4.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9721f1b7275d3112dc7ccf63f0553c769f09b5c25a26ee45872c7f5c09edf6c1"},
-    {file = "coverage-4.4.2-cp35-cp35m-win32.whl", hash = "sha256:bd4800e32b4c8d99c3a2c943f1ac430cbf80658d884123d19639bcde90dad44a"},
-    {file = "coverage-4.4.2-cp35-cp35m-win_amd64.whl", hash = "sha256:f29841e865590af72c4b90d7b5b8e93fd560f5dea436c1d5ee8053788f9285de"},
-    {file = "coverage-4.4.2-cp36-cp36m-macosx_10_12_x86_64.whl", hash = "sha256:f3a5c6d054c531536a83521c00e5d4004f1e126e2e2556ce399bef4180fbe540"},
-    {file = "coverage-4.4.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:dd707a21332615108b736ef0b8513d3edaf12d2a7d5fc26cd04a169a8ae9b526"},
-    {file = "coverage-4.4.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2e1a5c6adebb93c3b175103c2f855eda957283c10cf937d791d81bef8872d6ca"},
-    {file = "coverage-4.4.2-cp36-cp36m-win32.whl", hash = "sha256:f87f522bde5540d8a4b11df80058281ac38c44b13ce29ced1e294963dd51a8f8"},
-    {file = "coverage-4.4.2-cp36-cp36m-win_amd64.whl", hash = "sha256:a7cfaebd8f24c2b537fa6a271229b051cdac9c1734bb6f939ccfc7c055689baa"},
-    {file = "coverage-4.4.2.tar.gz", hash = "sha256:309d91bd7a35063ec7a0e4d75645488bfab3f0b66373e7722f23da7f5b0f34cc"},
-    {file = "coverage-4.4.2.win-amd64-py2.7.exe", hash = "sha256:b6cebae1502ce5b87d7c6f532fa90ab345cfbda62b95aeea4e431e164d498a3d"},
-    {file = "coverage-4.4.2.win-amd64-py3.4.exe", hash = "sha256:a4497faa4f1c0fc365ba05eaecfb6b5d24e3c8c72e95938f9524e29dadb15e76"},
-    {file = "coverage-4.4.2.win-amd64-py3.5.exe", hash = "sha256:2b4d7f03a8a6632598cbc5df15bbca9f778c43db7cf1a838f4fa2c8599a8691a"},
-    {file = "coverage-4.4.2.win-amd64-py3.6.exe", hash = "sha256:1afccd7e27cac1b9617be8c769f6d8a6d363699c9b86820f40c74cfb3328921c"},
-    {file = "coverage-4.4.2.win32-py2.7.exe", hash = "sha256:0388c12539372bb92d6dde68b4627f0300d948965bbb7fc104924d715fdc0965"},
-    {file = "coverage-4.4.2.win32-py3.4.exe", hash = "sha256:ab3508df9a92c1d3362343d235420d08e2662969b83134f8a97dc1451cbe5e84"},
-    {file = "coverage-4.4.2.win32-py3.5.exe", hash = "sha256:43a155eb76025c61fc20c3d03b89ca28efa6f5be572ab6110b2fb68eda96bfea"},
-    {file = "coverage-4.4.2.win32-py3.6.exe", hash = "sha256:f98b461cb59f117887aa634a66022c0bd394278245ed51189f63a036516e32de"},
+    {file = "coverage-6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6dbc1536e105adda7a6312c778f15aaabe583b0e9a0b0a324990334fd458c94b"},
+    {file = "coverage-6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:174cf9b4bef0db2e8244f82059a5a72bd47e1d40e71c68ab055425172b16b7d0"},
+    {file = "coverage-6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:92b8c845527eae547a2a6617d336adc56394050c3ed8a6918683646328fbb6da"},
+    {file = "coverage-6.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c7912d1526299cb04c88288e148c6c87c0df600eca76efd99d84396cfe00ef1d"},
+    {file = "coverage-6.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d5d2033d5db1d58ae2d62f095e1aefb6988af65b4b12cb8987af409587cc0739"},
+    {file = "coverage-6.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3feac4084291642165c3a0d9eaebedf19ffa505016c4d3db15bfe235718d4971"},
+    {file = "coverage-6.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:276651978c94a8c5672ea60a2656e95a3cce2a3f31e9fb2d5ebd4c215d095840"},
+    {file = "coverage-6.2-cp310-cp310-win32.whl", hash = "sha256:f506af4f27def639ba45789fa6fde45f9a217da0be05f8910458e4557eed020c"},
+    {file = "coverage-6.2-cp310-cp310-win_amd64.whl", hash = "sha256:3f7c17209eef285c86f819ff04a6d4cbee9b33ef05cbcaae4c0b4e8e06b3ec8f"},
+    {file = "coverage-6.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:13362889b2d46e8d9f97c421539c97c963e34031ab0cb89e8ca83a10cc71ac76"},
+    {file = "coverage-6.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:22e60a3ca5acba37d1d4a2ee66e051f5b0e1b9ac950b5b0cf4aa5366eda41d47"},
+    {file = "coverage-6.2-cp311-cp311-win_amd64.whl", hash = "sha256:b637c57fdb8be84e91fac60d9325a66a5981f8086c954ea2772efe28425eaf64"},
+    {file = "coverage-6.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f467bbb837691ab5a8ca359199d3429a11a01e6dfb3d9dcc676dc035ca93c0a9"},
+    {file = "coverage-6.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2641f803ee9f95b1f387f3e8f3bf28d83d9b69a39e9911e5bfee832bea75240d"},
+    {file = "coverage-6.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1219d760ccfafc03c0822ae2e06e3b1248a8e6d1a70928966bafc6838d3c9e48"},
+    {file = "coverage-6.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9a2b5b52be0a8626fcbffd7e689781bf8c2ac01613e77feda93d96184949a98e"},
+    {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:8e2c35a4c1f269704e90888e56f794e2d9c0262fb0c1b1c8c4ee44d9b9e77b5d"},
+    {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:5d6b09c972ce9200264c35a1d53d43ca55ef61836d9ec60f0d44273a31aa9f17"},
+    {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e3db840a4dee542e37e09f30859f1612da90e1c5239a6a2498c473183a50e781"},
+    {file = "coverage-6.2-cp36-cp36m-win32.whl", hash = "sha256:4e547122ca2d244f7c090fe3f4b5a5861255ff66b7ab6d98f44a0222aaf8671a"},
+    {file = "coverage-6.2-cp36-cp36m-win_amd64.whl", hash = "sha256:01774a2c2c729619760320270e42cd9e797427ecfddd32c2a7b639cdc481f3c0"},
+    {file = "coverage-6.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb8b8ee99b3fffe4fd86f4c81b35a6bf7e4462cba019997af2fe679365db0c49"},
+    {file = "coverage-6.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:619346d57c7126ae49ac95b11b0dc8e36c1dd49d148477461bb66c8cf13bb521"},
+    {file = "coverage-6.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0a7726f74ff63f41e95ed3a89fef002916c828bb5fcae83b505b49d81a066884"},
+    {file = "coverage-6.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cfd9386c1d6f13b37e05a91a8583e802f8059bebfccde61a418c5808dea6bbfa"},
+    {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:17e6c11038d4ed6e8af1407d9e89a2904d573be29d51515f14262d7f10ef0a64"},
+    {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c254b03032d5a06de049ce8bca8338a5185f07fb76600afff3c161e053d88617"},
+    {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dca38a21e4423f3edb821292e97cec7ad38086f84313462098568baedf4331f8"},
+    {file = "coverage-6.2-cp37-cp37m-win32.whl", hash = "sha256:600617008aa82032ddeace2535626d1bc212dfff32b43989539deda63b3f36e4"},
+    {file = "coverage-6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:bf154ba7ee2fd613eb541c2bc03d3d9ac667080a737449d1a3fb342740eb1a74"},
+    {file = "coverage-6.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f9afb5b746781fc2abce26193d1c817b7eb0e11459510fba65d2bd77fe161d9e"},
+    {file = "coverage-6.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edcada2e24ed68f019175c2b2af2a8b481d3d084798b8c20d15d34f5c733fa58"},
+    {file = "coverage-6.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a9c8c4283e17690ff1a7427123ffb428ad6a52ed720d550e299e8291e33184dc"},
+    {file = "coverage-6.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f614fc9956d76d8a88a88bb41ddc12709caa755666f580af3a688899721efecd"},
+    {file = "coverage-6.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9365ed5cce5d0cf2c10afc6add145c5037d3148585b8ae0e77cc1efdd6aa2953"},
+    {file = "coverage-6.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8bdfe9ff3a4ea37d17f172ac0dff1e1c383aec17a636b9b35906babc9f0f5475"},
+    {file = "coverage-6.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:63c424e6f5b4ab1cf1e23a43b12f542b0ec2e54f99ec9f11b75382152981df57"},
+    {file = "coverage-6.2-cp38-cp38-win32.whl", hash = "sha256:49dbff64961bc9bdd2289a2bda6a3a5a331964ba5497f694e2cbd540d656dc1c"},
+    {file = "coverage-6.2-cp38-cp38-win_amd64.whl", hash = "sha256:9a29311bd6429be317c1f3fe4bc06c4c5ee45e2fa61b2a19d4d1d6111cb94af2"},
+    {file = "coverage-6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:03b20e52b7d31be571c9c06b74746746d4eb82fc260e594dc662ed48145e9efd"},
+    {file = "coverage-6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:215f8afcc02a24c2d9a10d3790b21054b58d71f4b3c6f055d4bb1b15cecce685"},
+    {file = "coverage-6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a4bdeb0a52d1d04123b41d90a4390b096f3ef38eee35e11f0b22c2d031222c6c"},
+    {file = "coverage-6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c332d8f8d448ded473b97fefe4a0983265af21917d8b0cdcb8bb06b2afe632c3"},
+    {file = "coverage-6.2-cp39-cp39-win32.whl", hash = "sha256:6e1394d24d5938e561fbeaa0cd3d356207579c28bd1792f25a068743f2d5b282"},
+    {file = "coverage-6.2-cp39-cp39-win_amd64.whl", hash = "sha256:86f2e78b1eff847609b1ca8050c9e1fa3bd44ce755b2ec30e70f2d3ba3844644"},
+    {file = "coverage-6.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:5829192582c0ec8ca4a2532407bc14c2f338d9878a10442f5d03804a95fac9de"},
+    {file = "coverage-6.2.tar.gz", hash = "sha256:e2cad8093172b7d1595b4ad66f24270808658e11acf43a8f95b41276162eb5b8"},
 ]
 dataclasses = [
     {file = "dataclasses-0.6-py3-none-any.whl", hash = "sha256:454a69d788c7fda44efd71e259be79577822f5e3f53f029a22d08004e951dc9f"},
@@ -593,12 +572,12 @@ pyparsing = [
     {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
 ]
 pytest = [
-    {file = "pytest-6.2.3-py3-none-any.whl", hash = "sha256:6ad9c7bdf517a808242b998ac20063c41532a570d088d77eec1ee12b0b5574bc"},
-    {file = "pytest-6.2.3.tar.gz", hash = "sha256:671238a46e4df0f3498d1c3270e5deb9b32d25134c99b7d75370a68cfbe9b634"},
+    {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"},
+    {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"},
 ]
 pytest-cov = [
-    {file = "pytest-cov-2.10.1.tar.gz", hash = "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e"},
-    {file = "pytest_cov-2.10.1-py2.py3-none-any.whl", hash = "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191"},
+    {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"},
+    {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"},
 ]
 pyyaml = [
     {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"},
@@ -607,18 +586,26 @@ pyyaml = [
     {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"},
     {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"},
     {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"},
+    {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"},
+    {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541"},
     {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"},
     {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"},
     {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"},
     {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"},
+    {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa"},
+    {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"},
     {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"},
     {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"},
     {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"},
     {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"},
+    {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247"},
+    {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc"},
     {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"},
     {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"},
     {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"},
     {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"},
+    {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122"},
+    {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6"},
     {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"},
     {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"},
     {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"},
@@ -667,44 +654,38 @@ regex = [
     {file = "regex-2021.4.4.tar.gz", hash = "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb"},
 ]
 six = [
-    {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
-    {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},
+    {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
+    {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
 ]
 toml = [
     {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
     {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
 ]
 typed-ast = [
-    {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"},
-    {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"},
-    {file = "typed_ast-1.4.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528"},
-    {file = "typed_ast-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428"},
-    {file = "typed_ast-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3"},
-    {file = "typed_ast-1.4.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f"},
-    {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341"},
-    {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace"},
-    {file = "typed_ast-1.4.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f"},
-    {file = "typed_ast-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363"},
-    {file = "typed_ast-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7"},
-    {file = "typed_ast-1.4.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266"},
-    {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e"},
-    {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04"},
-    {file = "typed_ast-1.4.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899"},
-    {file = "typed_ast-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c"},
-    {file = "typed_ast-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805"},
-    {file = "typed_ast-1.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a"},
-    {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff"},
-    {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41"},
-    {file = "typed_ast-1.4.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39"},
-    {file = "typed_ast-1.4.3-cp38-cp38-win32.whl", hash = "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927"},
-    {file = "typed_ast-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40"},
-    {file = "typed_ast-1.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3"},
-    {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4"},
-    {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0"},
-    {file = "typed_ast-1.4.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3"},
-    {file = "typed_ast-1.4.3-cp39-cp39-win32.whl", hash = "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808"},
-    {file = "typed_ast-1.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"},
-    {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"},
+    {file = "typed_ast-1.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:669dd0c4167f6f2cd9f57041e03c3c2ebf9063d0757dc89f79ba1daa2bfca9d4"},
+    {file = "typed_ast-1.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:211260621ab1cd7324e0798d6be953d00b74e0428382991adfddb352252f1d62"},
+    {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:267e3f78697a6c00c689c03db4876dd1efdfea2f251a5ad6555e82a26847b4ac"},
+    {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c542eeda69212fa10a7ada75e668876fdec5f856cd3d06829e6aa64ad17c8dfe"},
+    {file = "typed_ast-1.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:a9916d2bb8865f973824fb47436fa45e1ebf2efd920f2b9f99342cb7fab93f72"},
+    {file = "typed_ast-1.5.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:79b1e0869db7c830ba6a981d58711c88b6677506e648496b1f64ac7d15633aec"},
+    {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a94d55d142c9265f4ea46fab70977a1944ecae359ae867397757d836ea5a3f47"},
+    {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:183afdf0ec5b1b211724dfef3d2cad2d767cbefac291f24d69b00546c1837fb6"},
+    {file = "typed_ast-1.5.4-cp36-cp36m-win_amd64.whl", hash = "sha256:639c5f0b21776605dd6c9dbe592d5228f021404dafd377e2b7ac046b0349b1a1"},
+    {file = "typed_ast-1.5.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf4afcfac006ece570e32d6fa90ab74a17245b83dfd6655a6f68568098345ff6"},
+    {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed855bbe3eb3715fca349c80174cfcfd699c2f9de574d40527b8429acae23a66"},
+    {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6778e1b2f81dfc7bc58e4b259363b83d2e509a65198e85d5700dfae4c6c8ff1c"},
+    {file = "typed_ast-1.5.4-cp37-cp37m-win_amd64.whl", hash = "sha256:0261195c2062caf107831e92a76764c81227dae162c4f75192c0d489faf751a2"},
+    {file = "typed_ast-1.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2efae9db7a8c05ad5547d522e7dbe62c83d838d3906a3716d1478b6c1d61388d"},
+    {file = "typed_ast-1.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7d5d014b7daa8b0bf2eaef684295acae12b036d79f54178b92a2b6a56f92278f"},
+    {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:370788a63915e82fd6f212865a596a0fefcbb7d408bbbb13dea723d971ed8bdc"},
+    {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4e964b4ff86550a7a7d56345c7864b18f403f5bd7380edf44a3c1fb4ee7ac6c6"},
+    {file = "typed_ast-1.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:683407d92dc953c8a7347119596f0b0e6c55eb98ebebd9b23437501b28dcbb8e"},
+    {file = "typed_ast-1.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4879da6c9b73443f97e731b617184a596ac1235fe91f98d279a7af36c796da35"},
+    {file = "typed_ast-1.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3e123d878ba170397916557d31c8f589951e353cc95fb7f24f6bb69adc1a8a97"},
+    {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebd9d7f80ccf7a82ac5f88c521115cc55d84e35bf8b446fcd7836eb6b98929a3"},
+    {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98f80dee3c03455e92796b58b98ff6ca0b2a6f652120c263efdba4d6c5e58f72"},
+    {file = "typed_ast-1.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1"},
+    {file = "typed_ast-1.5.4.tar.gz", hash = "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2"},
 ]
 typing-extensions = [
     {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"},
diff --git a/py_linq/core.py b/py_linq/core.py
index ac191cb..5a42ae3 100644
--- a/py_linq/core.py
+++ b/py_linq/core.py
@@ -1,5 +1,26 @@
 import itertools
+import inspect
 import io
+from dataclasses import dataclass
+from typing import (
+    TypeVar,
+    Any,
+    Iterable,
+    Optional,
+)
+from copy import deepcopy
+
+TNode = TypeVar("TNode", bound="Node")
+
+
+@dataclass
+class Node(object):
+    """
+    Node for linked list
+    """
+
+    value: Any
+    next: Optional[TNode] = None
 
 
 class Key(object):
@@ -30,7 +51,7 @@ def __init__(self, key, reverse):
 
 
 class RepeatableIterable(object):
-    def __init__(self, data):
+    def __init__(self, data: Iterable[Any] = None):
         """
         Constructor. Pretty straight forward except for the is_generator check. This is so we
         can detect when a function that generates data is passed as a datasource. In this case we
@@ -42,24 +63,82 @@ def __init__(self, data):
             raise TypeError(
                 u"RepeatableIterable must be instantiated with an iterable object"
             )
-        is_generator = hasattr(data, "gi_running") or isinstance(data, io.TextIOBase)
-        self._data = data if not is_generator else [i for i in data]
+        self._data: Iterable[Any] = data
         self._len = None
-        self.cycle = itertools.cycle(self._data)
+        self._root: TNode = None
+        self._current: TNode = None
 
     def __len__(self):
         if self._len is None:
-            self._len = sum(1 for item in self._data)
+            self._len = sum(1 for item in iter(self))
         return self._len
 
-    def __iter__(self):
-        i = 0
-        while i < len(self):
-            yield next(self.cycle)
-            i += 1
+    def __reversed__(self) -> Any:
+        if inspect.isgenerator(self._data) or isinstance(self._data, itertools.chain):
+            # We need to collect the data from a generator since reversing a generator
+            # is not possible
+            self._data = list(self._data)
+        if self._root is None:
+            i = 0
+            for index, item in enumerate(reversed(self._data)):
+                node = Node(value=item)
+                if index == 0:
+                    self._root = node
+                    self._current = self._root
+                else:
+                    self._current.next = node
+                    self._current = self._current.next
+                yield node.value
+                i += 1
+            self._len = i
+        else:
+            while self._current is not None:
+                yield self._current.value
+                self._current = self._current.next
+        self._current = self._root
+
+    def __iter__(self) -> Any:
+        if self._root is None:
+            i = 0
+            for index, item in enumerate(self._data):
+                node = Node(value=item)
+                if index == 0:
+                    self._root = node
+                    self._current = self._root
+                else:
+                    self._current.next = node
+                    self._current = self._current.next
+                yield node.value
+                i += 1
+            self._len = i
+        else:
+            while self._current is not None:
+                yield self._current.value
+                self._current = self._current.next
+        self._current = self._root
 
     def __next__(self):
-        return self.next()
+        if self._current.next is None:
+            self._current = self._root
+            raise StopIteration
+        self._current = self._current.next
+        return self._current.value
+
+    @property
+    def current(self) -> Optional[TNode]:
+        return self._current
+
+    @current.setter
+    def current(self, node: TNode) -> None:
+        self._current = node
+
+    @property
+    def head(self) -> Optional[TNode]:
+        return self._root
+
+    @head.setter
+    def head(self, node: TNode) -> None:
+        self._root = node
 
     def next(self):
-        return next(self.cycle)
+        return self.__next__()
diff --git a/py_linq/py_linq.py b/py_linq/py_linq.py
index bac46e8..a1c151a 100644
--- a/py_linq/py_linq.py
+++ b/py_linq/py_linq.py
@@ -1,6 +1,16 @@
+from ast import Call
 import itertools
 import json
-import io
+from typing import (
+    List,
+    Any,
+    Iterable,
+    TypeVar,
+    Union,
+    Dict,
+    Optional,
+    Callable,
+)
 from queue import LifoQueue
 from six import string_types
 
@@ -12,7 +22,7 @@
 except ImportError:
     pass
 from builtins import range
-from .core import Key, OrderingDirection, RepeatableIterable
+from .core import Key, OrderingDirection, RepeatableIterable, Node
 from .decorators import deprecated
 from .exceptions import (
     NoElementsError,
@@ -22,6 +32,13 @@
 )
 
 
+TEnumerable = TypeVar("TEnumerable", bound="Enumerable")
+TGrouping = TypeVar("TGrouping", bound="Grouping")
+TSortedEnumerable = TypeVar("TSortedEnumerable", bound="SortedEnumerable")
+TGroupedEnumerable = TypeVar("TGroupedEnumerable", bound="GroupedEnumerable")
+Number = Union[float, int]
+
+
 class Enumerable(object):
     def __init__(self, data=None):
         """
@@ -33,16 +50,19 @@ def __init__(self, data=None):
         """
         self._iterable = RepeatableIterable(data)
 
-    def __iter__(self):
+    def __iter__(self) -> Iterable[Any]:
         return iter(self._iterable)
 
-    def next(self):
+    def __reversed__(self) -> Iterable[Any]:
+        return reversed(self._iterable)
+
+    def next(self) -> Any:
         return next(self._iterable)
 
-    def __next__(self):
+    def __next__(self) -> Any:
         return self.next()
 
-    def __getitem__(self, n):
+    def __getitem__(self, n) -> Any:
         """
         Gets item in iterable at specified zero-based index
         :param n: the index of the item to get
@@ -53,23 +73,23 @@ def __getitem__(self, n):
             if i == n:
                 return e
 
-    def __len__(self):
+    def __len__(self) -> int:
         """
         Gets the number of elements in the collection
         """
         return len(self._iterable)
 
-    def __repr__(self):
+    def __repr__(self) -> str:
         return list(self).__repr__()
 
-    def to_list(self):
+    def to_list(self) -> List[Any]:
         """
         Converts the iterable into a list
         :return: list object
         """
         return [x for x in self]
 
-    def count(self, predicate=None):
+    def count(self, predicate=None) -> int:
         """
         Returns the number of elements in iterable
         :return: integer object
@@ -78,15 +98,15 @@ def count(self, predicate=None):
             return sum(1 for element in self.where(predicate))
         return sum(1 for element in self)
 
-    def select(self, func=lambda x: x):
+    def select(self, func=lambda x: x) -> TEnumerable:
         """
         Transforms data into different form
         :param func: lambda expression on how to perform transformation
         :return: new Enumerable object containing transformed data
         """
-        return SelectEnumerable(Enumerable(iter(self)), func)
+        return Enumerable(map(func, self))
 
-    def sum(self, func=lambda x: x):
+    def sum(self, func=lambda x: x) -> Number:
         """
         Returns the sum of af data elements
         :param func: lambda expression to transform data
@@ -94,43 +114,43 @@ def sum(self, func=lambda x: x):
         """
         return sum(func(x) for x in self)
 
-    def min(self, func=lambda x: x):
+    def min(self, func=lambda x: x) -> Number:
         """
         Returns the min value of data elements
         :param func: lambda expression to transform data
         :return: minimum value
         """
-        if not self.any():
+        if len(self) == 0:
             raise NoElementsError(u"Iterable contains no elements")
         return func(min(self, key=func))
 
-    def max(self, func=lambda x: x):
+    def max(self, func=lambda x: x) -> Number:
         """
         Returns the max value of data elements
         :param func: lambda expression to transform data
         :return: maximum value
         """
-        if not self.any():
+        if len(self) == 0:
             raise NoElementsError(u"Iterable contains no elements")
         return func(max(self, key=func))
 
-    def avg(self, func=lambda x: x):
+    def avg(self, func=lambda x: x) -> Number:
         """
         Returns the average value of data elements
         :param func: lambda expression to transform data
         :return: average value as float object
         """
-        if not self.any():
+        if len(self) == 0:
             raise NoElementsError(u"Iterable contains no elements")
         return float(self.sum(func)) / float(self.count())
 
-    def median(self, func=lambda x: x):
+    def median(self, func=lambda x: x) -> Number:
         """
         Return the median value of data elements
         :param func: lambda expression to project and sort data
         :return: median value
         """
-        if not self.any():
+        if len(self) == 0:
             raise NoElementsError(u"Iterable contains no elements")
         result = self.order_by(func).select(func).to_list()
         length = len(result)
@@ -141,7 +161,7 @@ def median(self, func=lambda x: x):
             else (float(result[i - 1]) + float(result[i])) / float(2)
         )
 
-    def element_at(self, n):
+    def element_at(self, n) -> Any:
         """
         Returns element at given index.
             * Raises IndexError if no element found at specified position
@@ -155,7 +175,7 @@ def element_at(self, n):
             raise IndexError
         return result
 
-    def element_at_or_default(self, n):
+    def element_at_or_default(self, n) -> Optional[Any]:
         """
         Returns element at given index or None if no element found
             * Raises IndexError if n is greater than the number of elements in
@@ -168,7 +188,7 @@ def element_at_or_default(self, n):
         except IndexError:
             return None
 
-    def first(self, func=None):
+    def first(self, func=None) -> Any:
         """
         Returns the first element in a collection
         :func: predicate as lambda expression used to filter collection
@@ -179,7 +199,7 @@ def first(self, func=None):
             return self.where(func).element_at(0)
         return self.element_at(0)
 
-    def first_or_default(self, func=None):
+    def first_or_default(self, func=None) -> Optional[Any]:
         """
         Return the first element in a collection. If collection is empty, then returns None
         :func: predicate as lambda expression used to filter collection
@@ -190,7 +210,7 @@ def first_or_default(self, func=None):
             return self.where(func).element_at_or_default(0)
         return self.element_at_or_default(0)
 
-    def last(self, func=None):
+    def last(self, func=None) -> Any:
         """
         Return the last element in a collection
         :func: predicate as a lambda expression used to filter collection
@@ -198,10 +218,10 @@ def last(self, func=None):
         contains no elements
         """
         if func is not None:
-            return self.where(func).reverse().first()
+            self.reverse().where(func).first()
         return self.reverse().first()
 
-    def last_or_default(self, func=None):
+    def last_or_default(self, func=None) -> Optional[Any]:
         """
         Return the last element in a collection or None if the collection is empty
         :func: predicate as a lambda expression used to filter collection
@@ -209,7 +229,7 @@ def last_or_default(self, func=None):
          elements
         """
         if func is not None:
-            return self.where(func).reverse().first_or_default()
+            return self.reverse().where(func).first_or_default()
         return self.reverse().first_or_default()
 
     def order_by(self, key):
@@ -234,23 +254,23 @@ def order_by_descending(self, key):
         kf = [OrderingDirection(key, reverse=True)]
         return SortedEnumerable(Enumerable(iter(self)), key_funcs=kf)
 
-    def skip(self, n):
+    def skip(self, n) -> TEnumerable:
         """
         Returns new Enumerable where n elements have been skipped
         :param n: Number of elements to skip as int
         :return: new Enumerable object
         """
-        return SkipEnumerable(Enumerable(iter(self)), n)
+        return Enumerable(data=itertools.islice(self, n, None, 1))
 
-    def take(self, n):
+    def take(self, n) -> TEnumerable:
         """
         Return new Enumerable where first n elements are taken
         :param n: Number of elements to take
         :return: new Enumerable object
         """
-        return TakeEnumerable(Enumerable(iter(self)), n)
+        return Enumerable(data=itertools.islice(self, 0, n, 1))
 
-    def where(self, predicate):
+    def where(self, predicate) -> TEnumerable:
         """
         Returns new Enumerable where elements matching predicate are selected
         :param predicate: predicate as a lambda expression
@@ -258,9 +278,9 @@ def where(self, predicate):
         """
         if predicate is None:
             raise NullArgumentError("No predicate given for where clause")
-        return WhereEnumerable(Enumerable(iter(self)), predicate)
+        return Enumerable(filter(predicate, self))
 
-    def single(self, predicate=None):
+    def single(self, predicate=None) -> Any:
         """
         Returns single element that matches given predicate.
         Raises:
@@ -271,13 +291,13 @@ def single(self, predicate=None):
         :return: Matching element as object
         """
         result = self.where(predicate) if predicate is not None else self
-        if not result.any():
+        if len(result) == 0:
             raise NoMatchingElement("No matching elements are found")
         if result.count() > 1:
             raise MoreThanOneMatchingElement("More than one matching element is found")
         return result.to_list()[0]
 
-    def single_or_default(self, predicate=None):
+    def single_or_default(self, predicate=None) -> Optional[Any]:
         """
         Return single element that matches given predicate. If no matching
         element is found, returns None
@@ -292,25 +312,30 @@ def single_or_default(self, predicate=None):
         except NoMatchingElement:
             return None
 
-    def select_many(self, func=lambda x: x):
+    def select_many(self, func=lambda x: x) -> TEnumerable:
         """
         Flattens an iterable of iterables returning a new Enumerable
         :param func: selector as lambda expression
         :return: new Enumerable object
         """
-        return SelectManyEnumerable(Enumerable(iter(self)), func)
+        selected = self.select(func)
+        return Enumerable(data=itertools.chain.from_iterable(selected))
 
-    def add(self, element):
+    def add(self, element) -> TEnumerable:
         """
         Adds an element to the enumerable.
         :param element: An element
         :return: new Enumerable object
+
+        The behavior here is no longer in place to conform more to typical
+        functional programming practices. This is a breaking change from
+        1.X versions.
         """
         if element is None:
             return self
         return self.concat(Enumerable([element]))
 
-    def concat(self, enumerable):
+    def concat(self, enumerable) -> TEnumerable:
         """
         Adds enumerable to an enumerable
         :param enumerable: An iterable object
@@ -318,11 +343,11 @@ def concat(self, enumerable):
         """
         if not isinstance(enumerable, Enumerable):
             raise TypeError(u"enumerable argument must be an instance of Enumerable")
-        concatenated = ConcatenateEnumerable(Enumerable(iter(self)), enumerable)
-        self._iterable = concatenated
-        return concatenated
+        return Enumerable(data=itertools.chain(self._iterable, enumerable._iterable))
 
-    def group_by(self, key_names=[], key=lambda x: x, result_func=lambda x: x):
+    def group_by(
+        self, key_names=[], key=lambda x: x, result_func=lambda x: x
+    ) -> TEnumerable:
         """
         Groups an enumerable on given key selector. Index of key name
         corresponds to index of key lambda function.
@@ -355,18 +380,16 @@ def group_by(self, key_names=[], key=lambda x: x, result_func=lambda x: x):
         :param result_func: transformation function as lambda expression
         :return: Enumerable of grouping objects
         """
-        return GroupedEnumerable(Enumerable(iter(self)), key, key_names, result_func)
+        return GroupedEnumerable(self, key, key_names, result_func)
 
-    def distinct(self, key=lambda x: x):
+    def distinct(self, key=lambda x: x) -> TEnumerable:
         """
         Returns enumerable containing elements that are distinct based on
         given key selector
         :param key: key selector as lambda expression
         :return: new Enumerable object
         """
-        return GroupedEnumerable(
-            Enumerable(iter(self)), key, ["distinct"], lambda g: g.first()
-        )
+        return GroupedEnumerable(self, key, ["distinct"], lambda g: g.first())
 
     def join(
         self,
@@ -387,8 +410,10 @@ def join(
             raise TypeError(
                 u"inner_enumerable parameter must be an instance of Enumerable"
             )
-        return JoinEnumerable(
-            Enumerable(iter(self)), inner_enumerable, outer_key, inner_key, result_func
+        return (
+            Enumerable(data=itertools.product(self, inner_enumerable))
+            .where(lambda r: outer_key(r[0]) == inner_key(r[1]))
+            .select(lambda r: result_func(r))
         )
 
     def default_if_empty(self, value=None):
@@ -397,16 +422,16 @@ def default_if_empty(self, value=None):
         empty, otherwise the enumerable itself
         :return: an Enumerable object
         """
-        if not self.any():
+        if len(self) == 0:
             return Enumerable([value])
         return self
 
     def group_join(
         self,
-        inner_enumerable,
-        outer_key=lambda x: x,
-        inner_key=lambda x: x,
-        result_func=lambda x: x,
+        inner_enumerable: TEnumerable,
+        outer_key: Callable = lambda x: x,
+        inner_key: Callable = lambda x: x,
+        result_func: Callable = lambda x: x,
     ):
         """
         Return enumerable of group join between two enumerables
@@ -421,29 +446,15 @@ def group_join(
             raise TypeError(
                 u"inner enumerable parameter must be an instance of Enumerable"
             )
-        group_joined = (
-            Enumerable(iter(self))
-            .join(
-                inner_enumerable=inner_enumerable,
-                outer_key=outer_key,
-                inner_key=inner_key,
-                result_func=lambda e: (e[0], e[1]),
-            )
-            .group_by(
-                key_names=["id"],
-                key=lambda t: outer_key(t[0]),
-                result_func=lambda g: (
-                    g.first()[0],
-                    g.where(lambda i: inner_key(i[1]) == g.key.id).select(
-                        lambda i: i[1]
-                    ),
-                ),
-            )
-            .select(lambda gj: result_func(gj))
-        )
+        group_joined = self.join(
+            inner_enumerable=inner_enumerable.group_by(key_names=["id"], key=inner_key),
+            outer_key=outer_key,
+            inner_key=lambda g: g.key.id,
+            result_func=lambda gj: (gj[0], Enumerable(gj[1]._iterable)),
+        ).select(result_func)
         return group_joined
 
-    def any(self, predicate=None):
+    def any(self, predicate: Callable = None):
         """
         Returns true if any elements that satisfy predicate are found
         :param predicate: condition to satisfy as lambda expression
@@ -451,7 +462,7 @@ def any(self, predicate=None):
         """
         return self.first_or_default(predicate) is not None
 
-    def intersect(self, enumerable, key=lambda x: x):
+    def intersect(self, enumerable: TEnumerable, key: Callable):
         """
         Returns enumerable that is the intersection between given enumerable
         and self
@@ -461,9 +472,11 @@ def intersect(self, enumerable, key=lambda x: x):
         """
         if not isinstance(enumerable, Enumerable):
             raise TypeError(u"enumerable parameter must be an instance of Enumerable")
-        return IntersectEnumerable(Enumerable(iter(self)), enumerable, key)
+        membership = set((key(j) for j in enumerable))
+        intrsct = (i for i in self if key(i) in membership)
+        return Enumerable(data=intrsct).distinct(key)
 
-    def aggregate(self, func, seed=None):
+    def aggregate(self, func: Callable, seed: Any = None) -> Any:
         """
         Perform a calculation over a given enumerable using the initial seed
         value
@@ -481,7 +494,7 @@ def aggregate(self, func, seed=None):
             result = func(result, e)
         return result
 
-    def union(self, enumerable, key=lambda x: x):
+    def union(self, enumerable: TEnumerable, key: Callable):
         """
         Returns enumerable that is a union of elements between self and given
         enumerable
@@ -491,9 +504,9 @@ def union(self, enumerable, key=lambda x: x):
         """
         if not isinstance(enumerable, Enumerable):
             raise TypeError(u"enumerable parameter must be an instance of Enumerable")
-        return UnionEnumerable(Enumerable(iter(self)), enumerable, key)
+        return Enumerable(data=self.concat(enumerable)).distinct(key)
 
-    def except_(self, enumerable, key=lambda x: x):
+    def except_(self, enumerable: TEnumerable, key: Callable):
         """
         Returns enumerable that subtracts given enumerable elements from self
         :param enumerable: enumerable object
@@ -502,7 +515,9 @@ def except_(self, enumerable, key=lambda x: x):
         """
         if not isinstance(enumerable, Enumerable):
             raise TypeError(u"enumerable parameter must be an instance of Enumerable")
-        return ExceptEnumerable(Enumerable(iter(self)), enumerable, key)
+        membership = set((key(j) for j in enumerable))
+        exc = (i for i in self if key(i) not in membership)
+        return Enumerable(data=exc).distinct(key)
 
     def contains(self, element, key=lambda x: x):
         """
@@ -513,7 +528,7 @@ def contains(self, element, key=lambda x: x):
         """
         return self.select(key).any(lambda x: x == key(element))
 
-    def all(self, predicate):
+    def all(self, predicate: Callable) -> bool:
         """
         Determines whether all elements in an enumerable satisfy the given
         predicate
@@ -564,14 +579,14 @@ def repeat(element, length):
         :param length: the number of times to repeat the element
         :return: Enumerable of the repeated elements
         """
-        return RepeatEnumerable(element, length)
+        return Enumerable(data=itertools.repeat(element, length))
 
     def reverse(self):
         """
         Inverts the order of the elements in a sequence
         :return: Enumerable with elements in reversed order
         """
-        return ReversedEnumerable(Enumerable(iter(self)))
+        return Enumerable(data=reversed(self))
 
     def skip_last(self, n):
         """
@@ -632,71 +647,6 @@ def zip(self, enumerable, func=lambda x: x):
         return ZipEnumerable(Enumerable(iter(self)), enumerable, func)
 
 
-class SelectEnumerable(Enumerable):
-    """
-    Class to hold state for projection of elements in a collection
-    """
-
-    def __init__(self, enumerable, func):
-        super(SelectEnumerable, self).__init__(enumerable)
-        self.func = func
-
-    def __iter__(self):
-        for e in iter(self._iterable):
-            yield self.func(e)
-
-
-class WhereEnumerable(Enumerable):
-    """
-    Class to hold state for filtering elements in a collection
-    """
-
-    def __init__(self, enumerable, predicate):
-        super(WhereEnumerable, self).__init__(enumerable)
-        self.predicate = predicate
-
-    def __iter__(self):
-        for e in iter(self._iterable):
-            if self.predicate(e):
-                yield e
-
-    def __getitem__(self, n):
-        for i, e in enumerate(filter(self.predicate, self)):
-            if i == n:
-                return e
-
-
-class SelectManyEnumerable(Enumerable):
-    """
-    Class to hold state for flattening nested collections within a collection
-    """
-
-    def __init__(self, enumerable, selector):
-        super(SelectManyEnumerable, self).__init__(enumerable)
-        self.selector = selector
-
-    def __iter__(self):
-        for element in iter(self._iterable):
-            collection = self.selector(element)
-            for subelement in collection:
-                yield subelement
-
-
-class SkipEnumerable(Enumerable):
-    """
-    Class to hold state for skipping elements in a collection
-    """
-
-    def __init__(self, enumerable, n):
-        super(SkipEnumerable, self).__init__(enumerable)
-        self.n = n
-
-    def __iter__(self):
-        for index, element in enumerate(self._iterable):
-            if index >= self.n:
-                yield element
-
-
 class SkipWhileEnumerable(Enumerable):
     """
     Class to hold state for skipping elements while a given predicate is true
@@ -738,139 +688,23 @@ def __iter__(self):
         return itertools.takewhile(self.predicate, self._iterable)
 
 
-class ReversedEnumerable(Enumerable):
-    """
-    Class to hold state for reversing elements in a collection
-    """
-
-    def __init__(self, enumerable):
-        super(ReversedEnumerable, self).__init__(enumerable)
-
-    def __iter__(self):
-        stack = LifoQueue()
-        for element in self._iterable:
-            stack.put(element)
-        while not stack.empty():
-            yield stack.get()
-
-
-class ConcatenateEnumerable(Enumerable):
-    """
-    Class to hold state for concatenating Enumerable collections
-    """
-
-    def __init__(self, enumerable1, enumerable2):
-        super(ConcatenateEnumerable, self).__init__(enumerable1)
-        self.enumerable = enumerable2
-
-    def __iter__(self):
-        for i in iter(self._iterable):
-            yield i
-        for j in iter(self.enumerable):
-            yield j
-
-    def __getitem__(self, n):
-        if n < 0:
-            raise IndexError
-        if n == len(self) + len(self.enumerable):
-            return None
-        i = 0
-        it = self._iterable if n < len(self._iterable) else self.enumerable._iterable
-        while i < n:
-            next(it)
-            i += 1
-        return next(it)
-
-
-class IntersectEnumerable(Enumerable):
-    """
-    Class to hold state for determining the intersection between two sets
-    """
-
-    def __init__(self, enumerable1, enumerable2, key):
-        super(IntersectEnumerable, self).__init__(enumerable1)
-        self.enumerable = enumerable2
-        self.key = key
-
-    def __iter__(self):
-        for i in iter(self._iterable):
-            k1 = self.key(i)
-            if any(self.key(i2) == k1 for i2 in self.enumerable):
-                yield i
-
-
-class ExceptEnumerable(IntersectEnumerable):
-    """
-    Class to hold state for determining the set minus of collection given another collection
-    """
-
-    def __init__(self, enumerable1, enumerable2, key):
-        super(ExceptEnumerable, self).__init__(enumerable1, enumerable2, key)
-
-    def __iter__(self):
-        for i in iter(self._iterable):
-            k1 = self.key(i)
-            if not any(self.key(i2) == k1 for i2 in self.enumerable):
-                yield i
-
-
-class UnionEnumerable(Enumerable):
-    """
-    Class to hold state for determining the set union of a collection with another collection
-    """
-
-    def __init__(self, enumerable1, enumerable2, key):
-        super(UnionEnumerable, self).__init__(enumerable1)
-        self.enumerable = enumerable2
-        self.key = key
-
-    def __iter__(self):
-        union = dict()
-        for e in itertools.chain(iter(self._iterable), self.enumerable):
-            key = self.key(e)
-            key_hash = hash(json.dumps(key, default=str))
-            if key_hash not in union:
-                yield e
-                union[key_hash] = e
-
-
 class GroupedEnumerable(Enumerable):
-    def __init__(self, enumerable, key, key_names, func=lambda x: x):
+    def __init__(
+        self,
+        data: Iterable,
+        key: Callable,
+        key_names: List[Dict],
+        func: Callable = lambda x: x,
+    ) -> None:
         """
         Constructor for GroupedEnumerable class
-        :param grouped_data: Iterable of grouped data
+        :param data: Iterable of grouped data obtained by itertools.groupby. The data structure is
+            (key, grouper) where grouper is an iterable of items that match the key
+        :param key: function to get the key
+        :param key_names: list of names to use for the keys
+        :param func: function to transform the Grouping result.
         """
-        super(GroupedEnumerable, self).__init__(enumerable)
-        self.key = key
-        self.key_names = key_names
-        self.func = func
-        self.grouping = dict()
-        self._load_data()
-
-    def _load_data(self):
-        for d in iter(self._iterable):
-            key_value = self.key(d)
-            kv_hash = hash(json.dumps(key_value))
-            if kv_hash not in self.grouping:
-                key_prop = {}
-                for i, prop in enumerate(self.key_names):
-                    key_prop[prop] = (
-                        key_value[i] if self._can_enumerate(key_value) else key_value
-                    )
-                self.grouping[kv_hash] = Grouping(Key(key_prop), [d])
-            else:
-                self.grouping[kv_hash].add(d)
-
-    def _can_enumerate(self, key_value):
-        return (
-            hasattr(key_value, "__len__")
-            and len(key_value) > 0
-            and not isinstance(key_value, string_types)
-        )
-
-    def __iter__(self):
-        for k in self.grouping:
-            yield self.func(self.grouping[k])
+        self._iterable = GroupedRepeatableIterable(key, key_names, func, data)
 
 
 class Grouping(Enumerable):
@@ -893,12 +727,62 @@ def __repr__(self):
             "enumerable": list(self._iterable).__repr__(),
         }.__repr__()
 
-    def add(self, item):
-        self._iterable._data.append(item)
+
+class GroupedRepeatableIterable(RepeatableIterable):
+    def __init__(
+        self,
+        key: Callable,
+        key_names: List[Dict],
+        func: Callable,
+        data: Iterable[Any] = None,
+    ):
+        self.key = key
+        self.key_names = key_names
+        self.func = func
+        super().__init__(data)
+
+    def _can_enumerate(self, key_value):
+        return (
+            hasattr(key_value, "__len__")
+            and len(key_value) > 0
+            and not isinstance(key_value, string_types)
+        )
+
+    def __iter__(self) -> Any:
+        if self._root is None:
+            grouped_iterable = (
+                (k, list(g))
+                for k, g in itertools.groupby(
+                    sorted(self._data, key=self.key), self.key
+                )
+            )
+            i = 0
+            for key, group in grouped_iterable:
+                key_prop = {}
+                for j, prop in enumerate(self.key_names):
+                    key_prop.setdefault(
+                        prop, key[j] if self._can_enumerate(key) else key
+                    )
+                key_object = Key(key_prop)
+                node = Node(value=self.func(Grouping(key_object, list(group))))
+                if i == 0:
+                    self._root = node
+                    self._current = self._root
+                else:
+                    self._current.next = node
+                    self._current = self._current.next
+                yield node.value
+                i += 1
+            self._len = i
+        else:
+            while self._current is not None:
+                yield self._current.value
+                self._current = self._current.next
+        self._current = self._root
 
 
 class SortedEnumerable(Enumerable):
-    def __init__(self, enumerable, key_funcs):
+    def __init__(self, data: Iterable, key_funcs):
         """
         Constructor
         :param key_funcs: list of OrderingDirection instances in order of primary key --> less important keys
@@ -909,18 +793,10 @@ def __init__(self, enumerable, key_funcs):
         if not isinstance(key_funcs, list):
             raise TypeError(u"key_funcs should be a list instance")
         self._key_funcs = [f for f in key_funcs if isinstance(f, OrderingDirection)]
-        data = enumerable
         for o in reversed(self._key_funcs):
-            data = sorted(iter(data), key=o.key, reverse=o.descending)
+            data = sorted(data, key=o.key, reverse=o.descending)
         super(SortedEnumerable, self).__init__(data)
 
-    def __getitem__(self, n):
-        result = None
-        for i, e in enumerate(self):
-            if i == n:
-                result = e
-        return result
-
     def then_by(self, func):
         """
         Subsequent sorting function in ascending order
@@ -960,47 +836,3 @@ def __iter__(self):
         return map(
             lambda r: self.result_func(r), zip(iter(self._iterable), self.enumerable)
         )
-
-
-class RepeatEnumerable(Enumerable):
-    """
-    Class to hold state for creating an Enumerable of a repeated element
-    """
-
-    def __init__(self, element, length):
-        self.element = element
-        self.length = length
-
-    def __iter__(self):
-        return itertools.repeat(self.element, self.length)
-
-
-class JoinEnumerable(Enumerable):
-    """
-    Class to hold state for performing inner join of 2 enumerables
-    """
-
-    def __init__(
-        self, outer_enumerable, inner_enumerable, outer_key, inner_key, result_func
-    ):
-        """
-        Constructor
-        :param outer_enumerable -> the outer collection to join against
-        :param inner_enumerable -> the inner collection to join to outer enumerable
-        :param outer_key -> lambda function for selecting the outer enumerable key
-        :param inner_key -> lambda function for selecting the inner enumerable key
-        :param result_func -> lambda function for transforming the result
-        """
-        super(JoinEnumerable, self).__init__(outer_enumerable)
-        self.inner_enumerable = inner_enumerable
-        self.outer_key = outer_key
-        self.inner_key = inner_key
-        self.result_func = result_func
-
-    def __iter__(self):
-        for outer in iter(self._iterable):
-            ok = self.outer_key(outer)
-            for inner in iter(self.inner_enumerable):
-                ik = self.inner_key(inner)
-                if ok == ik:
-                    yield self.result_func((outer, inner))
diff --git a/pyproject.toml b/pyproject.toml
index df82379..3bfed7e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "py-linq"
-version = "1.3.0"
+version = "1.4.0"
 description = "LINQ (Language Integrated Query) is a popular querying language available in .NET. This library ports the language so that developers can query collections of objects using the same syntax. This library would be useful for Python developers with experience using the expressiveness and power of LINQ."
 authors = ["Bruce Fenske <bwfenske@ualberta.ca>"]
 license = "MIT"
@@ -12,6 +12,7 @@ keywords = ["LINQ", "py-linq"]
 [tool.poetry.dependencies]
 python = ">=3.6.1"
 future = "^0.18.2"
+six = "^1.16.0"
 
 [tool.poetry.dev-dependencies]
 pytest = "^6.1.2"


Test patch:
diff --git a/.github/workflows/test-cd.yml b/.github/workflows/test-cd.yml
index d097bfd..f14ec2f 100644
--- a/.github/workflows/test-cd.yml
+++ b/.github/workflows/test-cd.yml
@@ -9,7 +9,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [3.6, 3.7, 3.8, 3.9]
+        python-version: ["3.7", "3.8", "3.9", "3.10"]
     steps:
     - uses: actions/checkout@v2
     - name: Set up Python ${{ matrix.python-version }}
@@ -24,15 +24,17 @@ jobs:
       env:
         POETRY_VIRTUALENVS_CREATE: false
     - name: Lint with flake8
-      run: poetry run flake8 . --ignore=W503,E712,E231,E203 --count --max-complexity=10 --max-line-length=88 --show-source --statistics  --exclude=.git,__pycache__,.venv
+      run: poetry run flake8 .
     - name: Test with pytest
-      run: poetry run pytest tests
+      run: poetry run pytest tests --cov=py_linq --cov-branch --cov-report term
+    - name: Upload coverage reports to Codecov
+      uses: codecov/codecov-action@v3
   deploy:
     needs: test
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [3.6]
+        python-version: [3.7]
     steps:
     - uses: actions/checkout@v2
     - name: Set up Python ${{ matrix.python-version }}
diff --git a/pytest.ini b/pytest.ini
index c95ba98..9d59c18 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,4 +1,3 @@
 [pytest]
-junit_family=legacy
-log_cli=1
+log_cli = 1
 log_cli_level = INFO
\ No newline at end of file
diff --git a/tests/__init__.py b/tests/__init__.py
index e112f4a..e69de29 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,19 +0,0 @@
-_empty = []
-_simple = [1, 2, 3]
-_complex = [{"value": 1}, {"value": 2}, {"value": 3}]
-
-_locations = [
-    ("Scotland", "Edinburgh", "Branch1", 20000),
-    ("Scotland", "Glasgow", "Branch1", 12500),
-    ("Scotland", "Glasgow", "Branch2", 12000),
-    ("Wales", "Cardiff", "Branch1", 29700),
-    ("Wales", "Cardiff", "Branch2", 30000),
-    ("Wales", "Bangor", "Branch1", 12800),
-    ("England", "London", "Branch1", 90000),
-    ("England", "London", "Branch2", 80000),
-    ("England", "London", "Branch3", 70000),
-    ("England", "Manchester", "Branch1", 45600),
-    ("England", "Manchester", "Branch2", 50000),
-    ("England", "Liverpool", "Branch1", 29700),
-    ("England", "Liverpool", "Branch2", 25000),
-]
diff --git a/tests/fixtures.py b/tests/fixtures.py
new file mode 100644
index 0000000..b6b9892
--- /dev/null
+++ b/tests/fixtures.py
@@ -0,0 +1,79 @@
+from py_linq import Enumerable
+import pytest
+
+
+_simple = [1, 2, 3]
+_complex = [{"value": 1}, {"value": 2}, {"value": 3}]
+
+_locations = [
+    ("Scotland", "Edinburgh", "Branch1", 20000),
+    ("Scotland", "Glasgow", "Branch1", 12500),
+    ("Scotland", "Glasgow", "Branch2", 12000),
+    ("Wales", "Cardiff", "Branch1", 29700),
+    ("Wales", "Cardiff", "Branch2", 30000),
+    ("Wales", "Bangor", "Branch1", 12800),
+    ("England", "London", "Branch1", 90000),
+    ("England", "London", "Branch2", 80000),
+    ("England", "London", "Branch3", 70000),
+    ("England", "Manchester", "Branch1", 45600),
+    ("England", "Manchester", "Branch2", 50000),
+    ("England", "Liverpool", "Branch1", 29700),
+    ("England", "Liverpool", "Branch2", 25000),
+]
+
+
+@pytest.fixture
+def empty() -> Enumerable:
+    return Enumerable([])
+
+
+@pytest.fixture
+def simple() -> Enumerable:
+    return Enumerable(_simple)
+
+
+@pytest.fixture
+def complex() -> Enumerable:
+    return Enumerable(_complex)
+
+
+@pytest.fixture
+def locations() -> Enumerable:
+    return Enumerable(_locations)
+
+
+def simple_generator(max: int):
+    for i in range(max + 1):
+        yield i
+
+
+class Val(object):
+    def __init__(self, number, power):
+        self.number = number
+        self.power = power
+
+    def __str__(self):
+        return "VAL {0}: {1}".format(self.number, self.power)
+
+
+def powers(base: int, num_iterations: int = 2):
+    for i in range(num_iterations):
+        yield Val(i, base ** i)
+
+
+class Obj(object):
+    def __init__(self, n, v):
+        self.n = n
+        self.v = v
+
+
+class MyObject(object):
+    def __init__(self, name):
+        self.field = name
+
+    def get_field(self):
+        return self.field
+
+
+def groupjoin_func(r):
+    return (r[0], r[1].count())
diff --git a/tests/test_constructor.py b/tests/test_constructor.py
deleted file mode 100644
index 2cbef4c..0000000
--- a/tests/test_constructor.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from unittest import TestCase
-from py_linq import Enumerable
-from tests import _empty, _simple, _complex
-
-
-class TestConstructor(TestCase):
-    def setUp(self):
-        self.empty = Enumerable(_empty)
-        self.simple = Enumerable(_simple)
-        self.complex = Enumerable(_complex)
-
-    def test_constructor(self):
-        self.assertIsInstance(
-            self.empty, Enumerable, u"TypeError: empty py_linq is not Enumerable type"
-        )
-        self.assertIsInstance(
-            self.simple, Enumerable, u"TypeError: simple py_linq is not Enumerable type"
-        )
-        self.assertIsInstance(
-            self.complex,
-            Enumerable,
-            u"TypeError: complex py_linq is not Enumerable type",
-        )
-        self.assertRaises(TypeError, Enumerable, 1)
diff --git a/tests/test_functions.py b/tests/test_functions.py
index 769e8f0..cd60a24 100644
--- a/tests/test_functions.py
+++ b/tests/test_functions.py
@@ -1,7 +1,6 @@
-from py_linq.py_linq import SelectEnumerable, WhereEnumerable
-from unittest import TestCase
+import io
+import os
 from py_linq import Enumerable
-from tests import _empty, _simple, _complex, _locations
 from py_linq.exceptions import (
     NoElementsError,
     NullArgumentError,
@@ -9,855 +8,855 @@
     MoreThanOneMatchingElement,
 )
 
+from py_linq.py_linq import Grouping, Key
 import six
+import pytest
+from tests.fixtures import (
+    _simple,
+    _complex,
+    _locations,
+    simple_generator,
+    powers,
+    Obj,
+    MyObject,
+    groupjoin_func,
+)
+from typing import (
+    List,
+    Any,
+    Type,
+    Union,
+    Callable,
+)
 
 
-class TestFunctions(TestCase):
-    def setUp(self):
-        self.empty = Enumerable(_empty)
-        self.simple = Enumerable(_simple)
-        self.complex = Enumerable(_complex)
+def reverse(result, element):
+    return element + " " + result
 
-    def test_iter(self):
-        self.assertListEqual(_simple, list(iter(self.simple)))
-        self.assertListEqual(_empty, list(iter(self.empty)))
-        self.assertListEqual(_complex, list(iter(self.complex)))
 
-    def test_iter_select(self):
-        self.assertListEqual(_empty, list(iter(self.empty.select(lambda x: x))))
-        self.assertListEqual(_simple, list(iter(self.simple.select(lambda x: x))))
-        self.assertListEqual(
-            _simple, list(iter(self.complex.select(lambda x: x["value"])))
-        )
+def sum(result, element):
+    return result + element
 
-    def test_iter_where(self):
-        self.assertListEqual(_empty, list(iter(self.empty.where(lambda x: x == 2))))
-        self.assertListEqual([2], list(iter(self.simple.where(lambda x: x == 2))))
-        self.assertListEqual(
-            [{"value": 2}], list(iter(self.complex.where(lambda x: x["value"] == 2)))
-        )
 
-    def test_len(self):
-        self.assertEqual(0, len(self.empty))
-        self.assertEqual(3, len(self.simple))
-
-    def test_get_item(self):
-        self.assertIsNone(self.empty[0])
-        self.assertEqual(2, self.simple[1])
-        self.assertDictEqual({"value": 2}, self.complex[1])
-
-    def test_get_item_select(self):
-        self.assertIsNone(self.empty.select(lambda x: x["value"])[0])
-        self.assertEqual(2, self.complex.select(lambda x: x["value"])[1])
-        self.assertEqual({"value": 2}, self.simple.select(lambda x: {"value": x})[1])
-
-    def test_to_list(self):
-        self.assertListEqual(_empty, self.empty.to_list())
-        self.assertListEqual(_simple, self.simple.to_list())
-        self.assertListEqual(_complex, self.complex.to_list())
-
-    def test_sum(self):
-        self.assertEqual(0, self.empty.sum())
-        self.assertEqual(6, self.simple.sum())
-
-    def test_sum_with_filter(self):
-        self.assertEqual(6, self.complex.sum(lambda x: x["value"]))
-
-    def test_count(self):
-        self.assertEqual(self.empty.count(), 0)
-        self.assertEqual(self.simple.count(), 3)
-        self.assertEqual(self.complex.count(), 3)
-
-    def test_count_with_filter(self):
-        self.assertEqual(self.empty.count(lambda x: x == 1), 0)
-        self.assertEqual(self.simple.count(lambda x: x == 1), 1)
-        self.assertEqual(self.complex.count(lambda x: x["value"] > 1), 2)
-
-    def test_select(self):
-        self.assertListEqual(
-            [], Enumerable.empty().select(lambda x: x["value"]).to_list()
-        )
-        self.assertListEqual(
-            [{"value": 1}, {"value": 2}, {"value": 3}],
-            Enumerable([1, 2, 3]).select(lambda x: {"value": x}).to_list(),
-        )
-        self.assertListEqual(
-            [1, 2, 3],
-            Enumerable(
-                [
-                    {"value": 1},
-                    {"value": 2},
-                    {"value": 3},
-                ]
-            )
-            .select(lambda x: x["value"])
-            .to_list(),
-        )
+@pytest.mark.parametrize(
+    "enumerable",
+    [
+        Enumerable(),
+        Enumerable([]),
+        Enumerable(_simple),
+        Enumerable(_complex),
+    ],
+)
+def test_constructor(enumerable: Enumerable) -> None:
+    assert isinstance(enumerable, Enumerable)
+
+
+@pytest.mark.parametrize(
+    "enumerable, expected",
+    [
+        (Enumerable(), []),
+        (Enumerable([]), []),
+        (Enumerable(_simple), _simple),
+        (Enumerable(_complex), _complex),
+        (Enumerable().select(lambda x: x), []),
+        (Enumerable([]).select(lambda x: x), []),
+        (Enumerable(_simple).select(lambda x: x), _simple),
+        (Enumerable(_complex).select(lambda x: x["value"]), _simple),
+        (Enumerable().where(lambda x: x == 2), []),
+        (Enumerable([]).where(lambda x: x == 2), []),
+        (Enumerable(_simple).where(lambda x: x == 2), [2]),
+        (Enumerable(_complex).where(lambda x: x["value"] == 2), [{"value": 2}]),
+        (Enumerable().to_list(), []),
+        (Enumerable(_simple).to_list(), _simple),
+        (Enumerable(_complex).to_list(), _complex),
+    ],
+)
+def test_iter(enumerable: Enumerable, expected: List) -> None:
+    assert list(iter(enumerable)) == expected
 
-    def test_min(self):
-        self.assertRaises(NoElementsError, self.empty.min)
-        self.assertEqual(1, self.simple.min())
-        self.assertEqual(1, self.complex.min(lambda x: x["value"]))
-
-    def test_max(self):
-        self.assertRaises(NoElementsError, self.empty.max)
-        self.assertEqual(3, self.simple.max())
-        self.assertEqual(3, self.complex.max(lambda x: x["value"]))
-
-    def test_avg(self):
-        avg = float(2)
-        self.assertRaises(NoElementsError, self.empty.avg)
-        self.assertEqual(self.simple.avg(), avg)
-        self.assertEqual(self.complex.avg(lambda x: x["value"]), avg)
-
-    def test_element_at(self):
-        self.assertRaises(IndexError, self.empty.element_at, 0)
-        self.assertEqual(2, self.simple.element_at(1))
-        self.assertDictEqual({"value": 2}, self.complex.element_at(1))
-
-    def test_first(self):
-        self.assertRaises(IndexError, self.empty.first)
-        self.assertIsInstance(self.simple.first(), int)
-        self.assertEqual(1, self.simple.order_by(lambda x: x).first())
-        self.assertIsInstance(self.complex.first(), dict)
-        self.assertDictEqual(
-            {"value": 1}, self.complex.order_by(lambda x: x["value"]).first()
-        )
 
-    def test_first_or_default(self):
-        self.assertIsNone(self.empty.first_or_default())
-        self.assertIsInstance(self.simple.first_or_default(), int)
-        self.assertDictEqual({"value": 1}, self.complex.first_or_default())
-
-    def test_last(self):
-        self.assertRaises(IndexError, self.empty.last)
-        self.assertIsInstance(self.simple.last(), int)
-        self.assertEqual(3, self.simple.order_by(lambda x: x).last())
-        self.assertIsInstance(self.complex.last(), dict)
-        self.assertDictEqual(
-            {"value": 3}, self.complex.order_by(lambda x: x["value"]).last()
-        )
-        self.assertDictEqual(
-            self.complex.order_by(lambda x: x["value"]).last(),
-            self.complex.order_by(lambda x: x["value"]).last_or_default(),
-        )
+@pytest.mark.parametrize(
+    "enumerable, count",
+    [
+        (Enumerable(), 0),
+        (Enumerable(_simple), len(_simple)),
+        (Enumerable(_complex), len(_complex)),
+    ],
+)
+def test_len(enumerable: Enumerable, count: int) -> None:
+    assert len(enumerable) == count
+
+
+@pytest.mark.parametrize(
+    "enumerable, index, expected",
+    [
+        (Enumerable(), 0, None),
+        (Enumerable(_simple), 1, 2),
+        (Enumerable(_complex), 1, {"value": 2}),
+        (Enumerable().select(lambda x: x["value"]), 0, None),
+        (Enumerable(_complex).select(lambda x: x["value"]), 1, 2),
+        (Enumerable(_simple).select(lambda x: x), 1, 2),
+    ],
+)
+def test_get_item(enumerable: Enumerable, index: int, expected: Any) -> None:
+    assert enumerable[index] == expected
 
-    def test_last_or_default(self):
-        self.assertIsNone(self.empty.last_or_default())
-        self.assertEqual(3, self.simple.order_by(lambda x: x).last_or_default())
-        self.assertIsInstance(self.complex.last_or_default(), dict)
-        self.assertDictEqual(
-            {"value": 3}, self.complex.order_by(lambda x: x["value"]).last_or_default()
-        )
 
-    def test_order_by(self):
-        self.assertRaises(NullArgumentError, self.simple.order_by, None)
-        self.assertListEqual(_simple, self.simple.order_by(lambda x: x).to_list())
-        self.assertListEqual(
-            _complex, self.complex.order_by(lambda x: x["value"]).to_list()
-        )
+@pytest.mark.parametrize(
+    "enumerable, index, expected",
+    [
+        (Enumerable(_simple).element_at, 1, 2),
+        (Enumerable(_complex).element_at, 1, {"value": 2}),
+    ],
+)
+def test_element_at(enumerable: Callable, index: int, expected: Any) -> None:
+    assert enumerable(index) == expected
 
-    def test_order_by_descending(self):
-        self.assertRaises(NullArgumentError, self.simple.order_by_descending, None)
-        self.assertListEqual(
-            [3, 2, 1], self.simple.order_by_descending(lambda x: x).to_list()
-        )
-        self.assertListEqual(
-            [{"value": 3}, {"value": 2}, {"value": 1}],
-            self.complex.order_by_descending(lambda x: x["value"]).to_list(),
-        )
 
-    def test_order_by_with_select(self):
-        self.assertListEqual(
+@pytest.mark.parametrize(
+    "enumerable, index, error",
+    [
+        (Enumerable().element_at, 0, IndexError),
+    ],
+)
+def test_element_at_error(enumerable: Callable, index: int, error: Exception) -> None:
+    with pytest.raises(error):
+        enumerable(index)
+
+
+@pytest.mark.parametrize(
+    "value, total",
+    [
+        (Enumerable().sum(), 0),
+        (Enumerable(_simple).sum(), 6),
+        (Enumerable(_complex).select(lambda x: x["value"]).sum(), 6),
+        (Enumerable(_complex).sum(lambda x: x["value"]), 6),
+        (Enumerable().count(), 0),
+        (Enumerable(_simple).count(), 3),
+        (Enumerable(_complex).count(), 3),
+        (Enumerable().count(lambda x: x == 1), 0),
+        (Enumerable(_simple).count(lambda x: x == 1), 1),
+        (Enumerable(_complex).count(lambda x: x["value"] > 1), 2),
+        (Enumerable(_simple).min(), 1),
+        (Enumerable(_complex).min(lambda x: x["value"]), 1),
+        (Enumerable(_simple).max(), 3),
+        (Enumerable(_complex).max(lambda x: x["value"]), 3),
+        (Enumerable(_simple).avg(), float(2)),
+        (Enumerable(_complex).avg(lambda x: x["value"]), float(2)),
+        (Enumerable(_simple).median(), 2),
+        (Enumerable(_complex).median(lambda x: x["value"]), 2),
+        (Enumerable([1, 2, 3, 4]).median(), float(5) / float(2)),
+        (Enumerable(_simple).first(), 1),
+        (Enumerable(_complex).first(), {"value": 1}),
+        (Enumerable().first_or_default(), None),
+        (Enumerable(_simple).first_or_default(), 1),
+        (Enumerable(_complex).first_or_default(), {"value": 1}),
+        (Enumerable(_simple).last(), 3),
+        (Enumerable(_complex).last(), {"value": 3}),
+        (Enumerable().last_or_default(), None),
+        (Enumerable(_simple).last_or_default(), 3),
+        (Enumerable(_complex).last_or_default(), {"value": 3}),
+        (Enumerable(_simple).single(lambda x: x == 2), 2),
+        (Enumerable(_complex).single(lambda x: x["value"] == 2), {"value": 2}),
+        (Enumerable(_complex).select(lambda x: x["value"]).single(lambda x: x == 2), 2),
+        (Enumerable(_simple).single_or_default(lambda x: x > 3), None),
+        (Enumerable(_simple).single_or_default(lambda x: x == 3), 3),
+        (Enumerable(_simple).add(4).count(), 4),
+        (Enumerable(_simple).add(4).add(5).count(), 5),
+        (Enumerable(_simple).add(4).add(5).last(), 5),
+        (Enumerable(_simple).order_by_descending(lambda x: x)[0], 3),
+        (Enumerable(_locations).distinct(lambda x: x[0]).count(), 3),
+        (Enumerable().any(lambda x: x == 1), False),
+        (Enumerable().any(), False),
+        (Enumerable(_simple).any(lambda x: x == 1), True),
+        (Enumerable(_simple).any(), True),
+        (Enumerable(_complex).any(), True),
+        (Enumerable(_complex).any(lambda x: x["value"] < 1), False),
+        (Enumerable(_complex).any(lambda x: x["value"] > 1), True),
+        (Enumerable().contains(1), False),
+        (Enumerable(_simple).contains(1), True),
+        (Enumerable(_complex).select(lambda x: x["value"]).contains(1), True),
+        (
+            Enumerable(_complex)
+            .group_join(Enumerable([1, 2, 3, 3]), outer_key=lambda x: x["value"])
+            .count(),
+            3,
+        ),
+        (Enumerable([1, 1, 1]).all(lambda x: x == 1), True),
+        (Enumerable([]).all(lambda x: x == 1), True),
+        (Enumerable(_simple).all(lambda x: x == 1), False),
+        (
+            Enumerable(["ab", "bc", "cd", "de"]).to_dictionary(lambda t: t[0]),
+            {"a": "ab", "b": "bc", "c": "cd", "d": "de"},
+        ),
+        (
+            Enumerable([[0, 1, 2], [3, 4, 5], [6, 7, 8]]).to_dictionary(
+                lambda t: t[0], lambda t: t[1:]
+            ),
+            {0: [1, 2], 3: [4, 5], 6: [7, 8]},
+        ),
+        (Enumerable(_simple).first(lambda x: x == 2), 2),
+        (Enumerable(_complex).first(lambda x: x["value"] == 2), {"value": 2}),
+        (Enumerable.empty().first_or_default(lambda x: x == 0), None),
+        (
+            Enumerable(_simple).first(lambda x: x == 2),
+            Enumerable(_simple).first_or_default(lambda x: x == 2),
+        ),
+        (
+            Enumerable(_complex).first(lambda x: x["value"] == 2),
+            Enumerable(_complex).first_or_default(lambda x: x["value"] == 2),
+        ),
+        (
+            Enumerable(_simple).last(lambda x: x == 2),
+            Enumerable(_simple).last_or_default(lambda x: x == 2),
+        ),
+        (
+            Enumerable(_complex).last(lambda x: x["value"] == 2),
+            Enumerable(_complex).last_or_default(lambda x: x["value"] == 2),
+        ),
+        (
+            Enumerable(_simple).first(lambda x: x == 2),
+            Enumerable(_simple).last(lambda x: x == 2),
+        ),
+        (Enumerable.empty().last_or_default(lambda x: x == 0), None),
+        (
+            Enumerable(_complex).last(lambda x: x["value"] == 2),
+            Enumerable(_complex).last_or_default(lambda x: x["value"] == 2),
+        ),
+        (
+            Enumerable(_simple).last_or_default(lambda x: x == 2),
+            Enumerable(_simple).first_or_default(lambda x: x == 2),
+        ),
+    ],
+)
+def test_executors(value: Union[int, float], total: Union[int, float]) -> None:
+    assert value == total
+
+
+@pytest.mark.parametrize(
+    "enumerable, func, error",
+    [
+        (Enumerable().min, None, NoElementsError),
+        (Enumerable().max, None, NoElementsError),
+        (Enumerable().avg, None, NoElementsError),
+        (Enumerable().median, None, NoElementsError),
+        (Enumerable().first, None, IndexError),
+        (Enumerable().last, None, IndexError),
+        (Enumerable.empty().first, lambda x: x == 0, IndexError),
+        (Enumerable.empty().last, lambda x: x == 0, IndexError),
+    ],
+)
+def test_executors_error(
+    enumerable: Callable, func: Callable, error: Exception
+) -> None:
+    with pytest.raises(error):
+        enumerable(func)
+
+
+@pytest.mark.parametrize(
+    "enumerable, expected",
+    [
+        (Enumerable.empty(), []),
+        (Enumerable().select(lambda x: x["value"]), []),
+        (Enumerable(_simple).select(lambda x: {"value": x}), _complex),
+        (Enumerable(_complex).select(lambda x: x["value"]), _simple),
+        (Enumerable([2, 1, 3]).order_by(lambda x: x), _simple),
+        (
+            Enumerable([{"value": 2}, {"value": 1}, {"value": 3}]).order_by(
+                lambda x: x["value"]
+            ),
+            _complex,
+        ),
+        (Enumerable(_simple).order_by_descending(lambda x: x), list(reversed(_simple))),
+        (
+            Enumerable(_complex).order_by_descending(lambda x: x["value"]),
+            [{"value": 3}, {"value": 2}, {"value": 1}],
+        ),
+        (
+            Enumerable([{"value": 2}, {"value": 1}, {"value": 3}])
+            .select(lambda x: x["value"])
+            .order_by(lambda x: x),
             _simple,
-            self.complex.select(lambda x: x["value"]).order_by(lambda x: x).to_list(),
-        )
-
-    def test_order_by_descending_with_select(self):
-        self.assertListEqual(
+        ),
+        (
+            Enumerable(_complex)
+            .select(lambda x: x["value"])
+            .order_by_descending(lambda x: x),
             [3, 2, 1],
-            self.complex.select(lambda x: x["value"])
-            .order_by_descending(lambda x: x)
-            .to_list(),
-        )
-
-    def test_order_by_with_where(self):
-        self.assertListEqual(
-            [2, 3], self.simple.where(lambda x: x >= 2).order_by(lambda x: x).to_list()
-        )
-
-    def test_order_by_descending_with_where(self):
-        self.assertListEqual(
+        ),
+        (Enumerable([2, 1, 3]).where(lambda x: x >= 2).order_by(lambda x: x), [2, 3]),
+        (
+            Enumerable(_simple)
+            .where(lambda x: x >= 2)
+            .order_by_descending(lambda x: x),
             [3, 2],
-            self.simple.where(lambda x: x >= 2)
-            .order_by_descending(lambda x: x)
-            .to_list(),
-        )
-
-    def test_median(self):
-        self.assertRaises(NoElementsError, self.empty.median)
-        median = float(2)
-        self.assertEqual(median, self.simple.median())
-        self.assertEqual(median, self.complex.median(lambda x: x["value"]))
-
-    def test_skip(self):
-        self.assertListEqual([], Enumerable().skip(2).to_list())
-        self.assertListEqual([], Enumerable([1, 2, 3]).skip(3).to_list())
-        self.assertListEqual([2, 3], Enumerable([1, 2, 3]).skip(1).to_list())
-
-    def test_take(self):
-        self.assertListEqual(_simple, self.simple.take(4).to_list())
-
-    def test_skip_with_take(self):
-        self.assertListEqual([2], self.simple.skip(1).take(1).to_list())
-
-    def test_skip_take_with_select(self):
-        self.assertListEqual(
-            [2], self.complex.select(lambda x: x["value"]).skip(1).take(1).to_list()
-        )
-
-    def test_filter(self):
-        self.assertListEqual([], self.empty.where(lambda x: x == 0).to_list())
-        self.assertListEqual([2], self.simple.where(lambda x: x == 2).to_list())
-        self.assertListEqual(
-            [{"value": 2}], self.complex.where(lambda x: x["value"] == 2).to_list()
-        )
-        self.assertListEqual([], self.simple.where(lambda x: x == 0).to_list())
-
-    def test_select_with_filter(self):
-        self.assertListEqual(
-            [2],
-            Enumerable([{"value": 1}, {"value": 2}, {"value": 3}])
+        ),
+        (Enumerable().skip(2), []),
+        (Enumerable(_simple).skip(3), []),
+        (Enumerable(_simple).skip(1), [2, 3]),
+        (Enumerable(_simple).take(4), _simple),
+        (Enumerable(_simple).take(0), []),
+        (Enumerable(_simple).take(2), [1, 2]),
+        (Enumerable(_simple).skip(1).take(1), [2]),
+        (Enumerable(_complex).select(lambda x: x["value"]).skip(1).take(1), [2]),
+        (Enumerable().where(lambda x: x == 0), []),
+        (Enumerable(_simple).where(lambda x: x == 2), [2]),
+        (Enumerable(_complex).where(lambda x: x["value"] == 2), [{"value": 2}]),
+        (Enumerable(_simple).where(lambda x: x == 0), []),
+        (
+            Enumerable(_complex)
             .where(lambda x: x["value"] == 2)
-            .select(lambda x: x["value"])
-            .to_list(),
-        )
-
-    def test_single(self):
-        self.assertRaises(NoMatchingElement, self.empty.single, lambda x: x == 0)
-        self.assertRaises(NoMatchingElement, self.empty.single, None)
-
-        self.assertRaises(NoMatchingElement, self.simple.single, lambda x: x == 0)
-        self.assertRaises(MoreThanOneMatchingElement, self.simple.single, None)
-
-        self.assertRaises(
-            NoMatchingElement, self.complex.single, lambda x: x["value"] == 0
-        )
-        self.assertRaises(MoreThanOneMatchingElement, self.complex.single, None)
-
-        self.assertRaises(
-            MoreThanOneMatchingElement, self.simple.single, lambda x: x > 0
-        )
-        self.assertRaises(
-            MoreThanOneMatchingElement, self.complex.single, lambda x: x["value"] > 0
-        )
-
-    def test_single(self):
-        simple_single = self.simple.single(lambda x: x == 2)
-        self.assertIsInstance(simple_single, int)
-        self.assertEqual(2, simple_single)
-
-        complex_single = self.complex.single(lambda x: x["value"] == 2)
-        self.assertIsInstance(complex_single, dict)
-        self.assertDictEqual({"value": 2}, complex_single)
-
-        select_single = self.complex.select(lambda x: x["value"]).single(
-            lambda x: x == 2
-        )
-        self.assertEqual(2, select_single)
-
-    def test_single_or_default(self):
-        self.assertRaises(
-            MoreThanOneMatchingElement, self.simple.single_or_default, lambda x: x > 0
-        )
-        self.assertIsNone(self.simple.single_or_default(lambda x: x > 3))
-        self.assertRaises(
-            MoreThanOneMatchingElement,
-            self.complex.single_or_default,
-            lambda x: x["value"] > 0,
-        )
-
-    def test_select_many(self):
-        self.assertListEqual([], Enumerable([[], [], []]).select_many().to_list())
-        self.assertListEqual(
-            [1, 2, 3, 4, 5, 6, 7, 8, 9],
-            Enumerable([[1, 2, 3], [4, 5, 6], [7, 8, 9]]).select_many().to_list(),
-        )
-        self.assertEqual(
-            9, Enumerable([[1, 2, 3], [4, 5, 6], [7, 8, 9]]).select_many().count()
-        )
-        self.assertListEqual(
+            .select(lambda x: x["value"]),
+            [2],
+        ),
+        (Enumerable().concat(Enumerable()), []),
+        (Enumerable().concat(Enumerable(_simple)), _simple),
+        (Enumerable(_simple).concat(Enumerable()), _simple),
+        (
+            Enumerable(_simple).concat(
+                Enumerable(_complex).select(lambda c: c["value"])
+            ),
+            [1, 2, 3, 1, 2, 3],
+        ),
+        (
+            Enumerable(_complex)
+            .select(lambda c: c["value"])
+            .concat(Enumerable(_simple)),
+            [1, 2, 3, 1, 2, 3],
+        ),
+        (
+            Enumerable(_simple).concat(Enumerable(_complex)),
+            [1, 2, 3, {"value": 1}, {"value": 2}, {"value": 3}],
+        ),
+        (Enumerable([[], [], []]).select_many(), []),
+        (
+            Enumerable([[1, 2, 3], [4, 5, 6], [7, 8, 9]]).select_many(),
             [1, 2, 3, 4, 5, 6, 7, 8, 9],
+        ),
+        (
             Enumerable(
                 [
                     {"key": 1, "values": [1, 2, 3]},
                     {"key": 2, "values": [4, 5, 6]},
                     {"key": 3, "values": [7, 8, 9]},
                 ]
-            )
-            .select_many(lambda x: x["values"])
-            .to_list(),
-        )
-        self.assertEqual(
-            9,
-            Enumerable(
-                [
-                    {"key": 1, "values": [1, 2, 3]},
-                    {"key": 2, "values": [4, 5, 6]},
-                    {"key": 3, "values": [7, 8, 9]},
-                ]
-            )
-            .select_many(lambda x: x["values"])
-            .count(),
-        )
-
-    def test_concat(self):
-        self.assertListEqual([], Enumerable().concat(Enumerable()).to_list())
-        self.assertListEqual(
-            [1, 2, 3], Enumerable().concat(Enumerable([1, 2, 3])).to_list()
-        )
-        self.assertListEqual(
-            [1, 2, 3], Enumerable([1, 2, 3]).concat(Enumerable()).to_list()
-        )
-        self.assertListEqual(
-            [1, 2, 3, 1, 2, 3],
-            Enumerable([1, 2, 3])
-            .concat(
-                Enumerable(
-                    [
-                        {"value": 1},
-                        {"value": 2},
-                        {"value": 3},
-                    ]
-                ).select(lambda c: c["value"])
-            )
-            .to_list(),
-        )
-        self.assertListEqual(
-            [1, 2, 3, 1, 2, 3],
+            ).select_many(lambda x: x["values"]),
+            [1, 2, 3, 4, 5, 6, 7, 8, 9],
+        ),
+        (Enumerable.empty().distinct(), []),
+        (Enumerable(_simple).concat(Enumerable(_simple)).distinct(), _simple),
+        (Enumerable().default_if_empty(), [None]),
+        (Enumerable(_simple).default_if_empty(), _simple),
+        (Enumerable(_complex).default_if_empty(), _complex),
+        (Enumerable().join(Enumerable()), []),
+        (Enumerable().join(Enumerable(_simple)), []),
+        (Enumerable().join(Enumerable(_complex)), []),
+        (Enumerable(_simple).join(Enumerable(_simple)), [(1, 1), (2, 2), (3, 3)]),
+        (
+            Enumerable(_simple).join(
+                Enumerable(_complex),
+                inner_key=lambda x: x["value"],
+                result_func=lambda x: (x[0], x[1]["value"]),
+            ),
+            [(1, 1), (2, 2), (3, 3)],
+        ),
+        (
+            Enumerable(_complex).join(
+                Enumerable(_complex),
+                result_func=lambda x: (x[0]["value"], x[1]["value"]),
+            ),
+            [(1, 1), (2, 2), (3, 3)],
+        ),
+        (Enumerable().intersect(Enumerable(), lambda x: x), []),
+        (Enumerable().intersect(Enumerable(_simple), lambda x: x), []),
+        (Enumerable(_simple).intersect(Enumerable(_simple), lambda x: x), _simple),
+        (Enumerable(_simple).intersect(Enumerable([2]), lambda x: x), [2]),
+        (Enumerable(_simple).intersect(Enumerable(_complex), lambda x: x["value"]), []),
+        (
+            Enumerable(_complex).intersect(Enumerable(_complex), lambda x: x["value"]),
+            _complex,
+        ),
+        (
+            Enumerable(_complex).intersect(
+                Enumerable([{"value": 1}]), lambda x: x["value"]
+            ),
+            [{"value": 1}],
+        ),
+        (
             Enumerable(
-                [
-                    {"value": 1},
-                    {"value": 2},
-                    {"value": 3},
-                ]
-            )
-            .select(lambda c: c["value"])
-            .concat(Enumerable([1, 2, 3]))
-            .to_list(),
-        )
-        self.assertListEqual(
-            [1, 2, 3, {"value": 1}, {"value": 2}, {"value": 3}],
-            Enumerable([1, 2, 3])
-            .concat(
+                [{"course": "Chemistry", "mark": 90}, {"course": "Biology", "mark": 85}]
+            ).intersect(
                 Enumerable(
                     [
-                        {"value": 1},
-                        {"value": 2},
-                        {"value": 3},
+                        {"course": "Chemistry", "mark": 65},
+                        {"course": "Computer Science", "mark": 96},
                     ]
-                )
-            )
-            .to_list(),
-        )
-
-    def test_concat_mutating(self):
-        empty = Enumerable()
-        empty.concat(Enumerable())
-        self.assertListEqual([], empty.to_list())
-
-        simple = Enumerable()
-        simple.concat(Enumerable([1, 2, 3]))
-        self.assertListEqual([1, 2, 3], simple.to_list())
-
-        double_simple = Enumerable([1, 2, 3])
-        double_simple.concat(
-            Enumerable(
-                [
-                    {"value": 1},
-                    {"value": 2},
-                    {"value": 3},
-                ]
-            ).select(lambda c: c["value"])
-        )
-        self.assertListEqual([1, 2, 3, 1, 2, 3], double_simple.to_list())
-
-        simple_complex = Enumerable([1, 2, 3])
-        simple_complex.concat(
-            Enumerable(
-                [
-                    {"value": 1},
-                    {"value": 2},
-                    {"value": 3},
-                ]
-            )
-        )
-        self.assertListEqual(
-            [1, 2, 3, {"value": 1}, {"value": 2}, {"value": 3}],
-            simple_complex.to_list(),
-        )
-
-    def test_multiple_concatenate(self):
-        simple = Enumerable([1, 2, 3])
-        simple.add(4)
-        self.assertEqual(4, simple.count())
-        simple.add(5)
-        self.assertEqual(5, simple.count())
-        self.assertEqual(5, simple.last())
-
-    def test_group_by(self):
-        simple_grouped = Enumerable([1, 2, 3]).group_by(key_names=["id"])
-        self.assertEqual(3, simple_grouped.count())
-        second = simple_grouped.single(lambda s: s.key.id == 2)
-        self.assertListEqual([2], second.to_list())
-
-        complex_grouped = Enumerable(
-            [{"value": 1}, {"value": 2}, {"value": 3}]
-        ).group_by(key_names=["value"], key=lambda x: x["value"])
-        self.assertEqual(complex_grouped.count(), 3)
-        self.assertListEqual(
-            [1, 2, 3],
-            complex_grouped.select(lambda x: x.key.value)
-            .order_by(lambda x: x)
-            .to_list(),
-        )
-
-        locations_grouped = Enumerable(_locations).group_by(
-            key_names=["country", "city"], key=lambda x: [x[0], x[1]]
-        )
-        self.assertEqual(locations_grouped.count(), 7)
-
-        london = locations_grouped.single(
-            lambda c: c.key.city == "London" and c.key.country == "England"
-        )
-        self.assertEqual(240000, london.sum(lambda c: c[3]))
-
-    def test_distinct(self):
-        self.assertListEqual([], Enumerable.empty().distinct().to_list())
-        six.assertCountEqual(
-            self,
-            [1, 2, 3],
-            Enumerable([1, 2, 3]).concat(Enumerable([1, 2, 3])).distinct().to_list(),
-        )
-
-        locations = Enumerable(_locations).distinct(lambda x: x[0])
-        self.assertEqual(locations.count(), 3)
-
-    def test_default_if_empty(self):
-        self.assertListEqual([None], self.empty.default_if_empty().to_list())
-        six.assertCountEqual(self, _simple, self.simple.default_if_empty().to_list())
-        six.assertCountEqual(self, _complex, self.complex.default_if_empty().to_list())
-
-    def test_any(self):
-        self.assertFalse(Enumerable.empty().any(lambda x: x == 1))
-        self.assertFalse(Enumerable.empty().any())
-
-        self.assertTrue(Enumerable([1, 2, 3]).any(lambda x: x == 1))
-        self.assertTrue(Enumerable([1, 2, 3]).any())
-
-        self.assertTrue(Enumerable([{"value": 1}, {"value": 2}, {"value": 3}]).any())
-        self.assertFalse(
-            Enumerable([{"value": 1}, {"value": 2}, {"value": 3}]).any(
-                lambda x: x["value"] < 1
-            )
-        )
-        self.assertTrue(
-            Enumerable([{"value": 1}, {"value": 2}, {"value": 3}]).any(
-                lambda x: x["value"] >= 1
-            )
-        )
-
-    def test_contains(self):
-        self.assertFalse(self.empty.contains(1))
-        self.assertTrue(self.simple.contains(1))
-        self.assertTrue(self.complex.select(lambda x: x["value"]).contains(1))
-
-    def test_intersect(self):
-        self.assertRaises(TypeError, Enumerable.empty().intersect, [])
-        self.assertListEqual([], Enumerable().intersect(Enumerable.empty()).to_list())
-        self.assertListEqual(
-            [], Enumerable.empty().intersect(Enumerable([1, 2, 3])).to_list()
-        )
-        self.assertListEqual(
-            [1, 2, 3], Enumerable([1, 2, 3]).intersect(Enumerable([1, 2, 3])).to_list()
-        )
-        self.assertListEqual(
-            [2], Enumerable([1, 2, 3]).intersect(Enumerable([2])).to_list()
-        )
-        self.assertListEqual(
+                ),
+                lambda c: c["course"],
+            ),
+            [{"course": "Chemistry", "mark": 90}],
+        ),
+        (Enumerable().except_(Enumerable(), lambda x: x), []),
+        (Enumerable().except_(Enumerable(_simple), lambda x: x), []),
+        (Enumerable(_simple).except_(Enumerable(_simple), lambda x: x), []),
+        (Enumerable(_simple).except_(Enumerable([2]), lambda x: x), [1, 3]),
+        (Enumerable(_simple).except_(Enumerable(), lambda x: x), _simple),
+        (Enumerable(_simple).except_(Enumerable(_complex), lambda x: x["value"]), []),
+        (
+            Enumerable(_complex)
+            .select(lambda x: x["value"])
+            .except_(Enumerable(_simple), lambda x: x),
             [],
-            Enumerable([1, 2, 3])
-            .intersect(
-                Enumerable(
-                    [
-                        {"value": 1},
-                        {"value": 2},
-                        {"value": 3},
-                    ]
-                )
-            )
-            .to_list(),
-        )
-        self.assertListEqual(
-            [
-                {"value": 1},
-                {"value": 2},
-                {"value": 3},
-            ],
+        ),
+        (
+            Enumerable(_complex).except_(
+                Enumerable(_complex), key=lambda x: x["value"]
+            ),
+            [],
+        ),
+        (
+            Enumerable(_complex).except_(
+                Enumerable([{"value": 1}]), key=lambda x: x["value"]
+            ),
+            [{"value": 2}, {"value": 3}],
+        ),
+        (
             Enumerable(
                 [
-                    {"value": 1},
-                    {"value": 2},
-                    {"value": 3},
+                    {"course": "Chemistry", "mark": 90},
+                    {"course": "Biology", "mark": 85},
                 ]
-            )
-            .intersect(
+            ).except_(
                 Enumerable(
                     [
-                        {"value": 1},
-                        {"value": 2},
-                        {"value": 3},
+                        {"course": "Chemistry", "mark": 65},
+                        {"course": "Computer Science", "mark": 96},
                     ]
-                )
-            )
-            .to_list(),
-        )
-        self.assertListEqual(
-            Enumerable(
-                [
-                    {"value": 1},
-                    {"value": 2},
-                    {"value": 3},
-                ]
-            )
-            .intersect(Enumerable([{"value": 1}]))
-            .to_list(),
-            [{"value": 1}],
-        )
-
-    def test_except(self):
-        self.assertRaises(TypeError, self.empty.except_, [])
-        self.assertListEqual([], self.empty.except_(self.empty).to_list())
-        self.assertListEqual([], self.empty.except_(self.simple).to_list())
-        self.assertListEqual(_simple, self.simple.except_(self.empty).to_list())
-        self.assertListEqual([], self.simple.except_(self.simple).to_list())
-        self.assertListEqual([1, 3], self.simple.except_(Enumerable([2])).to_list())
-        self.assertListEqual(_simple, self.simple.except_(self.complex).to_list())
-        self.assertListEqual(_complex, self.complex.except_(self.simple).to_list())
-        self.assertListEqual([], self.complex.except_(self.complex).to_list())
-        self.assertListEqual(
-            [{"value": 2}, {"value": 3}],
-            self.complex.except_(Enumerable([{"value": 1}])).to_list(),
-        )
-
-    def test_marks_intersect(self):
-        marks1 = Enumerable(
-            [{"course": "Chemistry", "mark": 90}, {"course": "Biology", "mark": 85}]
-        )
-        marks2 = Enumerable(
-            [
-                {"course": "Chemistry", "mark": 65},
-                {"course": "Computer Science", "mark": 96},
-            ]
-        )
-        self.assertListEqual(
-            [{"course": "Chemistry", "mark": 90}],
-            marks1.intersect(marks2, lambda c: c["course"]).to_list(),
-        )
-
-    def test_marks_except(self):
-        marks1 = Enumerable(
-            [{"course": "Chemistry", "mark": 90}, {"course": "Biology", "mark": 85}]
-        )
-        marks2 = Enumerable(
-            [
-                {"course": "Chemistry", "mark": 65},
-                {"course": "Computer Science", "mark": 96},
-            ]
-        )
-        self.assertListEqual(
+                ),
+                lambda c: c["course"],
+            ),
             [{"course": "Biology", "mark": 85}],
-            marks1.except_(marks2, lambda c: c["course"]).to_list(),
-        )
-
-    def test_union(self):
-        self.assertListEqual([], self.empty.union(self.empty).to_list())
-        self.assertListEqual(
-            _simple, self.empty.union(self.simple).order_by(lambda x: x).to_list()
-        )
-        self.assertListEqual(
-            _simple, self.simple.union(self.empty).order_by(lambda x: x).to_list()
-        )
-        self.assertListEqual(
-            _complex,
-            self.empty.union(self.complex).order_by(lambda x: x["value"]).to_list(),
-        )
-        self.assertListEqual(
+        ),
+        (Enumerable().union(Enumerable(), lambda x: x), []),
+        (Enumerable().union(Enumerable(_simple), lambda x: x), _simple),
+        (Enumerable(_simple).union(Enumerable.empty(), lambda x: x), _simple),
+        (
+            Enumerable(_complex).union(Enumerable.empty(), lambda x: x["value"]),
             _complex,
-            self.complex.union(self.empty).order_by(lambda x: x["value"]).to_list(),
-        )
-        self.assertListEqual(
-            _simple + [4, 5],
-            self.simple.union(Enumerable([4, 5])).order_by(lambda x: x).to_list(),
-        )
-        self.assertListEqual(
-            _simple + [4, 5],
-            self.simple.union(Enumerable([1, 4, 5])).order_by(lambda x: x).to_list(),
-        )
-        self.assertListEqual(
-            _complex + [{"value": 4}, {"value": 5}],
-            self.complex.union(
+        ),
+        (Enumerable().union(Enumerable(_complex), lambda x: x["value"]), _complex),
+        (Enumerable(_simple).union(Enumerable([4, 5]), lambda x: x), [1, 2, 3, 4, 5]),
+        (
+            Enumerable(_simple).union(Enumerable([1, 4, 5]), lambda x: x),
+            [1, 2, 3, 4, 5],
+        ),
+        (
+            Enumerable(_complex).union(
                 Enumerable([{"value": 4}, {"value": 5}]), lambda x: x["value"]
-            )
-            .order_by(lambda x: x["value"])
-            .to_list(),
-        )
-        self.assertListEqual(
-            _complex + [{"value": 4}, {"value": 5}],
-            self.complex.union(
+            ),
+            [{"value": 1}, {"value": 2}, {"value": 3}, {"value": 4}, {"value": 5}],
+        ),
+        (
+            Enumerable(_complex).union(
                 Enumerable([{"value": 1}, {"value": 4}, {"value": 5}]),
                 lambda x: x["value"],
-            )
-            .order_by(lambda x: x["value"])
-            .order_by(lambda x: x["value"])
-            .to_list(),
-        )
-
-    def test_join(self):
-        self.assertRaises(TypeError, Enumerable.empty().join, [])
-        self.assertListEqual([], Enumerable().join(Enumerable()).to_list())
-        self.assertListEqual([], Enumerable().join(Enumerable([1, 2, 3])).to_list())
-        self.assertListEqual(
-            [],
-            Enumerable()
-            .join(Enumerable([{"value": 1}, {"value": 2}, {"value": 3}]))
-            .to_list(),
-        )
-
-        self.assertListEqual(
-            [(1, 1), (2, 2), (3, 3)],
-            Enumerable([1, 2, 3])
-            .join(Enumerable([1, 2, 3]))
-            .order_by(lambda x: (x[0], x[1]))
-            .to_list(),
-        )
-        self.assertListEqual(
-            [(1, 1), (2, 2), (3, 3)],
-            Enumerable([1, 2, 3])
-            .join(
-                Enumerable([{"value": 1}, {"value": 2}, {"value": 3}]),
-                inner_key=lambda x: x["value"],
-                result_func=lambda x: (x[0], x[1]["value"]),
-            )
-            .order_by(lambda x: (x[0], x[1]))
-            .to_list(),
-        )
-
-        self.assertListEqual(
-            [(1, 1), (2, 2), (3, 3)],
-            Enumerable([{"value": 1}, {"value": 2}, {"value": 3}])
-            .join(
-                Enumerable([{"value": 1}, {"value": 2}, {"value": 3}]),
-                result_func=lambda x: (x[0]["value"], x[1]["value"]),
-            )
-            .order_by(lambda x: (x[0], x[1]))
-            .to_list(),
-        )
-
-    def test_group_join(self):
-        self.assertRaises(TypeError, Enumerable.empty().group_join, [])
-        self.assertListEqual([], Enumerable().group_join(Enumerable()).to_list())
-
-        simple_empty_gj = Enumerable([1, 2, 3]).group_join(self.empty)
-        self.assertListEqual([], simple_empty_gj.to_list())
-
-        complex_simple_gj = Enumerable(
-            [{"value": 1}, {"value": 2}, {"value": 3}]
-        ).group_join(Enumerable([1, 2, 3, 3]), outer_key=lambda x: x["value"])
-        self.assertListEqual(
+            ),
+            [{"value": 1}, {"value": 2}, {"value": 3}, {"value": 4}, {"value": 5}],
+        ),
+        (Enumerable().group_join(Enumerable()), []),
+        (Enumerable(_simple).group_join(Enumerable()), []),
+        (
+            Enumerable(_complex)
+            .group_join(Enumerable([1, 2, 3, 3]), outer_key=lambda x: x["value"])
+            .select(lambda t: (t[0], t[1].to_list())),
             [({"value": 1}, [1]), ({"value": 2}, [2]), ({"value": 3}, [3, 3])],
-            complex_simple_gj.select(lambda g: (g[0], g[1].to_list())).to_list(),
-        )
-
-        simple_gj = Enumerable([1, 2, 3]).group_join(
-            Enumerable([2, 3]),
-            result_func=lambda x: {"number": x[0], "collection": x[1].to_list()},
-        )
-        self.assertEqual(2, simple_gj.count())
-        self.assertListEqual(
+        ),
+        (
+            Enumerable(_simple).group_join(
+                Enumerable([2, 3]),
+                result_func=lambda x: {"number": x[0], "collection": x[1].to_list()},
+            ),
+            [{"number": 2, "collection": [2]}, {"number": 3, "collection": [3]}],
+        ),
+        (
+            Enumerable(_locations)
+            .order_by(lambda l: l[0])
+            .then_by(lambda l: l[1])
+            .select(lambda l: f"{l[1]}, {l[0]}"),
             [
-                {"number": 2, "collection": [2]},
-                {"number": 3, "collection": [3]},
+                "Liverpool, England",
+                "Liverpool, England",
+                "London, England",
+                "London, England",
+                "London, England",
+                "Manchester, England",
+                "Manchester, England",
+                "Edinburgh, Scotland",
+                "Glasgow, Scotland",
+                "Glasgow, Scotland",
+                "Bangor, Wales",
+                "Cardiff, Wales",
+                "Cardiff, Wales",
             ],
-            simple_gj.to_list(),
-        )
-
-    def test_then_by(self):
-        locations = Enumerable(_locations)
-        self.assertRaises(
-            NullArgumentError, locations.order_by(lambda l: l[0]).then_by, None
-        )
-        self.assertListEqual(
+        ),
+        (
+            Enumerable(_locations)
+            .order_by(lambda l: l[0])
+            .then_by_descending(lambda l: l[3])
+            .select(lambda l: f"{l[1]}, {l[0]}: {l[3]}"),
             [
-                u"Liverpool, England",
-                u"Liverpool, England",
-                u"London, England",
-                u"London, England",
-                u"London, England",
-                u"Manchester, England",
-                u"Manchester, England",
-                u"Edinburgh, Scotland",
-                u"Glasgow, Scotland",
-                u"Glasgow, Scotland",
-                u"Bangor, Wales",
-                u"Cardiff, Wales",
-                u"Cardiff, Wales",
+                "London, England: 90000",
+                "London, England: 80000",
+                "London, England: 70000",
+                "Manchester, England: 50000",
+                "Manchester, England: 45600",
+                "Liverpool, England: 29700",
+                "Liverpool, England: 25000",
+                "Edinburgh, Scotland: 20000",
+                "Glasgow, Scotland: 12500",
+                "Glasgow, Scotland: 12000",
+                "Cardiff, Wales: 30000",
+                "Cardiff, Wales: 29700",
+                "Bangor, Wales: 12800",
             ],
-            locations.order_by(lambda l: l[0])
-            .then_by(lambda l: l[1])
-            .select(lambda l: u"{0}, {1}".format(l[1], l[0]))
-            .to_list(),
-        )
-
-    def test_then_by_descending(self):
-        locations = Enumerable(_locations)
-        self.assertListEqual(
+        ),
+        (Enumerable(_simple).append(4), [1, 2, 3, 4]),
+        (Enumerable(_simple).prepend(4), [4, 1, 2, 3]),
+        (Enumerable.range(1, 3), _simple),
+        (
+            Enumerable.repeat("Z", 10),
+            ["Z", "Z", "Z", "Z", "Z", "Z", "Z", "Z", "Z", "Z"],
+        ),
+        (Enumerable().reverse(), []),
+        (Enumerable(_simple).reverse(), [3, 2, 1]),
+        (Enumerable(_simple).reverse().reverse(), _simple),
+        (
+            Enumerable(
+                "the quick brown fox jumps over the lazy dog".split(" ")
+            ).reverse(),
+            ["dog", "lazy", "the", "over", "jumps", "fox", "brown", "quick", "the"],
+        ),
+        (Enumerable(range(1, 6)).skip_last(2), [1, 2, 3]),
+        (
+            Enumerable(["one", "two", "three", "four", "five"]).skip(1).skip_last(1),
+            ["two", "three", "four"],
+        ),
+        (Enumerable([1, 4, 6, 4, 1]).skip_while(lambda x: x < 5), [6, 4, 1]),
+        (Enumerable([]).skip_while(lambda x: x < 5), []),
+        (Enumerable([1, 2, 3, 4, 5]).take_last(2), [4, 5]),
+        (
+            Enumerable(["one", "two", "three", "four", "five"]).take(3).take_last(1),
+            ["three"],
+        ),
+        (Enumerable([1, 4, 6, 4, 1]).take_while(lambda x: x < 5), [1, 4]),
+        (Enumerable([]).skip_while(lambda x: x < 5), []),
+        (
+            Enumerable(["A", "B", "C", "D"]).zip(
+                Enumerable(["x", "y"]), lambda t: "{0}{1}".format(t[0], t[1])
+            ),
+            ["Ax", "By"],
+        ),
+        (Enumerable([1]).intersect(Enumerable([1]), lambda x: x), [1]),
+        (Enumerable([1]).intersect(Enumerable([1]).distinct(), lambda x: x), [1]),
+        (Enumerable([1]).distinct().intersect(Enumerable([1]), lambda x: x), [1]),
+        (
+            Enumerable(simple_generator(4)).zip(Enumerable(simple_generator(4))),
+            [(0, 0), (1, 1), (2, 2), (3, 3), (4, 4)],
+        ),
+        (
+            Enumerable(simple_generator(9))
+            .where(lambda i: i < 5)
+            .zip(Enumerable(simple_generator(9)).where(lambda i: i >= 5)),
+            [(0, 5), (1, 6), (2, 7), (3, 8), (4, 9)],
+        ),
+        (
+            Enumerable(simple_generator(9))
+            .where(lambda i: i >= 5)
+            .zip(Enumerable(simple_generator(9)).where(lambda i: i < 5)),
+            [(5, 0), (6, 1), (7, 2), (8, 3), (9, 4)],
+        ),
+        (Enumerable(simple_generator(2)), [0, 1, 2]),
+        (Enumerable((i for i in simple_generator(2))), [0, 1, 2]),
+        (
+            Enumerable(powers(2)).join(
+                Enumerable(powers(10)),
+                lambda x: x.power,
+                lambda y: y.power,
+                lambda r: (r[0].power, r[1].power),
+            ),
+            [(1, 1)],
+        ),
+    ],
+)
+def test_non_executors(enumerable: Enumerable, expected: List) -> None:
+    assert enumerable.to_list() == expected
+
+
+@pytest.mark.parametrize(
+    "enumerable, func, error",
+    [
+        (Enumerable(_simple).order_by, None, NullArgumentError),
+        (Enumerable(_simple).order_by_descending, None, NullArgumentError),
+        (Enumerable().single, lambda x: x == 0, NoMatchingElement),
+        (Enumerable().single, None, NoMatchingElement),
+        (Enumerable(_simple).single, lambda x: x == 0, NoMatchingElement),
+        (Enumerable(_simple).single, None, MoreThanOneMatchingElement),
+        (Enumerable(_complex).single, lambda x: x["value"] == 0, NoMatchingElement),
+        (Enumerable(_complex).single, None, MoreThanOneMatchingElement),
+        (Enumerable(_simple).single, lambda x: x > 0, MoreThanOneMatchingElement),
+        (
+            Enumerable(_complex).single,
+            lambda x: x["value"] > 0,
+            MoreThanOneMatchingElement,
+        ),
+        (
+            Enumerable(_simple).single_or_default,
+            lambda x: x > 0,
+            MoreThanOneMatchingElement,
+        ),
+        (
+            Enumerable(_complex).single_or_default,
+            lambda x: x["value"] > 0,
+            MoreThanOneMatchingElement,
+        ),
+        (Enumerable().join, None, TypeError),
+        (Enumerable().intersect, None, TypeError),
+        (Enumerable().except_, None, TypeError),
+        (Enumerable().union, None, TypeError),
+        (Enumerable.empty().group_join, None, TypeError),
+        (
+            Enumerable(_locations).order_by(lambda l: l[0]).then_by,
+            None,
+            NullArgumentError,
+        ),
+    ],
+)
+def test_non_executors_error(
+    enumerable: Callable, func: Callable, error: Exception
+) -> None:
+    with pytest.raises(error):
+        enumerable(func)
+
+
+@pytest.mark.parametrize(
+    "enumerable, expected",
+    [
+        (
+            Enumerable(_simple).group_by(key_names=["id"]),
             [
-                u"Liverpool, England: 29700",
-                u"Liverpool, England: 25000",
-                u"London, England: 90000",
-                u"London, England: 80000",
-                u"London, England: 70000",
-                u"Manchester, England: 50000",
-                u"Manchester, England: 45600",
-                u"Edinburgh, Scotland: 20000",
-                u"Glasgow, Scotland: 12500",
-                u"Glasgow, Scotland: 12000",
-                u"Bangor, Wales: 12800",
-                u"Cardiff, Wales: 30000",
-                u"Cardiff, Wales: 29700",
+                Grouping(Key({"id": 1}), data=[1]),
+                Grouping(Key({"id": 2}), data=[2]),
+                Grouping(Key({"id": 3}), data=[3]),
             ],
-            locations.order_by(lambda l: l[0])
-            .then_by(lambda l: l[1])
-            .then_by_descending(lambda l: l[3])
-            .select(lambda l: u"{0}, {1}: {2}".format(l[1], l[0], l[3]))
-            .to_list(),
-        )
-
-    def reverse(self, result, element):
-        return element + " " + result
-
-    def sum(self, result, element):
-        return result + element
-
-    def test_aggregate(self):
-        words = u"the quick brown fox jumps over the lazy dog".split(" ")
-        self.assertListEqual(
-            words,
-            ["the", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"],
-        )
-        test = Enumerable(words).aggregate(self.reverse)
-        self.assertEqual(test, "dog lazy the over jumps fox brown quick the")
-
-        self.assertEqual(0, Enumerable().aggregate(lambda x: x[0] + x[1], 0))
-
-        test = self.simple.aggregate(self.sum, seed=0)
-        self.assertEqual(test, 6)
-
-    def test_all(self):
-        test = Enumerable([1, 1, 1]).all(lambda x: x == 1)
-        self.assertTrue(test)
-
-        test = Enumerable([]).all(lambda x: x == 1)
-        self.assertTrue(test)
-
-        test = self.simple.all(lambda x: x == 1)
-        self.assertFalse(test)
-
-    def test_append(self):
-        test = self.simple.append(4)
-        self.assertEqual(test.count(), 4)
-        self.assertEqual(test.element_at(3), 4)
-
-    def test_prepend(self):
-        test = self.simple.prepend(4)
-        self.assertEqual(test.count(), 4)
-        self.assertEqual(test.element_at(0), 4)
-
-    def test_empty(self):
-        test = Enumerable.empty()
-        self.assertIsInstance(test, Enumerable)
-        self.assertEqual(test.count(), 0)
-
-    def test_range(self):
-        test = Enumerable.range(1, 3)
-        self.assertEqual(test.count(), 3)
-        self.assertListEqual(self.simple.to_list(), test.to_list())
-
-    def test_repeat(self):
-        test = Enumerable.repeat(u"Z", 10)
-        self.assertEqual(10, test.count())
-        self.assertEqual(u"ZZZZZZZZZZ", u"".join(test.to_list()))
-
-    def test_reverse(self):
-        test = self.empty.reverse()
-        self.assertListEqual(test.to_list(), [])
-
-        test = self.simple.reverse()
-        self.assertListEqual(test.to_list(), [3, 2, 1])
-        self.assertEqual(3, test.count())
-
-        words = u"the quick brown fox jumps over the lazy dog".split(" ")
-        self.assertListEqual(
-            words,
-            ["the", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"],
-        )
-        test = Enumerable(words).reverse()
-        self.assertEqual(
-            u" ".join(test.to_list()), u"dog lazy the over jumps fox brown quick the"
-        )
-
-    def test_skip_last(self):
-        test = Enumerable([1, 2, 3, 4, 5]).skip_last(2)
-        self.assertListEqual([1, 2, 3], test.to_list())
-
-        test = Enumerable(["one", "two", "three", "four", "five"]).skip(1).skip_last(1)
-        self.assertListEqual(test.to_list(), ["two", "three", "four"])
-
-    def test_skip_while(self):
-        test = Enumerable([1, 4, 6, 4, 1]).skip_while(lambda x: x < 5)
-        self.assertListEqual([6, 4, 1], test.to_list())
-
-        test = Enumerable([]).skip_while(lambda x: x < 5)
-        self.assertListEqual([], test.to_list())
-
-    def test_take_last(self):
-        test = Enumerable([1, 2, 3, 4, 5]).take_last(2)
-        self.assertListEqual(test.to_list(), [4, 5])
-
-        test = Enumerable(["one", "two", "three", "four", "five"]).take(3).take_last(1)
-        self.assertListEqual(test.to_list(), ["three"])
-
-    def test_take_while(self):
-        test = Enumerable([1, 4, 6, 4, 1]).take_while(lambda x: x < 5)
-        self.assertListEqual(test.to_list(), [1, 4])
-
-        test = Enumerable([]).skip_while(lambda x: x < 5)
-        self.assertListEqual(test.to_list(), [])
-
-    def test_to_dictionary(self):
-        test = Enumerable(["ab", "bc", "cd", "de"]).to_dictionary(lambda t: t[0])
-        self.assertDictEqual(test, {"a": "ab", "b": "bc", "c": "cd", "d": "de"})
-
-        test = Enumerable([
-            [0, 1, 2],
-            [3, 4, 5],
-            [6, 7, 8]
-        ]).to_dictionary(lambda t: t[0], lambda t: t[1:])
-        self.assertDictEqual(test, {0: [1, 2], 3: [4, 5], 6: [7, 8]})
-
-    def test_zip(self):
-        test = Enumerable(["A", "B", "C", "D"]).zip(
-            Enumerable(["x", "y"]), lambda t: "{0}{1}".format(t[0], t[1])
-        )
-        self.assertListEqual(test.to_list(), ["Ax", "By"])
+        ),
+        (
+            Enumerable(_complex).group_by(
+                key_names=["value"], key=lambda x: x["value"]
+            ),
+            [
+                Grouping(Key({"value": 1}), data=[{"value": 1}]),
+                Grouping(Key({"value": 2}), data=[{"value": 2}]),
+                Grouping(Key({"value": 3}), data=[{"value": 3}]),
+            ],
+        ),
+    ],
+)
+def test_group_by_structure(enumerable: Enumerable, expected: List) -> None:
+    assert len(enumerable) == len(expected)
+    for i, v in enumerate(enumerable):
+        v == expected[i]
+
+
+def test_group_by_operations() -> None:
+    locations_grouped = Enumerable(_locations).group_by(
+        key_names=["country", "city"], key=lambda x: [x[0], x[1]]
+    )
+    assert locations_grouped.count() == 7
+
+    london = locations_grouped.single(
+        lambda c: c.key.city == "London" and c.key.country == "England"
+    )
+    assert london.sum(lambda c: c[3]) == 240000
+
+
+@pytest.mark.parametrize(
+    "enumerable, args, expected",
+    [
+        (
+            Enumerable("the quick brown fox jumps over the lazy dog".split()),
+            [reverse],
+            ["dog lazy the over jumps fox brown quick the"],
+        ),
+        (Enumerable(), [lambda x: x[0] + x[1]], 0),
+        (Enumerable(_simple), [sum, 0], 6),
+    ],
+)
+def test_aggregate(enumerable: Enumerable, args: Callable, expected: Any) -> None:
+    return enumerable.aggregate(*args) == expected
+
+
+def test_repeated_first_calls() -> None:
+    """
+    This is a test case submitted for Issue 35
+    """
+    foo = Enumerable([Obj("foo", 1), Obj("bar", 2)])
+
+    assert foo.any(lambda x: x.n == "foo")
+    assert foo.any(lambda x: x.n == "foo")
+
+    filtered_foo = foo.where(lambda x: x.n == "foo")
+    assert filtered_foo.first() is not None
+    assert filtered_foo.first().n == "foo"
+    assert filtered_foo.any(lambda x: x.v == 1)
+    assert filtered_foo.any(lambda x: x.v == 1)
+
+    a = Enumerable([9, 8, -7, 6, 5])
+    b = a.order_by(lambda x: x)
+    assert b.first() == -7
+    assert list(b)[0] == -7
+    assert b.first() == -7
+
+
+def test_aggregate_callback() -> None:
+    """
+    This is a test case submitted for Issue 36
+    """
+
+    def append_to_list(lst, element):
+        lst.append(element)
+
+    filepath = os.path.join(os.getcwd(), "tests", "files", "test_file1.txt")
+    result = []
+    with io.open(filepath) as f:
+        lines = (
+            Enumerable(f)
+            .skip(1)
+            .where(lambda l: not l.startswith("#"))
+            .aggregate(append_to_list, result)
+        )
+    assert len(result) == 1
+    assert result[0] == "This line should be counted"
+
+
+def test_join_operation() -> None:
+    marks = Enumerable([(25, "a"), (49, "b"), (50, "c"), (80, "d"), (90, "e")])
+    passing = marks.where(lambda x: x[0] >= 50)
+    assert passing.to_list() == [(50, "c"), (80, "d"), (90, "e")]
+
+    omarks = Enumerable([(80, "eighty"), (49, "fortynine")])
+    join_result = omarks.join(
+        passing, lambda o: o[0], lambda y: y[0], lambda result: result
+    ).to_list()
+    assert join_result == [((80, "eighty"), (80, "d"))]
+
+
+def test_group_by_keys() -> None:
+    """
+    This is a test case for Issue 50 where grouping on an object field was giving
+    single character keys
+    """
+    test = Enumerable(
+        [MyObject("Bruce"), MyObject("Bruce"), MyObject("Fenske"), MyObject("Luke")]
+    )
+    group = test.group_by(
+        key_names=["field_name"], key=lambda r: r.get_field()
+    ).order_by(lambda g: g.key.field_name)
+    assert group[0].key.field_name == "Bruce"
+
+
+def test_group_by_keys_with_grouping_callback() -> None:
+    """
+    This is another test case for Issue 50
+    """
+
+    def get_field(item):
+        return item.get_field()
+
+    test = Enumerable(
+        [MyObject("Bruce"), MyObject("Bruce"), MyObject("Fenske"), MyObject("Luke")]
+    )
+    group = test.group_by(key_names=["field_name"], key=get_field).order_by(
+        lambda g: g.key.field_name
+    )
+    assert group[0].key.field_name == "Bruce"
+
+
+def test_add_operation() -> None:
+    """
+    This is a test case for Issue 53
+    """
+    test = Enumerable([{"name": "test", "value": "test"}])
+    test = test.add({"name": "test2", "value": "test2"})
+    assert [
+        {"name": "test", "value": "test"},
+        {"name": "test2", "value": "test2"},
+    ] == test.to_list()
+
+    test = test.add(42)
+
+    assert [
+        {"name": "test", "value": "test"},
+        {"name": "test2", "value": "test2"},
+        42,
+    ] == test.to_list()
+
+    test = Enumerable()
+    assert [] == test.to_list()
+
+    test = test.add({"name": "test", "value": "test"})
+    assert [{"name": "test", "value": "test"}] == test.to_list()
+
+    test = test.add(42)
+    assert [{"name": "test", "value": "test"}, 42] == test.to_list()
+
+
+@pytest.mark.parametrize(
+    "enumerable1, enumerable2, result_callback, expected",
+    [
+        (
+            Enumerable([{"value": 1}, {"value": 2}, {"value": 3}, {"value": 0}]),
+            Enumerable([1, 2, 3, 1, 2, 1]),
+            lambda r: (r[0], r[1].to_list()),
+            [
+                ({"value": 1}, [1, 1, 1]),
+                ({"value": 2}, [2, 2]),
+                ({"value": 3}, [3]),
+            ],
+        ),
+        (
+            Enumerable([{"value": 1}, {"value": 2}, {"value": 3}, {"value": 0}]),
+            Enumerable([1, 2, 3, 1, 2, 1]),
+            groupjoin_func,
+            [
+                ({"value": 1}, 3),
+                ({"value": 2}, 2),
+                ({"value": 3}, 1),
+            ],
+        ),
+    ],
+)
+def test_group_join(
+    enumerable1: Enumerable,
+    enumerable2: Enumerable,
+    result_callback: Callable,
+    expected: List[Any],
+) -> None:
+    result = enumerable1.group_join(
+        enumerable2,
+        outer_key=lambda x: x["value"],
+        inner_key=lambda y: y,
+        result_func=result_callback,
+    ).to_list()
+
+    assert expected == result
+
+
+def test_retain_ordering():
+    """
+    This is a test case submitted around the ordering of an Enumerable not being
+    retained around successive executor calls. This bug forced a re-implementation
+    of underlying Enumerable data structure.
+    """
+    e = Enumerable((x for x in range(0, 5)))
+    assert e.first() == 0
+    assert e.first() == 0
diff --git a/tests/test_issues.py b/tests/test_issues.py
deleted file mode 100644
index b865ff1..0000000
--- a/tests/test_issues.py
+++ /dev/null
@@ -1,294 +0,0 @@
-import os
-import io
-from unittest import TestCase
-from py_linq import Enumerable
-from tests import _empty, _simple, _complex
-from py_linq import exceptions
-import itertools
-
-
-class IssueTests(TestCase):
-    def setUp(self):
-        self.empty = Enumerable(_empty)
-        self.simple = Enumerable(_simple)
-        self.complex = Enumerable(_complex)
-
-    def test_issue19_1(self):
-        foo = Enumerable([1])
-        bar = Enumerable([1])
-        self.assertEqual(foo.intersect(bar).count(), 1)
-
-    def test_issue19_2(self):
-        foo = Enumerable([1])
-        bar = Enumerable([1]).distinct()
-        self.assertEqual(foo.intersect(bar).count(), 1)
-
-    def test_issue19_3(self):
-        foo = Enumerable([1]).distinct()
-        bar = Enumerable([1])
-        self.assertEqual(foo.intersect(bar).count(), 1)
-
-    def test_issue22(self):
-        def my_iter():
-            for i in range(10):
-                yield i
-
-        data = my_iter()
-        a = Enumerable(data)
-
-        low = a.where(lambda x: x < 5)
-        high = a.where(lambda x: x >= 5)
-
-        self.assertListEqual(
-            [(0, 5), (1, 6), (2, 7), (3, 8), (4, 9)], low.zip(high).to_list()
-        )
-
-        self.assertListEqual(
-            [(0, 5), (1, 6), (2, 7), (3, 8), (4, 9)], list(zip(low, high))
-        )
-
-    def test_generator_to_Enumerable(self):
-        def first_3():
-            for i in range(3):
-                yield i
-
-        p2 = Enumerable(first_3())
-        self.assertListEqual([0, 1, 2], p2.to_list())
-
-        p3 = Enumerable((i for i in first_3()))
-        self.assertListEqual([0, 1, 2], p3.to_list())
-
-    def test_issue22_join(self):
-        class Val(object):
-            def __init__(self, number, power):
-                self.number = number
-                self.power = power
-
-            def __str__(self):
-                return "VAL {0}: {1}".format(self.number, self.power)
-
-        def powers_of_2():
-            for i in range(2):
-                yield Val(i, 2 ** i)
-
-        def powers_of_10():
-            for i in range(2):
-                yield Val(i, 10 ** i)
-
-        en2 = Enumerable(powers_of_2())
-        en10 = Enumerable(powers_of_10())
-        joined = en2.join(
-            en10,
-            lambda x: x.number,
-            lambda y: y.number,
-            lambda r: (r[0].power, r[1].power),
-        )
-        truth = zip([2 ** i for i in range(2)], [10 ** y for y in range(2)])
-        self.assertListEqual(list(truth), joined.to_list())
-
-    def test_first_with_lambda(self):
-        self.assertRaises(IndexError, self.empty.first, lambda x: x == 0)
-        self.assertEqual(2, self.simple.first(lambda x: x == 2))
-        self.assertDictEqual(
-            {"value": 2}, self.complex.first(lambda x: x["value"] == 2)
-        )
-
-    def test_first_or_default_with_lambda(self):
-        self.assertIsNone(self.empty.first_or_default(lambda x: x == 0))
-        self.assertEqual(
-            self.simple.first(lambda x: x == 2),
-            self.simple.first_or_default(lambda x: x == 2),
-        )
-        self.assertEqual(
-            self.complex.first(lambda x: x["value"] == 2),
-            self.complex.first_or_default(lambda x: x["value"] == 2),
-        )
-
-    def test_last_with_lambda(self):
-        self.assertRaises(IndexError, self.empty.last, lambda x: x == 0)
-        self.assertEqual(2, self.simple.last(lambda x: x == 2))
-        self.assertDictEqual({"value": 2}, self.complex.last(lambda x: x["value"] == 2))
-        self.assertEqual(
-            self.simple.first(lambda x: x == 2), self.simple.last(lambda x: x == 2)
-        )
-
-    def test_last_or_default_with_lambda(self):
-        self.assertIsNone(self.empty.last_or_default(lambda x: x == 0))
-        self.assertEqual(
-            self.simple.last(lambda x: x == 2),
-            self.simple.last_or_default(lambda x: x == 2),
-        )
-        self.assertEqual(
-            self.complex.last(lambda x: x["value"] == 2),
-            self.complex.last_or_default(lambda x: x["value"] == 2),
-        )
-        self.assertEqual(
-            self.simple.first_or_default(lambda x: x == 2),
-            self.simple.last_or_default(lambda x: x == 2),
-        )
-
-    def test_issue_34(self):
-        class Obj(object):
-            def __init__(self, n, v):
-                self.n = n
-                self.v = v
-
-        foo = Enumerable([Obj("foo", 1), Obj("bar", 2)])
-        self.assertTrue(foo.any(lambda x: x.n == "foo"))
-        self.assertTrue(foo.any(lambda x: x.n == "foo"))
-        filtered_foo = foo.where(lambda x: x.n == "foo")
-        self.assertIsNotNone(filtered_foo.first())
-        self.assertEqual("foo", filtered_foo.first().n)
-        self.assertTrue(filtered_foo.any(lambda x: x.v == 1))
-        self.assertTrue(filtered_foo.any(lambda x: x.v == 1))
-
-    def test_issue_35(self):
-        a = Enumerable([9, 8, -7, 6, 5])
-        b = a.order_by(lambda x: x)
-        self.assertEqual(-7, b.first())
-        self.assertEqual(-7, list(b)[0])
-        self.assertEqual(-7, b.first())
-
-    def test_issue_36(self):
-        def append_to_list(lst, element):
-            lst.append(element)
-
-        filepath = os.path.join(os.getcwd(), "tests", "files", "test_file1.txt")
-        result = []
-        with io.open(filepath) as f:
-            lines = (
-                Enumerable(f)
-                .skip(1)
-                .where(lambda l: not l.startswith("#"))
-                .aggregate(append_to_list, result)
-            )
-        self.assertEqual(1, len(result))
-        self.assertEqual("This line should be counted", result[0])
-
-    def test_issue_47(self):
-        marks = Enumerable([(25, "a"), (49, "b"), (50, "c"), (80, "d"), (90, "e")])
-        passing = marks.where(lambda x: x[0] >= 50)
-        self.assertListEqual([(50, "c"), (80, "d"), (90, "e")], passing.to_list())
-        omarks = Enumerable([(80, "eighty"), (49, "fortynine")])
-        join_result = omarks.join(
-            passing, lambda o: o[0], lambda y: y[0], lambda result: result
-        ).to_list()
-        self.assertListEqual([((80, "eighty"), (80, "d"))], join_result)
-
-    def test_empty_join(self):
-        marks = Enumerable([(25, "a"), (49, "b"), (50, "c"), (80, "d"), (90, "e")])
-        passing = marks.where(lambda x: x[0] > 90)
-        self.assertListEqual([], passing.to_list())
-        omarks = Enumerable([(80, "eighty"), (49, "fortynine")])
-        join_result = omarks.join(
-            passing, lambda o: o[0], lambda y: y[0], lambda result: result
-        ).to_list()
-        self.assertListEqual([], join_result)
-
-    def test_issue_50(self):
-        """
-        Only single char for key value on group_by
-        """
-
-        class MyObject(object):
-            def __init__(self, name):
-                self.field = name
-
-            def get_field(self):
-                return self.field
-
-        test = Enumerable([MyObject("Bruce"), MyObject("Bruce"), MyObject("Fenske"), MyObject("Luke")])
-        group = test.group_by(key_names=['field_name'], key=lambda r: r.get_field()) \
-            .order_by(lambda g: g.key.field_name)
-        self.assertEqual("Bruce", group[0].key.field_name)
-
-    def test_issue_50_2(self):
-        class MyObject(object):
-            def __init__(self, name):
-                self.field = name
-
-            def get_field(self):
-                return self.field
-
-        def get_field(item):
-            return item.get_field()
-
-        test = Enumerable(
-            [MyObject("Bruce"), MyObject("Bruce"), MyObject("Fenske"), MyObject("Luke")]
-        )
-        group = test.group_by(key_names=["field_name"], key=get_field).order_by(
-            lambda g: g.key.field_name
-        )
-        self.assertEqual("Bruce", group[0].key.field_name)
-
-    def test_issue_53(self):
-        test = Enumerable([{"name": "test", "value": "test"}])
-        test.add({"name": "test2", "value": "test2"})
-        self.assertListEqual(
-            [
-                {"name": "test", "value": "test"},
-                {"name": "test2", "value": "test2"},
-            ],
-            test.to_list(),
-        )
-        test.add(42)
-        self.assertListEqual(
-            [
-                {"name": "test", "value": "test"},
-                {"name": "test2", "value": "test2"},
-                42,
-            ],
-            test.to_list(),
-        )
-
-    def test_issue_53_2(self):
-        test = Enumerable()
-        self.assertListEqual([], test.to_list())
-        test.add({"name": "test", "value": "test"})
-        self.assertListEqual([{"name": "test", "value": "test"}], test.to_list())
-        test.add(42)
-        self.assertListEqual([{"name": "test", "value": "test"}, 42], test.to_list())
-
-    def test_issue_52(self):
-        def func(r):
-            return r
-
-        e1 = Enumerable([{"value": 1}, {"value": 2}, {"value": 3}, {"value": 0}])
-        e2 = Enumerable([1, 2, 3, 1, 2, 1])
-        res = e1.group_join(
-            e2,
-            outer_key=lambda x: x["value"],
-            inner_key=lambda y: y,
-            result_func=lambda r: func(r),
-        )
-
-        expected = [
-            ({"value": 1}, {"key": "{'id': 1}", "enumerable": "[1, 1, 1]"}),
-            ({"value": 2}, {"key": "{'id': 2}", "enumerable": "[2, 2]"}),
-            ({"value": 3}, {"key": "{'id': 3}", "enumerable": "[3]"}),
-        ]
-
-        self.assertEqual(len(expected), res.count())
-        self.assertEqual(3, next(res)[1].count())
-        self.assertEqual(2, next(res)[1].count())
-        self.assertEqual(1, next(res)[1].count())
-
-    def test_issue_52_2(self):
-
-        e1 = Enumerable([{"value": 1}, {"value": 2}, {"value": 3}, {"value": 0}])
-        e2 = Enumerable([1, 2, 3, 1, 2, 1])
-        res = e1.group_join(
-            e2,
-            outer_key=lambda x: x["value"],
-            inner_key=lambda y: y,
-            result_func=lambda r: (r[0], r[1].to_list()),
-        )
-
-        self.assertListEqual(
-            [
-                ({"value": 1}, [1, 1, 1]),
-                ({"value": 2}, [2, 2]),
-                ({"value": 3}, [3]),
-            ],
-            res.to_list(),
-        )
diff --git a/tests/test_performance.py b/tests/test_performance.py
index bb1d5c0..edbabf0 100644
--- a/tests/test_performance.py
+++ b/tests/test_performance.py
@@ -1,69 +1,74 @@
-import unittest
-import logging
+# import unittest
+# import logging
 
-try:
-    from time import clock as clock
-except ImportError:
-    from time import perf_counter as clock
-import time
-from py_linq import Enumerable
+# try:
+#     from time import clock as clock
+# except ImportError:
+#     from time import perf_counter as clock
+# import time
+# from py_linq import Enumerable
+# import pytest
 
 
-class GeneratorPerformanceTests(unittest.TestCase):
-    def setUp(self):
-        self.test_generator = ({"id": x, "value": x} for x in range(0, 80000))
-        self.num_experiments = 100
-        self.logger = logging.getLogger(__name__)
+# class GeneratorPerformanceTests(unittest.TestCase):
+#     def setUp(self):
+#         self.test_generator = ({"id": x, "value": x} for x in range(0, 80000))
+#         self.num_experiments = 100
+#         self.logger = logging.getLogger(__name__)
 
-    def test_constructor(self):
-        tic = clock()
-        for i in range(0, self.num_experiments):
-            e = Enumerable(self.test_generator)
-        toc = clock()
-        py_linq_time = (toc - tic) / self.num_experiments
-        self.assertTrue(py_linq_time < 0.035)
+#     def test_constructor(self):
+#         tic = clock()
+#         for i in range(0, self.num_experiments):
+#             e = Enumerable(self.test_generator)
+#         toc = clock()
+#         py_linq_time = (toc - tic) / self.num_experiments
+#         self.assertTrue(py_linq_time < 0.035)
 
-    def test_select(self):
-        tic = clock()
-        for i in range(0, self.num_experiments):
-            e = Enumerable(self.test_generator).select(lambda b: {"id": b["id"]})
-        toc = clock()
-        py_linq_time = (toc - tic) / self.num_experiments
-        self.assertTrue(py_linq_time < 0.0035)
+#     def test_select(self):
+#         tic = clock()
+#         for i in range(0, self.num_experiments):
+#             e = Enumerable(self.test_generator).select(lambda b: {"id": b["id"]})
+#         toc = clock()
+#         py_linq_time = (toc - tic) / self.num_experiments
+#         self.assertTrue(py_linq_time < 0.0035)
 
-    def test_to_list(self):
-        num_experiments = 10
-        tic = clock()
-        for i in range(0, num_experiments):
-            e = (
-                Enumerable(({"id": x, "value": x} for x in range(0, 80000)))
-                .select(lambda b: {"id": b["id"]})
-                .to_list()
-            )
-        toc = clock()
-        py_linq_time = (toc - tic) / num_experiments
+#     @pytest.mark.xfail(reason="Performance of to_list function needs to be improved")
+#     def test_to_list(self):
+#         """
+#         This performance really needs to be improved.
+#         """
+#         num_experiments = 10
+#         tic = clock()
+#         for i in range(0, num_experiments):
+#             e = (
+#                 Enumerable(({"id": x, "value": x} for x in range(0, 80000)))
+#                 .select(lambda b: {"id": b["id"]})
+#                 .to_list()
+#             )
+#         toc = clock()
+#         py_linq_time = (toc - tic) / num_experiments
 
-        tic = clock()
-        for i in range(0, num_experiments):
-            l = list(
-                map(
-                    lambda b: {"id": b["id"]},
-                    ({"id": x, "value": x} for x in range(0, 80000)),
-                )
-            )
-        toc = clock()
-        python_list_time = (toc - tic) / num_experiments
+#         tic = clock()
+#         for i in range(0, num_experiments):
+#             l = list(
+#                 map(
+#                     lambda b: {"id": b["id"]},
+#                     ({"id": x, "value": x} for x in range(0, 80000)),
+#                 )
+#             )
+#         toc = clock()
+#         python_list_time = (toc - tic) / num_experiments
 
-        tic = clock()
-        for i in range(0, num_experiments):
-            l = [
-                i
-                for i in map(
-                    lambda b: {"id": b["id"]},
-                    ({"id": x, "value": x} for x in range(0, 80000)),
-                )
-            ]
-        toc = clock()
-        python_list_comp_time = (toc - tic) / num_experiments
+#         tic = clock()
+#         for i in range(0, num_experiments):
+#             l = [
+#                 i
+#                 for i in map(
+#                     lambda b: {"id": b["id"]},
+#                     ({"id": x, "value": x} for x in range(0, 80000)),
+#                 )
+#             ]
+#         toc = clock()
+#         python_list_comp_time = (toc - tic) / num_experiments
 
-        self.assertTrue(py_linq_time < python_list_comp_time * 10)
+#         self.assertTrue(py_linq_time < python_list_comp_time * 14)
diff --git a/tests/test_repeatable.py b/tests/test_repeatable.py
new file mode 100644
index 0000000..bed0754
--- /dev/null
+++ b/tests/test_repeatable.py
@@ -0,0 +1,46 @@
+import pytest
+import itertools
+from py_linq.core import RepeatableIterable
+
+
+def test_iter_none() -> None:
+    ri = RepeatableIterable()
+    assert sum((1 for item in ri)) == 0
+
+
+def test_iter_range() -> None:
+    ri = RepeatableIterable(range(0, 5))
+    for i, v in enumerate(ri):
+        assert i == v
+    assert ri.current.value == 0
+
+
+def test_len() -> None:
+    ri = RepeatableIterable(range(0, 3))
+    assert len(ri) == 3
+    assert ri.current.value == 0
+
+
+def test_next() -> None:
+    ri = RepeatableIterable(range(0, 2))
+    assert len(ri) == 2
+    assert ri.current.value == 0
+    assert next(ri) == 1
+    with pytest.raises(StopIteration):
+        next(ri)
+
+
+def test_iter_generator() -> None:
+    def next_name() -> str:
+        for n in ("Bruce", "viralogic", "software"):
+            yield n
+
+    ri = RepeatableIterable(next_name())
+    assert len(ri) == 3
+    assert next(ri) == "viralogic"
+
+
+def test_reverse_chain() -> None:
+    ri = RepeatableIterable(itertools.chain(range(1, 4), [4]))
+    reversed_ri = reversed(ri)
+    assert [4, 3, 2, 1] == list(reversed_ri)