test_operator.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. #
  2. # Copyright 2018-2022 Elyra Authors
  3. #
  4. # Licensed under the Apache License, Version 2.0 (the "License");
  5. # you may not use this file except in compliance with the License.
  6. # You may obtain a copy of the License at
  7. #
  8. # http://www.apache.org/licenses/LICENSE-2.0
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. #
  16. import string
  17. from kfp.dsl import RUN_ID_PLACEHOLDER
  18. import pytest
  19. from elyra.kfp.operator import ExecuteFileOp
  20. def test_fail_without_cos_endpoint():
  21. with pytest.raises(TypeError):
  22. ExecuteFileOp(
  23. name="test",
  24. pipeline_name="test-pipeline",
  25. experiment_name="experiment-name",
  26. notebook="test_notebook.ipynb",
  27. cos_bucket="test_bucket",
  28. cos_directory="test_directory",
  29. cos_dependencies_archive="test_archive.tgz",
  30. image="test/image:dev",
  31. )
  32. def test_fail_without_cos_bucket():
  33. with pytest.raises(TypeError):
  34. ExecuteFileOp(
  35. name="test",
  36. pipeline_name="test-pipeline",
  37. experiment_name="experiment-name",
  38. notebook="test_notebook.ipynb",
  39. cos_endpoint="http://testserver:32525",
  40. cos_directory="test_directory",
  41. cos_dependencies_archive="test_archive.tgz",
  42. image="test/image:dev",
  43. )
  44. def test_fail_without_cos_directory():
  45. with pytest.raises(TypeError):
  46. ExecuteFileOp(
  47. name="test",
  48. pipeline_name="test-pipeline",
  49. experiment_name="experiment-name",
  50. notebook="test_notebook.ipynb",
  51. cos_endpoint="http://testserver:32525",
  52. cos_bucket="test_bucket",
  53. cos_dependencies_archive="test_archive.tgz",
  54. image="test/image:dev",
  55. )
  56. def test_fail_without_cos_dependencies_archive():
  57. with pytest.raises(TypeError):
  58. ExecuteFileOp(
  59. name="test",
  60. pipeline_name="test-pipeline",
  61. experiment_name="experiment-name",
  62. notebook="test_notebook.ipynb",
  63. cos_endpoint="http://testserver:32525",
  64. cos_bucket="test_bucket",
  65. cos_directory="test_directory",
  66. image="test/image:dev",
  67. )
  68. def test_fail_without_runtime_image():
  69. with pytest.raises(ValueError) as error_info:
  70. ExecuteFileOp(
  71. name="test",
  72. pipeline_name="test-pipeline",
  73. experiment_name="experiment-name",
  74. notebook="test_notebook.ipynb",
  75. cos_endpoint="http://testserver:32525",
  76. cos_bucket="test_bucket",
  77. cos_directory="test_directory",
  78. cos_dependencies_archive="test_archive.tgz",
  79. )
  80. assert "You need to provide an image." == str(error_info.value)
  81. def test_fail_without_notebook():
  82. with pytest.raises(TypeError):
  83. ExecuteFileOp(
  84. name="test",
  85. pipeline_name="test-pipeline",
  86. experiment_name="experiment-name",
  87. cos_endpoint="http://testserver:32525",
  88. cos_bucket="test_bucket",
  89. cos_directory="test_directory",
  90. cos_dependencies_archive="test_archive.tgz",
  91. image="test/image:dev",
  92. )
  93. def test_fail_without_name():
  94. with pytest.raises(TypeError):
  95. ExecuteFileOp(
  96. pipeline_name="test-pipeline",
  97. experiment_name="experiment-name",
  98. notebook="test_notebook.ipynb",
  99. cos_endpoint="http://testserver:32525",
  100. cos_bucket="test_bucket",
  101. cos_directory="test_directory",
  102. cos_dependencies_archive="test_archive.tgz",
  103. image="test/image:dev",
  104. )
  105. def test_fail_with_empty_string_as_name():
  106. with pytest.raises(ValueError):
  107. ExecuteFileOp(
  108. name="",
  109. pipeline_name="test-pipeline",
  110. experiment_name="experiment-name",
  111. notebook="test_notebook.ipynb",
  112. cos_endpoint="http://testserver:32525",
  113. cos_bucket="test_bucket",
  114. cos_directory="test_directory",
  115. cos_dependencies_archive="test_archive.tgz",
  116. image="test/image:dev",
  117. )
  118. def test_fail_with_empty_string_as_notebook():
  119. with pytest.raises(ValueError) as error_info:
  120. ExecuteFileOp(
  121. name="test",
  122. pipeline_name="test-pipeline",
  123. experiment_name="experiment-name",
  124. notebook="",
  125. cos_endpoint="http://testserver:32525",
  126. cos_bucket="test_bucket",
  127. cos_directory="test_directory",
  128. cos_dependencies_archive="test_archive.tgz",
  129. image="test/image:dev",
  130. )
  131. assert "You need to provide a notebook." == str(error_info.value)
  132. def test_fail_without_pipeline_name():
  133. with pytest.raises(TypeError):
  134. ExecuteFileOp(
  135. name="test",
  136. experiment_name="experiment-name",
  137. notebook="test_notebook.ipynb",
  138. cos_endpoint="http://testserver:32525",
  139. cos_bucket="test_bucket",
  140. cos_directory="test_directory",
  141. cos_dependencies_archive="test_archive.tgz",
  142. image="test/image:dev",
  143. )
  144. def test_fail_without_experiment_name():
  145. with pytest.raises(TypeError):
  146. ExecuteFileOp(
  147. name="test",
  148. pipeline_name="test-pipeline",
  149. notebook="test_notebook.ipynb",
  150. cos_endpoint="http://testserver:32525",
  151. cos_bucket="test_bucket",
  152. cos_directory="test_directory",
  153. cos_dependencies_archive="test_archive.tgz",
  154. image="test/image:dev",
  155. )
  156. def test_properly_set_notebook_name_when_in_subdirectory():
  157. notebook_op = ExecuteFileOp(
  158. name="test",
  159. pipeline_name="test-pipeline",
  160. experiment_name="experiment-name",
  161. notebook="foo/test_notebook.ipynb",
  162. cos_endpoint="http://testserver:32525",
  163. cos_bucket="test_bucket",
  164. cos_directory="test_directory",
  165. cos_dependencies_archive="test_archive.tgz",
  166. image="test/image:dev",
  167. )
  168. assert "test_notebook.ipynb" == notebook_op.notebook_name
  169. def test_properly_set_python_script_name_when_in_subdirectory():
  170. notebook_op = ExecuteFileOp(
  171. name="test",
  172. pipeline_name="test-pipeline",
  173. experiment_name="experiment-name",
  174. notebook="foo/test.py",
  175. cos_endpoint="http://testserver:32525",
  176. cos_bucket="test_bucket",
  177. cos_directory="test_directory",
  178. cos_dependencies_archive="test_archive.tgz",
  179. image="test/image:dev",
  180. )
  181. assert "test.py" == notebook_op.notebook_name
  182. def test_user_crio_volume_creation():
  183. notebook_op = ExecuteFileOp(
  184. name="test",
  185. pipeline_name="test-pipeline",
  186. experiment_name="experiment-name",
  187. notebook="test_notebook.ipynb",
  188. cos_endpoint="http://testserver:32525",
  189. cos_bucket="test_bucket",
  190. cos_directory="test_directory",
  191. cos_dependencies_archive="test_archive.tgz",
  192. image="test/image:dev",
  193. emptydir_volume_size="20Gi",
  194. )
  195. assert notebook_op.emptydir_volume_size == "20Gi"
  196. assert notebook_op.container_work_dir_root_path == "/opt/app-root/src/"
  197. assert notebook_op.container.volume_mounts.__len__() == 1
  198. # Environment variables: PYTHONPATH, ELYRA_RUN_NAME
  199. assert notebook_op.container.env.__len__() == 2, notebook_op.container.env
  200. def test_override_bootstrap_url():
  201. notebook_op = ExecuteFileOp(
  202. name="test",
  203. pipeline_name="test-pipeline",
  204. experiment_name="experiment-name",
  205. bootstrap_script_url="https://test.server.com/bootscript.py",
  206. notebook="test_notebook.ipynb",
  207. cos_endpoint="http://testserver:32525",
  208. cos_bucket="test_bucket",
  209. cos_directory="test_directory",
  210. cos_dependencies_archive="test_archive.tgz",
  211. image="test/image:dev",
  212. )
  213. assert notebook_op.bootstrap_script_url == "https://test.server.com/bootscript.py"
  214. def test_override_requirements_url():
  215. notebook_op = ExecuteFileOp(
  216. name="test",
  217. pipeline_name="test-pipeline",
  218. experiment_name="experiment-name",
  219. requirements_url="https://test.server.com/requirements.py",
  220. notebook="test_notebook.ipynb",
  221. cos_endpoint="http://testserver:32525",
  222. cos_bucket="test_bucket",
  223. cos_directory="test_directory",
  224. cos_dependencies_archive="test_archive.tgz",
  225. image="test/image:dev",
  226. )
  227. assert notebook_op.requirements_url == "https://test.server.com/requirements.py"
  228. def test_construct_with_both_pipeline_inputs_and_outputs():
  229. notebook_op = ExecuteFileOp(
  230. name="test",
  231. pipeline_name="test-pipeline",
  232. experiment_name="experiment-name",
  233. notebook="test_notebook.ipynb",
  234. cos_endpoint="http://testserver:32525",
  235. cos_bucket="test_bucket",
  236. cos_directory="test_directory",
  237. cos_dependencies_archive="test_archive.tgz",
  238. pipeline_inputs=["test_input1.txt", "test_input2.txt"],
  239. pipeline_outputs=["test_output1.txt", "test_output2.txt"],
  240. image="test/image:dev",
  241. )
  242. assert notebook_op.pipeline_inputs == ["test_input1.txt", "test_input2.txt"]
  243. assert notebook_op.pipeline_outputs == ["test_output1.txt", "test_output2.txt"]
  244. assert '--inputs "test_input1.txt;test_input2.txt"' in notebook_op.container.args[0]
  245. assert '--outputs "test_output1.txt;test_output2.txt"' in notebook_op.container.args[0]
  246. def test_construct_wildcard_outputs():
  247. notebook_op = ExecuteFileOp(
  248. name="test",
  249. pipeline_name="test-pipeline",
  250. experiment_name="experiment-name",
  251. notebook="test_notebook.ipynb",
  252. cos_endpoint="http://testserver:32525",
  253. cos_bucket="test_bucket",
  254. cos_directory="test_directory",
  255. cos_dependencies_archive="test_archive.tgz",
  256. pipeline_inputs=["test_input1.txt", "test_input2.txt"],
  257. pipeline_outputs=["test_out*", "foo.tar"],
  258. image="test/image:dev",
  259. )
  260. assert notebook_op.pipeline_inputs == ["test_input1.txt", "test_input2.txt"]
  261. assert notebook_op.pipeline_outputs == ["test_out*", "foo.tar"]
  262. assert '--inputs "test_input1.txt;test_input2.txt"' in notebook_op.container.args[0]
  263. assert '--outputs "test_out*;foo.tar"' in notebook_op.container.args[0]
  264. def test_construct_with_only_pipeline_inputs():
  265. notebook_op = ExecuteFileOp(
  266. name="test",
  267. pipeline_name="test-pipeline",
  268. experiment_name="experiment-name",
  269. notebook="test_notebook.ipynb",
  270. cos_endpoint="http://testserver:32525",
  271. cos_bucket="test_bucket",
  272. cos_directory="test_directory",
  273. cos_dependencies_archive="test_archive.tgz",
  274. pipeline_inputs=["test_input1.txt", "test,input2.txt"],
  275. pipeline_outputs=[],
  276. image="test/image:dev",
  277. )
  278. assert notebook_op.pipeline_inputs == ["test_input1.txt", "test,input2.txt"]
  279. assert '--inputs "test_input1.txt;test,input2.txt"' in notebook_op.container.args[0]
  280. def test_construct_with_bad_pipeline_inputs():
  281. with pytest.raises(ValueError) as error_info:
  282. ExecuteFileOp(
  283. name="test",
  284. pipeline_name="test-pipeline",
  285. experiment_name="experiment-name",
  286. notebook="test_notebook.ipynb",
  287. cos_endpoint="http://testserver:32525",
  288. cos_bucket="test_bucket",
  289. cos_directory="test_directory",
  290. cos_dependencies_archive="test_archive.tgz",
  291. pipeline_inputs=["test_input1.txt", "test;input2.txt"],
  292. pipeline_outputs=[],
  293. image="test/image:dev",
  294. )
  295. assert "Illegal character (;) found in filename 'test;input2.txt'." == str(error_info.value)
  296. def test_construct_with_only_pipeline_outputs():
  297. notebook_op = ExecuteFileOp(
  298. name="test",
  299. pipeline_name="test-pipeline",
  300. experiment_name="experiment-name",
  301. notebook="test_notebook.ipynb",
  302. cos_endpoint="http://testserver:32525",
  303. cos_bucket="test_bucket",
  304. cos_directory="test_directory",
  305. cos_dependencies_archive="test_archive.tgz",
  306. pipeline_outputs=["test_output1.txt", "test,output2.txt"],
  307. pipeline_envs={},
  308. image="test/image:dev",
  309. )
  310. assert notebook_op.pipeline_outputs == ["test_output1.txt", "test,output2.txt"]
  311. assert '--outputs "test_output1.txt;test,output2.txt"' in notebook_op.container.args[0]
  312. def test_construct_with_bad_pipeline_outputs():
  313. with pytest.raises(ValueError) as error_info:
  314. ExecuteFileOp(
  315. name="test",
  316. pipeline_name="test-pipeline",
  317. experiment_name="experiment-name",
  318. notebook="test_notebook.ipynb",
  319. cos_endpoint="http://testserver:32525",
  320. cos_bucket="test_bucket",
  321. cos_directory="test_directory",
  322. cos_dependencies_archive="test_archive.tgz",
  323. pipeline_outputs=["test_output1.txt", "test;output2.txt"],
  324. image="test/image:dev",
  325. )
  326. assert "Illegal character (;) found in filename 'test;output2.txt'." == str(error_info.value)
  327. def test_construct_with_env_variables_argo():
  328. notebook_op = ExecuteFileOp(
  329. name="test",
  330. pipeline_name="test-pipeline",
  331. experiment_name="experiment-name",
  332. notebook="test_notebook.ipynb",
  333. cos_endpoint="http://testserver:32525",
  334. cos_bucket="test_bucket",
  335. cos_directory="test_directory",
  336. cos_dependencies_archive="test_archive.tgz",
  337. pipeline_envs={"ENV_VAR_ONE": "1", "ENV_VAR_TWO": "2", "ENV_VAR_THREE": "3"},
  338. image="test/image:dev",
  339. )
  340. confirmation_names = ["ENV_VAR_ONE", "ENV_VAR_TWO", "ENV_VAR_THREE", "ELYRA_RUN_NAME"]
  341. confirmation_values = ["1", "2", "3", RUN_ID_PLACEHOLDER]
  342. for env_val in notebook_op.container.env:
  343. assert env_val.name in confirmation_names
  344. assert env_val.value in confirmation_values
  345. confirmation_names.remove(env_val.name)
  346. confirmation_values.remove(env_val.value)
  347. # Verify confirmation values have been drained.
  348. assert len(confirmation_names) == 0
  349. assert len(confirmation_values) == 0
  350. # same as before but explicitly specify the workflow engine type
  351. # as Argo
  352. notebook_op = ExecuteFileOp(
  353. name="test",
  354. pipeline_name="test-pipeline",
  355. experiment_name="experiment-name",
  356. notebook="test_notebook.ipynb",
  357. cos_endpoint="http://testserver:32525",
  358. cos_bucket="test_bucket",
  359. cos_directory="test_directory",
  360. cos_dependencies_archive="test_archive.tgz",
  361. pipeline_envs={"ENV_VAR_ONE": "1", "ENV_VAR_TWO": "2", "ENV_VAR_THREE": "3"},
  362. image="test/image:dev",
  363. workflow_engine="Argo",
  364. )
  365. confirmation_names = ["ENV_VAR_ONE", "ENV_VAR_TWO", "ENV_VAR_THREE", "ELYRA_RUN_NAME"]
  366. confirmation_values = ["1", "2", "3", RUN_ID_PLACEHOLDER]
  367. for env_val in notebook_op.container.env:
  368. assert env_val.name in confirmation_names
  369. assert env_val.value in confirmation_values
  370. confirmation_names.remove(env_val.name)
  371. confirmation_values.remove(env_val.value)
  372. # Verify confirmation values have been drained.
  373. assert len(confirmation_names) == 0
  374. assert len(confirmation_values) == 0
  375. def test_construct_with_env_variables_tekton():
  376. notebook_op = ExecuteFileOp(
  377. name="test",
  378. pipeline_name="test-pipeline",
  379. experiment_name="experiment-name",
  380. notebook="test_notebook.ipynb",
  381. cos_endpoint="http://testserver:32525",
  382. cos_bucket="test_bucket",
  383. cos_directory="test_directory",
  384. cos_dependencies_archive="test_archive.tgz",
  385. pipeline_envs={"ENV_VAR_ONE": "1", "ENV_VAR_TWO": "2", "ENV_VAR_THREE": "3"},
  386. image="test/image:dev",
  387. workflow_engine="Tekton",
  388. )
  389. confirmation_names = ["ENV_VAR_ONE", "ENV_VAR_TWO", "ENV_VAR_THREE", "ELYRA_RUN_NAME"]
  390. confirmation_values = ["1", "2", "3"]
  391. field_path = "metadata.annotations['pipelines.kubeflow.org/run_name']"
  392. for env_val in notebook_op.container.env:
  393. assert env_val.name in confirmation_names
  394. confirmation_names.remove(env_val.name)
  395. if env_val.name == "ELYRA_RUN_NAME":
  396. assert env_val.value_from.field_ref.field_path == field_path, env_val.value_from.field_ref
  397. else:
  398. assert env_val.value in confirmation_values
  399. confirmation_values.remove(env_val.value)
  400. # Verify confirmation values have been drained.
  401. assert len(confirmation_names) == 0
  402. assert len(confirmation_values) == 0
  403. def test_normalize_label_value():
  404. valid_middle_chars = "-_."
  405. # test min length
  406. assert ExecuteFileOp._normalize_label_value(None) == ""
  407. assert ExecuteFileOp._normalize_label_value("") == ""
  408. # test max length (63)
  409. assert ExecuteFileOp._normalize_label_value("a" * 63) == "a" * 63
  410. assert ExecuteFileOp._normalize_label_value("a" * 64) == "a" * 63 # truncated
  411. # test first and last char
  412. assert ExecuteFileOp._normalize_label_value("1") == "1"
  413. assert ExecuteFileOp._normalize_label_value("22") == "22"
  414. assert ExecuteFileOp._normalize_label_value("3_3") == "3_3"
  415. assert ExecuteFileOp._normalize_label_value("4u4") == "4u4"
  416. assert ExecuteFileOp._normalize_label_value("5$5") == "5_5"
  417. # test first char
  418. for c in string.printable:
  419. if c in string.ascii_letters + string.digits:
  420. # first char is valid
  421. # no length violation
  422. assert ExecuteFileOp._normalize_label_value(c) == c
  423. assert ExecuteFileOp._normalize_label_value(c + "B") == c + "B"
  424. # max length
  425. assert ExecuteFileOp._normalize_label_value(c + "B" * 62) == (c + "B" * 62)
  426. # max length exceeded
  427. assert ExecuteFileOp._normalize_label_value(c + "B" * 63) == (c + "B" * 62) # truncated
  428. else:
  429. # first char is invalid, e.g. '#a', and becomes the
  430. # second char, which might require replacement
  431. rv = c
  432. if c not in valid_middle_chars:
  433. rv = "_"
  434. # no length violation
  435. assert ExecuteFileOp._normalize_label_value(c) == "a" + rv + "a"
  436. assert ExecuteFileOp._normalize_label_value(c + "B") == "a" + rv + "B"
  437. # max length
  438. assert ExecuteFileOp._normalize_label_value(c + "B" * 62) == ("a" + rv + "B" * 61) # truncated
  439. # max length exceeded
  440. assert ExecuteFileOp._normalize_label_value(c + "B" * 63) == ("a" + rv + "B" * 61) # truncated
  441. # test last char
  442. for c in string.printable:
  443. if c in string.ascii_letters + string.digits:
  444. # no length violation
  445. assert ExecuteFileOp._normalize_label_value("b" + c) == "b" + c
  446. # max length
  447. assert ExecuteFileOp._normalize_label_value("b" * 62 + c) == ("b" * 62 + c)
  448. # max length exceeded
  449. assert ExecuteFileOp._normalize_label_value("b" * 63 + c) == ("b" * 63)
  450. else:
  451. # last char is invalid, e.g. 'a#', and requires
  452. # patching
  453. rv = c
  454. if c not in valid_middle_chars:
  455. rv = "_"
  456. # no length violation (char is appended)
  457. assert ExecuteFileOp._normalize_label_value("b" + c) == "b" + rv + "a"
  458. # max length (char is replaced)
  459. assert ExecuteFileOp._normalize_label_value("b" * 62 + c) == ("b" * 62 + "a")
  460. # max length exceeded (no action required)
  461. assert ExecuteFileOp._normalize_label_value("b" * 63 + c) == ("b" * 63)
  462. # test first and last char
  463. for c in string.printable:
  464. if c in string.ascii_letters + string.digits:
  465. # no length violation
  466. assert ExecuteFileOp._normalize_label_value(c + "b" + c) == c + "b" + c # nothing is modified
  467. # max length
  468. assert ExecuteFileOp._normalize_label_value(c + "b" * 61 + c) == (c + "b" * 61 + c) # nothing is modified
  469. # max length exceeded
  470. assert ExecuteFileOp._normalize_label_value(c + "b" * 62 + c) == c + "b" * 62 # truncate only
  471. else:
  472. # first and last characters are invalid, e.g. '#a#'
  473. rv = c
  474. if c not in valid_middle_chars:
  475. rv = "_"
  476. # no length violation
  477. assert ExecuteFileOp._normalize_label_value(c + "b" + c) == "a" + rv + "b" + rv + "a"
  478. # max length
  479. assert ExecuteFileOp._normalize_label_value(c + "b" * 59 + c) == ("a" + rv + "b" * 59 + rv + "a")
  480. # max length exceeded after processing, scenario 1
  481. # resolved by adding char before first, replace last
  482. assert ExecuteFileOp._normalize_label_value(c + "b" * 60 + c) == ("a" + rv + "b" * 60 + "a")
  483. # max length exceeded after processing, scenario 2
  484. # resolved by adding char before first, appending after last
  485. assert ExecuteFileOp._normalize_label_value(c + "b" * 59 + c) == ("a" + rv + "b" * 59 + rv + "a")
  486. # max length exceeded before processing, scenario 1
  487. # resolved by adding char before first, truncating last
  488. assert ExecuteFileOp._normalize_label_value(c + "b" * 62 + c) == ("a" + rv + "b" * 61)
  489. # max length exceeded before processing, scenario 2
  490. # resolved by adding char before first, replacing last
  491. assert ExecuteFileOp._normalize_label_value(c + "b" * 60 + c * 3) == ("a" + rv + "b" * 60 + "a")
  492. # test char in a position other than first and last
  493. # if invalid, the char is replaced with '_'
  494. for c in string.printable:
  495. if c in string.ascii_letters + string.digits + "-_.":
  496. assert ExecuteFileOp._normalize_label_value("A" + c + "Z") == "A" + c + "Z"
  497. else:
  498. assert ExecuteFileOp._normalize_label_value("A" + c + "Z") == "A_Z"
  499. # encore
  500. assert ExecuteFileOp._normalize_label_value(r"¯\_(ツ)_/¯") == "a_________a"