├── requirements.txt ├── .gitignore ├── pyproject.toml ├── examples ├── frozenset.py ├── unix.py ├── cases.py ├── dedent.py ├── duration_3.py ├── duration_1.py ├── duration_2.py └── f_bytes.py ├── setup.cfg ├── LICENSE ├── tests └── test_literals.py ├── README.md └── custom_literals └── __init__.py /requirements.txt: -------------------------------------------------------------------------------- 1 | forbiddenfruit>=0.1.4 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/__pycache__/ 2 | .venv/ 3 | .git/ 4 | .DS_Store 5 | .vscode/ 6 | *.egg-info/ 7 | dist/ -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /examples/frozenset.py: -------------------------------------------------------------------------------- 1 | from custom_literals import literal 2 | 3 | @literal(set, name="f") 4 | def as_frozenset(self): 5 | return frozenset(self) 6 | 7 | assert {1, 2, 3, 4}.f == frozenset({1, 2, 3, 4}) -------------------------------------------------------------------------------- /examples/unix.py: -------------------------------------------------------------------------------- 1 | from custom_literals import literally 2 | from datetime import datetime 3 | 4 | with literally(int, unix=datetime.fromtimestamp): 5 | print((1647804818).unix) # 2022-03-20 21:33:38 6 | 7 | assert not hasattr(1647804818, "unix") 8 | -------------------------------------------------------------------------------- /examples/cases.py: -------------------------------------------------------------------------------- 1 | from custom_literals import literals 2 | 3 | @literals(str, bytes) 4 | class CaseLiterals: 5 | def u(self): 6 | return self.upper() 7 | def l(self): 8 | return self.lower() 9 | 10 | print("Hello, World!".u) # HELLO, WORLD! 11 | print(b"Hello, World!".l) # b"hello, world!"" 12 | -------------------------------------------------------------------------------- /examples/dedent.py: -------------------------------------------------------------------------------- 1 | from custom_literals import literal 2 | import textwrap 3 | 4 | @literal(str, name="d") 5 | def dedented(self): 6 | return textwrap.dedent(self).strip() 7 | 8 | def foo(): 9 | return """ 10 | this multiline string 11 | looks cleaner in source 12 | """.d 13 | 14 | print(foo()) 15 | -------------------------------------------------------------------------------- /examples/duration_3.py: -------------------------------------------------------------------------------- 1 | from custom_literals import literally 2 | from datetime import timedelta 3 | 4 | with literally(float, int, 5 | s=lambda x: timedelta(seconds=x), 6 | m=lambda x: timedelta(minutes=x), 7 | h=lambda x: timedelta(hours=x) 8 | ): 9 | print(10 .s) # 0:00:10 10 | print(1.5.m) # 0:01:30 11 | 12 | assert not hasattr(10, "s") -------------------------------------------------------------------------------- /examples/duration_1.py: -------------------------------------------------------------------------------- 1 | from custom_literals import literal 2 | from datetime import timedelta 3 | 4 | @literal(float, int, name="s") 5 | def seconds(self): 6 | return timedelta(seconds=self) 7 | 8 | @literal(float, int, name="m") 9 | def minutes(self): 10 | return timedelta(seconds=60 * self) 11 | 12 | @literal(float, int, name="h") 13 | def hours(self): 14 | return timedelta(seconds=3600 * self) 15 | 16 | print(10 .s) # 0:00:10 17 | print(1.5.m) # 0:01:30 18 | 19 | -------------------------------------------------------------------------------- /examples/duration_2.py: -------------------------------------------------------------------------------- 1 | from custom_literals import literals, lie, rename 2 | from datetime import timedelta 3 | 4 | @literals(float, int) 5 | class Duration(lie(float)): 6 | @rename("s") 7 | def seconds(self): 8 | return timedelta(seconds=self) 9 | @rename("m") 10 | def minutes(self): 11 | return timedelta(seconds=60 * self) 12 | @rename("h") 13 | def hours(self): 14 | return timedelta(seconds=3600 * self) 15 | 16 | print(10 .s) # 0:00:10 17 | print(1.5.m) # 0:01:30 18 | -------------------------------------------------------------------------------- /examples/f_bytes.py: -------------------------------------------------------------------------------- 1 | from custom_literals import literal 2 | import re 3 | 4 | @literal(bytes, name="f") 5 | def format_bytes(self): 6 | # this ignores unicode identifiers 7 | identifier = re.compile(br"\$([a-zA-Z_][a-zA-Z0-9_]*)") 8 | # this ignores locals & builtins 9 | names = globals() 10 | def substitution(match): 11 | name = match.group(1).decode("utf-8") 12 | try: 13 | value = names[name] 14 | except KeyError: 15 | raise NameError(f"name '{name}' is not defined") 16 | if isinstance(value, bytes): 17 | return value 18 | try: 19 | return value.__bytes__() 20 | except AttributeError: 21 | return str(value).encode("utf-8") 22 | return re.sub(identifier, substitution, self) 23 | 24 | animal = b"cat" 25 | number = 25 26 | 27 | print(b"$animal #$number is the best!".f) 28 | # b"cat #25 is the best!" 29 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = custom-literals 3 | version = 0.1.4 4 | author = RocketRace 5 | author_email = hiemankaranteenissa@proton.me 6 | description = A module implementing custom literal suffixes using pure Python 7 | long_description = file: README.md 8 | long_description_content_type = text/markdown 9 | url = https://github.com/RocketRace/custom-literals 10 | 11 | project_urls = 12 | Bug Tracker = https://github.com/RocketRace/custom-literals/issues 13 | 14 | classifiers = 15 | Programming Language :: Python :: 3 :: Only 16 | Programming Language :: Python :: 3.7 17 | Programming Language :: Python :: 3.8 18 | Programming Language :: Python :: 3.9 19 | Programming Language :: Python :: 3.10 20 | License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0) 21 | Operating System :: OS Independent 22 | Environment :: Console 23 | Natural Language :: English 24 | Topic :: Software Development :: Libraries :: Python Modules 25 | Topic :: Utilities 26 | Topic :: Internet 27 | Typing :: Typed 28 | Intended Audience :: Developers 29 | Intended Audience :: Information Technology 30 | 31 | 32 | [options] 33 | packages = 34 | custom_literals 35 | python_requires = >=3.7 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at https://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /tests/test_literals.py: -------------------------------------------------------------------------------- 1 | from custom_literals import * 2 | 3 | import unittest 4 | 5 | class TestLiteral(unittest.TestCase): 6 | def run_multiple_hooks(self, number): 7 | import itertools 8 | for combination in itertools.combinations(ALLOWED_TARGETS, number): 9 | with self.subTest(combination=combination): 10 | def foo(self): 11 | return "correct" 12 | 13 | literal(*combination)(foo) 14 | try: 15 | for target in combination: 16 | self.assert_(hasattr(target, "foo"), f"target hook failed for {target}") 17 | 18 | target_type = lambda target: target if isinstance(target, type) else type(target) 19 | combination_types = tuple(map(target_type, combination)) 20 | for other in set(ALLOWED_TARGETS) - set(combination): 21 | if not issubclass(target_type(other), combination_types): 22 | self.assert_(not hasattr(other, "foo"), f"types {other} affected by hooks on {combination}") 23 | # otherwise the other tests are affected 24 | # (because builtins are shared) 25 | finally: 26 | for target in combination: 27 | unliteral(target, "foo") 28 | 29 | for other in ALLOWED_TARGETS: 30 | if other in combination: 31 | self.assert_(not hasattr(other, "foo"), f"unhooking target {other} failed") 32 | else: 33 | self.assert_(not hasattr(other, "foo"), f"unhooking {combination} affected type {other}") 34 | 35 | def test_single_literal_hook(self): 36 | self.run_multiple_hooks(1) 37 | 38 | def test_many_literal_hooks(self): 39 | for n in range(2, 5): 40 | self.run_multiple_hooks(n) 41 | 42 | def test_str(self): 43 | @literal(str) 44 | def foo_str(self): 45 | return "correct" 46 | 47 | try: 48 | self.assertEqual("aa".foo_str, "correct", "str hook failed") 49 | finally: 50 | unliteral(str, "foo_str") 51 | 52 | with self.assertRaises(AttributeError, msg="str unhook failed"): 53 | "aa".foo_str 54 | 55 | def test_none(self): 56 | @literal(None) 57 | def foo_none(self): 58 | return "correct" 59 | try: 60 | self.assertEqual(None.foo_none, "correct", "none hook failed") 61 | 62 | finally: 63 | unliteral(None, "foo_none") 64 | with self.assertRaises(AttributeError, msg="none unhook failed"): 65 | None.foo_none 66 | 67 | def test_int_bool(self): 68 | @literal(int) 69 | def foo_int(self): 70 | return "correct" 71 | 72 | try: 73 | self.assertEqual((1).foo_int, "correct", "int hook failed") 74 | with self.assertRaises(AttributeError, msg="int hook affects bool"): 75 | True.foo_int 76 | 77 | finally: 78 | unliteral(int, "foo_int") 79 | 80 | @literal(int, bool) 81 | def foo_int_bool(self): 82 | return "true" 83 | 84 | self.assertEqual((1).foo_int_bool, "true", "int rehook failed") 85 | self.assertEqual(False.foo_int_bool, "true", "int+bool hook failed") 86 | unliteral(int, "foo_int_bool") 87 | unliteral(bool, "foo_int_bool") 88 | 89 | with self.assertRaises(AttributeError, msg="int unhook failed"): 90 | (0).foo_int_bool 91 | 92 | with self.assertRaises(AttributeError, msg="bool unhook failed"): 93 | False.foo_int_bool 94 | 95 | def test_float(self): 96 | @literal(float) 97 | def foo_float(self): 98 | return "correct" 99 | 100 | try: 101 | self.assertEqual((1.0).foo_float, "correct", "float hook failed") 102 | finally: 103 | unliteral(float, "foo_float") 104 | 105 | with self.assertRaises(AttributeError, msg="float unhook failed"): 106 | (0.0).foo_float 107 | 108 | def test_complex(self): 109 | @literal(complex) 110 | def foo_complex(self): 111 | return "correct" 112 | 113 | try: 114 | self.assertEqual((1j).foo_complex, "correct", "complex hook failed") 115 | finally: 116 | unliteral(complex, "foo_complex") 117 | 118 | with self.assertRaises(AttributeError, msg="complex unhook failed"): 119 | (0j).foo_complex 120 | 121 | def test_literally(self): 122 | from datetime import datetime 123 | try: 124 | with literally(int, unix=datetime.fromtimestamp): 125 | self.assertEqual((1647804818).unix, datetime(2022, 3, 20, 21, 33, 38), "context manager hook failed") 126 | 127 | with self.assertRaises(AttributeError, msg="context manager unhook failed"): 128 | (0).unix 129 | 130 | finally: 131 | try: 132 | unliteral(int, "unix") 133 | except AttributeError: 134 | pass # Already unhooked 135 | 136 | 137 | def test_class_based(self): 138 | try: 139 | @literals(str) 140 | class Foo: 141 | def bar(self): 142 | return "correct" 143 | 144 | self.assertEqual("aa".bar, "correct", "class based hook failed") 145 | 146 | finally: 147 | unliteral(str, "bar") 148 | 149 | with self.assertRaises(AttributeError, msg="class based unhook failed"): 150 | "aa".bar 151 | 152 | def test_renamed_class_literal(self): 153 | try: 154 | @literals(str) 155 | class Foo: 156 | @rename("bar") 157 | def bees(self): 158 | return "correct" 159 | 160 | self.assertEqual("aa".bar, "correct", "renamed class based hook failed") 161 | 162 | finally: 163 | unliteral(str, "bar") 164 | 165 | with self.assertRaises(AttributeError, msg="renamed class based unhook failed"): 166 | "aa".bar 167 | 168 | def test_class_multiple_targets(self): 169 | try: 170 | @literals(str, int) 171 | class Foo: 172 | def bar(self): 173 | return "correct" 174 | 175 | self.assertEqual("aa".bar, "correct", "class based hook failed") 176 | self.assertEqual((1).bar, "correct", "class based hook failed") 177 | 178 | finally: 179 | unliteral(str, "bar") 180 | unliteral(int, "bar") 181 | 182 | with self.assertRaises(AttributeError, msg="class based unhook failed"): 183 | "aa".bar 184 | with self.assertRaises(AttributeError, msg="class based unhook failed"): 185 | (1).bar 186 | 187 | def test_class_multiple_literals(self): 188 | try: 189 | @literals(str) 190 | class CaseLiterals: 191 | @rename("u") 192 | def uppercase(self): 193 | return self.upper() 194 | @rename("l") 195 | def lowercase(self): 196 | return self.lower() 197 | 198 | self.assertEqual("aa".u, "AA", "class based hook failed") 199 | self.assertEqual("AA".l, "aa", "class based hook failed") 200 | 201 | finally: 202 | unliteral(str, "u") 203 | unliteral(str, "l") 204 | 205 | def test_class_with_lie(self): 206 | try: 207 | @literals(str) 208 | class Foo(lie(str)): 209 | @rename("bar") 210 | def bees(self): 211 | return "correct" 212 | 213 | self.assertEqual("aa".bar, "correct", "renamed class based hook failed") 214 | 215 | finally: 216 | unliteral(str, "bar") 217 | 218 | with self.assertRaises(AttributeError, msg="renamed class based unhook failed"): 219 | "aa".bar 220 | 221 | def test_tuple(self): 222 | @literal(tuple) 223 | def bar_tuple(self): 224 | return "correct" 225 | 226 | try: 227 | self.assertEqual((1, 2).bar_tuple, "correct", "tuple hook failed") 228 | finally: 229 | unliteral(tuple, "bar_tuple") 230 | 231 | with self.assertRaises(AttributeError, msg="tuple unhook failed"): 232 | (1, 2).bar_tuple 233 | 234 | def test_strict_list(self): 235 | @literal(list, strict=True) 236 | def bar_list(self): 237 | return "correct" 238 | 239 | try: 240 | self.assertEqual([1, 2].bar_list, "correct", "list hook failed") 241 | finally: 242 | unliteral(list, "bar_list") 243 | 244 | with self.assertRaises(AttributeError, msg="list unhook failed"): 245 | [1, 2].bar_list 246 | 247 | def test_strict_listcomp(self): 248 | @literal(list, strict=True) 249 | def bar_list(self): 250 | return "correct" 251 | 252 | try: 253 | self.assertEqual([x for x in [1, 2]].bar_list, "correct", "listcomp hook failed") 254 | finally: 255 | unliteral(list, "bar_list") 256 | 257 | with self.assertRaises(AttributeError, msg="list unhook failed"): 258 | [x for x in [1, 2]].bar_list 259 | 260 | def test_list(self): 261 | @literal(list) 262 | def bar_list(self): 263 | return "correct" 264 | 265 | try: 266 | self.assertEqual([1, 2].bar_list, "correct", "list hook failed") 267 | finally: 268 | unliteral(list, "bar_list") 269 | 270 | with self.assertRaises(AttributeError, msg="list unhook failed"): 271 | [1, 2].bar_list 272 | 273 | def test_listcomp(self): 274 | @literal(list) 275 | def bar_list(self): 276 | return "correct" 277 | 278 | try: 279 | self.assertEqual([x for x in [1, 2]].bar_list, "correct", "listcomp hook failed") 280 | finally: 281 | unliteral(list, "bar_list") 282 | 283 | with self.assertRaises(AttributeError, msg="list unhook failed"): 284 | [x for x in [1, 2]].bar_list 285 | 286 | def test_strict_dict(self): 287 | @literal(dict, strict=True) 288 | def bar_dict(self): 289 | return "correct" 290 | 291 | try: 292 | self.assertEqual({1: 2}.bar_dict, "correct", "dict hook failed") 293 | finally: 294 | unliteral(dict, "bar_dict") 295 | 296 | with self.assertRaises(AttributeError, msg="dict unhook failed"): 297 | {1: 2}.bar_dict 298 | 299 | def test_strict_dictcomp(self): 300 | @literal(dict, strict=True) 301 | def bar_dict(self): 302 | return "correct" 303 | 304 | try: 305 | self.assertEqual({x: x for x in [1, 2]}.bar_dict, "correct", "dictcomp hook failed") 306 | finally: 307 | unliteral(dict, "bar_dict") 308 | 309 | with self.assertRaises(AttributeError, msg="dict unhook failed"): 310 | {x: x for x in [1, 2]}.bar_dict 311 | 312 | def test_dict(self): 313 | @literal(dict) 314 | def bar_dict(self): 315 | return "correct" 316 | 317 | try: 318 | self.assertEqual({1: 2}.bar_dict, "correct", "dict hook failed") 319 | finally: 320 | unliteral(dict, "bar_dict") 321 | 322 | with self.assertRaises(AttributeError, msg="dict unhook failed"): 323 | {1: 2}.bar_dict 324 | 325 | def test_dictcomp(self): 326 | @literal(dict) 327 | def bar_dict(self): 328 | return "correct" 329 | 330 | try: 331 | self.assertEqual({x: x for x in [1, 2]}.bar_dict, "correct", "dictcomp hook failed") 332 | finally: 333 | unliteral(dict, "bar_dict") 334 | 335 | with self.assertRaises(AttributeError, msg="dict unhook failed"): 336 | {x: x for x in [1, 2]}.bar_dict 337 | 338 | def test_strict_set(self): 339 | @literal(set, strict=True) 340 | def bar_set(self): 341 | return "correct" 342 | 343 | try: 344 | self.assertEqual({1, 2}.bar_set, "correct", "set hook failed") 345 | finally: 346 | unliteral(set, "bar_set") 347 | 348 | with self.assertRaises(AttributeError, msg="set unhook failed"): 349 | {1, 2}.bar_set 350 | 351 | def test_strict_setcomp(self): 352 | @literal(set, strict=True) 353 | def bar_set(self): 354 | return "correct" 355 | 356 | try: 357 | self.assertEqual({x for x in [1, 2]}.bar_set, "correct", "setcomp hook failed") 358 | finally: 359 | unliteral(set, "bar_set") 360 | 361 | with self.assertRaises(AttributeError, msg="set unhook failed"): 362 | {x for x in [1, 2]}.bar_set 363 | 364 | def test_set(self): 365 | @literal(set) 366 | def bar_set(self): 367 | return "correct" 368 | 369 | try: 370 | self.assertEqual({1, 2}.bar_set, "correct", "set hook failed") 371 | finally: 372 | unliteral(set, "bar_set") 373 | 374 | with self.assertRaises(AttributeError, msg="set unhook failed"): 375 | {1, 2}.bar_set 376 | 377 | def test_setcomp(self): 378 | @literal(set) 379 | def bar_set(self): 380 | return "correct" 381 | 382 | try: 383 | self.assertEqual({x for x in [1, 2]}.bar_set, "correct", "setcomp hook failed") 384 | finally: 385 | unliteral(set, "bar_set") 386 | 387 | with self.assertRaises(AttributeError, msg="set unhook failed"): 388 | {x for x in [1, 2]}.bar_set 389 | 390 | def test_variable_suffix(self): 391 | @literal(int, strict=True) 392 | def not_for_variables(self): 393 | return 0 394 | 395 | with self.assertRaises(TypeError, msg="variable suffixes not properly disallowed"): 396 | x = 1 397 | x.not_for_variables 398 | 399 | unliteral(int, "not_for_variables") 400 | 401 | def test_non_strict(self): 402 | @literal(int) 403 | def not_strict(self): 404 | return True 405 | 406 | try: 407 | x = 1 408 | self.assertTrue(x.not_strict, "non strict access failed, was too strict") 409 | finally: 410 | unliteral(int, "not_strict") 411 | 412 | def test_fstring(self): 413 | @literal(str) 414 | def bar_str(self): 415 | return "correct" 416 | 417 | try: 418 | self.assertEqual(f"{1}".bar_str, "correct", "fstring hook failed") 419 | finally: 420 | unliteral(str, "bar_str") 421 | 422 | with self.assertRaises(AttributeError, msg="fstring unhook failed"): 423 | f"{1}".bar_str 424 | 425 | def test_tuple_non_const_strict(self): 426 | @literal(tuple, strict=True) 427 | def bar_tuple(self): 428 | return "correct" 429 | 430 | x = 1 431 | y = 2 432 | try: 433 | self.assertEqual((x, y).bar_tuple, "correct", "tuple hook failed") 434 | finally: 435 | unliteral(tuple, "bar_tuple") 436 | 437 | with self.assertRaises(AttributeError, msg="tuple unhook failed"): 438 | (x, y).bar_tuple 439 | 440 | def test_list_unpack_strict(self): 441 | @literal(list, strict=True) 442 | def bar_list(self): 443 | return "correct" 444 | 445 | a = [1, 2, 3] 446 | b = [4, 5, 6] 447 | try: 448 | self.assertEqual([*a, *b].bar_list, "correct", "list hook failed") 449 | finally: 450 | unliteral(list, "bar_list") 451 | 452 | with self.assertRaises(AttributeError, msg="list unhook failed"): 453 | [*a, *b].bar_list 454 | 455 | def test_tuple_unpack_strict(self): 456 | @literal(tuple, strict=True) 457 | def bar_tuple(self): 458 | return "correct" 459 | 460 | a = (1, 2, 3) 461 | b = (4, 5, 6) 462 | try: 463 | self.assertEqual((*a, *b).bar_tuple, "correct", "tuple hook failed") 464 | finally: 465 | unliteral(tuple, "bar_tuple") 466 | 467 | with self.assertRaises(AttributeError, msg="tuple unhook failed"): 468 | (*a, *b).bar_tuple 469 | 470 | def test_set_unpack_strict(self): 471 | @literal(set, strict=True) 472 | def bar_set(self): 473 | return "correct" 474 | 475 | a = {1, 2, 3} 476 | b = {4, 5, 6} 477 | try: 478 | self.assertEqual({*a, *b}.bar_set, "correct", "set hook failed") 479 | finally: 480 | unliteral(set, "bar_set") 481 | 482 | with self.assertRaises(AttributeError, msg="set unhook failed"): 483 | {*a, *b}.bar_set 484 | 485 | def test_dict_unpack_strict(self): 486 | @literal(dict, strict=True) 487 | def bar_dict(self): 488 | return "correct" 489 | 490 | a = {1: 1, 2: 2, 3: 3} 491 | b = {4: 4, 5: 5, 6: 6} 492 | try: 493 | self.assertEqual({**a, **b}.bar_dict, "correct", "dict hook failed") 494 | finally: 495 | unliteral(dict, "bar_dict") 496 | 497 | with self.assertRaises(AttributeError, msg="dict unhook failed"): 498 | {**a, **b}.bar_dict 499 | 500 | def test_implicit_string_concat_strict(self): 501 | @literal(str, strict=True) 502 | def bar_str(self): 503 | return "correct" 504 | 505 | try: 506 | self.assertEqual("1" "2" "3".bar_str, "correct", "implicit string concat hook failed") 507 | finally: 508 | unliteral(str, "bar_str") 509 | 510 | with self.assertRaises(AttributeError, msg="implicit string concat unhook failed"): 511 | "1" "2" "3".bar_str 512 | 513 | if __name__ == '__main__': 514 | unittest.main() -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | `custom-literals` 2 | =============== 3 | 4 | A module implementing custom literal suffixes using pure Python. `custom-literals` 5 | mimics C++'s user-defined literals (UDLs) by defining literal suffixes that can 6 | be accessed as attributes of literal values, such as numeric constants, string 7 | literals and more. 8 | 9 | (c) RocketRace 2022-present. See LICENSE file for more. 10 | 11 | Examples 12 | ======== 13 | 14 | See the `examples/` directory for more. 15 | 16 | Function decorator syntax: 17 | ```py 18 | from custom_literals import literal 19 | from datetime import timedelta 20 | 21 | @literal(float, int, name="s") 22 | def seconds(self): 23 | return timedelta(seconds=self) 24 | 25 | @literal(float, int, name="m") 26 | def minutes(self): 27 | return timedelta(seconds=60 * self) 28 | 29 | print(30 .s + 0.5.m) # 0:01:00 30 | ``` 31 | Class decorator syntax: 32 | ```py 33 | from custom_literals import literals 34 | from datetime import timedelta 35 | 36 | @literals(float, int) 37 | class Duration: 38 | def s(self): 39 | return timedelta(seconds=self) 40 | def m(self): 41 | return timedelta(seconds=60 * self) 42 | 43 | print(30 .s + 0.5.m) # 0:01:00 44 | ``` 45 | Removing a custom literal: 46 | ```py 47 | from custom_literals import literal, unliteral 48 | 49 | @literal(str) 50 | def u(self): 51 | return self.upper() 52 | 53 | print("hello".u) # "HELLO" 54 | 55 | unliteral(str, "u") 56 | assert not hasattr("hello", "u") 57 | ``` 58 | Context manager syntax (automatically removes literals afterwards): 59 | ```py 60 | from custom_literals import literally 61 | from datetime import timedelta 62 | 63 | with literally(float, int, 64 | s=lambda x: timedelta(seconds=x), 65 | m=lambda x: timedelta(seconds=60 * x) 66 | ): 67 | print(30 .s + 0.5.m) # 0:01:00 68 | ``` 69 | 70 | Features 71 | ======== 72 | 73 | Currently, three methods of defining custom literals are supported: 74 | The function decorator syntax `@literal`, the class decorator syntax `@literals`, and the 75 | context manager syntax `with literally`. (The latter will automatically unhook the literal 76 | suffixes when the context is exited.) To remove a custom literal, use `unliteral`. 77 | 78 | Custom literals are defined for literal values of the following types: 79 | 80 | | Type | Example | Notes | 81 | |------|---------|-------| 82 | | `int` | `(42).x` | The Python parser interprets `42.x` as a float literal followed by an identifier. To avoid this, use `(42).x` or `42 .x` instead. | 83 | | `float` | `3.14.x` | | 84 | | `complex` | `1j.x` | | 85 | | `bool` | `True.x` | Since `bool` is a subclass of `int`, `int` hooks may influence `bool` as well. | 86 | | `str` | `"hello".x` | F-strings (`f"{a}".x`) are also supported. The string will be formatted *before* the literal suffix is applied. | 87 | | `bytes` | `b"hello".x` | | 88 | | `None` | `None.x` | | 89 | | `Ellipsis` | `....x` | Yes, this is valid syntax. | 90 | | `tuple` | `(1, 2, 3).x` | Generator expressions (`(x for x in ...)`) are not tuple literals and thus won't be affected by literal suffixes. | 91 | | `list` | `[1, 2, 3].x` | List comprehensions (`[x for x in ...]`) may not function properly. | 92 | | `set` | `{1, 2, 3}.x` | Set comprehensions (`{x for x in ...}`) may not function properly. | 93 | | `dict` | `{"a": 1, "b": 2}.x` | Dict comprehensions (`{x: y for x, y in ...}`) may not function properly. | 94 | 95 | In addition, custom literals can be defined to be *strict*, that is, only allow the given 96 | literal suffix to be invoked on constant, literal values. This means that the following 97 | code will raise a `TypeError`: 98 | 99 | ```py 100 | @literal(str, name="u", strict=True) 101 | def utf_8(self): 102 | return self.encode("utf-8") 103 | 104 | my_string = "hello" 105 | print(my_string.u) 106 | # TypeError: the strict custom literal `u` of `str` objects can only be invoked on literal values 107 | ``` 108 | 109 | By default, custom literals are *not* strict. This is because determining whether a suffix was 110 | invoked on a literal value relies on bytecode analysis, which is a feature of the CPython 111 | interpreter, and is not guaranteed to be forwards compatible. It can be enabled by passing 112 | `strict=True` to the `@literal`, `@literals` or `literally` functions. 113 | 114 | Caveats 115 | ======== 116 | 117 | Stability 118 | --------- 119 | 120 | This library relies almost entirely on implementation-specific behavior of the CPython 121 | interpreter. It is not guaranteed to work on all platforms, or on all versions of Python. 122 | It has been tested on common platforms (windows, ubuntu, macos) using python 3.7 through 123 | to 3.10, but while changes that would break the library are quite unlikely, they are not 124 | impossible either. 125 | 126 | **That being said,** `custom-literals` does its absolute best to guarantee maximum 127 | stability of the library, even in light of possible breaking changes in CPython internals. 128 | The code base is well tested. In the future, the library may also expose multiple 129 | different backends for the actual implementation of builtin type patching. As of now, 130 | the only valid backend is `forbiddenfruit`, which uses the `forbiddenfruit` library. 131 | 132 | | Feature | Stability | 133 | |---------|-----------| 134 | | Hooking with the `forbiddenfruit` backend | Quite stable, but may be affected by future releases. Relies on the `ctypes` module. | 135 | | Strict mode using the `strict=True` kwarg | Quite stable, but not forwards compatible. Relies on stack frame analysis and opcode checks to detect non-literal access. | 136 | 137 | Type safety 138 | ----------- 139 | 140 | The library code, including the public API, is fully typed. Registering and unregistering 141 | hooks is type-safe, and static analysis tools should have nothing to complain about. 142 | 143 | However, accessing custom literal suffixes is impossible to type-check. This is because 144 | all major static analysis tools available for python right now (understandably) assume 145 | that builtins types are immutable. That is, the attributes and methods builtin types 146 | cannot be dynamically modified. This goes against the core idea of the library, which 147 | is to patch custom attributes on builtin types. 148 | 149 | Therefore, if you are using linters, type checkers or other static analysis tools, you 150 | will likely encounter many warnings and errors. If your tool allows it, you should disable 151 | these warnings (ideally on a per-diagnostic, scoped basis) if you want to use this library 152 | without false positives. 153 | 154 | FAQ 155 | ===== 156 | 157 | Should I use this in production? 158 | ------------------------------- 159 | 160 | Emphatically, no! But I won't stop you. 161 | 162 | Nooooooo (runs away from computer) 163 | ---------------------------------- 164 | 165 | I kind of disagree: yessss (dances in front of computer) 166 | 167 | Why? 168 | ----- 169 | 170 | Python's operator overloading allows for flexible design of domain-specific languages. 171 | However, Python pales in comparison to C++ in this aspect. In particular, User-Defined 172 | Literals (UDLs) are a powerful feature of C++ missing in Python. This library is designed 173 | to emulate UDLs in Python, with syntactic sugar comparable to C++ in elegance. 174 | 175 | But *really*, why? 176 | ------------------- 177 | 178 | Because it's possible. 179 | 180 | How? (please keep it short) 181 | -------------------------- 182 | 183 | `custom-literals` works by patching builtin types with custom objects satisfying the 184 | [descriptor protocol](https://docs.python.org/3/howto/descriptor.html), similar to 185 | the builtin `property` decorator. The patching is done through a "backend", which 186 | is an interface implementing functions to mutate the `__dict__` of builtin types. 187 | If `strict=True` mode is enabled, the descriptor will also traverse stack frames 188 | backwards to the invocation site of the literal suffix, and check the most recently 189 | executed bytecode opcode to ensure that the literal suffix was invoked on a literal value. 190 | 191 | How? (I love detail) 192 | --------------------- 193 | 194 | Builtin types in CPython are implemented in C, and include checks to prevent 195 | mutation at runtime. This is why the following lines will each raise a `TypeError`: 196 | 197 | ```py 198 | int.x = 42 # TypeError: cannot set 'x' attribute of immutable type 'int' 199 | setattr(int, "x", 42) # TypeError: cannot set 'x' attribute of immutable type 'int' 200 | int.__dict__["x"] = 42 # TypeError: 'mappingproxy' object does not support item assignment 201 | ``` 202 | 203 | However, these checks can be subverted in a number of ways. One method is to use 204 | CPython's APIs directly to bypass the checks. For the sake of stability, `custom-literals` 205 | calls the `curse()` and `reverse()` functions of the `forbiddenfruit` library 206 | to implement these bypasses. Internally, `forbiddenfruit` uses the `ctypes` module 207 | to access the C API and use the `ctypes.pythonapi.PyType_Modified()` function to 208 | signal that a builtin type has been modified. Other backends may also be available in the future, 209 | but are not implemented at the moment. (As an example, there is currently a bug 210 | in CPython that allows `mappingproxy` objects to be mutated without using `ctypes`. 211 | This was deemed too fragile to be included in the library.) 212 | 213 | Python's [`@property`](https://docs.python.org/3/howto/descriptor.html#properties) decorator 214 | implements the [descriptor protocol](https://docs.python.org/3/howto/descriptor.html). 215 | This is a protocol that allows for custom code to be executed when accessing specific 216 | attributes of a type. For example, the following code will print `42`: 217 | 218 | ```py 219 | class MyClass: 220 | @property 221 | def x(self): 222 | print(42) 223 | 224 | MyClass().x 225 | ``` 226 | 227 | `custom-literals` patches builtin types with objects implementing the same protocol, 228 | allowing for user-defined & library-defined code to be executed when invoking a literal 229 | suffix on a builtin type. It cannot however use `@property` directly, as elaborated 230 | below. 231 | 232 | The descriptor protocol is very flexible, used as the backbone of bound methods, 233 | class methods, and static methods and more. It is defined by the presence of one 234 | of the following methods\*: 235 | 236 | ```py 237 | class SomeDescriptor: 238 | # . 239 | def __get__(self, instance, owner) -> value: ... 240 | # . = 241 | def __set__(self, instance, value) -> None: ... 242 | # del . 243 | def __delete__(self, instance) -> None: ... 244 | ``` 245 | 246 | \**and optionally [`__set_name__`](https://docs.python.org/3/reference/datamodel.html#object.__set_name__)* 247 | 248 | The descriptor methods can be invoked from an instance (`some_instance.x`) or from 249 | a class (`SomeClass.x`). Importantly for us, the `__get__` method is called with 250 | different arguments depending on whether the descriptor is accessed from an instance 251 | or a class: 252 | 253 | ```py 254 | class MyDesciptor: 255 | def __get__(self, instance, owner) -> value: 256 | print(f"Instance: {instance}") 257 | print(f"Owner: {owner}") 258 | 259 | class MyClass: 260 | x = MyDesciptor() 261 | 262 | MyClass().x 263 | # Instance: <__main__.MyClass object at 0x110e3a170> 264 | # Owner: 265 | MyClass.x 266 | # Instance: None 267 | # Owner: 268 | ``` 269 | 270 | This is used to differentiate between the two cases. `@property`'s implementation 271 | simply returns the descriptor instance if `instance` is `None`, which is a fair 272 | test for whether the descriptor is accessed from a class or an instance. 273 | 274 | Keen-eyed readers may notice however that this is not a perfect test. What if `MyClass` 275 | is somehow `type(None)`? In this case, the two cases will be indistinguishable. 276 | In normal code, this is not a problem, as `type(None)` is a builtin type, and 277 | thus cannot be mutated. In `custom-literals`, however, this breaks custom literals 278 | that are defined on `type(None)`. 279 | 280 | This can thankfully be mitigated thanks to the concept of a 281 | [data descriptor](https://docs.python.org/3/howto/descriptor.html#data-descriptors). 282 | A data descriptor is a descriptor that defines `__set__` or `__delete__`. When 283 | Python tries to resolve attribute access on an instance, it will first check whether 284 | its *type* has a data descriptor for the attribute, overriding any descriptors 285 | defined on the *instance* itself. For example, suppose the following example using 286 | a metaclass (a class inheriting from `type`): 287 | 288 | ```py 289 | class DataDescriptor: 290 | def __get__(self, instance, owner): 291 | print("The data descriptor was called!") 292 | print(f"Instance: {instance}") 293 | 294 | # Simply the presence of the method is enough 295 | # to convert this into a data descriptor 296 | def __set__(self, instance, value): 297 | raise AttributeError 298 | 299 | class NormalDescriptor: 300 | def __get__(self, instance, owner): 301 | print("The normal descriptor was called!") 302 | print(f"Instance: {instance}") 303 | 304 | class MyMeta(type): 305 | x = DataDescriptor() 306 | 307 | class MyClass(metaclass=MyMeta): 308 | x = NormalDescriptor() 309 | 310 | MyClass.x 311 | # The data descriptor was called! 312 | # Instance: 313 | MyClass().x 314 | # The normal descriptor was called! 315 | # Instance: <__main__.MyClass object at 0x10f468ee0>s 316 | ``` 317 | 318 | This example shows that it is possible to ensure that a descriptor is always 319 | called on an instance with `instance` set to an instance of the class. In the case of 320 | `custom-literals`, this is achieved by patching a data descriptor (any data descriptor) 321 | on `type` when `type(None)` is also being patched. This removes the ambiguity of 322 | whether the descriptor is called on an instance or a class. Yay! 323 | 324 | Finally, `custom-literals` also provides a mechanism for optionally detecting when a custom 325 | literal suffix is invoked on a constant and literal type. (This is invoked when the 326 | `strict` argument is set to `True`.) This is achieved by attaching 327 | extra code to the `__get__` method of the custom literal descriptor. The code performs 328 | *bytecode analysis* at the invocation site of the custom literal suffix. 329 | 330 | The CPython interpreter uses stack frames to implement function calls. When a function is 331 | called, a new frame is created and pushed to the stack, and when the function returns, the 332 | frame is popped off the stack. Importantly, these frame objects can be accessed directly 333 | from Python: 334 | 335 | ```py 336 | import inspect 337 | 338 | def foo(): 339 | local_variable = 123 340 | bar() 341 | 342 | def bar(): 343 | # Alternatively, use `sys._getframe()` 344 | frame = inspect.currentframe() 345 | # The `f_back` attribute of a frame object 346 | # points to the frame that called it 347 | previous_frame = frame.f_back 348 | # Frame objects have information about the 349 | # invocation context of the frame, including 350 | # e.g. local variables 351 | previous_locals = previous_frame.f_locals 352 | print(previous_locals['local_variable']) # 123 353 | ``` 354 | 355 | The `f_code` attribute of a frame object contains information about the bytecode of the 356 | currently executed code. CPython being an interpreter, this bytecode corresponds roughly 357 | to the source code of the function. For example, see the disassembly of the following: 358 | 359 | ```py 360 | import dis 361 | 362 | def add(a, b): 363 | c = a + b 364 | return int(c) 365 | 366 | dis.dis(add) # Outputs: 367 | # 4 0 LOAD_FAST 0 (a) 368 | # 2 LOAD_FAST 1 (b) 369 | # 4 BINARY_ADD 370 | # 6 STORE_FAST 2 (c) 371 | # 372 | # 5 8 LOAD_GLOBAL 0 (int) 373 | # 10 LOAD_FAST 2 (c) 374 | # 12 CALL_FUNCTION 1 375 | # 14 RETURN_VALUE 376 | ``` 377 | 378 | * First, the two arguments `a` and `b` are pushed onto the stack. 379 | * The arguments are popped from the stack and used as the operands for `+`. The result is pushed onto the stack. 380 | * The top of the stack is popped and stored in a local variable `c`. 381 | * The `int` function is fetched from the global namespace and pushed to the stack. 382 | * The local variable `c` is pushed to the stack. 383 | * The `int` function is called with one argument, and the return value of `int` is pushed to the stack. 384 | * The result is popped from the stack and returned. 385 | 386 | In the case of custom literals, the opcodes we are concerned about are the following: 387 | 388 | * `LOAD_CONST`, used to load a constant (including most literal values) to the stack 389 | * `BUILD_TUPLE`/`BUILD_LIST`/`BUILD_SET`/`BUILD_MAP`, used to push tuple/list/set/dict literals to the stack 390 | * `FORMAT_VALUE`, used to push a formatted f-string literal (`f"{a} {b} {c}"`) to the stack 391 | * `LIST_TO_TUPLE`/`LIST_EXTEND`/`SET_UPDATE`/`DICT_UPDATE`, sometimes used in list/set/dict literals, for example when using the star unpack syntax (`[a, b, c, *x]`) 392 | 393 | (Do keep in mind that opcodes are not necessarily forwards compatible. Python 3.11 could release 394 | a dozen new opcodes tomorrow that need to be accounted for by the library! This is why 395 | `custom-literals` does not perform bytecode analysis by default.) 396 | 397 | If strict mode is enabled, the library will traverse up through the stack frames, inspect the bytecode, 398 | check the most recently executed opcode (available in `frame.f_lasti`), and check if it is one of the 399 | opcodes listed above. If the opcode is not in the allowed list, an error is raised, which is why 400 | the following code raises an error: 401 | 402 | ```py 403 | @literal(str, strict=True) 404 | def xyz(self): 405 | return 123 406 | 407 | abc = "abc" 408 | abc.xyz 409 | # TypeError: the strict custom literal `xyz` of `str` objects can only be invoked on literal values 410 | ``` 411 | 412 | Putting all of these features together, `custom-literals` is able to do the seemingly impossible - 413 | define custom literal suffixes on builtin types that can only be invoked on literal values! 414 | 415 | Making this project has been a fascinating deep dive into some of the internals of CPython, and 416 | I hope it has been equally interesting to you, the reader. 417 | 418 | Could this ever be type safe? 419 | ----------------------------- 420 | 421 | I doubt it. The assumptions made by static analysis tools are incredibly useful, and 422 | this is such an edge case it makes no sense for them to assume builtin literal types can have 423 | dynamically set attributes. In addition, there isn't a good way to signal to your type 424 | checker that an immutable type is going to be endowed with new attributes! 425 | 426 | License 427 | ======= 428 | 429 | (c) RocketRace 2022-present. This library is under the Mozilla Public License 2.0. 430 | See the `LICENSE` file for more details. 431 | 432 | Contributing 433 | ============ 434 | 435 | Patches, bug reports, feature requests and pull requests are welcome. 436 | 437 | Links 438 | ===== 439 | 440 | * [GitHub repository](https://github.com/RocketRace/custom-literals) 441 | * [PyPI](https://pypi.org/project/custom-literals/) 442 | -------------------------------------------------------------------------------- /custom_literals/__init__.py: -------------------------------------------------------------------------------- 1 | ''' 2 | `custom-literals` 3 | =============== 4 | 5 | A module implementing custom literal suffixes using pure Python. `custom-literals` 6 | mimics C++'s user-defined literals (UDLs) by defining literal suffixes that can 7 | be accessed as attributes of literal values, such as numeric constants, string 8 | literals and more. 9 | 10 | (c) RocketRace 2022-present. See LICENSE file for more. 11 | 12 | Examples 13 | ======== 14 | 15 | See the `examples/` directory for more. 16 | 17 | Function decorator syntax: 18 | ```py 19 | from custom_literals import literal 20 | from datetime import timedelta 21 | 22 | @literal(float, int, name="s") 23 | def seconds(self): 24 | return timedelta(seconds=self) 25 | 26 | @literal(float, int, name="m") 27 | def minutes(self): 28 | return timedelta(seconds=60 * self) 29 | 30 | print(30 .s + 0.5.m) # 0:01:00 31 | ``` 32 | Class decorator syntax: 33 | ```py 34 | from custom_literals import literals 35 | from datetime import timedelta 36 | 37 | @literals(float, int) 38 | class Duration: 39 | def s(self): 40 | return timedelta(seconds=self) 41 | def m(self): 42 | return timedelta(seconds=60 * self) 43 | 44 | print(30 .s + 0.5.m) # 0:01:00 45 | ``` 46 | Removing a custom literal: 47 | ```py 48 | from custom_literals import literal, unliteral 49 | 50 | @literal(str) 51 | def u(self): 52 | return self.upper() 53 | 54 | print("hello".u) # "HELLO" 55 | 56 | unliteral(str, "u") 57 | assert not hasattr("hello", "u") 58 | ``` 59 | Context manager syntax (automatically removes literals afterwards): 60 | ```py 61 | from custom_literals import literally 62 | from datetime import timedelta 63 | 64 | with literally(float, int, 65 | s=lambda x: timedelta(seconds=x), 66 | m=lambda x: timedelta(seconds=60 * x) 67 | ): 68 | print(30 .s + 0.5.m) # 0:01:00 69 | ``` 70 | 71 | For more information, see the README.md file. 72 | ''' 73 | from __future__ import annotations 74 | 75 | import dis 76 | import inspect 77 | import os 78 | from contextlib import contextmanager 79 | from typing import Any, Callable, Dict, Generic, Iterator, List, Set, Tuple, Type, TypeVar, Union 80 | 81 | __all__ = ( 82 | "literal", 83 | "literals", 84 | "literally", 85 | "unliteral", 86 | "rename", 87 | "is_hooked", 88 | "lie", 89 | "ALLOWED_TARGETS", 90 | ) 91 | 92 | _ALLOWED_BACKENDS = ("forbiddenfruit",) # In the future, this may be expanded 93 | _DEFAULT_BACKEND = "forbiddenfruit" 94 | 95 | ALLOWED_TARGETS = (bool, int, float, complex, str, bytes, None, ..., tuple, list, dict, set) 96 | ALLOWED_TARGET_TYPES = (bool, int, float, complex, str, bytes, type(None), type(...), tuple, list, dict, set) 97 | 98 | _PrimitiveType = Union[bool, int, float, complex, str, bytes] 99 | _NoneType = type(None) 100 | _EllipsisType = type(...) 101 | _SingletonType = Union[_NoneType, _EllipsisType] 102 | _CollectionType = Union[Tuple[Any, ...], List[Any], Dict[Any, Any], Set[Any]] 103 | 104 | _LiteralType = Union[_PrimitiveType, _SingletonType, _CollectionType] 105 | _LiteralTarget = Union[Type[_PrimitiveType], _SingletonType, Type[_CollectionType]] 106 | 107 | _T = TypeVar("_T") 108 | _U = TypeVar("_U") 109 | _LiteralT = TypeVar("_LiteralT", bound=_LiteralType) 110 | 111 | _ALLOWED_BYTECODE_OPS = ( 112 | "LOAD_CONST", 113 | "BUILD_TUPLE", 114 | "BUILD_LIST", 115 | "BUILD_SET", 116 | "BUILD_MAP", 117 | "FORMAT_VALUE", 118 | "LIST_TO_TUPLE", 119 | "LIST_EXTEND", 120 | "MAP_UPDATE", 121 | "SET_UPDATE", 122 | "DICT_UPDATE", 123 | ) 124 | 125 | def _to_type(target: _LiteralTarget) -> type[_LiteralType]: 126 | return target if isinstance(target, type) else type(target) 127 | 128 | # Builtin types are static across the interpreter, so active 129 | # custom literals can be stored globally 130 | _HOOKED_INSTANCES: dict[type, list[str]] = {type: [] for type in ALLOWED_TARGET_TYPES} 131 | 132 | class _LiteralDescriptor(Generic[_LiteralT, _U]): 133 | def __init__(self, type: type[_LiteralT], fn: Callable[[_LiteralT], _U] , *, name: str, strict: bool): 134 | if name in _HOOKED_INSTANCES[type]: 135 | raise AttributeError(f"the custom literal `{name}` is already defined on `{type}` objects") 136 | # We are willing to shadow attributes but not to override them directly 137 | elif name in type.__dict__: 138 | raise AttributeError(f"the name `{name}` is already defined on `{type}` objects") 139 | 140 | self.type: type[_LiteralT] = type 141 | self.fn: Callable[[_LiteralT], _U] = fn 142 | self.name: str = name 143 | self.strict: bool = strict 144 | 145 | def __get__(self, obj: _LiteralT, owner: type[_LiteralT]) -> _U | None: 146 | # When __get__ is called with the arguments 147 | # (self, instance, cls) 148 | # we know that it's being called on an instance, 149 | # whereas if it's called with the arguments 150 | # (self, None, cls) 151 | # it's being called on the class itself. 152 | # 153 | # Note that there is in fact a glaring ambiguity in this! 154 | # If cls is NoneType, then instance must be None. Oh no! 155 | # It's crucial for us to be able to distinguish between 156 | # __get__ being called on an instance vs being called on 157 | # the class itself. Otherwise, we can't tell the difference 158 | # between `hasattr` used to check the existence of an attribute 159 | # and fetching the attribute itself. As you may guess, this is 160 | # quite annoying. 161 | # 162 | # How do we fix this? There is in fact a solution but it's bad. 163 | # If we also define a DATA descriptor (any data descriptor) 164 | # on type itself in addition to patching our custom literal 165 | # descriptor to NoneType, then the new data descriptor will 166 | # be given higher priority when calling `NoneType.foo`. That 167 | # is to say, we can fix the consequences of our monkeypatching 168 | # with more monkeypatching. As a result of this, we can simply 169 | # assume that if this __get__ is passed an instance and its type, 170 | # then it is being accessed directly through the instance. 171 | if not isinstance(obj, owner): 172 | return None 173 | 174 | if type(obj) is not self.type: 175 | raise AttributeError(f"the custom literal `{self.name}` of `{owner}` objects is not defined") 176 | 177 | if self.strict: 178 | current_frame = inspect.currentframe() 179 | # Running on a different python implementation 180 | if current_frame is None: 181 | raise RuntimeError("unreachable") 182 | 183 | frame = current_frame.f_back 184 | # Can only occur if this code is pasted into the global scope 185 | if frame is None: 186 | raise RuntimeError("unreachable") 187 | 188 | # We ensure the last executed bytecode instruction 189 | # (before the attribute lookup) is LOAD_CONST, i.e., 190 | # the object being acted on was just fetched from the 191 | # code object's co_consts field. Any other opcode means 192 | # that the object has been computed, e.g. by storing it 193 | # in a variable first. 194 | # 195 | # Note that this is not forward-compatible due to the 196 | # possibility of a future change in the bytecode structure 197 | # and opcode numbering. 198 | load_instr = frame.f_lasti - 2 199 | load_kind = dis.opname[frame.f_code.co_code[load_instr]] 200 | if load_kind not in _ALLOWED_BYTECODE_OPS: 201 | raise TypeError(f"the strict custom literal `{self.name}` of `{self.type}` objects can only be invoked on literal values") 202 | return self.fn(obj) 203 | 204 | # Defined to make this a data descriptor, giving it 205 | # higher precedence in attribute lookup. This is *not* 206 | # required for the patching to work. 207 | def __set__(self, _obj, _value): 208 | raise AttributeError 209 | 210 | # WARNING 211 | # THIS CLASS IS USED TO FACILITATE AN AWFUL HACK 212 | # THERE IS NO OTHER WORKAROUND AS FAR AS I'M AWARE 213 | # DO NOT TOUCH (the tests will fail if you do) 214 | # 215 | # For more, check out the big comment block in _LiteralDescriptor.__get__ 216 | class _NoneTypeDescriptorHack: 217 | def __init__(self, name): 218 | self.name = name 219 | 220 | def __get__(self, obj, type): 221 | if self.name not in _HOOKED_INSTANCES[obj]: 222 | raise AttributeError 223 | 224 | def __set__(self, _obj, _value): 225 | raise AttributeError 226 | 227 | def _hook_literal(cls: type[_LiteralT], name: str, descriptor: _LiteralDescriptor[_LiteralT, Any], backend: str) -> None: 228 | _HOOKED_INSTANCES[cls].append(name) 229 | hook = _select_hook_backend(backend) 230 | # See the comments in _LiteralDescriptor.__get__ 231 | if cls is type(None): 232 | hook(type, name, _NoneTypeDescriptorHack(name)) 233 | hook(cls, name, descriptor) 234 | 235 | def _unhook_literal(cls: type[_LiteralType], name: str, backend: str) -> None: 236 | unhook = _select_unhook_backend(backend) 237 | unhook(cls, name) 238 | # See the comments in _LiteralDescriptor.__get__ 239 | if cls is type(None): 240 | unhook(type, name) 241 | _HOOKED_INSTANCES[cls].remove(name) 242 | 243 | # In the future, these may be useful 244 | def _get_backend(override: str | None = None) -> str: 245 | if override is not None: 246 | return override 247 | return os.environ.get("CUSTOM_LITERALS_BACKEND", _DEFAULT_BACKEND) 248 | 249 | def _select_hook_backend(backend: str) -> Callable[[type[Any], str, Any], None]: 250 | if backend == "forbiddenfruit": 251 | import forbiddenfruit 252 | return forbiddenfruit.curse 253 | raise ValueError(f"unsupported backend: {backend}") 254 | 255 | def _select_unhook_backend(backend: str) -> Callable[[type[Any], str], None]: 256 | if backend == "forbiddenfruit": 257 | import forbiddenfruit 258 | return forbiddenfruit.reverse 259 | raise ValueError(f"unsupported backend: {backend}") 260 | 261 | 262 | def literal(*targets: _LiteralTarget, name: str | None = None, strict: bool = False) -> Callable[[Callable[[_LiteralT], _U]], Callable[[_LiteralT], _U]]: 263 | '''A decorator defining a custom literal suffix 264 | for objects of the given types. 265 | 266 | Examples 267 | ======== 268 | 269 | ```py 270 | @literal(str, name="u") 271 | def utf_8(self): 272 | return self.encode("utf-8") 273 | 274 | my_string = "hello 😃".u 275 | print(my_string) 276 | # b'hello \\xf0\\x9f\\x98\\x83' 277 | ``` 278 | 279 | With multiple target types: 280 | ```py 281 | from datetime import timedelta 282 | 283 | @literal(float, int, name="s") 284 | def seconds(self): 285 | return timedelta(seconds=self) 286 | 287 | @literal(float, int, name="m") 288 | def minutes(self): 289 | return timedelta(seconds=60 * self) 290 | 291 | assert (1).m == (30).s + 0.5.m 292 | ``` 293 | 294 | Parameters 295 | ======== 296 | 297 | *types: type 298 | The types to define the literal for. 299 | 300 | name: str | None 301 | The name of the literal suffix used, or the name of 302 | the decorated function if passed `None`. 303 | 304 | strict: bool 305 | If the custom literal is invoked for objects other than 306 | constant literals in the source code, raises `TypeError`. 307 | By default, this is `False`. 308 | 309 | backend: str | None 310 | The name of the backend to use. If this is `None`, the 311 | environment variable `CUSTOM_LITERAL_BACKEND` is used. 312 | If the environment variable is not set, the default 313 | backend (given by the `DEFAULT_BACKEND` constant) is used. 314 | 315 | Raises 316 | ======== 317 | 318 | AttributeError: 319 | Raised if the custom literal name is already defined as 320 | an attribute of the given type. 321 | ''' 322 | def inner(fn: Callable[[_LiteralT], _U]) -> Callable[[_LiteralT], _U]: 323 | for target in targets: 324 | type = _to_type(target) 325 | real_name = fn.__name__ if name is None else name 326 | # As far as I can tell, there's no way to make this type check properly 327 | descriptor: _LiteralDescriptor[Any, _U] = _LiteralDescriptor(type, fn, name=real_name, strict=strict) # type: ignore 328 | _hook_literal(type, real_name, descriptor, _get_backend()) 329 | return fn 330 | return inner 331 | 332 | def literals(*targets: _LiteralTarget, strict: bool = False): 333 | '''A decorator enabling syntactic sugar for class-based 334 | custom literal definitions. Decorating a class with 335 | `@literals(*targets)` is equivalent to decorating each of 336 | its methods with `@literal(*targets)`. 337 | 338 | Note: Methods beginning with `__` are ignored, to prevent 339 | accidental shadowing of builtin methods. 340 | 341 | Examples 342 | ======== 343 | 344 | ```py 345 | from datetime import timedelta 346 | 347 | @literals(float, int) 348 | class Duration: 349 | @rename("h") 350 | def hours(self): 351 | return timedelta(seconds=60 * 60 * self) 352 | 353 | @rename("m") 354 | def minutes(self): 355 | return timedelta(seconds=60 * self) 356 | 357 | @rename("s") 358 | def seconds(self): 359 | return timedelta(seconds=self) 360 | 361 | assert 0.5.h + (1).m == (30).m + 60.0.s 362 | ``` 363 | 364 | Parameters 365 | ======== 366 | 367 | *targets: type 368 | The types to define the literal for. 369 | 370 | strict: bool 371 | If the custom literal is invoked for objects other than 372 | constant literals in the source code, raises `TypeError`. 373 | By default, this is `False`. 374 | 375 | backend: str | None 376 | The name of the backend to use. If this is `None`, the 377 | environment variable `CUSTOM_LITERAL_BACKEND` is used. 378 | If the environment variable is not set, the default 379 | backend (given by the `DEFAULT_BACKEND` constant) is used. 380 | 381 | Raises 382 | ======== 383 | 384 | AttributeError: 385 | Raised if the custom literal names are already defined as 386 | an attribute of the given type, or if any of the methods 387 | begin with `__`. 388 | ''' 389 | def inner(cls: type) -> type: 390 | for target in targets: 391 | type = _to_type(target) 392 | for name in dir(cls): 393 | fn = getattr(cls, name) 394 | if not name.startswith("__") and callable(fn): 395 | # Check for explicitly renamed methods 396 | if isinstance(fn, _RenamedFunction): 397 | real_name = fn.name 398 | else: 399 | real_name = name 400 | descriptor = _LiteralDescriptor(type, fn, name=real_name, strict=strict) 401 | _hook_literal(type, real_name, descriptor, _get_backend()) 402 | return cls 403 | return inner 404 | 405 | def unliteral(target: _LiteralTarget, name: str): 406 | '''Removes a custom literal from the given type. 407 | 408 | Examples 409 | ======== 410 | 411 | ```py 412 | from datetime import datetime 413 | 414 | @literal(int) 415 | def unix(self): 416 | return datetime.fromtimestamp(self) 417 | 418 | print(1647804818.unix) # 2022-03-20 21:33:38 419 | 420 | unliteral(int, "unix") 421 | assert not hasattr(int, "unix") 422 | ``` 423 | 424 | Parameters 425 | ======== 426 | 427 | cls: type 428 | The type to remove the custom literal from. 429 | 430 | name: str 431 | The name of the custom literal being removed. 432 | 433 | Raises 434 | ======== 435 | 436 | AttributeError: 437 | Raised when the type does not define a custom literal with the given name. 438 | 439 | ''' 440 | type = _to_type(target) 441 | if name not in _HOOKED_INSTANCES[type]: 442 | raise AttributeError(f"the custom literal `{name}` of `{type}` objects is not defined") 443 | 444 | _unhook_literal(type, name=name, backend=_get_backend()) 445 | 446 | @contextmanager 447 | def literally(*targets: _LiteralTarget, strict: bool = False, **fns: Callable[[_LiteralT], Any]) -> Iterator[None]: 448 | '''A context manager for temporarily defining custom literals. When 449 | the context manager exits, the custom literals are removed. 450 | 451 | Note: Due to the overlap in function signature, it is not possible to use 452 | `literally` to define a custom literal named `strict`. To avoid this, 453 | you can manually hook and unhook your custom literal using `@literal` and 454 | `@unliteral` respectively. 455 | 456 | Examples 457 | ======== 458 | 459 | ```py 460 | from datetime import datetime 461 | 462 | with literally(int, unix=datetime.fromtimestamp): 463 | print((1647804818).unix) # 2022-03-20 21:33:38 464 | ``` 465 | 466 | Parameters 467 | ======== 468 | 469 | *targets: type 470 | The types to define the literals for. 471 | 472 | strict: bool 473 | If the custom literal is invoked for objects other than 474 | constant literals in the source code, raises `TypeError`. 475 | By default, this is `False`. 476 | 477 | **fns: (type -> Any) 478 | The functions to call when the literal is invoked. The name 479 | of the keyword argument is used as the name of the custom literal. 480 | 481 | Raises 482 | ======== 483 | 484 | AttributeError: 485 | Raised if the custom literal name is already defined as 486 | an attribute of the given type. 487 | ''' 488 | types = [_to_type(target) for target in targets] 489 | for type in types: 490 | for name, fn in fns.items(): 491 | descriptor = _LiteralDescriptor(type, fn, name=name, strict=strict) 492 | _hook_literal(type, name, descriptor, _get_backend()) 493 | yield 494 | for type in types: 495 | for name in fns: 496 | _unhook_literal(type, name=name, backend=_get_backend()) 497 | 498 | def is_hooked(target: _LiteralTarget, name: str) -> bool: 499 | '''Returns whether the given custom literal is 500 | hooked in the given type. 501 | 502 | Examples 503 | ======== 504 | 505 | ```py 506 | from datetime import datetime 507 | 508 | @literal(int) 509 | def unix(self): 510 | return datetime.fromtimestamp(self) 511 | 512 | print(is_hooked(int, "unix")) # True 513 | ``` 514 | 515 | Parameters 516 | ======== 517 | 518 | target: type 519 | The type to check. 520 | 521 | name: str 522 | The name of the custom literal. 523 | 524 | Returns 525 | ======== 526 | 527 | bool 528 | Whether the given custom literal is hooked. 529 | ''' 530 | return name in _HOOKED_INSTANCES[_to_type(target)] 531 | 532 | class _RenamedFunction(Generic[_T, _U]): 533 | # To signal that a function has been renamed. 534 | # This is necessary because the `__name__` attribute 535 | # of a method can be different from its name in the 536 | # class dirs for multiple reasons, and we need to 537 | # be able to tell when that has happened as a result 538 | # of the `rename` decorator. 539 | def __init__(self, fn: Callable[[_T], _U], name: str): 540 | self.fn = fn 541 | self.name = name 542 | 543 | def __call__(self, arg: _T) -> _U: 544 | return self.fn(arg) 545 | 546 | def rename(name: str) -> Callable[[Callable[[_T], _U]], Callable[[_T], _U]]: 547 | '''A utility decorator for renaming functions. Useful when combined 548 | with class-based custom literal definitions using `literals`. 549 | 550 | Examples 551 | ======== 552 | 553 | ```py 554 | @literals(str) 555 | class CaseLiterals: 556 | @rename("u") 557 | def uppercase(self): 558 | return self.upper() 559 | @rename("l") 560 | def lowercase(self): 561 | return self.lower() 562 | 563 | print("Hello, World!".u) # HELLO, WORLD! 564 | print("Hello, World!".l) # hello, world! 565 | ``` 566 | 567 | Parameters 568 | ======== 569 | 570 | name: str 571 | The updated name. 572 | ''' 573 | def inner(fn: Callable[[_T], _U]) -> Callable[[_T], _U]: 574 | return _RenamedFunction(fn, name) 575 | return inner 576 | 577 | def lie(target: type[_LiteralT]) -> type[_LiteralT]: 578 | '''A utility function for lying to type checkers. 579 | Useful in conjunction with class-based custom literals 580 | using `@literals`, since the type checker cannot infer 581 | the type of `self` in methods to be compatible with 582 | the target types. 583 | 584 | The signature of this function is a lie. It does not actually 585 | return the input type, but instead returns `object`. This 586 | makes it a no-op when used as the base class in a class definition, 587 | whilst tricking some static analysis tools into thinking that 588 | the resulting class is a subclass of the input type. 589 | 590 | Examples 591 | ======== 592 | ```py 593 | @literals(int) 594 | class Naughty(lie(int)): 595 | # lie is marked to return `int`, meaning 596 | # `self` is assumed to subclass `int`. 597 | @rename("s") 598 | def successor(self): 599 | # type checkers may otherwise complain that 600 | # `Naughty + int` is not a valid operation. 601 | return self + 1 602 | ``` 603 | 604 | Parameters 605 | ======== 606 | 607 | target: type 608 | The type to lie about. 609 | ''' 610 | # this type-ignore comment cannot be removed, by design 611 | return object # type: ignore 612 | --------------------------------------------------------------------------------