├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── azure-pipelines.yml ├── future_fstrings.py ├── requirements-dev.txt ├── setup.cfg ├── setup.py ├── testing ├── fix_coverage.py └── remove_pycdir.py ├── tests ├── __init__.py └── future_fstrings_test.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | /.pytest_cache 4 | /.coverage 5 | /.tox 6 | /venv* 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.1.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | - id: name-tests-test 11 | - id: requirements-txt-fixer 12 | - repo: https://github.com/PyCQA/flake8 13 | rev: 4.0.1 14 | hooks: 15 | - id: flake8 16 | - repo: https://github.com/pre-commit/mirrors-autopep8 17 | rev: v1.6.0 18 | hooks: 19 | - id: autopep8 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Anthony Sottile 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DEPRECATED 2 | 3 | this library is no longer needed: 4 | 5 | - python 2 and python3.5 have reached end of life 6 | - micropython added f-strings support 7 | 8 | ___ 9 | 10 | [![Build Status](https://asottile.visualstudio.com/asottile/_apis/build/status/asottile.future-fstrings?branchName=master)](https://asottile.visualstudio.com/asottile/_build/latest?definitionId=15&branchName=master) 11 | [![Azure DevOps coverage](https://img.shields.io/azure-devops/coverage/asottile/asottile/15/master.svg)](https://dev.azure.com/asottile/asottile/_build/latest?definitionId=15&branchName=master) 12 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/asottile/future-fstrings/master.svg)](https://results.pre-commit.ci/latest/github/asottile/future-fstrings/master) 13 | 14 | future-fstrings 15 | =============== 16 | 17 | A backport of fstrings to python<3.6. 18 | 19 | 20 | ## Installation 21 | 22 | `pip install future-fstrings` 23 | 24 | 25 | ## Usage 26 | 27 | Include the following encoding cookie at the top of your file (this replaces 28 | the utf-8 cookie if you already have it): 29 | 30 | ```python 31 | # -*- coding: future_fstrings -*- 32 | ``` 33 | 34 | And then write python3.6 fstring code as usual! 35 | 36 | ```python 37 | # -*- coding: future_fstrings -*- 38 | thing = 'world' 39 | print(f'hello {thing}') 40 | ``` 41 | 42 | ```console 43 | $ python2.7 main.py 44 | hello world 45 | ``` 46 | 47 | ## Showing transformed source 48 | 49 | `future-fstrings` also includes a cli to show transformed source. 50 | 51 | ```console 52 | $ future-fstrings-show main.py 53 | # -*- coding: future_fstrings -*- 54 | thing = 'world' 55 | print('hello {}'.format((thing))) 56 | ``` 57 | 58 | ## Transform source for micropython 59 | 60 | The `future-fstrings-show` command can be used to transform source before 61 | distributing. This can allow you to write f-string code but target platforms 62 | which do not support f-strings, such as [micropython]. 63 | 64 | To use this on modern versions of python, install using: 65 | 66 | ```bash 67 | pip install future-fstrings[rewrite] 68 | ``` 69 | 70 | and then use `future-fstrings-show` as above. 71 | 72 | For instance: 73 | 74 | ```bash 75 | future-fstrings-show code.py > code_rewritten.py 76 | ``` 77 | 78 | [micropython]: https://github.com/micropython/micropython 79 | 80 | ## How does this work? 81 | 82 | `future-fstrings` has two parts: 83 | 84 | 1. A utf-8 compatible `codec` which performs source manipulation 85 | - The `codec` first decodes the source bytes using the UTF-8 codec 86 | - The `codec` then leverages 87 | [tokenize-rt](https://github.com/asottile/tokenize-rt) to rewrite 88 | f-strings. 89 | 2. A `.pth` file which registers a codec on interpreter startup. 90 | 91 | ## when you aren't using normal `site` registration 92 | 93 | in setups (such as aws lambda) where you utilize `PYTHONPATH` or `sys.path` 94 | instead of truly installed packages, the `.pth` magic above will not take. 95 | 96 | for those circumstances, you'll need to manually initialize `future-fstrings` 97 | in a non-fstring wrapper. for instance: 98 | 99 | ```python 100 | import future_fstrings 101 | 102 | future_fstrings.register() 103 | 104 | from actual_main import main 105 | 106 | if __name__ == '__main__': 107 | raise SystemExit(main()) 108 | ``` 109 | 110 | ## you may also like 111 | 112 | - [future-annotations](https://github.com/asottile/future-annotations) 113 | - [future-breakpoint](https://github.com/asottile/future-breakpoint) 114 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | trigger: 2 | branches: 3 | include: [master, test-me-*] 4 | tags: 5 | include: ['*'] 6 | 7 | resources: 8 | repositories: 9 | - repository: asottile 10 | type: github 11 | endpoint: github 12 | name: asottile/azure-pipeline-templates 13 | ref: refs/tags/v2.1.0 14 | 15 | jobs: 16 | - template: job--python-tox.yml@asottile 17 | parameters: 18 | toxenvs: [pypy, py27, py35] 19 | os: linux 20 | - template: job--python-tox.yml@asottile 21 | parameters: 22 | toxenvs: [pypy3, py36, py37] 23 | os: linux 24 | name_postfix: _nocover 25 | coverage: False 26 | -------------------------------------------------------------------------------- /future_fstrings.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from __future__ import unicode_literals 3 | 4 | import argparse 5 | import codecs 6 | import encodings 7 | import io 8 | import sys 9 | 10 | 11 | utf_8 = encodings.search_function('utf8') 12 | 13 | 14 | class TokenSyntaxError(SyntaxError): 15 | def __init__(self, e, token): 16 | super(TokenSyntaxError, self).__init__(e) 17 | self.e = e 18 | self.token = token 19 | 20 | 21 | def _find_literal(s, start, level, parts, exprs): 22 | """Roughly Python/ast.c:fstring_find_literal""" 23 | i = start 24 | parse_expr = True 25 | 26 | while i < len(s): 27 | ch = s[i] 28 | 29 | if ch in ('{', '}'): 30 | if level == 0: 31 | if i + 1 < len(s) and s[i + 1] == ch: 32 | i += 2 33 | parse_expr = False 34 | break 35 | elif ch == '}': 36 | raise SyntaxError("f-string: single '}' is not allowed") 37 | break 38 | 39 | i += 1 40 | 41 | parts.append(s[start:i]) 42 | return i, parse_expr and i < len(s) 43 | 44 | 45 | def _find_expr(s, start, level, parts, exprs): 46 | """Roughly Python/ast.c:fstring_find_expr""" 47 | i = start 48 | nested_depth = 0 49 | quote_char = None 50 | triple_quoted = None 51 | 52 | def _check_end(): 53 | if i == len(s): 54 | raise SyntaxError("f-string: expecting '}'") 55 | 56 | if level >= 2: 57 | raise SyntaxError("f-string: expressions nested too deeply") 58 | 59 | parts.append(s[i]) 60 | i += 1 61 | 62 | while i < len(s): 63 | ch = s[i] 64 | 65 | if ch == '\\': 66 | raise SyntaxError( 67 | 'f-string expression part cannot include a backslash', 68 | ) 69 | if quote_char is not None: 70 | if ch == quote_char: 71 | if triple_quoted: 72 | if i + 2 < len(s) and s[i + 1] == ch and s[i + 2] == ch: 73 | i += 2 74 | quote_char = None 75 | triple_quoted = None 76 | else: 77 | quote_char = None 78 | triple_quoted = None 79 | elif ch in ('"', "'"): 80 | quote_char = ch 81 | if i + 2 < len(s) and s[i + 1] == ch and s[i + 2] == ch: 82 | triple_quoted = True 83 | i += 2 84 | else: 85 | triple_quoted = False 86 | elif ch in ('[', '{', '('): 87 | nested_depth += 1 88 | elif nested_depth and ch in (']', '}', ')'): 89 | nested_depth -= 1 90 | elif ch == '#': 91 | raise SyntaxError("f-string expression cannot include '#'") 92 | elif nested_depth == 0 and ch in ('!', ':', '}'): 93 | if ch == '!' and i + 1 < len(s) and s[i + 1] == '=': 94 | # Allow != at top level as `=` isn't a valid conversion 95 | pass 96 | else: 97 | break 98 | i += 1 99 | 100 | if quote_char is not None: 101 | raise SyntaxError('f-string: unterminated string') 102 | elif nested_depth: 103 | raise SyntaxError("f-string: mismatched '(', '{', or '['") 104 | _check_end() 105 | 106 | exprs.append(s[start + 1:i]) 107 | 108 | if s[i] == '!': 109 | parts.append(s[i]) 110 | i += 1 111 | _check_end() 112 | parts.append(s[i]) 113 | i += 1 114 | 115 | _check_end() 116 | 117 | if s[i] == ':': 118 | parts.append(s[i]) 119 | i += 1 120 | _check_end() 121 | i = _fstring_parse(s, i, level + 1, parts, exprs) 122 | 123 | _check_end() 124 | if s[i] != '}': 125 | raise SyntaxError("f-string: expecting '}'") 126 | 127 | parts.append(s[i]) 128 | i += 1 129 | return i 130 | 131 | 132 | def _fstring_parse(s, i, level, parts, exprs): 133 | """Roughly Python/ast.c:fstring_find_literal_and_expr""" 134 | while True: 135 | i, parse_expr = _find_literal(s, i, level, parts, exprs) 136 | if i == len(s) or s[i] == '}': 137 | return i 138 | if parse_expr: 139 | i = _find_expr(s, i, level, parts, exprs) 140 | 141 | 142 | def _fstring_parse_outer(s, i, level, parts, exprs): 143 | for q in ('"' * 3, "'" * 3, '"', "'"): 144 | if s.startswith(q): 145 | s = s[len(q):len(s) - len(q)] 146 | break 147 | else: 148 | raise AssertionError('unreachable') 149 | parts.append(q) 150 | ret = _fstring_parse(s, i, level, parts, exprs) 151 | parts.append(q) 152 | return ret 153 | 154 | 155 | def _is_f(token): 156 | import tokenize_rt 157 | 158 | prefix, _ = tokenize_rt.parse_string_literal(token.src) 159 | return 'f' in prefix.lower() 160 | 161 | 162 | def _make_fstring(tokens): 163 | import tokenize_rt 164 | 165 | new_tokens = [] 166 | exprs = [] 167 | 168 | for i, token in enumerate(tokens): 169 | if token.name == 'STRING' and _is_f(token): 170 | prefix, s = tokenize_rt.parse_string_literal(token.src) 171 | parts = [] 172 | try: 173 | _fstring_parse_outer(s, 0, 0, parts, exprs) 174 | except SyntaxError as e: 175 | raise TokenSyntaxError(e, tokens[i - 1]) 176 | if 'r' in prefix.lower(): 177 | parts = [s.replace('\\', '\\\\') for s in parts] 178 | token = token._replace(src=''.join(parts)) 179 | elif token.name == 'STRING': 180 | new_src = token.src.replace('{', '{{').replace('}', '}}') 181 | token = token._replace(src=new_src) 182 | new_tokens.append(token) 183 | 184 | exprs = ('({})'.format(expr) for expr in exprs) 185 | format_src = '.format({})'.format(', '.join(exprs)) 186 | new_tokens.append(tokenize_rt.Token('FORMAT', src=format_src)) 187 | 188 | return new_tokens 189 | 190 | 191 | def decode(b, errors='strict'): 192 | import tokenize_rt # pip install future-fstrings[rewrite] 193 | 194 | u, length = utf_8.decode(b, errors) 195 | tokens = tokenize_rt.src_to_tokens(u) 196 | 197 | to_replace = [] 198 | start = end = seen_f = None 199 | 200 | for i, token in enumerate(tokens): 201 | if start is None: 202 | if token.name == 'STRING': 203 | start, end = i, i + 1 204 | seen_f = _is_f(token) 205 | elif token.name == 'STRING': 206 | end = i + 1 207 | seen_f |= _is_f(token) 208 | elif token.name not in tokenize_rt.NON_CODING_TOKENS: 209 | if seen_f: 210 | to_replace.append((start, end)) 211 | start = end = seen_f = None 212 | 213 | for start, end in reversed(to_replace): 214 | try: 215 | tokens[start:end] = _make_fstring(tokens[start:end]) 216 | except TokenSyntaxError as e: 217 | msg = str(e.e) 218 | line = u.splitlines()[e.token.line - 1] 219 | bts = line.encode('UTF-8')[:e.token.utf8_byte_offset] 220 | indent = len(bts.decode('UTF-8')) 221 | raise SyntaxError(msg + '\n\n' + line + '\n' + ' ' * indent + '^') 222 | return tokenize_rt.tokens_to_src(tokens), length 223 | 224 | 225 | class IncrementalDecoder(codecs.BufferedIncrementalDecoder): 226 | def _buffer_decode(self, input, errors, final): # pragma: no cover 227 | if final: 228 | return decode(input, errors) 229 | else: 230 | return '', 0 231 | 232 | 233 | class StreamReader(utf_8.streamreader, object): 234 | """decode is deferred to support better error messages""" 235 | _stream = None 236 | _decoded = False 237 | 238 | @property 239 | def stream(self): 240 | if not self._decoded: 241 | text, _ = decode(self._stream.read()) 242 | self._stream = io.BytesIO(text.encode('UTF-8')) 243 | self._decoded = True 244 | return self._stream 245 | 246 | @stream.setter 247 | def stream(self, stream): 248 | self._stream = stream 249 | self._decoded = False 250 | 251 | 252 | def _natively_supports_fstrings(): 253 | try: 254 | return eval('f"hi"') == 'hi' 255 | except SyntaxError: 256 | return False 257 | 258 | 259 | fstring_decode = decode 260 | SUPPORTS_FSTRINGS = _natively_supports_fstrings() 261 | if SUPPORTS_FSTRINGS: # pragma: no cover 262 | decode = utf_8.decode # noqa 263 | IncrementalDecoder = utf_8.incrementaldecoder # noqa 264 | StreamReader = utf_8.streamreader # noqa 265 | 266 | # codec api 267 | 268 | codec_map = { 269 | name: codecs.CodecInfo( 270 | name=name, 271 | encode=utf_8.encode, 272 | decode=decode, 273 | incrementalencoder=utf_8.incrementalencoder, 274 | incrementaldecoder=IncrementalDecoder, 275 | streamreader=StreamReader, 276 | streamwriter=utf_8.streamwriter, 277 | ) 278 | for name in ('future-fstrings', 'future_fstrings') 279 | } 280 | 281 | 282 | def register(): # pragma: no cover 283 | codecs.register(codec_map.get) 284 | 285 | 286 | def main(argv=None): 287 | parser = argparse.ArgumentParser(description='Prints transformed source.') 288 | parser.add_argument('filename') 289 | args = parser.parse_args(argv) 290 | 291 | with open(args.filename, 'rb') as f: 292 | text, _ = fstring_decode(f.read()) 293 | getattr(sys.stdout, 'buffer', sys.stdout).write(text.encode('UTF-8')) 294 | 295 | 296 | if __name__ == '__main__': 297 | raise SystemExit(main()) 298 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | covdefaults 2 | coverage>=5 3 | pre-commit 4 | pytest 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = future_fstrings 3 | version = 1.2.0 4 | description = A backport of fstrings to python<3.6 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | url = https://github.com/asottile/future-fstrings 8 | author = Anthony Sottile 9 | author_email = asottile@umich.edu 10 | license = MIT 11 | license_file = LICENSE 12 | classifiers = 13 | License :: OSI Approved :: MIT License 14 | Programming Language :: Python :: 2 15 | Programming Language :: Python :: 2.7 16 | Programming Language :: Python :: 3 17 | Programming Language :: Python :: 3.5 18 | Programming Language :: Python :: 3.6 19 | Programming Language :: Python :: 3.7 20 | Programming Language :: Python :: 3.8 21 | Programming Language :: Python :: Implementation :: CPython 22 | Programming Language :: Python :: Implementation :: PyPy 23 | 24 | [options] 25 | py_modules = future_fstrings 26 | install_requires = 27 | tokenize-rt>=3;python_version<"3.6" 28 | python_requires = >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.* 29 | 30 | [options.entry_points] 31 | console_scripts = 32 | future-fstrings-show=future_fstrings:main 33 | 34 | [options.extras_require] 35 | rewrite = tokenize-rt>=3 36 | 37 | [bdist_wheel] 38 | universal = True 39 | 40 | [coverage:run] 41 | plugins = covdefaults 42 | 43 | [coverage:covdefaults] 44 | subtract_omit = */.tox/* 45 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import distutils 2 | import os.path 3 | 4 | from setuptools import setup 5 | from setuptools.command.install import install as _install 6 | 7 | 8 | PTH = ( 9 | 'try:\n' 10 | ' import future_fstrings\n' 11 | 'except ImportError:\n' 12 | ' pass\n' 13 | 'else:\n' 14 | ' future_fstrings.register()\n' 15 | ) 16 | 17 | 18 | class install(_install): 19 | def initialize_options(self): 20 | _install.initialize_options(self) 21 | # Use this prefix to get loaded as early as possible 22 | name = 'aaaaa_' + self.distribution.metadata.name 23 | 24 | contents = 'import sys; exec({!r})\n'.format(PTH) 25 | self.extra_path = (name, contents) 26 | 27 | def finalize_options(self): 28 | _install.finalize_options(self) 29 | 30 | install_suffix = os.path.relpath( 31 | self.install_lib, self.install_libbase, 32 | ) 33 | if install_suffix == '.': 34 | distutils.log.info('skipping install of .pth during easy-install') 35 | elif install_suffix == self.extra_path[1]: 36 | self.install_lib = self.install_libbase 37 | distutils.log.info( 38 | "will install .pth to '%s.pth'", 39 | os.path.join(self.install_lib, self.extra_path[0]), 40 | ) 41 | else: 42 | raise AssertionError( 43 | 'unexpected install_suffix', 44 | self.install_lib, self.install_libbase, install_suffix, 45 | ) 46 | 47 | 48 | setup(cmdclass={'install': install}) 49 | -------------------------------------------------------------------------------- /testing/fix_coverage.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | 3 | QUERY_SITE_PACKAGES = '''\ 4 | SELECT id FROM file WHERE path LIKE '%site-packages/future_fstrings.py' 5 | ''' 6 | QUERY_NOT_SITE_PACKAGES = '''\ 7 | SELECT id FROM file 8 | WHERE ( 9 | path LIKE '%/future_fstrings.py' AND 10 | path NOT LIKE '%site-packages/future_fstrings.py' 11 | ) 12 | ''' 13 | QUERY_TEST = '''\ 14 | SELECT id FROM file WHERE path LIKE '%/future_fstrings_test.py' 15 | ''' 16 | MERGE_FILE_IN_ARC = 'UPDATE arc SET file_id = ? WHERE file_id = ?' 17 | DELETE_FROM_ARC = 'DELETE FROM arc WHERE file_id NOT IN (?, ?)' 18 | DELETE_FROM_FILE = 'DELETE FROM file WHERE id NOT IN (?, ?)' 19 | 20 | 21 | def main(): 22 | with sqlite3.connect('.coverage') as db: 23 | (site_packages,) = db.execute(QUERY_SITE_PACKAGES).fetchone() 24 | (src,) = db.execute(QUERY_NOT_SITE_PACKAGES).fetchone() 25 | (test,) = db.execute(QUERY_TEST).fetchone() 26 | db.execute(MERGE_FILE_IN_ARC, (src, site_packages)) 27 | db.execute(DELETE_FROM_ARC, (src, test)) 28 | db.execute(DELETE_FROM_FILE, (src, test)) 29 | 30 | 31 | if __name__ == '__main__': 32 | raise SystemExit(main()) 33 | -------------------------------------------------------------------------------- /testing/remove_pycdir.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import shutil 3 | 4 | 5 | def main(): 6 | pth = 'tests/__pycache__' 7 | if os.path.exists(pth): 8 | shutil.rmtree(pth) 9 | 10 | 11 | if __name__ == '__main__': 12 | raise SystemExit(main()) 13 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asottile-archive/future-fstrings/9b8bea12280dbc8776ef3094e98659e29be0bd9d/tests/__init__.py -------------------------------------------------------------------------------- /tests/future_fstrings_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: future_fstrings -*- 2 | from __future__ import absolute_import 3 | from __future__ import unicode_literals 4 | 5 | import io 6 | import subprocess 7 | import sys 8 | 9 | import pytest 10 | 11 | import future_fstrings 12 | 13 | 14 | xfailif_native = pytest.mark.xfail( 15 | future_fstrings.SUPPORTS_FSTRINGS, reason='natively supports fstrings', 16 | ) 17 | 18 | 19 | def test_hello_world(): 20 | thing = 'world' 21 | assert f'hello {thing}' == 'hello world' 22 | 23 | 24 | def test_maths(): 25 | assert f'{5 + 5}' == '10' 26 | 27 | 28 | def test_long_unicode(tmpdir): 29 | # This only reproduces outside pytest 30 | f = tmpdir.join('f.py') 31 | f.write_text( 32 | '# -*- coding: future_fstrings -*-\n' 33 | 'def test(a):\n' 34 | ' f"ЙЙЙЙЙЙЙЙЙЙЙЙЙЙЙЙЙЙЙЙЙЙЙЙЙЙЙЙ {a}"\n' 35 | 'test(1)\n', 36 | encoding='UTF-8', 37 | ) 38 | assert not subprocess.check_output((sys.executable, f.strpath)) 39 | 40 | 41 | def test_very_large_file(tmpdir): 42 | # This only reproduces outside pytest. See #23 43 | f = tmpdir.join('f.py') 44 | f.write(''.join(( 45 | '# -*- coding: future_fstrings -*-\n' 46 | 'def f(x): pass\n' 47 | 'f(\n', 48 | ' "hi"\n' * 8192, 49 | ')\n', 50 | ))) 51 | assert not subprocess.check_output((sys.executable, f.strpath)) 52 | 53 | 54 | def test_with_bangs(): 55 | thing = 'world' 56 | assert f'hello {thing.replace("d", "d!")}' == 'hello world!' 57 | assert f'hello {1 != 2}' == 'hello True' 58 | 59 | 60 | def test_with_braces(): 61 | hello = 'hello' 62 | assert f'hello {{ hi }} {hello}' == 'hello { hi } hello' 63 | 64 | 65 | def test_strings_quoting_variables(): 66 | assert f'hello {"{x}"}' == 'hello {x}' 67 | assert f"hello {'{x}'}" == 'hello {x}' 68 | assert f'hello {"""{x}"""}' == 'hello {x}' 69 | assert f"hello {'''{x}'''}" == 'hello {x}' 70 | assert f'hello {"""a"a"""}' == 'hello a"a' 71 | assert f"""hello {'''hi '" hello'''}""" == 'hello hi \'" hello' 72 | 73 | 74 | def test_raw_strings(): 75 | assert fr'hi\ {1}' == r'hi\ 1' 76 | assert fr'\n {1}' == r'\n 1' 77 | assert RF'\n {1}' == r'\n 1' 78 | assert FR'\n {1}' == r'\n 1' 79 | 80 | 81 | def test_sequence_literals(): 82 | assert f'hello {[1, 2, 3][0]}' == 'hello 1' 83 | assert f'hello {sorted({1, 2, 3})[0]}' == 'hello 1' 84 | assert f'hello {(1, 2, 3)[0]}' == 'hello 1' 85 | 86 | 87 | def test_nested_format_literals(): 88 | x = 5 89 | y = 6 90 | fmt = '6d' 91 | assert f'{x:{y}d}' == ' 5' 92 | assert f'{x:{fmt}}' == ' 5' 93 | 94 | 95 | def test_conversion_modifiers(): 96 | assert f'hello {str("hi")!r}' == "hello 'hi'" 97 | 98 | 99 | def test_upper_case_f(): 100 | thing = 'world' 101 | assert F'hello {thing}' == 'hello world' 102 | 103 | 104 | def test_implicitly_joined(): 105 | assert 'hello {1} ' f'hi {1}' == 'hello {1} hi 1' 106 | assert f'hello {1} ' 'hi {1}' == 'hello 1 hi {1}' 107 | s = ( 108 | f'hi {1} ' 109 | 'hello {1}' 110 | ) 111 | assert s == 'hi 1 hello {1}' 112 | s = f'hi {1} ' \ 113 | 'hello {1}' 114 | assert s == 'hi 1 hello {1}' 115 | 116 | 117 | def _assert_fails_with_msg(s, expected_msg): 118 | with pytest.raises(SyntaxError) as excinfo: 119 | future_fstrings._fstring_parse_outer(s, 0, 0, [], []) 120 | msg, = excinfo.value.args 121 | assert msg == expected_msg 122 | 123 | 124 | def test_error_only_one_closing_paren(): 125 | _assert_fails_with_msg("'hi }'", "f-string: single '}' is not allowed") 126 | 127 | 128 | def test_error_unterminated_brace(): 129 | _assert_fails_with_msg("'hi {'", "f-string: expecting '}'") 130 | 131 | 132 | def test_error_backslash(): 133 | _assert_fails_with_msg( 134 | r"""'hi {"\\"}'""", 135 | 'f-string expression part cannot include a backslash', 136 | ) 137 | 138 | 139 | def test_error_contains_comment_character(): 140 | _assert_fails_with_msg( 141 | "'hi {#}'", "f-string expression cannot include '#'", 142 | ) 143 | 144 | 145 | def test_unterminated_quotes(): 146 | _assert_fails_with_msg("""'hi {"s""", 'f-string: unterminated string') 147 | 148 | 149 | def test_incorrectly_nested_braces(): 150 | _assert_fails_with_msg( 151 | "'hi {[1, 2'", "f-string: mismatched '(', '{', or '['", 152 | ) 153 | 154 | 155 | def test_too_deep(): 156 | _assert_fails_with_msg( 157 | "'{x:{y:{z}}}'", 'f-string: expressions nested too deeply', 158 | ) 159 | 160 | 161 | def test_no_curly_at_end(): 162 | _assert_fails_with_msg("'{x!s{y}}'", "f-string: expecting '}'") 163 | 164 | 165 | @xfailif_native 166 | def test_better_error_messages(): 167 | with pytest.raises(SyntaxError) as excinfo: 168 | future_fstrings.decode(b"def test():\n f'bad {'\n") 169 | msg, = excinfo.value.args 170 | assert msg == ( 171 | "f-string: expecting '}'\n\n" 172 | " f'bad {'\n" 173 | ' ^' 174 | ) 175 | 176 | 177 | def test_streamreader_does_not_error_on_construction(): 178 | future_fstrings.StreamReader(io.BytesIO(b"f'error{'")) 179 | 180 | 181 | @xfailif_native 182 | def test_streamreader_read(): 183 | reader = future_fstrings.StreamReader(io.BytesIO(b"f'hi {x}'")) 184 | assert reader.read() == "'hi {}'.format((x))" 185 | 186 | 187 | def test_main(tmpdir, capsys): 188 | f = tmpdir.join('f.py') 189 | f.write( 190 | '# -*- coding: future_fstrings\n' 191 | "print(f'hello {5 + 5}')\n" 192 | ) 193 | assert not future_fstrings.main((f.strpath,)) 194 | out, _ = capsys.readouterr() 195 | assert out == ( 196 | '# -*- coding: future_fstrings\n' 197 | "print('hello {}'.format((5 + 5)))\n" 198 | ) 199 | 200 | 201 | def test_fix_coverage(): 202 | """Because our module is loaded so early in python startup, coverage 203 | doesn't have a chance to instrument the module-level scope. 204 | 205 | Run this last so it doesn't interfere with tests in any way. 206 | """ 207 | if sys.version_info < (3,): # pragma: no cover (PY2) 208 | import imp 209 | imp.reload(future_fstrings) 210 | else: # pragma: no cover (PY3) 211 | import importlib 212 | importlib.reload(future_fstrings) 213 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py35,py36,py37,pypy,pypy3,pre-commit 3 | 4 | [testenv] 5 | deps = -rrequirements-dev.txt 6 | extras = rewrite 7 | commands = 8 | # Since our encoding modifies the source, clear the pyc files 9 | python testing/remove_pycdir.py 10 | coverage erase 11 | coverage run -m pytest {posargs:tests} 12 | python testing/fix_coverage.py 13 | coverage report 14 | 15 | [testenv:py36] 16 | # Don't run coverage when our implementation is not used 17 | commands = pytest {posargs:tests} 18 | [testenv:py37] 19 | commands = {[testenv:py36]commands} 20 | [testenv:pypy3] 21 | commands = {[testenv:py36]commands} 22 | 23 | [testenv:pre-commit] 24 | skip_install = true 25 | deps = pre-commit 26 | commands = pre-commit run --all-files --show-diff-on-failure 27 | 28 | [pep8] 29 | ignore = E265,E501,W504 30 | --------------------------------------------------------------------------------