pyinfra 3.0.dev0__py2.py3-none-any.whl → 3.0.2__py2.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.
Files changed (148) hide show
  1. pyinfra/api/__init__.py +3 -0
  2. pyinfra/api/arguments.py +115 -97
  3. pyinfra/api/arguments_typed.py +80 -0
  4. pyinfra/api/command.py +5 -3
  5. pyinfra/api/config.py +139 -39
  6. pyinfra/api/connectors.py +5 -2
  7. pyinfra/api/deploy.py +19 -19
  8. pyinfra/api/exceptions.py +35 -4
  9. pyinfra/api/facts.py +62 -86
  10. pyinfra/api/host.py +102 -15
  11. pyinfra/api/inventory.py +4 -0
  12. pyinfra/api/operation.py +188 -120
  13. pyinfra/api/operations.py +66 -113
  14. pyinfra/api/state.py +53 -34
  15. pyinfra/api/util.py +64 -33
  16. pyinfra/connectors/base.py +65 -20
  17. pyinfra/connectors/chroot.py +15 -13
  18. pyinfra/connectors/docker.py +62 -72
  19. pyinfra/connectors/dockerssh.py +20 -19
  20. pyinfra/connectors/local.py +32 -22
  21. pyinfra/connectors/ssh.py +162 -86
  22. pyinfra/connectors/sshuserclient/client.py +1 -1
  23. pyinfra/connectors/terraform.py +57 -39
  24. pyinfra/connectors/util.py +26 -27
  25. pyinfra/connectors/vagrant.py +27 -26
  26. pyinfra/context.py +1 -0
  27. pyinfra/facts/apk.py +7 -2
  28. pyinfra/facts/apt.py +15 -7
  29. pyinfra/facts/brew.py +28 -13
  30. pyinfra/facts/bsdinit.py +9 -6
  31. pyinfra/facts/cargo.py +6 -3
  32. pyinfra/facts/choco.py +8 -4
  33. pyinfra/facts/deb.py +21 -9
  34. pyinfra/facts/dnf.py +11 -6
  35. pyinfra/facts/docker.py +30 -5
  36. pyinfra/facts/files.py +49 -33
  37. pyinfra/facts/gem.py +7 -2
  38. pyinfra/facts/git.py +14 -21
  39. pyinfra/facts/gpg.py +4 -1
  40. pyinfra/facts/hardware.py +186 -138
  41. pyinfra/facts/launchd.py +7 -2
  42. pyinfra/facts/lxd.py +8 -2
  43. pyinfra/facts/mysql.py +19 -12
  44. pyinfra/facts/npm.py +3 -1
  45. pyinfra/facts/openrc.py +8 -2
  46. pyinfra/facts/pacman.py +13 -5
  47. pyinfra/facts/pip.py +2 -0
  48. pyinfra/facts/pkg.py +5 -1
  49. pyinfra/facts/pkgin.py +7 -2
  50. pyinfra/facts/postgres.py +170 -0
  51. pyinfra/facts/postgresql.py +5 -162
  52. pyinfra/facts/rpm.py +21 -15
  53. pyinfra/facts/runit.py +70 -0
  54. pyinfra/facts/selinux.py +12 -4
  55. pyinfra/facts/server.py +240 -82
  56. pyinfra/facts/snap.py +8 -2
  57. pyinfra/facts/systemd.py +37 -13
  58. pyinfra/facts/sysvinit.py +7 -4
  59. pyinfra/facts/upstart.py +7 -2
  60. pyinfra/facts/util/packaging.py +3 -2
  61. pyinfra/facts/vzctl.py +8 -4
  62. pyinfra/facts/xbps.py +7 -2
  63. pyinfra/facts/yum.py +10 -5
  64. pyinfra/facts/zypper.py +9 -4
  65. pyinfra/operations/apk.py +5 -3
  66. pyinfra/operations/apt.py +28 -25
  67. pyinfra/operations/brew.py +60 -29
  68. pyinfra/operations/bsdinit.py +6 -4
  69. pyinfra/operations/cargo.py +3 -1
  70. pyinfra/operations/choco.py +3 -1
  71. pyinfra/operations/dnf.py +16 -20
  72. pyinfra/operations/docker.py +339 -0
  73. pyinfra/operations/files.py +187 -168
  74. pyinfra/operations/gem.py +3 -1
  75. pyinfra/operations/git.py +23 -25
  76. pyinfra/operations/iptables.py +33 -25
  77. pyinfra/operations/launchd.py +5 -6
  78. pyinfra/operations/lxd.py +7 -4
  79. pyinfra/operations/mysql.py +59 -55
  80. pyinfra/operations/npm.py +8 -1
  81. pyinfra/operations/openrc.py +5 -3
  82. pyinfra/operations/pacman.py +6 -7
  83. pyinfra/operations/pip.py +19 -12
  84. pyinfra/operations/pkg.py +3 -1
  85. pyinfra/operations/pkgin.py +5 -3
  86. pyinfra/operations/postgres.py +349 -0
  87. pyinfra/operations/postgresql.py +18 -335
  88. pyinfra/operations/puppet.py +3 -1
  89. pyinfra/operations/python.py +8 -19
  90. pyinfra/operations/runit.py +182 -0
  91. pyinfra/operations/selinux.py +47 -29
  92. pyinfra/operations/server.py +138 -67
  93. pyinfra/operations/snap.py +3 -1
  94. pyinfra/operations/ssh.py +18 -16
  95. pyinfra/operations/systemd.py +18 -12
  96. pyinfra/operations/sysvinit.py +7 -5
  97. pyinfra/operations/upstart.py +7 -5
  98. pyinfra/operations/util/__init__.py +12 -0
  99. pyinfra/operations/util/docker.py +177 -0
  100. pyinfra/operations/util/files.py +24 -16
  101. pyinfra/operations/util/packaging.py +54 -38
  102. pyinfra/operations/util/service.py +39 -47
  103. pyinfra/operations/vzctl.py +12 -10
  104. pyinfra/operations/xbps.py +5 -3
  105. pyinfra/operations/yum.py +15 -19
  106. pyinfra/operations/zypper.py +9 -10
  107. pyinfra/version.py +5 -2
  108. {pyinfra-3.0.dev0.dist-info → pyinfra-3.0.2.dist-info}/METADATA +51 -58
  109. pyinfra-3.0.2.dist-info/RECORD +168 -0
  110. {pyinfra-3.0.dev0.dist-info → pyinfra-3.0.2.dist-info}/WHEEL +1 -1
  111. {pyinfra-3.0.dev0.dist-info → pyinfra-3.0.2.dist-info}/entry_points.txt +0 -3
  112. pyinfra_cli/__main__.py +4 -3
  113. pyinfra_cli/commands.py +3 -2
  114. pyinfra_cli/exceptions.py +75 -43
  115. pyinfra_cli/inventory.py +52 -31
  116. pyinfra_cli/log.py +10 -2
  117. pyinfra_cli/main.py +88 -65
  118. pyinfra_cli/prints.py +37 -109
  119. pyinfra_cli/util.py +15 -10
  120. tests/test_api/test_api.py +2 -0
  121. tests/test_api/test_api_arguments.py +9 -9
  122. tests/test_api/test_api_deploys.py +15 -19
  123. tests/test_api/test_api_facts.py +4 -5
  124. tests/test_api/test_api_operations.py +18 -20
  125. tests/test_api/test_api_util.py +41 -2
  126. tests/test_cli/test_cli.py +14 -50
  127. tests/test_cli/test_cli_deploy.py +17 -14
  128. tests/test_cli/test_cli_exceptions.py +50 -19
  129. tests/test_cli/test_cli_inventory.py +66 -0
  130. tests/test_cli/util.py +1 -1
  131. tests/test_connectors/test_dockerssh.py +11 -8
  132. tests/test_connectors/test_ssh.py +88 -23
  133. tests/test_connectors/test_sshuserclient.py +1 -1
  134. tests/test_connectors/test_terraform.py +11 -8
  135. tests/test_connectors/test_vagrant.py +6 -6
  136. pyinfra/connectors/ansible.py +0 -175
  137. pyinfra/connectors/mech.py +0 -189
  138. pyinfra/connectors/pyinfrawinrmsession/__init__.py +0 -28
  139. pyinfra/connectors/winrm.py +0 -312
  140. pyinfra/facts/windows.py +0 -366
  141. pyinfra/facts/windows_files.py +0 -90
  142. pyinfra/operations/windows.py +0 -59
  143. pyinfra/operations/windows_files.py +0 -538
  144. pyinfra-3.0.dev0.dist-info/RECORD +0 -170
  145. tests/test_connectors/test_ansible.py +0 -64
  146. tests/test_connectors/test_mech.py +0 -126
  147. {pyinfra-3.0.dev0.dist-info → pyinfra-3.0.2.dist-info}/LICENSE.md +0 -0
  148. {pyinfra-3.0.dev0.dist-info → pyinfra-3.0.2.dist-info}/top_level.txt +0 -0
@@ -11,17 +11,18 @@ import traceback
11
11
  from datetime import timedelta
12
12
  from fnmatch import fnmatch
13
13
  from io import StringIO
14
- from typing import Union
14
+ from pathlib import Path
15
+ from typing import IO, Any, Union
15
16
 
16
17
  from jinja2 import TemplateRuntimeError, TemplateSyntaxError, UndefinedError
17
18
 
18
- import pyinfra
19
19
  from pyinfra import host, logger, state
20
20
  from pyinfra.api import (
21
21
  FileDownloadCommand,
22
22
  FileUploadCommand,
23
23
  OperationError,
24
24
  OperationTypeError,
25
+ OperationValueError,
25
26
  QuoteString,
26
27
  RsyncCommand,
27
28
  StringCommand,
@@ -30,6 +31,7 @@ from pyinfra.api import (
30
31
  from pyinfra.api.command import make_formatted_string_command
31
32
  from pyinfra.api.util import (
32
33
  get_call_location,
34
+ get_file_io,
33
35
  get_file_sha1,
34
36
  get_path_permissions_mode,
35
37
  get_template,
@@ -56,22 +58,21 @@ from .util import files as file_utils
56
58
  from .util.files import adjust_regex, ensure_mode_int, get_timestamp, sed_replace, unix_path_join
57
59
 
58
60
 
59
- @operation(
60
- pipeline_facts={"file": "dest"},
61
- )
61
+ @operation()
62
62
  def download(
63
- src,
64
- dest,
65
- user=None,
66
- group=None,
67
- mode=None,
68
- cache_time=None,
63
+ src: str,
64
+ dest: str,
65
+ user: str | None = None,
66
+ group: str | None = None,
67
+ mode: str | None = None,
68
+ cache_time: int | None = None,
69
69
  force=False,
70
- sha256sum=None,
71
- sha1sum=None,
72
- md5sum=None,
73
- headers=None,
70
+ sha256sum: str | None = None,
71
+ sha1sum: str | None = None,
72
+ md5sum: str | None = None,
73
+ headers: dict[str, str] | None = None,
74
74
  insecure=False,
75
+ proxy: str | None = None,
75
76
  ):
76
77
  """
77
78
  Download files from remote locations using ``curl`` or ``wget``.
@@ -88,6 +89,7 @@ def download(
88
89
  + md5sum: md5 hash to checksum the downloaded file against
89
90
  + headers: optional dictionary of headers to set for the HTTP request
90
91
  + insecure: disable SSL verification for the HTTP request
92
+ + proxy: simple HTTP proxy through which we can download files, form `http://<yourproxy>:<port>`
91
93
 
92
94
  **Example:**
93
95
 
@@ -121,8 +123,8 @@ def download(
121
123
  if cache_time:
122
124
  # Time on files is not tz-aware, and will be the same tz as the server's time,
123
125
  # so we can safely remove the tzinfo from the Date fact before comparison.
124
- cache_time = host.get_fact(Date).replace(tzinfo=None) - timedelta(seconds=cache_time)
125
- if info["mtime"] and info["mtime"] < cache_time:
126
+ ctime = host.get_fact(Date).replace(tzinfo=None) - timedelta(seconds=cache_time)
127
+ if info["mtime"] and info["mtime"] < ctime:
126
128
  download = True
127
129
 
128
130
  if sha1sum:
@@ -139,10 +141,16 @@ def download(
139
141
 
140
142
  # If we download, always do user/group/mode as SSH user may be different
141
143
  if download:
142
- temp_file = state.get_temp_filename(dest)
144
+ temp_file = host.get_temp_filename(dest)
143
145
 
144
146
  curl_args: list[Union[str, StringCommand]] = ["-sSLf"]
145
147
  wget_args: list[Union[str, StringCommand]] = ["-q"]
148
+
149
+ if proxy:
150
+ curl_args.append(f"--proxy {proxy}")
151
+ wget_args.append("-e use_proxy=yes")
152
+ wget_args.append(f"-e http_proxy={proxy}")
153
+
146
154
  if insecure:
147
155
  curl_args.append("--insecure")
148
156
  wget_args.append("--no-check-certificate")
@@ -219,11 +227,11 @@ def download(
219
227
 
220
228
  @operation()
221
229
  def line(
222
- path,
223
- line,
230
+ path: str,
231
+ line: str,
224
232
  present=True,
225
- replace=None,
226
- flags=None,
233
+ replace: str | None = None,
234
+ flags: list[str] | None = None,
227
235
  backup=False,
228
236
  interpolate_variables=False,
229
237
  escape_regex_characters=False,
@@ -261,7 +269,7 @@ def line(
261
269
  it will be append to the end of the file.
262
270
 
263
271
  Ensure new line:
264
- This will ensure that the ``line`` being appended is always on a seperate new
272
+ This will ensure that the ``line`` being appended is always on a separate new
265
273
  line in case the file doesn't end with a newline character.
266
274
 
267
275
 
@@ -375,48 +383,27 @@ def line(
375
383
 
376
384
  # No line and we want it, append it
377
385
  if not present_lines and present:
378
- # If the file does not exist - it *might* be created, so we handle it
379
- # dynamically with a little script.
380
- if present_lines is None:
381
- yield make_formatted_string_command(
382
- """
383
- if [ -f '{target}' ]; then
384
- ( grep {match_line} '{target}' && \
385
- {sed_replace_command}) 2> /dev/null || \
386
- {echo_command} ;
387
- else
388
- {echo_command} ;
389
- fi
390
- """,
391
- target=QuoteString(path),
392
- match_line=QuoteString(match_line),
393
- echo_command=echo_command,
394
- sed_replace_command=sed_replace_command,
386
+ # If we're doing replacement, only append if the *replacement* line
387
+ # does not exist (as we are appending the replacement).
388
+ if replace:
389
+ # Ensure replace explicitly matches a whole line
390
+ replace_line = replace
391
+ if not replace_line.startswith("^"):
392
+ replace_line = f"^{replace_line}"
393
+ if not replace_line.endswith("$"):
394
+ replace_line = f"{replace_line}$"
395
+
396
+ present_lines = host.get_fact(
397
+ FindInFile,
398
+ path=path,
399
+ pattern=replace_line,
400
+ interpolate_variables=interpolate_variables,
395
401
  )
396
402
 
397
- # File exists but has no matching lines - append it.
403
+ if not present_lines:
404
+ yield echo_command
398
405
  else:
399
- # If we're doing replacement, only append if the *replacement* line
400
- # does not exist (as we are appending the replacement).
401
- if replace:
402
- # Ensure replace explicitly matches a whole line
403
- replace_line = replace
404
- if not replace_line.startswith("^"):
405
- replace_line = f"^{replace_line}"
406
- if not replace_line.endswith("$"):
407
- replace_line = f"{replace_line}$"
408
-
409
- present_lines = host.get_fact(
410
- FindInFile,
411
- path=path,
412
- pattern=replace_line,
413
- interpolate_variables=interpolate_variables,
414
- )
415
-
416
- if not present_lines:
417
- yield echo_command
418
- else:
419
- host.noop('line "{0}" exists in {1}'.format(replace or line, path))
406
+ host.noop('line "{0}" exists in {1}'.format(replace or line, path))
420
407
 
421
408
  # Line(s) exists and we want to remove them, replace with nothing
422
409
  elif present_lines and not present:
@@ -440,10 +427,10 @@ def line(
440
427
 
441
428
  @operation()
442
429
  def replace(
443
- path,
444
- text=None,
445
- replace=None,
446
- flags=None,
430
+ path: str,
431
+ text: str | None = None,
432
+ replace: str | None = None,
433
+ flags: list[str] | None = None,
447
434
  backup=False,
448
435
  interpolate_variables=False,
449
436
  match=None, # deprecated
@@ -512,19 +499,17 @@ def replace(
512
499
  host.noop('string "{0}" does not exist in {1}'.format(text, path))
513
500
 
514
501
 
515
- @operation(
516
- pipeline_facts={"find_files": "destination"},
517
- )
502
+ @operation()
518
503
  def sync(
519
- src,
520
- dest,
521
- user=None,
522
- group=None,
523
- mode=None,
524
- dir_mode=None,
504
+ src: str,
505
+ dest: str,
506
+ user: str | None = None,
507
+ group: str | None = None,
508
+ mode: str | None = None,
509
+ dir_mode: str | None = None,
525
510
  delete=False,
526
- exclude=None,
527
- exclude_dir=None,
511
+ exclude: str | list[str] | tuple[str] | None = None,
512
+ exclude_dir: str | list[str] | tuple[str] | None = None,
528
513
  add_deploy_dir=True,
529
514
  ):
530
515
  """
@@ -586,7 +571,7 @@ def sync(
586
571
  put_files = []
587
572
  ensure_dirnames = []
588
573
  for dirpath, dirnames, filenames in os.walk(src, topdown=True):
589
- remote_dirpath = os.path.normpath(os.path.relpath(dirpath, src))
574
+ remote_dirpath = Path(os.path.normpath(os.path.relpath(dirpath, src))).as_posix()
590
575
 
591
576
  # Filter excluded dirs
592
577
  for child_dir in dirnames[:]:
@@ -620,7 +605,7 @@ def sync(
620
605
  if dest_link_info:
621
606
  dest_to_ensure = dest_link_info["link_target"]
622
607
 
623
- yield from directory(
608
+ yield from directory._inner(
624
609
  path=dest_to_ensure,
625
610
  user=user,
626
611
  group=group,
@@ -629,7 +614,7 @@ def sync(
629
614
 
630
615
  # Ensure any remote dirnames
631
616
  for dir_path_curr, dir_mode_curr in ensure_dirnames:
632
- yield from directory(
617
+ yield from directory._inner(
633
618
  path=unix_path_join(dest, dir_path_curr),
634
619
  user=user,
635
620
  group=group,
@@ -638,7 +623,7 @@ def sync(
638
623
 
639
624
  # Put each file combination
640
625
  for local_filename, remote_filename in put_files:
641
- yield from put(
626
+ yield from put._inner(
642
627
  src=local_filename,
643
628
  dest=remote_filename,
644
629
  user=user,
@@ -658,7 +643,7 @@ def sync(
658
643
  if exclude and any(fnmatch(filename, match) for match in exclude):
659
644
  continue
660
645
 
661
- yield from file(path=filename, present=False)
646
+ yield from file._inner(path=filename, present=False)
662
647
 
663
648
 
664
649
  @memoize
@@ -667,7 +652,7 @@ def show_rsync_warning():
667
652
 
668
653
 
669
654
  @operation(is_idempotent=False)
670
- def rsync(src, dest, flags=["-ax", "--delete"]):
655
+ def rsync(src: str, dest: str, flags: list[str] | None = None):
671
656
  """
672
657
  Use ``rsync`` to sync a local directory to the remote system. This operation will actually call
673
658
  the ``rsync`` binary on your system.
@@ -682,6 +667,8 @@ def rsync(src, dest, flags=["-ax", "--delete"]):
682
667
  global arguments.
683
668
  """
684
669
 
670
+ if flags is None:
671
+ flags = ["-ax", "--delete"]
685
672
  show_rsync_warning()
686
673
 
687
674
  try:
@@ -692,11 +679,11 @@ def rsync(src, dest, flags=["-ax", "--delete"]):
692
679
  yield RsyncCommand(src, dest, flags)
693
680
 
694
681
 
695
- def _create_remote_dir(state, host, remote_filename, user, group):
682
+ def _create_remote_dir(remote_filename, user, group):
696
683
  # Always use POSIX style path as local might be Windows, remote always *nix
697
684
  remote_dirname = posixpath.dirname(remote_filename)
698
685
  if remote_dirname:
699
- yield from directory(
686
+ yield from directory._inner(
700
687
  path=remote_dirname,
701
688
  user=user,
702
689
  group=group,
@@ -709,14 +696,10 @@ def _create_remote_dir(state, host, remote_filename, user, group):
709
696
  # We don't (currently) cache the local state, so there's nothing we can
710
697
  # update to flag the local file as present.
711
698
  is_idempotent=False,
712
- pipeline_facts={
713
- "file": "src",
714
- "sha1_file": "src",
715
- },
716
699
  )
717
700
  def get(
718
- src,
719
- dest,
701
+ src: str,
702
+ dest: str,
720
703
  add_deploy_dir=True,
721
704
  create_local_dir=False,
722
705
  force=False,
@@ -757,11 +740,11 @@ def get(
757
740
 
758
741
  # No remote file, so assume exists and download it "blind"
759
742
  if not remote_file or force:
760
- yield FileDownloadCommand(src, dest, remote_temp_filename=state.get_temp_filename(dest))
743
+ yield FileDownloadCommand(src, dest, remote_temp_filename=host.get_temp_filename(dest))
761
744
 
762
745
  # No local file, so always download
763
746
  elif not os.path.exists(dest):
764
- yield FileDownloadCommand(src, dest, remote_temp_filename=state.get_temp_filename(dest))
747
+ yield FileDownloadCommand(src, dest, remote_temp_filename=host.get_temp_filename(dest))
765
748
 
766
749
  # Remote file exists - check if it matches our local
767
750
  else:
@@ -770,21 +753,16 @@ def get(
770
753
 
771
754
  # Check sha1sum, upload if needed
772
755
  if local_sum != remote_sum:
773
- yield FileDownloadCommand(src, dest, remote_temp_filename=state.get_temp_filename(dest))
756
+ yield FileDownloadCommand(src, dest, remote_temp_filename=host.get_temp_filename(dest))
774
757
 
775
758
 
776
- @operation(
777
- pipeline_facts={
778
- "file": "dest",
779
- "sha1_file": "dest",
780
- },
781
- )
759
+ @operation()
782
760
  def put(
783
- src,
784
- dest,
785
- user=None,
786
- group=None,
787
- mode=None,
761
+ src: str | IO[Any],
762
+ dest: str,
763
+ user: str | None = None,
764
+ group: str | None = None,
765
+ mode: int | str | bool | None = None,
788
766
  add_deploy_dir=True,
789
767
  create_remote_dir=True,
790
768
  force=False,
@@ -845,6 +823,7 @@ def put(
845
823
 
846
824
  # Assume string filename
847
825
  else:
826
+ assert isinstance(src, (str, Path))
848
827
  # Add deploy directory?
849
828
  if add_deploy_dir and state.cwd:
850
829
  src = os.path.join(state.cwd, src)
@@ -859,7 +838,7 @@ def put(
859
838
  raise IOError("No such file: {0}".format(local_file))
860
839
 
861
840
  if mode is True:
862
- if os.path.isfile(local_file):
841
+ if isinstance(local_file, str) and os.path.isfile(local_file):
863
842
  mode = get_path_permissions_mode(local_file)
864
843
  else:
865
844
  logger.warning(
@@ -872,19 +851,20 @@ def put(
872
851
 
873
852
  remote_file = host.get_fact(File, path=dest)
874
853
 
875
- if not remote_file and host.get_fact(Directory, path=dest):
854
+ if not remote_file and bool(host.get_fact(Directory, path=dest)):
855
+ assert isinstance(src, str)
876
856
  dest = unix_path_join(dest, os.path.basename(src))
877
857
  remote_file = host.get_fact(File, path=dest)
878
858
 
879
859
  if create_remote_dir:
880
- yield from _create_remote_dir(state, host, dest, user, group)
860
+ yield from _create_remote_dir(dest, user, group)
881
861
 
882
862
  # No remote file, always upload and user/group/mode if supplied
883
863
  if not remote_file or force:
884
864
  yield FileUploadCommand(
885
865
  local_file,
886
866
  dest,
887
- remote_temp_filename=state.get_temp_filename(dest),
867
+ remote_temp_filename=host.get_temp_filename(dest),
888
868
  )
889
869
 
890
870
  if user or group:
@@ -902,7 +882,7 @@ def put(
902
882
  yield FileUploadCommand(
903
883
  local_file,
904
884
  dest,
905
- remote_temp_filename=state.get_temp_filename(dest),
885
+ remote_temp_filename=host.get_temp_filename(dest),
906
886
  )
907
887
 
908
888
  if user or group:
@@ -929,7 +909,15 @@ def put(
929
909
 
930
910
 
931
911
  @operation()
932
- def template(src, dest, user=None, group=None, mode=None, create_remote_dir=True, **data):
912
+ def template(
913
+ src: str | IO[Any],
914
+ dest: str,
915
+ user: str | None = None,
916
+ group: str | None = None,
917
+ mode: str | None = None,
918
+ create_remote_dir=True,
919
+ **data,
920
+ ):
933
921
  '''
934
922
  Generate a template using jinja2 and write it to the remote system.
935
923
 
@@ -1011,7 +999,6 @@ def template(src, dest, user=None, group=None, mode=None, create_remote_dir=True
1011
999
  data.setdefault("host", host)
1012
1000
  data.setdefault("state", state)
1013
1001
  data.setdefault("inventory", state.inventory)
1014
- data.setdefault("facts", pyinfra.facts)
1015
1002
 
1016
1003
  # Render and make file-like it's output
1017
1004
  try:
@@ -1026,7 +1013,7 @@ def template(src, dest, user=None, group=None, mode=None, create_remote_dir=True
1026
1013
  line_number = trace_frames[-1][1]
1027
1014
 
1028
1015
  # Quickly read the line in question and one above/below for nicer debugging
1029
- with open(src, "r") as f:
1016
+ with get_file_io(src, "r") as f:
1030
1017
  template_lines = f.readlines()
1031
1018
 
1032
1019
  template_lines = [line.strip() for line in template_lines]
@@ -1039,14 +1026,14 @@ def template(src, dest, user=None, group=None, mode=None, create_remote_dir=True
1039
1026
  e,
1040
1027
  "\n".join(relevant_lines),
1041
1028
  ),
1042
- )
1029
+ ) from None
1043
1030
 
1044
1031
  output_file = StringIO(output)
1045
1032
  # Set the template attribute for nicer debugging
1046
1033
  output_file.template = src # type: ignore[attr-defined]
1047
1034
 
1048
1035
  # Pass to the put function
1049
- yield from put(
1036
+ yield from put._inner(
1050
1037
  src=output_file,
1051
1038
  dest=dest,
1052
1039
  user=user,
@@ -1078,20 +1065,18 @@ def _raise_or_remove_invalid_path(fs_type, path, force, force_backup, force_back
1078
1065
  raise OperationError("{0} exists and is not a {1}".format(path, fs_type))
1079
1066
 
1080
1067
 
1081
- @operation(
1082
- pipeline_facts={"link": "path"},
1083
- )
1068
+ @operation()
1084
1069
  def link(
1085
- path,
1086
- target=None,
1070
+ path: str,
1071
+ target: str | None = None,
1087
1072
  present=True,
1088
- user=None,
1089
- group=None,
1073
+ user: str | None = None,
1074
+ group: str | None = None,
1090
1075
  symbolic=True,
1091
1076
  create_remote_dir=True,
1092
1077
  force=False,
1093
1078
  force_backup=True,
1094
- force_backup_dir=None,
1079
+ force_backup_dir: str | None = None,
1095
1080
  ):
1096
1081
  """
1097
1082
  Add/remove/update links.
@@ -1148,7 +1133,6 @@ def link(
1148
1133
  if symbolic:
1149
1134
  add_args.append("-s")
1150
1135
 
1151
- add_cmd = StringCommand(" ".join(add_args), QuoteString(target), QuoteString(path))
1152
1136
  remove_cmd = StringCommand("rm", "-f", QuoteString(path))
1153
1137
 
1154
1138
  if not present:
@@ -1158,9 +1142,12 @@ def link(
1158
1142
  host.noop("link {link} does not exist")
1159
1143
  return
1160
1144
 
1145
+ assert target is not None # appease typing QuoteString below
1146
+ add_cmd = StringCommand(" ".join(add_args), QuoteString(target), QuoteString(path))
1147
+
1161
1148
  if info is None: # create
1162
1149
  if create_remote_dir:
1163
- yield from _create_remote_dir(state, host, path, user, group)
1150
+ yield from _create_remote_dir(path, user, group)
1164
1151
 
1165
1152
  yield add_cmd
1166
1153
 
@@ -1185,20 +1172,18 @@ def link(
1185
1172
  host.noop("link {0} already exists".format(path))
1186
1173
 
1187
1174
 
1188
- @operation(
1189
- pipeline_facts={"file": "path"},
1190
- )
1175
+ @operation()
1191
1176
  def file(
1192
- path,
1177
+ path: str,
1193
1178
  present=True,
1194
- user=None,
1195
- group=None,
1196
- mode=None,
1179
+ user: str | None = None,
1180
+ group: str | None = None,
1181
+ mode: int | str | None = None,
1197
1182
  touch=False,
1198
1183
  create_remote_dir=True,
1199
1184
  force=False,
1200
1185
  force_backup=True,
1201
- force_backup_dir=None,
1186
+ force_backup_dir: str | None = None,
1202
1187
  ):
1203
1188
  """
1204
1189
  Add/remove/update files.
@@ -1259,7 +1244,7 @@ def file(
1259
1244
 
1260
1245
  if info is None: # create
1261
1246
  if create_remote_dir:
1262
- yield from _create_remote_dir(state, host, path, user, group)
1247
+ yield from _create_remote_dir(path, user, group)
1263
1248
 
1264
1249
  yield StringCommand("touch", QuoteString(path))
1265
1250
 
@@ -1289,19 +1274,17 @@ def file(
1289
1274
  host.noop("file {0} already exists".format(path))
1290
1275
 
1291
1276
 
1292
- @operation(
1293
- pipeline_facts={"directory": "path"},
1294
- )
1277
+ @operation()
1295
1278
  def directory(
1296
- path,
1279
+ path: str,
1297
1280
  present=True,
1298
- user=None,
1299
- group=None,
1300
- mode=None,
1281
+ user: str | None = None,
1282
+ group: str | None = None,
1283
+ mode: int | str | None = None,
1301
1284
  recursive=False,
1302
1285
  force=False,
1303
1286
  force_backup=True,
1304
- force_backup_dir=None,
1287
+ force_backup_dir: str | None = None,
1305
1288
  _no_check_owner_mode=False,
1306
1289
  _no_fail_on_link=False,
1307
1290
  ):
@@ -1393,8 +1376,8 @@ def directory(
1393
1376
  host.noop("directory {0} already exists".format(path))
1394
1377
 
1395
1378
 
1396
- @operation(pipeline_facts={"flags": "path"})
1397
- def flags(path, flags=None, present=True):
1379
+ @operation()
1380
+ def flags(path: str, flags: list[str] | None = None, present=True):
1398
1381
  """
1399
1382
  Set/clear file flags.
1400
1383
 
@@ -1441,21 +1424,20 @@ def flags(path, flags=None, present=True):
1441
1424
  )
1442
1425
 
1443
1426
 
1444
- @operation(
1445
- pipeline_facts={"file": "path"},
1446
- )
1427
+ @operation()
1447
1428
  def block(
1448
- path,
1449
- content=None,
1429
+ path: str,
1430
+ content: str | list[str] | None = None,
1450
1431
  present=True,
1451
- line=None,
1432
+ line: str | None = None,
1452
1433
  backup=False,
1453
1434
  escape_regex_characters=False,
1435
+ try_prevent_shell_expansion=False,
1454
1436
  before=False,
1455
1437
  after=False,
1456
- marker=None,
1457
- begin=None,
1458
- end=None,
1438
+ marker: str | None = None,
1439
+ begin: str | None = None,
1440
+ end: str | None = None,
1459
1441
  ):
1460
1442
  """
1461
1443
  Ensure content, surrounded by the appropriate markers, is present (or not) in the file.
@@ -1468,6 +1450,7 @@ def block(
1468
1450
  + line: regex before or after which the content should be added if it doesn't exist.
1469
1451
  + backup: whether to backup the file (see ``files.line``). Default False.
1470
1452
  + escape_regex_characters: whether to escape regex characters from the matching line
1453
+ + try_prevent_shell_expansion: tries to prevent shell expanding by values like `$`
1471
1454
  + marker: the base string used to mark the text. Default is ``# {mark} PYINFRA BLOCK``
1472
1455
  + begin: the value for ``{mark}`` in the marker before the content. Default is ``BEGIN``
1473
1456
  + end: the value for ``{mark}`` in the marker after the content. Default is ``END``
@@ -1484,12 +1467,15 @@ def block(
1484
1467
 
1485
1468
  Removal ignores ``content`` and ``line``
1486
1469
 
1470
+ Preventing shell expansion works by wrapping the content in '`' before passing to `awk`.
1471
+ WARNING: This will break if the content contains raw single quotes.
1472
+
1487
1473
  **Examples:**
1488
1474
 
1489
1475
  .. code:: python
1490
1476
 
1491
1477
  # add entry to /etc/host
1492
- files.marked_block(
1478
+ files.block(
1493
1479
  name="add IP address for red server",
1494
1480
  path="/etc/hosts",
1495
1481
  content="10.0.0.1 mars-one",
@@ -1498,7 +1484,7 @@ def block(
1498
1484
  )
1499
1485
 
1500
1486
  # have two entries in /etc/host
1501
- files.marked_block(
1487
+ files.block(
1502
1488
  name="add IP address for red server",
1503
1489
  path="/etc/hosts",
1504
1490
  content="10.0.0.1 mars-one\\n10.0.0.2 mars-two",
@@ -1507,14 +1493,14 @@ def block(
1507
1493
  )
1508
1494
 
1509
1495
  # remove marked entry from /etc/hosts
1510
- files.marked_block(
1496
+ files.block(
1511
1497
  name="remove all 10.* addresses from /etc/hosts",
1512
1498
  path="/etc/hosts",
1513
1499
  present=False
1514
1500
  )
1515
1501
 
1516
1502
  # add out of date warning to web page
1517
- files.marked_block(
1503
+ files.block(
1518
1504
  name="add out of date warning to web page",
1519
1505
  path="/var/www/html/something.html",
1520
1506
  content= "<p>Warning: this page is out of date.</p>",
@@ -1522,6 +1508,14 @@ def block(
1522
1508
  after=True
1523
1509
  marker="<!-- {mark} PYINFRA BLOCK -->",
1524
1510
  )
1511
+
1512
+ # put complex alias into .zshrc
1513
+ files.block(
1514
+ path="/home/user/.zshrc",
1515
+ content="eval $(thefuck -a)",
1516
+ try_prevent_shell_expansion=True,
1517
+ marker="## {mark} ALIASES ##"
1518
+ )
1525
1519
  """
1526
1520
 
1527
1521
  logger.warning("The `files.block` operation is currently in beta!")
@@ -1560,14 +1554,23 @@ def block(
1560
1554
  cmd = None
1561
1555
  if present:
1562
1556
  if not content:
1563
- raise ValueError("'content' must be supplied when 'present' == True")
1557
+ raise OperationValueError("'content' must be supplied when 'present' == True")
1564
1558
  if line:
1565
1559
  if before == after:
1566
- raise ValueError("only one of 'before' or 'after' used when 'line` is specified")
1560
+ raise OperationValueError(
1561
+ "only one of 'before' or 'after' used when 'line` is specified"
1562
+ )
1567
1563
  elif before != after:
1568
- raise ValueError("'line' must be supplied or 'before' and 'after' must be equal")
1564
+ raise OperationValueError(
1565
+ "'line' must be supplied or 'before' and 'after' must be equal"
1566
+ )
1567
+ if isinstance(content, str):
1568
+ # convert string to list of lines
1569
+ content = content.split("\n")
1570
+ if try_prevent_shell_expansion and any("'" in line for line in content):
1571
+ logger.warning("content contains single quotes, shell expansion prevention may fail")
1569
1572
 
1570
- the_block = "\n".join([mark_1, content, mark_2])
1573
+ the_block = "\n".join([mark_1, *content, mark_2])
1571
1574
 
1572
1575
  if (current is None) or ((current == []) and (before == after)):
1573
1576
  # a) no file or b) file but no markers and we're adding at start or end. Both use 'cat'
@@ -1575,8 +1578,14 @@ def block(
1575
1578
  stdin = "- " if ((current == []) and before) else ""
1576
1579
  # here = hex(random.randint(0, 2147483647))
1577
1580
  here = "PYINFRAHERE"
1578
- cmd = StringCommand(f"cat {stdin}{redirect}", q_path, f"<<{here}\n{the_block}\n{here}")
1581
+ cmd = StringCommand(
1582
+ f"cat {stdin}{redirect}",
1583
+ q_path,
1584
+ f"<<{here}" if not try_prevent_shell_expansion else f"<<'{here}'",
1585
+ f"\n{the_block}\n{here}",
1586
+ )
1579
1587
  elif current == []: # markers not found and have a pattern to match (not start or end)
1588
+ assert isinstance(line, str)
1580
1589
  regex = adjust_regex(line, escape_regex_characters)
1581
1590
  print_before = "{ print }" if before else ""
1582
1591
  print_after = "{ print }" if after else ""
@@ -1585,21 +1594,31 @@ def block(
1585
1594
  f"{print_after} f!=1 && /{regex}/ {{ print x; f=1}} "
1586
1595
  f"END {{if (f==0) print ARGV[2] }} {print_before}'"
1587
1596
  )
1588
- cmd = StringCommand(out_prep, prog, q_path, f'$"{the_block}"', "> $OUT &&", real_out)
1597
+ cmd = StringCommand(
1598
+ out_prep,
1599
+ prog,
1600
+ q_path,
1601
+ f'"{the_block}"' if not try_prevent_shell_expansion else f"'{the_block}'",
1602
+ "> $OUT &&",
1603
+ real_out,
1604
+ )
1589
1605
  else:
1590
- pieces = content.split("\n")
1591
- if (len(current) != len(pieces)) or (
1592
- not all(lines[0] == lines[1] for lines in zip(pieces, current))
1606
+ if (len(current) != len(content)) or (
1607
+ not all(lines[0] == lines[1] for lines in zip(content, current))
1593
1608
  ): # marked_block found but text is different
1594
1609
  prog = (
1595
1610
  'awk \'BEGIN {{f=1; x=ARGV[2]; ARGV[2]=""}}'
1596
- f"/{mark_1}/ {{print; print x; f=0}} /{mark_2}/ {{print; f=1}} f'"
1611
+ f"/{mark_1}/ {{print; print x; f=0}} /{mark_2}/ {{print; f=1; next}} f'"
1597
1612
  )
1598
1613
  cmd = StringCommand(
1599
1614
  out_prep,
1600
1615
  prog,
1601
1616
  q_path,
1602
- '$"' + content + '"',
1617
+ (
1618
+ '"' + "\n".join(content) + '"'
1619
+ if not try_prevent_shell_expansion
1620
+ else "'" + "\n".join(content) + "'"
1621
+ ),
1603
1622
  "> $OUT &&",
1604
1623
  real_out,
1605
1624
  )