├── .github └── workflows │ └── python-publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── linqit ├── __init__.py └── linq_list.py ├── setup.py └── tests ├── __init__.py └── test_list.py /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: '3.x' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install build 33 | - name: Build package 34 | run: python -m build 35 | - name: Publish package 36 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 37 | with: 38 | user: __token__ 39 | password: ${{ secrets.PYPI_API_TOKEN }} 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | .idea 104 | .vscode -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Linqit! 2 | Extends python's list builtin with fun, robust functionality - .NET's Language Integrated Queries (Linq) and more.
3 | Write clean code with powerful syntax.

4 | ```shell script 5 | pip install linqit 6 | ``` 7 |
8 | 9 |
10 |
11 | 12 | 13 | Stop using loops, complex conditions, list comprehension and filters.
14 | Doesn't it looks better?
15 | ```python 16 | from linqit import List 17 | 18 | # Go ahead and fill the list with whatever you want... like a list of objects. 19 | programmers = List() 20 | Avi = type("Avi", (), {}) 21 | Entrepreneur = type("Entrepreneur", ('talented'), {}) 22 | elon_musk = Entrepreneur(talented=True) 23 | 24 | # Then play: 25 | last_hot_pizza_slice = ( 26 | programmers.where(lambda e: e.experience > 15) 27 | .except_for(elon_musk) 28 | .of_type(Avi) 29 | .take(3) # [, , ] 30 | .select(lambda avi: avi.lunch) # [, , ] 31 | .where(lambda p: p.is_hot() and p.origin != "Pizza Hut") 32 | .last() # 33 | .slices.last() # 34 | ) 35 | 36 | # What do you think? 37 | ``` 38 | 39 | We all use multiple aggregations in our code, while multiple filters/comprehensions are not pythonic at all.
40 | The whole idea is is to use it for nested, multiple filters/modifications :).
41 | Some of the methods might look ridiculous for a single calls, comparing to the regular python syntax.
42 | Here are some use cases:
43 | 44 | #### Methods: 45 | ``` 46 | all 47 | any 48 | concat 49 | contains 50 | distinct 51 | except_for 52 | first 53 | get_by_attr 54 | intersect 55 | last 56 | select 57 | skip 58 | take 59 | where 60 | of_type 61 | ``` 62 | #### Properties: 63 | ``` 64 | sum 65 | min 66 | max 67 | avg 68 | sorted 69 | ``` 70 | 71 | ## Deeper - Let's play with a list of people, a custom type. 72 | ```python 73 | import List 74 | 75 | class Person(): 76 | def __init__(self, name, age): 77 | self.name = name 78 | self.age = age 79 | 80 | def __repr__(self): 81 | return f'Person(name="{self.name}", age={self.age})') 82 | 83 | 84 | # Creating a list of people 85 | avi, bill, bob, harry = Person('Avi', 23), Person('Bill', 41), Person('Bob', 77), Person('Harry', 55) 86 | 87 | people = List(avi, bill, bob, harry) 88 | ``` 89 | 90 | ## Use LINQ selections, write cleaner code 91 | ```python 92 | people = people.where(lambda p: p.age > 23) # [, , ] 93 | people.first() # 94 | people.last() # 95 | people.any(lambda p: p.name.lower().startswith('b')) # True 96 | people.where(age=55) # [] 97 | people.skip(3).any() # False 98 | people.skip(2).first() # 99 | 100 | # Isn't it better than "for", "if", "else", "filter", "map" and list comprehensions in the middle of your code? 101 | 102 | ``` 103 | ## More selections 104 | ```python 105 | new_kids_in_town = [Person('Chris', 18), Person('Danny', 16), Person('John', 17)] 106 | people += new_kids_in_town # Also works: people = people.concat(new_kids_in_town) 107 | 108 | teenagers = people.where(lambda p: 20 >= p.age >= 13) 109 | danny = teenagers.first(lambda t: t.name == 'Danny') # 110 | oldest_teen = teenagers.order_by(lambda t: t.age).last() # 111 | ``` 112 | 113 | ## Let's make python more dynamic 114 | ```python 115 | names = people.name # ['Avi', 'Bill', 'Bob', 'Harry', 'Chris', 'John'] 116 | ages = people.age # [23, 41, 77, 55, 18, 17] 117 | teenagers_names = teenagers.name # ['Chris', 'Danny', 'John'] 118 | teenagers_names.take(2).except_for(lambda n: n == 'Danny') # ['Chris'] 119 | teenagers.age.min # 16 120 | teenagers.age.avg # 17 121 | teenagers.age.max # 18 122 | ``` 123 | 124 | # Test Coverage 125 | ```python 126 | ➜ linqit git:(master) ✗ coverage report 127 | Name Stmts Miss Cover 128 | ----------------------------------------- 129 | linqit/__init__.py 2 0 100% 130 | linqit/linq_list.py 101 11 89% 131 | tests/__init__.py 0 0 100% 132 | tests/test_list.py 203 0 100% 133 | ----------------------------------------- 134 | TOTAL 306 11 96% 135 | ``` 136 | -------------------------------------------------------------------------------- /linqit/__init__.py: -------------------------------------------------------------------------------- 1 | from linqit.linq_list import ( 2 | List, 3 | ) 4 | 5 | __all__ = ["List"] 6 | -------------------------------------------------------------------------------- /linqit/linq_list.py: -------------------------------------------------------------------------------- 1 | from functools import ( 2 | reduce, 3 | ) 4 | from itertools import ( 5 | chain, 6 | ) 7 | 8 | _DEFAULT_LAZY = True 9 | _NO_EXPR = lambda x:x 10 | 11 | # A default variable for the function, so None as an argument will be valid, but not default. 12 | _NONE = type('_NONE', (object,), {}) 13 | 14 | class List(list): 15 | """ 16 | Extends Python's built-in list with additional functionality. 17 | Inspired by .NET's Language Integrated Queries (LINQ). 18 | This class wraps an iterable of objects. 19 | It enables them to be manipulated as a single entity. 20 | """ 21 | 22 | def __init__(self, *objects): 23 | """ 24 | Initializes a new List instance. 25 | 26 | :param objects: An iterable of objects to be contained. 27 | :type objects: iterable 28 | """ 29 | super(List, self,).__init__( 30 | objects[0] 31 | if len(objects) == 1 32 | and hasattr( 33 | objects[0], 34 | "__iter__", 35 | ) 36 | else objects 37 | ) 38 | 39 | def __getitem__(self, item): 40 | """ 41 | Retrieves the item(s) from the list at the specified index or slice. 42 | 43 | :param item: Index or slice specifying the item(s) to retrieve. 44 | :type item: int or slice 45 | :return: The item(s) from the list. 46 | :rtype: List or object 47 | """ 48 | _item = super( 49 | List, 50 | self, 51 | ).__getitem__(item) 52 | 53 | if isinstance( 54 | item, 55 | slice, 56 | ): 57 | _item = List(_item) 58 | return _item 59 | 60 | def __getattr__(self, item): 61 | """ 62 | Retrieves the attribute(s) with the specified name from the objects in the list. 63 | 64 | :param item: The attribute to retrieve. 65 | :type item: str 66 | :return: The attribute(s) from the objects in the list. 67 | :rtype: List or object 68 | :raises AttributeError: If the attribute does not exist in any object. 69 | """ 70 | relevant_objects = list( 71 | filter( 72 | lambda obj: hasattr( 73 | obj, 74 | item, 75 | ), 76 | self, 77 | ) 78 | ) 79 | if not relevant_objects: 80 | raise AttributeError(item) 81 | 82 | attributes_values = List( 83 | [ 84 | getattr( 85 | obj, 86 | item, 87 | ) 88 | for obj in relevant_objects 89 | ] 90 | ) 91 | 92 | if all( 93 | hasattr( 94 | attr_value, 95 | "__iter__", 96 | ) 97 | and not isinstance( 98 | attr_value, 99 | str, 100 | ) 101 | for attr_value in attributes_values 102 | ): 103 | return List(chain.from_iterable(attributes_values)) 104 | elif all(callable(attr_value) for attr_value in attributes_values): 105 | return lambda *args: List( 106 | [ 107 | attr_value(*args) 108 | for obj, attr_value in zip( 109 | relevant_objects, 110 | attributes_values, 111 | ) 112 | ] 113 | ) 114 | 115 | return attributes_values 116 | 117 | def __add__(self, other): 118 | """ 119 | Concatenates this list with another list or iterable. 120 | 121 | :param other: The list or iterable to concatenate. 122 | :type other: list or iterable 123 | :return: A new List containing the concatenated items. 124 | :rtype: List 125 | """ 126 | added_list = super( 127 | List, 128 | self, 129 | ).__add__(other) 130 | return List(added_list) 131 | 132 | def __mul__(self, other): 133 | """ 134 | Multiplies this list by an integer value. 135 | 136 | :param other: The integer value to multiply the list. 137 | :type other: int 138 | :return: A new List containing the multiplied items. 139 | :rtype: List 140 | """ 141 | multi_list = super( 142 | List, 143 | self, 144 | ).__mul__(other) 145 | return List(multi_list) 146 | 147 | @property 148 | def sum(self): 149 | """ 150 | Calculates the sum of all the values in the list. 151 | 152 | :return: The sum of the values. 153 | :rtype: object 154 | """ 155 | return sum(self) 156 | 157 | @property 158 | def min(self): 159 | """ 160 | Finds the lowest value in the list. 161 | 162 | :return: The lowest value. 163 | :rtype: object 164 | """ 165 | return min(self) 166 | 167 | @property 168 | def max(self): 169 | """ 170 | Finds the highest value in the list. 171 | 172 | :return: The highest value. 173 | :rtype: object 174 | """ 175 | return max(self) 176 | 177 | @property 178 | def avg(self): 179 | """ 180 | Calculates the average of the numerical values in the list. 181 | :return: The average of the values. 182 | :rtype: float 183 | """ 184 | return reduce( 185 | lambda x, y: x + y, 186 | self, 187 | ) / len(self) 188 | 189 | @property 190 | def sorted( 191 | self, 192 | ): 193 | """ 194 | Returns a new sorted list of the elements. 195 | 196 | :return: A new sorted list. 197 | :rtype: List 198 | """ 199 | return sorted(self) 200 | 201 | def all( 202 | self, 203 | expression=_NO_EXPR, 204 | ): 205 | """ 206 | Checks if all objects in the list satisfy the given expression. 207 | 208 | :param expression: The expression to evaluate for each object. 209 | :type expression: function 210 | :return: True if all objects satisfy the expression, False otherwise. 211 | :rtype: bool 212 | """ 213 | if self: 214 | for i in self: 215 | if not expression(i): 216 | return False 217 | return True 218 | 219 | 220 | def any( 221 | self, 222 | expression=_NO_EXPR, 223 | ): 224 | """ 225 | Checks if any object in the list satisfies the given expression. 226 | 227 | :param expression: The expression to evaluate for each object. 228 | :type expression: function 229 | :return: True if any object satisfies the expression, False otherwise. 230 | :rtype: bool 231 | """ 232 | if self: 233 | for i in self: 234 | if expression(i): 235 | return True 236 | return False # for an empty iterable, all returns False! 237 | 238 | def concat(self, second): 239 | """ 240 | Concatenates this list with another list or iterable. 241 | 242 | :param second: The list or iterable to concatenate. 243 | :type second: list or iterable 244 | :return: A new List containing the concatenated items. 245 | :rtype: List 246 | """ 247 | return List(self + second) 248 | 249 | def contains(self, item): 250 | """ 251 | Checks if the list contains the specified item. 252 | 253 | :param item: The item to check for. 254 | :return: True if the item is in the list, False otherwise. 255 | :rtype: bool 256 | """ 257 | return item in self 258 | 259 | def distinct( 260 | self, 261 | ): 262 | """ 263 | Returns a new list with no duplicate items. 264 | 265 | :return: A new list with distinct items. 266 | :rtype: List 267 | """ 268 | return List(set(self)) 269 | 270 | def except_for( 271 | self, 272 | expression, 273 | ): 274 | """ 275 | Returns a new list containing the objects that do not satisfy the given expression. 276 | 277 | :param expression: The expression to evaluate for each object. 278 | :type expression: function 279 | :return: A new List containing the filtered objects. 280 | :rtype: List 281 | """ 282 | return List( 283 | filter( 284 | lambda e: not expression(e), 285 | self, 286 | ) 287 | ) 288 | 289 | def first( 290 | self, 291 | expression=_NO_EXPR, default=_NONE 292 | ): 293 | """ 294 | Returns the first object that satisfies the given expression, or the first element if no expression is provided. 295 | 296 | :param expression: The expression to evaluate for each object. 297 | :type expression: function or None 298 | :param default: The default value to return if no matching value is found. 299 | :return: The first matching object or the default value. 300 | :rtype: object 301 | :raises IndexError: If no matching value is found and no default value is provided. 302 | """ 303 | if self: 304 | for el in self: 305 | if expression(el): 306 | return(el) 307 | if default != _NONE: 308 | return default 309 | else: 310 | raise IndexError('No matching values') 311 | 312 | 313 | def get_by_attr(self, attr): 314 | """ 315 | Retrieves all objects in the list that have the specified attribute. 316 | 317 | :param attr: The attribute name. 318 | :type attr: str 319 | :return: A new List containing the objects with the attribute. 320 | :rtype: List 321 | """ 322 | try: 323 | return getattr( 324 | self, 325 | attr, 326 | ) 327 | except AttributeError: 328 | return List() 329 | 330 | def intersect(self, second): 331 | """ 332 | Returns a new list containing the objects that are present in both this list and the second list. 333 | 334 | :param second: The list or iterable to intersect with. 335 | :type second: list or iterable 336 | :return: A new List containing the intersecting objects. 337 | :rtype: List 338 | """ 339 | return List( 340 | filter( 341 | lambda e: e in second, 342 | self, 343 | ) 344 | ) 345 | 346 | def last( 347 | self, 348 | expression=_NO_EXPR, 349 | default=_NONE, 350 | ): 351 | """ 352 | Returns the last object that satisfies the given expression, or the last element if no expression is provided. 353 | 354 | :param expression: The expression to evaluate for each object. 355 | :type expression: function or None 356 | :param default: The default value to return if no matching value is found. 357 | :return: The last matching object or the default value. 358 | :rtype: object 359 | :raises IndexError: If no matching value is found and no default value is provided. 360 | """ 361 | return List(reversed(self)).first( 362 | expression, 363 | default, 364 | ) 365 | 366 | def order_by( 367 | self, 368 | expression=None, 369 | ): 370 | """ 371 | Returns a new list of objects sorted according to the provided expression. 372 | If no expression is given, the default sort is used. 373 | 374 | :param expression: The expression to determine the sorting order. 375 | :type expression: function or None 376 | :return: A new sorted List. 377 | :rtype: List 378 | """ 379 | sorted_data = sorted( 380 | self, 381 | key=expression, 382 | ) 383 | return List(sorted_data) 384 | 385 | def select( 386 | self, 387 | expression, 388 | ): 389 | """ 390 | Returns a new list containing the values obtained by applying the expression to each object in the list. 391 | 392 | :param expression: The expression to transform each object. 393 | :type expression: function 394 | :return: A new List containing the transformed values. 395 | :rtype: List 396 | """ 397 | return List( 398 | map( 399 | expression, 400 | self, 401 | ) 402 | ) 403 | 404 | def skip(self, count): 405 | """ 406 | Returns a new list containing the elements starting from the specified index. 407 | 408 | :param count: The number of elements to skip. 409 | :type count: int 410 | :return: A new List containing the remaining elements. 411 | :rtype: List 412 | """ 413 | return List(self[count:]) 414 | 415 | def take(self, count): 416 | """ 417 | Returns a new list containing the first n elements. 418 | 419 | :param count: The number of elements to take. 420 | :type count: int 421 | :return: A new List containing the first n elements. 422 | :rtype: List 423 | """ 424 | if not self or count == 0: 425 | return List() 426 | return self[:count] 427 | 428 | def where(self, expression=None, **filters): 429 | """ 430 | Returns a new list containing the objects that satisfy the given expression and filters. 431 | 432 | :param expression: The expression to evaluate for each object. 433 | :type expression: function or None 434 | :param filters: Additional attribute filters to apply. 435 | :type filters: dict 436 | :return: A new List containing the filtered objects. 437 | :rtype: List 438 | """ 439 | 440 | def filter_function( 441 | x, 442 | ): 443 | return (expression is None or expression(x)) and all( 444 | [ 445 | getattr( 446 | x, 447 | key, 448 | ) 449 | == value 450 | for key, value in filters.items() 451 | ] 452 | ) 453 | 454 | selection = filter( 455 | filter_function, 456 | self, 457 | ) 458 | return List(selection) 459 | 460 | def of_type(self, _type): 461 | """ 462 | Returns a new list containing the objects of the specified type. 463 | 464 | :param _type: The type of objects to filter. 465 | :type _type: type 466 | :return: A new List containing the filtered objects. 467 | :rtype: List 468 | :raises TypeError: If the argument is not a type. 469 | """ 470 | if not isinstance( 471 | _type, 472 | type, 473 | ): 474 | raise TypeError("The argument must be a type") 475 | return self.where( 476 | lambda e: isinstance( 477 | e, 478 | _type, 479 | ) 480 | ) 481 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="linqit", 8 | version="0.1.5", 9 | author="Avi Lumelsky", 10 | author_email="noticetheg@gmail.com", 11 | description="Extends python's list builtin with fun, robust functionality - " 12 | ".NET's Language Integrated Queries (Linq) and more. Write clean code with powerful syntax.", 13 | long_description=long_description, 14 | long_description_content_type="text/markdown", 15 | url="https://github.com/avilum/linqit", 16 | packages=setuptools.find_packages(), 17 | classifiers=[ 18 | "Programming Language :: Python :: 2", 19 | "Programming Language :: Python :: 3", 20 | "License :: OSI Approved :: Apache Software License", 21 | "Operating System :: OS Independent", 22 | ], 23 | python_requires=">=2.7,>=3.6", 24 | ) 25 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avilum/linqit/1eb357cf215b5382fdb3b82c6d4af286d5aebc17/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_list.py: -------------------------------------------------------------------------------- 1 | from datetime import ( 2 | datetime, 3 | ) 4 | from unittest import ( 5 | TestCase, 6 | ) 7 | 8 | from linqit import ( 9 | List, 10 | ) 11 | 12 | 13 | class Mock(object): 14 | pass 15 | 16 | 17 | counter = 0 18 | 19 | 20 | def func_samson(x): 21 | global counter 22 | counter += 1 23 | return x.last_name == "samson" 24 | 25 | 26 | def func_lee(x): 27 | global counter 28 | counter += 1 29 | return x.last_name == "lee" 30 | 31 | 32 | class Person(object): 33 | def __init__( 34 | self, 35 | first_name, 36 | last_name, 37 | hobby, 38 | age, 39 | ): 40 | self.first_name = first_name 41 | self.last_name = last_name 42 | self.hobby = hobby 43 | self.age = age 44 | self.full_name = " ".join( 45 | [ 46 | self.first_name, 47 | self.last_name, 48 | ] 49 | ) 50 | 51 | def do_something( 52 | self, 53 | ): 54 | return "{name} is {what}.".format( 55 | name=self.full_name, 56 | what=self.hobby.lower(), 57 | ) 58 | 59 | 60 | class ListTests(TestCase): 61 | """ 62 | UnitTests (edge cases tests) of the List class 63 | """ 64 | 65 | def setUp(self): 66 | self.list = self._get_dynamic_list_with_mocks() 67 | 68 | def _get_dynamic_list_with_mocks( 69 | self, 70 | ): 71 | a, b = ( 72 | Mock(), 73 | Mock(), 74 | ) 75 | a.name = "bob" 76 | a.age = 20 77 | b.name = "john" 78 | b.age = 20 79 | return List(a, b) 80 | 81 | # Sanity and edge cases tests 82 | 83 | def test_get_list_properties_should_combine_values( 84 | self, 85 | ): 86 | a, b = ( 87 | Mock(), 88 | Mock(), 89 | ) 90 | a.phones = [ 91 | 1, 92 | 2, 93 | 123321, 94 | 4, 95 | ] 96 | b.phones = [ 97 | 1, 98 | 2, 99 | 5, 100 | 789, 101 | ] 102 | self.list = List(a, b) 103 | self.assertEqual( 104 | self.list.phones.sort(), 105 | [ 106 | 1, 107 | 2, 108 | 123321, 109 | 4, 110 | 1, 111 | 2, 112 | 5, 113 | 789, 114 | ].sort(), 115 | ) 116 | self.assertEqual( 117 | len(self.list.phones), 118 | 8, 119 | ) 120 | 121 | def test_non_list_properties_should_combine_values( 122 | self, 123 | ): 124 | a, b = ( 125 | Mock(), 126 | Mock(), 127 | ) 128 | a.foo = 1 129 | b.foo = 2 130 | self.list = List(a, b) 131 | self.assertEqual( 132 | self.list.foo, 133 | [1, 2], 134 | ) 135 | 136 | def test_non_list_peoperties_with_unique_object_peoperty( 137 | self, 138 | ): 139 | a, b = ( 140 | Mock(), 141 | Mock(), 142 | ) 143 | a.foo = 1 144 | b.bar = 2 145 | self.list = List(a, b) 146 | self.assertEqual( 147 | self.list.foo, 148 | [1], 149 | ) 150 | self.assertEqual( 151 | self.list.bar, 152 | [2], 153 | ) 154 | 155 | def test_list_peoperties_with_unique_object_peoperty( 156 | self, 157 | ): 158 | a, b = ( 159 | Mock(), 160 | Mock(), 161 | ) 162 | a.foo = [1] 163 | b.bar = [2] 164 | self.list = List(a, b) 165 | self.assertEqual( 166 | self.list.foo, 167 | [1], 168 | ) 169 | self.assertEqual( 170 | self.list.bar, 171 | [2], 172 | ) 173 | 174 | def test_init_with_several_items( 175 | self, 176 | ): 177 | dynamic_list = List( 178 | Mock(), 179 | Mock(), 180 | Mock(), 181 | ) 182 | self.assertTrue(dynamic_list) 183 | 184 | def test_different_types_sanity( 185 | self, 186 | ): 187 | a, b = ( 188 | Mock(), 189 | Mock(), 190 | ) 191 | a.foo = "a" 192 | b.foo = 1 193 | self.list = List(a, b) 194 | self.assertTrue("a" in self.list.foo) 195 | self.assertTrue(1 in self.list.foo) 196 | self.assertTrue(len(self.list.foo) == 2) 197 | 198 | def test_runtime_changes_apply( 199 | self, 200 | ): 201 | john = Person( 202 | "John", 203 | "Doe", 204 | "Coding", 205 | 27, 206 | ) 207 | bob = Person( 208 | "Bob", 209 | "Marley", 210 | "Playing guitar", 211 | 33, 212 | ) 213 | 214 | # Creating a dynamic_list 215 | dynamic_list = List( 216 | john, 217 | bob, 218 | ) 219 | 220 | # Powerful functionality 221 | dynamic_list.first_name # ['John', 'Bob'] 222 | dynamic_list.full_name # ['John Doe', 'Bob Marley'] 223 | dynamic_list.do_something() # ['John Doe is coding', 'Bob Marley is playing guitar'] 224 | 225 | # Dynamic Runtime changes: 226 | bob.birthday = datetime( 227 | year=1945, 228 | month=2, 229 | day=6, 230 | ) 231 | dynamic_list.birthday # datetime.datetime(1945, 2, 6, 0, 0) 232 | john.birthday = datetime( 233 | year=1970, 234 | month=1, 235 | day=1, 236 | ) 237 | dynamic_list.birthday # : [datetime.datetime(1970, 1, 1, 0, 0), datetime.datetime(1945, 2, 6, 0, 0)] 238 | self.assertTrue(len(dynamic_list.birthday) == 2) 239 | 240 | def test_add_method( 241 | self, 242 | ): 243 | added_list = self.list + self.list 244 | self.assertTrue(len(added_list) == 4) 245 | self.assertTrue( 246 | isinstance( 247 | added_list, 248 | List, 249 | ) 250 | ) 251 | 252 | def test_times_method( 253 | self, 254 | ): 255 | timed_list = self.list * 3 256 | self.assertTrue(len(timed_list) == 6) 257 | self.assertTrue( 258 | isinstance( 259 | timed_list, 260 | List, 261 | ) 262 | ) 263 | 264 | def test_slice_method( 265 | self, 266 | ): 267 | sliced_list = self.list[:-1] 268 | self.assertTrue(len(sliced_list) == 1) 269 | self.assertTrue( 270 | isinstance( 271 | sliced_list, 272 | List, 273 | ) 274 | ) 275 | 276 | def test_non_existing_attribute_raises_attribute_error( 277 | self, 278 | ): 279 | self.assertRaises( 280 | AttributeError, 281 | self.list.__getattribute__, 282 | "blabla", 283 | ) 284 | 285 | # LINQ Methods tests 286 | 287 | def test_where_method( 288 | self, 289 | ): 290 | self.assertEqual( 291 | self.list.where(lambda p: p.name == "bob")[0], 292 | self.list[0], 293 | ) 294 | 295 | def test_where_method_without_expression( 296 | self, 297 | ): 298 | self.assertEqual( 299 | self.list.where(), 300 | self.list, 301 | ) 302 | 303 | def test_where_method_with_filter_kwargs( 304 | self, 305 | ): 306 | self.assertEqual( 307 | self.list.where(name="bob")[0], 308 | self.list[0], 309 | ) 310 | 311 | def test_where_method_with_expression_and_filter_kwargs( 312 | self, 313 | ): 314 | self.assertEqual( 315 | self.list.where( 316 | lambda x: len(x.name) == 4, 317 | age=20, 318 | )[0], 319 | self.list[1], 320 | ) 321 | 322 | def test_where_method_with_multiple_filter_kwargs( 323 | self, 324 | ): 325 | self.assertEqual( 326 | self.list.where( 327 | name="bob", 328 | age=15, 329 | ), 330 | [], 331 | ) 332 | 333 | def test_first_method( 334 | self, 335 | ): 336 | self.assertEqual( 337 | self.list.first(lambda p: p.name == "bob"), 338 | self.list[0], 339 | ) 340 | 341 | def test_first_method_with_nonexisisting_value_raises_indexerror( 342 | self, 343 | ): 344 | self.assertRaises( 345 | IndexError, 346 | self.list.first, 347 | lambda p: p.name == "id that dont exist", 348 | ) 349 | 350 | def test_first_method_with_nonexisisting_value_returns_none( 351 | self, 352 | ): 353 | self.assertEqual( 354 | None, 355 | self.list.first( 356 | lambda p: p.name == "danny", 357 | None, 358 | ), 359 | ) 360 | 361 | def test_any_method( 362 | self, 363 | ): 364 | self.assertEqual( 365 | True, 366 | self.list.any(lambda p: p.name == "bob"), 367 | ) 368 | 369 | def test_any_method_with_all_false( 370 | self, 371 | ): 372 | self.assertEqual( 373 | False, 374 | self.list.any(lambda p: p.name == "danny"), 375 | ) 376 | 377 | def test_any_method_without_expression( 378 | self, 379 | ): 380 | self.assertEqual( 381 | True, 382 | self.list.any(), 383 | ) 384 | 385 | def test_any_method_without_expression_and_empty_list( 386 | self, 387 | ): 388 | empty = List() 389 | self.assertEqual( 390 | False, 391 | empty.any(), 392 | ) 393 | 394 | def test_all_method( 395 | self, 396 | ): 397 | self.assertEqual( 398 | True, 399 | self.list.all(lambda p: p.age == 20), 400 | ) 401 | 402 | def test_all_method_with_false_arguments( 403 | self, 404 | ): 405 | self.assertEqual( 406 | False, 407 | self.list.all(lambda p: p.age == 19), 408 | ) 409 | 410 | def test_contains_method_with_existing_item( 411 | self, 412 | ): 413 | self.assertTrue(self.list.contains(self.list[0])) 414 | 415 | def test_contains_method_with_non_existing_item( 416 | self, 417 | ): 418 | self.assertFalse(self.list.contains("123")) 419 | 420 | def test_sum_method( 421 | self, 422 | ): 423 | self.list = List( 424 | 1, 425 | 2, 426 | 3, 427 | 4, 428 | 5, 429 | ) 430 | self.assertEqual( 431 | self.list.sum, 432 | 15, 433 | ) 434 | 435 | def test_min_method( 436 | self, 437 | ): 438 | self.list = List( 439 | 1, 440 | 2, 441 | 3, 442 | 4, 443 | 5, 444 | ) 445 | self.assertEqual( 446 | self.list.min, 447 | 1, 448 | ) 449 | 450 | def test_max_method( 451 | self, 452 | ): 453 | self.list = List( 454 | 1, 455 | 2, 456 | 3, 457 | 4, 458 | 5, 459 | ) 460 | self.assertEqual( 461 | self.list.max, 462 | 5, 463 | ) 464 | 465 | def test_avg_method( 466 | self, 467 | ): 468 | self.list = List( 469 | 1, 470 | 2, 471 | 3, 472 | 4, 473 | 5, 474 | ) 475 | self.assertEqual( 476 | self.list.avg, 477 | 3, 478 | ) 479 | 480 | def test_select_method( 481 | self, 482 | ): 483 | def expression( 484 | e, 485 | ): 486 | return e.name 487 | 488 | self.assertEqual( 489 | self.list.select(expression), 490 | self.list.name, 491 | ) 492 | 493 | def test_skip_method( 494 | self, 495 | ): 496 | self.assertEqual( 497 | self.list.skip(1)[0], 498 | self.list[1], 499 | ) 500 | 501 | def test_take_method( 502 | self, 503 | ): 504 | self.list = self.list * 6 505 | taken_elements = self.list.take(2) 506 | self.assertEqual( 507 | taken_elements, 508 | self.list[:2], 509 | ) 510 | 511 | def test_concat_method( 512 | self, 513 | ): 514 | m = Mock() 515 | m.name = "Avi" 516 | l2 = List(m) 517 | concatinated_list = self.list.concat(l2) 518 | self.assertEqual( 519 | len(concatinated_list), 520 | 3, 521 | ) 522 | self.assertEqual( 523 | concatinated_list[-1], 524 | m, 525 | ) 526 | 527 | def test_intersect_method( 528 | self, 529 | ): 530 | e1 = self.list[0] 531 | l2 = List(e1) 532 | intersected = self.list.intersect(l2) 533 | self.assertEqual( 534 | len(intersected), 535 | 1, 536 | ) 537 | self.assertEqual( 538 | intersected[0], 539 | e1, 540 | ) 541 | 542 | def test_except_for_method( 543 | self, 544 | ): 545 | excepted_list = self.list.except_for(lambda x: x.name.lower().startswith("b")) 546 | self.assertEqual( 547 | len(excepted_list), 548 | 1, 549 | ) 550 | self.assertEqual( 551 | excepted_list[0], 552 | self.list[1], 553 | ) 554 | 555 | def test_one_liner( 556 | self, 557 | ): 558 | self.assertEqual( 559 | 20, 560 | self.list.concat(self.list) 561 | .where(lambda e: e.name.lower() == "john") 562 | .skip(1) 563 | .select(lambda x: x.age) 564 | .avg, 565 | ) 566 | self.list[1].age = 89 567 | self.list[1].name = "Bobby Brown" 568 | self.assertEqual( 569 | self.list[1].name, 570 | self.list.concat(self.list) 571 | .where(lambda e: e.age > 18) 572 | .skip(1) 573 | .except_for(lambda e: e.name == "bob") 574 | .select(lambda x: x.name) 575 | .last(), 576 | ) 577 | 578 | def test_order_by_bare( 579 | self, 580 | ): 581 | data = [ 582 | 1, 583 | -1, 584 | 7, 585 | 200, 586 | 4, 587 | 3, 588 | ] 589 | sorted_data = sorted(data) 590 | 591 | linq_data = List(data).order_by() 592 | self.assertEqual( 593 | sorted_data, 594 | linq_data, 595 | ) 596 | 597 | def test_order_by_complex( 598 | self, 599 | ): 600 | data = [ 601 | Person( 602 | "jake", 603 | "samson", 604 | None, 605 | 32, 606 | ), 607 | Person( 608 | "sam", 609 | "thompson", 610 | None, 611 | 44, 612 | ), 613 | Person( 614 | "sarah", 615 | "smith", 616 | None, 617 | 41, 618 | ), 619 | Person( 620 | "zoe", 621 | "lee", 622 | None, 623 | 27, 624 | ), 625 | ] 626 | sorted_data = sorted( 627 | data, 628 | key=lambda p: p.age, 629 | reverse=True, 630 | ) 631 | 632 | linq_data = List(data).order_by(lambda p: -p.age) 633 | self.assertEqual( 634 | sorted_data, 635 | linq_data, 636 | ) 637 | 638 | def test_any_method_short_circuit( 639 | self, 640 | ): 641 | data = [ 642 | Person( 643 | "jake", 644 | "samson", 645 | None, 646 | 32, 647 | ), 648 | Person( 649 | "sam", 650 | "thompson", 651 | None, 652 | 44, 653 | ), 654 | Person( 655 | "sarah", 656 | "smith", 657 | None, 658 | 41, 659 | ), 660 | Person( 661 | "zoe", 662 | "lee", 663 | None, 664 | 27, 665 | ), 666 | ] 667 | 668 | global counter 669 | counter = 0 670 | linq_data = List(data).any(func_samson) 671 | self.assertEqual( 672 | linq_data, 673 | True, 674 | ) 675 | self.assertEqual( 676 | counter, 677 | 1, 678 | ) 679 | 680 | counter = 0 681 | linq_data = List(data).any(func_lee) 682 | self.assertEqual( 683 | linq_data, 684 | True, 685 | ) 686 | self.assertEqual( 687 | counter, 688 | 4, 689 | ) 690 | 691 | def test_all_method_short_circuit( 692 | self, 693 | ): 694 | data = [ 695 | Person( 696 | "jake", 697 | "samson", 698 | None, 699 | 32, 700 | ), 701 | Person( 702 | "sam", 703 | "james", 704 | None, 705 | 44, 706 | ), 707 | Person( 708 | "sarah", 709 | "smith", 710 | None, 711 | 41, 712 | ), 713 | Person( 714 | "zoe", 715 | "lee", 716 | None, 717 | 27, 718 | ), 719 | ] 720 | 721 | global counter 722 | counter = 0 723 | linq_data = List(data).all(func_samson) 724 | self.assertEqual( 725 | linq_data, 726 | False, 727 | ) 728 | self.assertEqual( 729 | counter, 730 | 2, 731 | ) # One true, then false 732 | 733 | data = [ 734 | Person( 735 | "jake", 736 | "samson", 737 | None, 738 | 32, 739 | ), 740 | Person( 741 | "sam", 742 | "samson", 743 | None, 744 | 44, 745 | ), 746 | Person( 747 | "sarah", 748 | "samson", 749 | None, 750 | 41, 751 | ), 752 | Person( 753 | "zoe", 754 | "samson", 755 | None, 756 | 27, 757 | ), 758 | ] 759 | 760 | counter = 0 761 | linq_data = List(data).all(func_samson) 762 | self.assertEqual( 763 | linq_data, 764 | True, 765 | ) 766 | self.assertEqual( 767 | counter, 768 | 4, 769 | ) # all 4 true 770 | --------------------------------------------------------------------------------