python-obfuscation-framework 1.9.1__py3-none-any.whl → 1.9.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,6 +25,7 @@ from .compression.gzip import GzipObfuscator
25
25
  from .compression.lzma import LzmaObfuscator
26
26
  from .compression.zlib import ZlibObfuscator
27
27
  from .constants import ConstantsObfuscator
28
+ from .controlflow.control_flow_flatten import ControlFlowFlattenObfuscator
28
29
  from .definitions import DefinitionsObfuscator
29
30
  from .encoding.a85 import ASCII85Obfuscator
30
31
  from .encoding.b16 import Base16Obfuscator
@@ -76,6 +77,7 @@ __all__ = [
76
77
  "CharFromDocObfuscator",
77
78
  "CommentsObfuscator",
78
79
  "ConstantsObfuscator",
80
+ "ControlFlowFlattenObfuscator",
79
81
  "DeepEncryptionObfuscator",
80
82
  "DefinitionsObfuscator",
81
83
  "DocstringObfuscator",
@@ -14,9 +14,19 @@
14
14
  # You should have received a copy of the GNU General Public License
15
15
  # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
16
 
17
- # FIXME (deoktr): work in progress !
18
17
  from base64 import b64encode
19
- from tokenize import DEDENT, INDENT, LPAR, NAME, NEWLINE, OP, RPAR, STRING, untokenize
18
+ from tokenize import (
19
+ DEDENT,
20
+ INDENT,
21
+ LPAR,
22
+ NAME,
23
+ NEWLINE,
24
+ NL,
25
+ OP,
26
+ RPAR,
27
+ STRING,
28
+ untokenize,
29
+ )
20
30
 
21
31
  from pof.logger import logger
22
32
 
@@ -25,14 +35,56 @@ class DeepEncryptionObfuscator:
25
35
  def __init__(self, encryption_depth=0) -> None:
26
36
  self.encryption_depth = encryption_depth
27
37
 
38
+ @staticmethod
39
+ def _nested_depth_at(tokens):
40
+ nested = 0
41
+ awaiting = False
42
+ for i, (toknum, tokval) in enumerate(tokens):
43
+ if toknum == NAME and tokval in ("def", "class") and nested == 0:
44
+ awaiting = True
45
+ if awaiting and toknum == INDENT:
46
+ awaiting = False
47
+ nested += 1
48
+ elif nested > 0 and toknum == INDENT:
49
+ nested += 1
50
+ elif nested > 0 and toknum == DEDENT:
51
+ nested -= 1
52
+ yield i, nested
53
+
54
+ @staticmethod
55
+ def _is_empty_return(tokens, pos):
56
+ """Check if the return at pos has no value (bare return)."""
57
+ for j in range(pos + 1, len(tokens)):
58
+ nt = tokens[j][0]
59
+ if nt in (NEWLINE, NL):
60
+ return True
61
+ if nt != DEDENT:
62
+ return False
63
+ return True
64
+
65
+ @classmethod
66
+ def _replace_returns(cls, tokens):
67
+ """Replace return with r= at the function body level.
68
+
69
+ Returns inside nested def/class are left intact.
70
+ """
71
+ depths = dict(cls._nested_depth_at(tokens))
72
+ result = []
73
+ for i, (toknum, tokval) in enumerate(tokens):
74
+ if toknum == NAME and tokval == "return" and depths.get(i, 0) == 0:
75
+ result.extend([(NAME, "r"), (OP, "=")])
76
+ if cls._is_empty_return(tokens, i):
77
+ result.append((NAME, "None"))
78
+ else:
79
+ result.append((toknum, tokval))
80
+ return result
81
+
28
82
  def obfuscate_tokens(self, tokens): # noqa: C901 PLR0912
29
83
  """Encrypt every function's source code.
30
84
 
31
- Encrypt every function's source code with different keys, and decrypt
32
- only when needed (just-in-time).
33
- This will prevent the entire source code being accessible at once in the
34
- memory, of course the draw back is the speed will be reduced.
35
- Also verify integrity dynamically, maybe also sign encrypted code.
85
+ Encrypt every function's source code and decrypt only when needed
86
+ (just-in-time) via exec(). This prevents the entire source code being
87
+ accessible at once in memory.
36
88
 
37
89
  Convert functions into the following:
38
90
 
@@ -47,12 +99,8 @@ class DeepEncryptionObfuscator:
47
99
  del r_dict
48
100
  return r_val
49
101
  ```
50
-
51
- Todo:
52
- - create a function 'exec_return' and call it with en encrypted source
53
102
  """
54
103
  result = [] # obfuscated tokens
55
- # just for testing
56
104
  result.extend(
57
105
  [
58
106
  (NAME, "from"),
@@ -91,11 +139,9 @@ class DeepEncryptionObfuscator:
91
139
  inside_function and depth <= self.encryption_depth and toknum == DEDENT
92
140
  ):
93
141
  inside_function = False
94
- # [2:-1] is to remove indent/dedent
95
142
 
96
143
  fixed_function_tokens = []
97
- # FIXME (deoktr): fix
98
- fixed_depth = -1 # should it be - (self.encryption_depth) ??
144
+ fixed_depth = -1
99
145
  for ftnum, ftval in function_tokens:
100
146
  ftval_d = ftval
101
147
  if ftnum == INDENT:
@@ -105,59 +151,43 @@ class DeepEncryptionObfuscator:
105
151
  fixed_depth -= 1
106
152
  fixed_function_tokens.append((ftnum, ftval_d))
107
153
 
108
- # TODO (deoktr): need to change ALL indents tokens
154
+ # [2:-1] removes the outer indent/dedent wrapper
109
155
  source = untokenize(fixed_function_tokens[2:-1])
110
156
 
111
- # obviously doesn't work with yield
112
157
  if not any(i in source for i in ["yield", "super"]):
113
- # TODO (deoktr): find a way better way
114
- # FIXME (deoktr): this should replace empty return statements
115
- source = source.replace("return\n", "r=None")
116
- source = source.replace("return", "r=")
158
+ body_tokens = fixed_function_tokens[2:-1]
159
+ replaced_tokens = self._replace_returns(body_tokens)
160
+ source = untokenize(replaced_tokens)
117
161
 
118
162
  encoded = b64encode(source.encode())
119
163
  globals_dict_name = "r_dict"
120
164
  new_tokens = [
121
165
  (NEWLINE, "\n"),
122
- (
123
- INDENT,
124
- " " * (self.encryption_depth + 1),
125
- ), # TODO (deoktr): change me
166
+ (INDENT, " " * (self.encryption_depth + 1)),
167
+ # r_dict = globals().copy()
126
168
  (NAME, globals_dict_name),
127
169
  (OP, "="),
128
170
  (NAME, "globals"),
129
171
  (LPAR, "("),
130
- (LPAR, ")"),
172
+ (RPAR, ")"),
131
173
  (OP, "."),
132
- (OP, "copy"),
174
+ (NAME, "copy"),
133
175
  (LPAR, "("),
134
- (LPAR, ")"),
176
+ (RPAR, ")"),
135
177
  (NEWLINE, "\n"),
178
+ # r_dict.update(locals())
136
179
  (NAME, globals_dict_name),
137
180
  (OP, "."),
138
181
  (NAME, "update"),
139
182
  (LPAR, "("),
140
183
  (NAME, "locals"),
141
184
  (LPAR, "("),
142
- (LPAR, ")"),
143
- (LPAR, ")"),
144
- (NEWLINE, "\n"),
145
- # print the code before executing it, for testing
146
- (NAME, "print"),
147
- (LPAR, "("),
148
- (NAME, "b64decode"),
149
- (LPAR, "("),
150
- (STRING, repr(encoded)),
151
- (RPAR, ")"),
152
- (OP, "."),
153
- (NAME, "decode"),
154
- (LPAR, "("),
155
185
  (RPAR, ")"),
156
186
  (RPAR, ")"),
157
187
  (NEWLINE, "\n"),
188
+ # exec(b64decode(b'...'), r_dict)
158
189
  (NAME, "exec"),
159
190
  (LPAR, "("),
160
- # just for testing
161
191
  (NAME, "b64decode"),
162
192
  (LPAR, "("),
163
193
  (STRING, repr(encoded)),
@@ -166,6 +196,7 @@ class DeepEncryptionObfuscator:
166
196
  (NAME, globals_dict_name),
167
197
  (RPAR, ")"),
168
198
  (NEWLINE, "\n"),
199
+ # if 'r' not in r_dict:
169
200
  (NAME, "if"),
170
201
  (STRING, "'r'"),
171
202
  (NAME, "not"),
@@ -173,14 +204,13 @@ class DeepEncryptionObfuscator:
173
204
  (NAME, globals_dict_name),
174
205
  (OP, ":"),
175
206
  (NEWLINE, "\n"),
176
- (
177
- INDENT,
178
- " " * (self.encryption_depth + 2),
179
- ), # TODO (deoktr): change me
207
+ # return None
208
+ (INDENT, " " * (self.encryption_depth + 2)),
180
209
  (NAME, "return"),
181
210
  (NAME, "None"),
182
211
  (DEDENT, ""),
183
212
  (NEWLINE, "\n"),
213
+ # r_val = r_dict['r']
184
214
  (NAME, "r_val"),
185
215
  (OP, "="),
186
216
  (NAME, globals_dict_name),
@@ -188,9 +218,11 @@ class DeepEncryptionObfuscator:
188
218
  (STRING, "'r'"),
189
219
  (OP, "]"),
190
220
  (NEWLINE, "\n"),
221
+ # del r_dict
191
222
  (NAME, "del"),
192
223
  (NAME, globals_dict_name),
193
224
  (NEWLINE, "\n"),
225
+ # return r_val
194
226
  (NAME, "return"),
195
227
  (NAME, "r_val"),
196
228
  (NEWLINE, "\n"),
@@ -23,13 +23,12 @@ from pof.utils.tokens import untokenize
23
23
  class ShiftObfuscator(ShiftCipher):
24
24
  """Shift cipher obfuscator."""
25
25
 
26
- @classmethod
27
- def obfuscate_tokens(cls, tokens):
26
+ def obfuscate_tokens(self, tokens):
28
27
  code = untokenize(tokens)
29
28
  return [
30
29
  (NAME, "exec"),
31
30
  (LPAR, "("),
32
- *cls.decode_tokens(cls.encode_tokens(code)),
31
+ *self.decode_tokens(self.encode_tokens(code)),
33
32
  (RPAR, ")"),
34
33
  (NEWLINE, "\n"),
35
34
  ]
@@ -41,6 +41,7 @@ import random
41
41
  from tokenize import DEDENT, ENCODING, INDENT, NAME, NEWLINE, NUMBER, OP, STRING
42
42
 
43
43
  from pof.utils.generator import BasicGenerator
44
+ from pof.utils.tokens import merge_implicit_strings
44
45
 
45
46
 
46
47
  class ConstantsObfuscator:
@@ -233,6 +234,7 @@ class ConstantsObfuscator:
233
234
  return [(NAME, variables[tokval][0])], variables
234
235
 
235
236
  def obfuscate_tokens(self, tokens):
237
+ tokens = merge_implicit_strings(tokens)
236
238
  variables = {}
237
239
  result = []
238
240
  parenthesis_depth = 0 # parenthesis depth
@@ -0,0 +1,19 @@
1
+ # POF, a free and open source Python obfuscation framework.
2
+ # Copyright (C) 2022 - 2026 Deoktr
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ from .control_flow_flatten import ControlFlowFlattenObfuscator
18
+
19
+ __all__ = ["ControlFlowFlattenObfuscator"]
@@ -0,0 +1,208 @@
1
+ # POF, a free and open source Python obfuscation framework.
2
+ # Copyright (C) 2022 - 2026 Deoktr
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ import ast
18
+ import io
19
+ import random
20
+ from tokenize import ENCODING, ENDMARKER, generate_tokens
21
+
22
+ from pof.utils.tokens import untokenize
23
+
24
+
25
+ class ControlFlowFlattenObfuscator:
26
+ """Transform sequential function code into a state-machine dispatcher."""
27
+
28
+ def __init__(self, min_statements: int = 3) -> None:
29
+ self.min_statements = min_statements
30
+
31
+ @staticmethod
32
+ def _should_skip_function(body: list[ast.stmt]) -> bool:
33
+ """Return True if the function body contains unsupported constructs."""
34
+ for node in ast.walk(ast.Module(body=body, type_ignores=[])):
35
+ if isinstance(
36
+ node,
37
+ (
38
+ ast.Yield,
39
+ ast.YieldFrom,
40
+ ast.AsyncFor,
41
+ ast.AsyncWith,
42
+ ast.Await,
43
+ ),
44
+ ):
45
+ return True
46
+ return False
47
+
48
+ @staticmethod
49
+ def _has_return(stmts: list[ast.stmt]) -> bool:
50
+ """Check if any statement in the list is or contains a return."""
51
+ for node in ast.walk(ast.Module(body=stmts, type_ignores=[])):
52
+ if isinstance(node, ast.Return):
53
+ return True
54
+ return False
55
+
56
+ @classmethod
57
+ def _flatten_body(cls, body: list[ast.stmt]) -> list[ast.stmt]:
58
+ """Transform a list of sequential statements into a state-machine dispatcher."""
59
+ num_blocks = len(body)
60
+ all_states = random.sample(range(100, 999), num_blocks + 1)
61
+ exit_state = all_states[-1]
62
+ block_states = all_states[:num_blocks]
63
+
64
+ state_var = "_state"
65
+ ret_var = "_ret"
66
+ has_ret = cls._has_return(body)
67
+
68
+ dispatcher_cases: list[ast.If | None] = []
69
+
70
+ for idx, (state_num, stmt) in enumerate(zip(block_states, body)):
71
+ next_state = block_states[idx + 1] if idx + 1 < num_blocks else exit_state
72
+
73
+ case_body: list[ast.stmt] = []
74
+
75
+ if isinstance(stmt, ast.Return):
76
+ # return value -> _ret = value; _state = exit
77
+ if stmt.value is not None:
78
+ case_body.append(
79
+ ast.Assign(
80
+ targets=[ast.Name(id=ret_var, ctx=ast.Store())],
81
+ value=stmt.value,
82
+ lineno=0,
83
+ ),
84
+ )
85
+ case_body.append(
86
+ ast.Assign(
87
+ targets=[ast.Name(id=state_var, ctx=ast.Store())],
88
+ value=ast.Constant(value=exit_state),
89
+ lineno=0,
90
+ ),
91
+ )
92
+ elif isinstance(stmt, ast.If):
93
+ # if/else -> execute block, then set state based on which branch
94
+ # for simplicity, keep the if/else inside the state block and
95
+ # set next state after
96
+ case_body.append(stmt)
97
+ case_body.append(
98
+ ast.Assign(
99
+ targets=[ast.Name(id=state_var, ctx=ast.Store())],
100
+ value=ast.Constant(value=next_state),
101
+ lineno=0,
102
+ ),
103
+ )
104
+ else:
105
+ case_body.append(stmt)
106
+ case_body.append(
107
+ ast.Assign(
108
+ targets=[ast.Name(id=state_var, ctx=ast.Store())],
109
+ value=ast.Constant(value=next_state),
110
+ lineno=0,
111
+ ),
112
+ )
113
+
114
+ test = ast.Compare(
115
+ left=ast.Name(id=state_var, ctx=ast.Load()),
116
+ ops=[ast.Eq()],
117
+ comparators=[ast.Constant(value=state_num)],
118
+ )
119
+ dispatcher_cases.append((test, case_body))
120
+
121
+ if not dispatcher_cases:
122
+ return body
123
+
124
+ random.shuffle(dispatcher_cases)
125
+
126
+ current: ast.stmt | None = None
127
+ for test, case_body in reversed(dispatcher_cases):
128
+ if current is None:
129
+ current = ast.If(test=test, body=case_body, orelse=[])
130
+ else:
131
+ current = ast.If(test=test, body=case_body, orelse=[current])
132
+
133
+ init_state = ast.Assign(
134
+ targets=[ast.Name(id=state_var, ctx=ast.Store())],
135
+ value=ast.Constant(value=block_states[0]),
136
+ lineno=0,
137
+ )
138
+
139
+ init_ret: list[ast.stmt] = []
140
+ if has_ret:
141
+ init_ret.append(
142
+ ast.Assign(
143
+ targets=[ast.Name(id=ret_var, ctx=ast.Store())],
144
+ value=ast.Constant(value=None),
145
+ lineno=0,
146
+ ),
147
+ )
148
+
149
+ while_loop = ast.While(
150
+ test=ast.Compare(
151
+ left=ast.Name(id=state_var, ctx=ast.Load()),
152
+ ops=[ast.NotEq()],
153
+ comparators=[ast.Constant(value=exit_state)],
154
+ ),
155
+ body=[current],
156
+ orelse=[],
157
+ )
158
+
159
+ result: list[ast.stmt] = [init_state, *init_ret, while_loop]
160
+
161
+ if has_ret:
162
+ result.append(ast.Return(value=ast.Name(id=ret_var, ctx=ast.Load())))
163
+
164
+ return result
165
+
166
+ def obfuscate_tokens(self, tokens: list) -> list:
167
+ source = untokenize(tokens)
168
+
169
+ try:
170
+ tree = ast.parse(source)
171
+ except SyntaxError:
172
+ return tokens
173
+
174
+ modified = False
175
+
176
+ for node in ast.walk(tree):
177
+ if not isinstance(node, ast.FunctionDef):
178
+ continue
179
+
180
+ body = node.body
181
+ if len(body) < self.min_statements:
182
+ continue
183
+
184
+ if self._should_skip_function(body):
185
+ continue
186
+
187
+ node.body = self._flatten_body(body)
188
+ modified = True
189
+
190
+ if not modified:
191
+ return tokens
192
+
193
+ ast.fix_missing_locations(tree)
194
+ new_source = ast.unparse(tree)
195
+
196
+ try:
197
+ new_tokens = list(generate_tokens(io.StringIO(new_source + "\n").readline))
198
+ except Exception: # noqa: BLE001
199
+ return tokens
200
+
201
+ # strip ENCODING and ENDMARKER
202
+ result: list[tuple[int, str]] = []
203
+ for toknum, tokval, *_ in new_tokens:
204
+ if toknum in (ENCODING, ENDMARKER):
205
+ continue
206
+ result.append((toknum, tokval))
207
+
208
+ return result
@@ -20,6 +20,7 @@ from tokenize import NAME, NUMBER, OP, STRING
20
20
 
21
21
  from pof.errors import PofError
22
22
  from pof.logger import logger
23
+ from pof.utils.tokens import merge_implicit_strings
23
24
 
24
25
 
25
26
  class CharFromDocObfuscator:
@@ -228,6 +229,7 @@ class CharFromDocObfuscator:
228
229
  def obfuscate_tokens(self, tokens):
229
230
  # print.__doc__[0] = 'P'
230
231
  # __builtins__.__doc__[0] = 'B'
232
+ tokens = merge_implicit_strings(tokens)
231
233
  result = []
232
234
 
233
235
  for _index, (toknum, tokval, *_) in enumerate(tokens):
@@ -14,55 +14,11 @@
14
14
  # You should have received a copy of the GNU General Public License
15
15
  # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
16
 
17
- # TODO (deoktr): WORK IN PROGRESS !
18
- #
19
- # Look at `Ruff` for "variable extraction"
20
- #
21
- # IDEA: maybe put every declaration at the start of the function, so that it has way
22
- # less chance to break the actual function
23
- #
24
- # example output:
25
- #
26
- # ```
27
- # import os
28
- # BASE = "/home/test/"
29
- # path = os.path.join(BASE, "file.txt")
30
- # print(path)
31
- # ```
32
- #
33
- # ```
34
- # import os
35
- # u = "/home/test/"
36
- # BASE = u
37
- # a = "file.txt"
38
- # path = os.path.join(BASE, a)
39
- # x = path
40
- # print(x)
41
- # ```
42
- #
43
- # FIXME (deoktr): parenthesis variables:
44
- # ```
45
- # if (
46
- # x < 1 and y > 2
47
- # )
48
- # ```
49
- # this would break because the variables would be added INSIDE the parenthesis
50
- #
51
- #
52
- # FIXME (deoktr): decorators:
53
- # ```
54
- # class Foo:
55
- # @classmethod
56
- # def bar(a=1, b=2):
57
- # pass
58
- # ```
59
- # after classmethod and before def variables a and b will be obfuscated,
60
- # breaking the code
61
- #
62
17
  import keyword
63
18
  from tokenize import DEDENT, ENCODING, INDENT, NAME, NEWLINE, NL, NUMBER, OP, STRING
64
19
 
65
20
  from pof.utils.generator import BasicGenerator
21
+ from pof.utils.tokens import merge_implicit_strings
66
22
 
67
23
 
68
24
  class ExtractVariablesObfuscator:
@@ -241,6 +197,8 @@ class ExtractVariablesObfuscator:
241
197
  RESERVED = RESERVED_WORDS + BUILTINS + tuple(keyword.kwlist)
242
198
  KEYWORDS = tuple(keyword.kwlist)
243
199
 
200
+ CONTINUATION_KEYWORDS = ("elif", "else", "except", "finally")
201
+
244
202
  def __init__(self, generator=None) -> None:
245
203
  if generator is None:
246
204
  generator = BasicGenerator.alphabet_generator()
@@ -249,12 +207,15 @@ class ExtractVariablesObfuscator:
249
207
  def generate_new_name(self):
250
208
  return next(self.generator)
251
209
 
252
- def obfuscate_tokens(self, tokens):
210
+ def obfuscate_tokens(self, tokens): # noqa: C901
211
+ tokens = merge_implicit_strings(tokens)
253
212
  result = []
254
213
  new_line_buffer = []
255
214
  line_buffer = []
256
- parenthesis_depth = 0 # parenthesis depth
215
+ parenthesis_depth = 0
257
216
  prev_toknum = None
217
+ in_decorator = False
218
+
258
219
  for toknum, tokval, *_ in tokens:
259
220
  new_tokens = [(toknum, tokval)]
260
221
 
@@ -263,17 +224,28 @@ class ExtractVariablesObfuscator:
263
224
  elif toknum == OP and tokval == ")":
264
225
  parenthesis_depth -= 1
265
226
 
227
+ # track decorator context, suppress flushing between @ and def/class
228
+ if toknum == OP and tokval == "@":
229
+ in_decorator = True
230
+ elif in_decorator and toknum == NAME and tokval in ("def", "class"):
231
+ in_decorator = False
232
+
266
233
  is_docstring = toknum == STRING and (
267
- prev_toknum
268
- in [
269
- NEWLINE,
270
- DEDENT,
271
- INDENT,
272
- ENCODING,
273
- ]
234
+ prev_toknum in [NEWLINE, DEDENT, INDENT, ENCODING]
274
235
  )
275
236
 
276
- if (toknum == STRING and not is_docstring) or toknum == NUMBER:
237
+ # check if current line starts with a continuation keyword if so,
238
+ # skip extraction to avoid scope issues
239
+ first_name_in_line = None
240
+ for tok in line_buffer:
241
+ if tok[0] == NAME:
242
+ first_name_in_line = tok[1]
243
+ break
244
+ on_continuation_line = first_name_in_line in self.CONTINUATION_KEYWORDS
245
+
246
+ if (
247
+ (toknum == STRING and not is_docstring) or toknum == NUMBER
248
+ ) and not on_continuation_line:
277
249
  random_name = self.generate_new_name()
278
250
  new_line_buffer.extend(
279
251
  [
@@ -285,19 +257,11 @@ class ExtractVariablesObfuscator:
285
257
  )
286
258
  new_tokens = [(NAME, random_name)]
287
259
 
288
- # TODO (deoktr): ensure that this works
289
- has_decorator = any("@" in t[1] for t in line_buffer)
290
- newline_count = [t[1] for t in line_buffer].count("\n")
291
-
292
- if (
293
- ((toknum in (NEWLINE, NL)) and tokval == "\n") and not has_decorator
294
- ) or (newline_count > 1):
295
- if has_decorator:
296
- line_buffer = [(NEWLINE, "\n"), *line_buffer]
297
- new_tokens = new_line_buffer + line_buffer + new_tokens
298
- else:
299
- new_tokens = new_line_buffer + new_tokens + line_buffer
260
+ is_newline = toknum in (NEWLINE, NL) and tokval == "\n"
261
+ can_flush = is_newline and parenthesis_depth == 0 and not in_decorator
300
262
 
263
+ if can_flush:
264
+ new_tokens = new_line_buffer + new_tokens + line_buffer
301
265
  new_line_buffer = []
302
266
  line_buffer = []
303
267
  elif toknum in (INDENT, DEDENT):
@@ -14,7 +14,7 @@
14
14
  # You should have received a copy of the GNU General Public License
15
15
  # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
16
 
17
- from tokenize import INDENT, NEWLINE, NL
17
+ from tokenize import COMMENT, NEWLINE, NL
18
18
 
19
19
 
20
20
  class NewlineObfuscator:
@@ -31,9 +31,8 @@ class NewlineObfuscator:
31
31
  # remove empty lines created after token manipulations
32
32
  # \n after \n --> 2 new lines in a row = one is useless
33
33
  # \n after NL --> same ^
34
- # \n after INDENT --> docstrings are placed after an indent
35
- if toknum == NL or (
36
- toknum == NEWLINE and (prev_toknum in (NEWLINE, NL, INDENT))
34
+ if (toknum == NL and prev_toknum != COMMENT) or (
35
+ toknum == NEWLINE and (prev_toknum in (NEWLINE, NL))
37
36
  ):
38
37
  new_tokens = None
39
38