test_lint_diff.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. from openhands.linter import DefaultLinter, LintResult
  2. from openhands.utils.diff import get_diff, parse_diff
  3. OLD_CONTENT = """
  4. def foo():
  5. print("Hello, World!")
  6. x = UNDEFINED_VARIABLE
  7. foo()
  8. """
  9. NEW_CONTENT_V1 = (
  10. OLD_CONTENT
  11. + """
  12. def new_function_that_causes_error():
  13. y = ANOTHER_UNDEFINED_VARIABLE
  14. """
  15. )
  16. NEW_CONTENT_V2 = """
  17. def foo():
  18. print("Hello, World!")
  19. x = UNDEFINED_VARIABLE
  20. y = ANOTHER_UNDEFINED_VARIABLE
  21. foo()
  22. """
  23. def test_get_and_parse_diff(tmp_path):
  24. diff = get_diff(OLD_CONTENT, NEW_CONTENT_V1, 'test.py')
  25. print(diff)
  26. assert (
  27. diff
  28. == """
  29. --- test.py
  30. +++ test.py
  31. @@ -6,0 +7,3 @@
  32. +def new_function_that_causes_error():
  33. + y = ANOTHER_UNDEFINED_VARIABLE
  34. +
  35. """.strip()
  36. )
  37. print(
  38. '\n'.join(
  39. [f'{i+1}|{line}' for i, line in enumerate(NEW_CONTENT_V1.splitlines())]
  40. )
  41. )
  42. changes = parse_diff(diff)
  43. assert len(changes) == 3
  44. assert (
  45. changes[0].old is None
  46. and changes[0].new == 7
  47. and changes[0].line == 'def new_function_that_causes_error():'
  48. )
  49. assert (
  50. changes[1].old is None
  51. and changes[1].new == 8
  52. and changes[1].line == ' y = ANOTHER_UNDEFINED_VARIABLE'
  53. )
  54. assert changes[2].old is None and changes[2].new == 9 and changes[2].line == ''
  55. def test_lint_with_diff_append(tmp_path):
  56. with open(tmp_path / 'old.py', 'w') as f:
  57. f.write(OLD_CONTENT)
  58. with open(tmp_path / 'new.py', 'w') as f:
  59. f.write(NEW_CONTENT_V1)
  60. linter = DefaultLinter()
  61. result: list[LintResult] = linter.lint_file_diff(
  62. str(tmp_path / 'old.py'),
  63. str(tmp_path / 'new.py'),
  64. )
  65. print(result)
  66. assert len(result) == 1
  67. assert (
  68. result[0].line == 8
  69. and result[0].column == 9
  70. and result[0].message == "F821 undefined name 'ANOTHER_UNDEFINED_VARIABLE'"
  71. )
  72. def test_lint_with_diff_insert(tmp_path):
  73. with open(tmp_path / 'old.py', 'w') as f:
  74. f.write(OLD_CONTENT)
  75. with open(tmp_path / 'new.py', 'w') as f:
  76. f.write(NEW_CONTENT_V2)
  77. linter = DefaultLinter()
  78. result: list[LintResult] = linter.lint_file_diff(
  79. str(tmp_path / 'old.py'),
  80. str(tmp_path / 'new.py'),
  81. )
  82. assert len(result) == 1
  83. assert (
  84. result[0].line == 5
  85. and result[0].column == 9
  86. and result[0].message == "F821 undefined name 'ANOTHER_UNDEFINED_VARIABLE'"
  87. )
  88. def test_lint_with_multiple_changes_and_errors(tmp_path):
  89. old_content = """
  90. def foo():
  91. print("Hello, World!")
  92. x = 10
  93. foo()
  94. """
  95. new_content = """
  96. def foo():
  97. print("Hello, World!")
  98. x = UNDEFINED_VARIABLE
  99. y = 20
  100. def bar():
  101. z = ANOTHER_UNDEFINED_VARIABLE
  102. return z + 1
  103. foo()
  104. bar()
  105. """
  106. with open(tmp_path / 'old.py', 'w') as f:
  107. f.write(old_content)
  108. with open(tmp_path / 'new.py', 'w') as f:
  109. f.write(new_content)
  110. linter = DefaultLinter()
  111. result: list[LintResult] = linter.lint_file_diff(
  112. str(tmp_path / 'old.py'),
  113. str(tmp_path / 'new.py'),
  114. )
  115. assert len(result) == 2
  116. assert (
  117. result[0].line == 4
  118. and result[0].column == 9
  119. and result[0].message == "F821 undefined name 'UNDEFINED_VARIABLE'"
  120. )
  121. assert (
  122. result[1].line == 8
  123. and result[1].column == 9
  124. and result[1].message == "F821 undefined name 'ANOTHER_UNDEFINED_VARIABLE'"
  125. )
  126. def test_lint_with_introduced_and_fixed_errors(tmp_path):
  127. old_content = """
  128. x = UNDEFINED_VARIABLE
  129. y = 10
  130. """
  131. new_content = """
  132. x = 5
  133. y = ANOTHER_UNDEFINED_VARIABLE
  134. z = UNDEFINED_VARIABLE
  135. """
  136. with open(tmp_path / 'old.py', 'w') as f:
  137. f.write(old_content)
  138. with open(tmp_path / 'new.py', 'w') as f:
  139. f.write(new_content)
  140. linter = DefaultLinter()
  141. result: list[LintResult] = linter.lint_file_diff(
  142. str(tmp_path / 'old.py'),
  143. str(tmp_path / 'new.py'),
  144. )
  145. assert len(result) == 2
  146. assert (
  147. result[0].line == 3
  148. and result[0].column == 5
  149. and result[0].message == "F821 undefined name 'ANOTHER_UNDEFINED_VARIABLE'"
  150. )
  151. assert (
  152. result[1].line == 4
  153. and result[1].column == 5
  154. and result[1].message == "F821 undefined name 'UNDEFINED_VARIABLE'"
  155. )
  156. def test_lint_with_multiline_changes(tmp_path):
  157. old_content = """
  158. def complex_function(a, b, c):
  159. return (a +
  160. b +
  161. c)
  162. """
  163. new_content = """
  164. def complex_function(a, b, c):
  165. return (a +
  166. UNDEFINED_VARIABLE +
  167. b +
  168. c)
  169. """
  170. with open(tmp_path / 'old.py', 'w') as f:
  171. f.write(old_content)
  172. with open(tmp_path / 'new.py', 'w') as f:
  173. f.write(new_content)
  174. linter = DefaultLinter()
  175. result: list[LintResult] = linter.lint_file_diff(
  176. str(tmp_path / 'old.py'),
  177. str(tmp_path / 'new.py'),
  178. )
  179. assert len(result) == 1
  180. assert (
  181. result[0].line == 4
  182. and result[0].column == 13
  183. and result[0].message == "F821 undefined name 'UNDEFINED_VARIABLE'"
  184. )
  185. def test_lint_with_syntax_error(tmp_path):
  186. old_content = """
  187. def foo():
  188. print("Hello, World!")
  189. """
  190. new_content = """
  191. def foo():
  192. print("Hello, World!"
  193. """
  194. with open(tmp_path / 'old.py', 'w') as f:
  195. f.write(old_content)
  196. with open(tmp_path / 'new.py', 'w') as f:
  197. f.write(new_content)
  198. linter = DefaultLinter()
  199. result: list[LintResult] = linter.lint_file_diff(
  200. str(tmp_path / 'old.py'),
  201. str(tmp_path / 'new.py'),
  202. )
  203. assert len(result) == 1
  204. assert (
  205. result[0].line == 3
  206. and result[0].column == 11
  207. and result[0].message == "E999 SyntaxError: '(' was never closed"
  208. )
  209. def test_lint_with_docstring_changes(tmp_path):
  210. old_content = '''
  211. def foo():
  212. """This is a function."""
  213. print("Hello, World!")
  214. '''
  215. new_content = '''
  216. def foo():
  217. """
  218. This is a function.
  219. It now has a multi-line docstring with an UNDEFINED_VARIABLE.
  220. """
  221. print("Hello, World!")
  222. '''
  223. with open(tmp_path / 'old.py', 'w') as f:
  224. f.write(old_content)
  225. with open(tmp_path / 'new.py', 'w') as f:
  226. f.write(new_content)
  227. linter = DefaultLinter()
  228. result: list[LintResult] = linter.lint_file_diff(
  229. str(tmp_path / 'old.py'),
  230. str(tmp_path / 'new.py'),
  231. )
  232. assert len(result) == 0 # Linter should ignore changes in docstrings
  233. def test_lint_with_multiple_errors_on_same_line(tmp_path):
  234. old_content = """
  235. def foo():
  236. print("Hello, World!")
  237. x = 10
  238. foo()
  239. """
  240. new_content = """
  241. def foo():
  242. print("Hello, World!")
  243. x = UNDEFINED_VARIABLE + ANOTHER_UNDEFINED_VARIABLE
  244. foo()
  245. """
  246. with open(tmp_path / 'old.py', 'w') as f:
  247. f.write(old_content)
  248. with open(tmp_path / 'new.py', 'w') as f:
  249. f.write(new_content)
  250. linter = DefaultLinter()
  251. result: list[LintResult] = linter.lint_file_diff(
  252. str(tmp_path / 'old.py'),
  253. str(tmp_path / 'new.py'),
  254. )
  255. print(result)
  256. assert len(result) == 2
  257. assert (
  258. result[0].line == 4
  259. and result[0].column == 9
  260. and result[0].message == "F821 undefined name 'UNDEFINED_VARIABLE'"
  261. )
  262. assert (
  263. result[1].line == 4
  264. and result[1].column == 30
  265. and result[1].message == "F821 undefined name 'ANOTHER_UNDEFINED_VARIABLE'"
  266. )
  267. def test_parse_diff_with_empty_patch():
  268. diff_patch = ''
  269. changes = parse_diff(diff_patch)
  270. assert len(changes) == 0
  271. def test_lint_file_diff_ignore_existing_errors(tmp_path):
  272. """
  273. Make sure we allow edits as long as it does not introduce new errors. In other
  274. words, we don't care about existing linting errors. Although they might be
  275. real syntax issues, sometimes they are just false positives, or errors that
  276. we don't care about.
  277. """
  278. content = """def some_valid_but_weird_function():
  279. # this function is legitimate, yet static analysis tools like flake8
  280. # reports 'F821 undefined name'
  281. if 'variable' in locals():
  282. print(variable)
  283. def some_wrong_but_unused_function():
  284. # this function has a linting error, but it is not modified by us, and
  285. # who knows, this function might be completely dead code
  286. x = 1
  287. def sum(a, b):
  288. return a - b
  289. """
  290. new_content = content.replace(' return a - b', ' return a + b')
  291. temp_file_old_path = tmp_path / 'problematic-file-test.py'
  292. temp_file_old_path.write_text(content)
  293. temp_file_new_path = tmp_path / 'problematic-file-test-new.py'
  294. temp_file_new_path.write_text(new_content)
  295. linter = DefaultLinter()
  296. result: list[LintResult] = linter.lint_file_diff(
  297. str(temp_file_old_path),
  298. str(temp_file_new_path),
  299. )
  300. assert len(result) == 0 # no new errors introduced
  301. def test_lint_file_diff_catch_new_errors_in_edits(tmp_path):
  302. """
  303. Make sure we catch new linting errors in our edit chunk, and at the same
  304. time, ignore old linting errors (in this case, the old linting error is
  305. a false positive)
  306. """
  307. content = """def some_valid_but_weird_function():
  308. # this function is legitimate, yet static analysis tools like flake8
  309. # reports 'F821 undefined name'
  310. if 'variable' in locals():
  311. print(variable)
  312. def sum(a, b):
  313. return a - b
  314. """
  315. temp_file_old_path = tmp_path / 'problematic-file-test.py'
  316. temp_file_old_path.write_text(content)
  317. new_content = content.replace(' return a - b', ' return a + variable')
  318. temp_file_new_path = tmp_path / 'problematic-file-test-new.py'
  319. temp_file_new_path.write_text(new_content)
  320. linter = DefaultLinter()
  321. result: list[LintResult] = linter.lint_file_diff(
  322. str(temp_file_old_path),
  323. str(temp_file_new_path),
  324. )
  325. print(result)
  326. assert len(result) == 1
  327. assert (
  328. result[0].line == 7
  329. and result[0].column == 16
  330. and result[0].message == "F821 undefined name 'variable'"
  331. )
  332. def test_lint_file_diff_catch_new_errors_outside_edits(tmp_path):
  333. """
  334. Make sure we catch new linting errors induced by our edits, even
  335. though the error itself is not in the edit chunk
  336. """
  337. content = """def valid_func1():
  338. print(my_sum(1, 2))
  339. def my_sum(a, b):
  340. return a - b
  341. def valid_func2():
  342. print(my_sum(0, 0))
  343. """
  344. # Add 100 lines of invalid code, which linter shall ignore
  345. # because they are not being edited. For testing purpose, we
  346. # must add these existing linting errors, otherwise the pre-edit
  347. # linting would pass, and thus there won't be any comparison
  348. # between pre-edit and post-edit linting.
  349. for _ in range(100):
  350. content += '\ninvalid_func()'
  351. temp_file_old_path = tmp_path / 'problematic-file-test.py'
  352. temp_file_old_path.write_text(content)
  353. new_content = content.replace('def my_sum(a, b):', 'def my_sum2(a, b):')
  354. temp_file_new_path = tmp_path / 'problematic-file-test-new.py'
  355. temp_file_new_path.write_text(new_content)
  356. linter = DefaultLinter()
  357. result: list[LintResult] = linter.lint_file_diff(
  358. str(temp_file_old_path),
  359. str(temp_file_new_path),
  360. )
  361. assert len(result) == 2
  362. assert (
  363. result[0].line == 2
  364. and result[0].column == 11
  365. and result[0].message == "F821 undefined name 'my_sum'"
  366. )
  367. assert (
  368. result[1].line == 6
  369. and result[1].column == 11
  370. and result[1].message == "F821 undefined name 'my_sum'"
  371. )