pypsrp 0.8.1__py3-none-any.whl → 0.9.0rc2__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.
@@ -0,0 +1,142 @@
1
+ # Copyright: (c) 2026, Jordan Borean (@jborean93) <jborean93@gmail.com>
2
+ # MIT License (see LICENSE or https://opensource.org/licenses/MIT)
3
+
4
+ from __future__ import annotations
5
+
6
+ import contextvars
7
+ import functools
8
+ import types
9
+ import typing as t
10
+
11
+ import requests.adapters
12
+ from requests.packages.urllib3.util.retry import Retry
13
+
14
+ T = t.TypeVar("T")
15
+
16
+
17
+ class NewConnectionDisabled(Exception):
18
+ """Raised when new connections are being made but have been disabled in the current context."""
19
+
20
+
21
+ class DisableNewConnectionsContext:
22
+ """Context manager to disable new connections from being created."""
23
+
24
+ __slots__ = "_contextvar_token"
25
+
26
+ _contextvar: t.ClassVar[contextvars.ContextVar] = contextvars.ContextVar("NewConnectionContext")
27
+ _contextvar_token: contextvars.Token
28
+
29
+ @classmethod
30
+ def current(cls) -> DisableNewConnectionsContext | None:
31
+ """Returns the current context or None if not set."""
32
+ try:
33
+ return cls._contextvar.get()
34
+ except LookupError:
35
+ return None
36
+
37
+ def __enter__(self) -> DisableNewConnectionsContext:
38
+ self._contextvar_token = self.__class__._contextvar.set(self)
39
+ return self
40
+
41
+ def __exit__(
42
+ self,
43
+ exc_type: type[BaseException] | None,
44
+ exc_val: BaseException | None,
45
+ exc_tb: types.TracebackType | None,
46
+ ) -> bool | None:
47
+ self.__class__._contextvar.reset(self._contextvar_token)
48
+ del self._contextvar_token
49
+
50
+ return None
51
+
52
+
53
+ class HTTPSAdapterWithKeyPassword(requests.adapters.HTTPAdapter):
54
+
55
+ def __init__(
56
+ self,
57
+ *args: t.Any,
58
+ _pypsrp_key_password: str | None = None,
59
+ **kwargs: t.Any,
60
+ ) -> None:
61
+ self.__key_password = _pypsrp_key_password
62
+ super().__init__(*args, **kwargs)
63
+
64
+ def init_poolmanager(
65
+ self,
66
+ connections,
67
+ maxsize,
68
+ block=False,
69
+ **pool_kwargs,
70
+ ):
71
+ return super().init_poolmanager(
72
+ connections,
73
+ maxsize,
74
+ block,
75
+ key_password=self.__key_password,
76
+ **pool_kwargs,
77
+ )
78
+
79
+
80
+ def _wrap(
81
+ func: t.Callable[..., T],
82
+ *,
83
+ before: t.Callable[[], None] | None = None,
84
+ after: t.Callable[[T], None] | None = None,
85
+ ) -> t.Callable[..., T]:
86
+ """Wraps a function with before and after callables."""
87
+
88
+ @functools.wraps(func)
89
+ def wrapper(*args: t.Any, **kwargs: t.Any) -> T:
90
+ if before:
91
+ before()
92
+
93
+ res = func(*args, **kwargs)
94
+
95
+ if after:
96
+ after(res)
97
+
98
+ return res
99
+
100
+ return wrapper
101
+
102
+
103
+ def _raise_if_connections_disabled():
104
+ if DisableNewConnectionsContext.current() is not None:
105
+ raise NewConnectionDisabled()
106
+
107
+
108
+ def _create_new_connection_with_failure(connection):
109
+ connection.connect = _wrap(connection.connect, before=_raise_if_connections_disabled)
110
+
111
+
112
+ def _create_connection_pool(pool):
113
+ # This is called in the urllib3 pool when a new connection is needed. We
114
+ # wrap it so we can wrap the connect method when a new connection is
115
+ # created.
116
+ pool.ConnectionCls = _wrap(pool.ConnectionCls, after=_create_new_connection_with_failure)
117
+
118
+
119
+ def create_request_adapter( # type: ignore[no-any-unimported] # requests does not have typing stubs for urllib3
120
+ *,
121
+ max_retries: Retry,
122
+ key_password: str | None = None,
123
+ ) -> requests.adapters.HTTPAdapter:
124
+ """Creates a HTTPAdapter with support for disabling new connections via context."""
125
+ adapter: requests.adapters.HTTPAdapter
126
+ if key_password is not None:
127
+ adapter = HTTPSAdapterWithKeyPassword(
128
+ max_retries=max_retries,
129
+ _pypsrp_key_password=key_password,
130
+ )
131
+ else:
132
+ adapter = requests.adapters.HTTPAdapter(max_retries=max_retries)
133
+
134
+ # pool_classes_by_scheme stores the urllib3 pool types used when creating
135
+ # the connection pool for http/https. We wrap the __init__ method so we can
136
+ # inject our custom ConnectionCls that wraps the connect method when
137
+ # created.
138
+ pool_classes = adapter.poolmanager.pool_classes_by_scheme
139
+ for scheme, pool_cls in list(pool_classes.items()):
140
+ pool_classes[scheme] = _wrap(pool_cls, after=_create_connection_pool)
141
+
142
+ return adapter
pypsrp/_utils.py CHANGED
@@ -107,7 +107,6 @@ def get_pwsh_script(name: str) -> str:
107
107
  block_comment = False
108
108
  new_lines = []
109
109
  for line in script.splitlines():
110
-
111
110
  line = line.strip()
112
111
  if block_comment:
113
112
  block_comment = not line.endswith("#>")
pypsrp/client.py CHANGED
@@ -15,7 +15,6 @@ import warnings
15
15
  import xml.etree.ElementTree as ET
16
16
 
17
17
  from pypsrp._utils import get_pwsh_script, to_bytes, to_unicode
18
- from pypsrp.complex_objects import ComplexObject
19
18
  from pypsrp.exceptions import WinRMError
20
19
  from pypsrp.powershell import (
21
20
  DEFAULT_CONFIGURATION_NAME,
pypsrp/complex_objects.py CHANGED
@@ -139,7 +139,7 @@ class Enum(ComplexObject):
139
139
  self.value = kwargs.get("value")
140
140
 
141
141
  @property # type: ignore[override]
142
- def _to_string(self) -> str: # type: ignore[override]
142
+ def _to_string(self) -> str:
143
143
  try:
144
144
  return self._string_map[self.value or 0]
145
145
  except KeyError as err:
@@ -378,7 +378,7 @@ class RemoteStreamOptions(Enum):
378
378
  )
379
379
 
380
380
  @property # type: ignore[override]
381
- def _to_string(self) -> str: # type: ignore[override]
381
+ def _to_string(self) -> str:
382
382
  if self.value == 15:
383
383
  return "AddInvocationInfo"
384
384
 
@@ -1149,7 +1149,7 @@ class CommandType(Enum):
1149
1149
  super(CommandType, self).__init__("System.Management.Automation.CommandTypes", {}, **kwargs)
1150
1150
 
1151
1151
  @property # type: ignore[override]
1152
- def _to_string(self) -> str: # type: ignore[override]
1152
+ def _to_string(self) -> str:
1153
1153
  if self.value == 0x01FF:
1154
1154
  return "All"
1155
1155
 
pypsrp/encryption.py CHANGED
@@ -10,7 +10,6 @@ log = logging.getLogger(__name__)
10
10
 
11
11
 
12
12
  class WinRMEncryption(object):
13
-
14
13
  SIXTEEN_KB = 16384
15
14
  MIME_BOUNDARY = "--Encrypted Boundary"
16
15
  CREDSSP = "application/HTTP-CredSSP-session-encrypted"
pypsrp/messages.py CHANGED
@@ -127,7 +127,8 @@ class Message(object):
127
127
  ):
128
128
  msg = ET.Element("S")
129
129
  else:
130
- msg = self._serializer.serialize(self.data) or b""
130
+ serialized_msg = self._serializer.serialize(self.data)
131
+ msg = serialized_msg if serialized_msg is not None else b""
131
132
 
132
133
  if not isinstance(msg, bytes):
133
134
  message_data = ET.tostring(msg, encoding="utf-8", method="xml")
@@ -155,7 +156,7 @@ class Message(object):
155
156
  rpid = str(uuid.UUID(bytes_le=data[8:24]))
156
157
  pid = str(uuid.UUID(bytes_le=data[24:40]))
157
158
 
158
- if data[40:43] == b"\xEF\xBB\xBF":
159
+ if data[40:43] == b"\xef\xbb\xbf":
159
160
  # 40-43 is the UTF-8 BOM which we don't care about
160
161
  message_data = to_string(data[43:])
161
162
  else:
pypsrp/negotiate.py CHANGED
@@ -11,7 +11,6 @@ import spnego
11
11
  import spnego.channel_bindings
12
12
  from cryptography import x509
13
13
  from cryptography.exceptions import UnsupportedAlgorithm
14
- from cryptography.hazmat.backends import default_backend
15
14
  from cryptography.hazmat.primitives import hashes
16
15
  from requests.auth import AuthBase
17
16
  from requests.packages.urllib3.response import HTTPResponse
@@ -37,7 +36,7 @@ class HTTPNegotiateAuth(AuthBase):
37
36
  password: typing.Optional[str] = None,
38
37
  auth_provider: str = "negotiate",
39
38
  send_cbt: bool = True,
40
- service: str = "WSMAN",
39
+ service: str = "host",
41
40
  delegate: bool = False,
42
41
  hostname_override: typing.Optional[str] = None,
43
42
  wrap_required: bool = False,
@@ -61,7 +60,7 @@ class HTTPNegotiateAuth(AuthBase):
61
60
  :param send_cbt: Try to bind the channel token (HTTPS only) to the auth
62
61
  process, default is True
63
62
  :param service: The service part of the SPN to authenticate with,
64
- defaults to HTTP
63
+ defaults to host
65
64
  :param delegate: Whether to get an auth token that allows the token to
66
65
  be delegated to other servers, this is only used with Kerberos and
67
66
  defaults to False
@@ -257,9 +256,7 @@ class HTTPNegotiateAuth(AuthBase):
257
256
  :return: The byte string containing the hash of the server's
258
257
  certificate
259
258
  """
260
- backend = default_backend()
261
-
262
- cert = x509.load_der_x509_certificate(certificate_der, backend)
259
+ cert = x509.load_der_x509_certificate(certificate_der)
263
260
 
264
261
  hash_algorithm = None
265
262
  try:
@@ -273,9 +270,9 @@ class HTTPNegotiateAuth(AuthBase):
273
270
  # If the cert signature algorithm is unknown, md5, or sha1 then use sha256 otherwise use the signature
274
271
  # algorithm of the cert itself.
275
272
  if not hash_algorithm or hash_algorithm.name in ["md5", "sha1"]:
276
- digest = hashes.Hash(hashes.SHA256(), backend)
273
+ digest = hashes.Hash(hashes.SHA256())
277
274
  else:
278
- digest = hashes.Hash(hash_algorithm, backend)
275
+ digest = hashes.Hash(hash_algorithm)
279
276
 
280
277
  digest.update(certificate_der)
281
278
  certificate_hash = digest.finalize()
pypsrp/powershell.py CHANGED
@@ -1,6 +1,8 @@
1
1
  # Copyright: (c) 2018, Jordan Borean (@jborean93) <jborean93@gmail.com>
2
2
  # MIT License (see LICENSE or https://opensource.org/licenses/MIT)
3
3
 
4
+ from __future__ import annotations
5
+
4
6
  import base64
5
7
  import logging
6
8
  import struct
@@ -11,10 +13,11 @@ import uuid
11
13
  import warnings
12
14
  import xml.etree.ElementTree as ET
13
15
 
14
- from cryptography.hazmat.backends import default_backend
16
+ import requests.exceptions
15
17
  from cryptography.hazmat.primitives.asymmetric import padding, rsa
16
18
  from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
17
19
 
20
+ from pypsrp import _pool_manager
18
21
  from pypsrp._utils import version_equal_or_newer
19
22
  from pypsrp.complex_objects import (
20
23
  ApartmentState,
@@ -103,6 +106,9 @@ class RunspacePool(object):
103
106
  min_runspaces: int = 1,
104
107
  max_runspaces: int = 1,
105
108
  session_key_timeout_ms: int = 60000,
109
+ *,
110
+ no_profile: bool = False,
111
+ idle_timeout: int | None = None,
106
112
  ) -> None:
107
113
  """
108
114
  Represents a Runspace pool on a remote host. This pool can contain
@@ -129,6 +135,11 @@ class RunspacePool(object):
129
135
  hold
130
136
  :param session_key_timeout_ms: The maximum time to wait for a session
131
137
  key transfer from the server
138
+ :param no_profile: If True, the user profile will not be loaded and
139
+ will use the machine defaults.
140
+ :param idle_timeout: The idle timeout, in seconds, for the runspace. The
141
+ runspace will be closed if no operations are performed in the time
142
+ specified. If None the server default will be used.
132
143
  """
133
144
  log.info("Initialising RunspacePool object for configuration %s" % configuration_name)
134
145
  # The below are defined in some way at
@@ -138,7 +149,13 @@ class RunspacePool(object):
138
149
  self.connection = connection
139
150
  resource_uri = "http://schemas.microsoft.com/powershell/%s" % configuration_name
140
151
  self.shell = WinRS(
141
- connection, resource_uri=resource_uri, id=self.id, input_streams="stdin pr", output_streams="stdout"
152
+ connection,
153
+ resource_uri=resource_uri,
154
+ id=self.id,
155
+ input_streams="stdin pr",
156
+ output_streams="stdout",
157
+ no_profile=no_profile,
158
+ idle_time_out=idle_timeout,
142
159
  )
143
160
  self.ci_table: typing.Dict = {}
144
161
  self.pipelines: typing.Dict[str, "PowerShell"] = {}
@@ -569,7 +586,6 @@ class RunspacePool(object):
569
586
  self._exchange_key = rsa.generate_private_key(
570
587
  public_exponent=65537,
571
588
  key_size=2048,
572
- backend=default_backend(),
573
589
  )
574
590
  public_numbers = self._exchange_key.public_key().public_numbers()
575
591
  exponent = struct.pack("<I", public_numbers.e)
@@ -652,6 +668,47 @@ class RunspacePool(object):
652
668
  """
653
669
  return self._serializer.serialize(obj, metadata=metadata)
654
670
 
671
+ def is_alive(
672
+ self,
673
+ *,
674
+ timeout: int | None = None,
675
+ ) -> bool:
676
+ """Checks whether the RunspacePool is still alive.
677
+
678
+ :param timeout: Override the connection timeout defaults for the
679
+ request. The WSMan operation timeout will be set to this value
680
+ and the HTTP connect/read timeouts will be this value + 2 seconds.
681
+ :return: A bool True if the pool is still alive, False otherwise.
682
+ """
683
+ is_closed = False
684
+
685
+ try:
686
+ # We don't want to try and open a new connection if the socket
687
+ # was closed, we treat it as the pool is not alive.
688
+ with _pool_manager.DisableNewConnectionsContext():
689
+ state = self.shell._get_shell(timeout=timeout)
690
+
691
+ is_closed = state.get("State", "") == "Disconnected"
692
+ except WSManFaultError as exc:
693
+ if exc.code in [
694
+ 0x80338029, # ERROR_WSMAN_OPERATION_TIMEDOUT
695
+ 0x8033805B, # ERROR_WSMAN_UNEXPECTED_SELECTORS
696
+ ]:
697
+ is_closed = True
698
+ else:
699
+ raise
700
+
701
+ except (_pool_manager.NewConnectionDisabled, TimeoutError, requests.exceptions.ConnectionError):
702
+ # If a timeout or connection error occurs, treat the pool as closed.
703
+ is_closed = True
704
+
705
+ if is_closed:
706
+ # Ensures that close() doesn't try and close an already closed pool
707
+ self.state = RunspacePoolState.CLOSED
708
+ return False
709
+
710
+ return True
711
+
655
712
  def _receive(
656
713
  self,
657
714
  id: typing.Optional[str] = None,
@@ -861,7 +918,7 @@ class RunspacePool(object):
861
918
  iv = b"\x00" * 16 # PSRP doesn't use an IV
862
919
  algorithm = algorithms.AES(decrypted_key)
863
920
  mode = modes.CBC(iv)
864
- cipher = Cipher(algorithm, mode, default_backend())
921
+ cipher = Cipher(algorithm, mode)
865
922
 
866
923
  self._serializer.cipher = cipher
867
924
  self._key_exchanged = True
@@ -907,7 +964,16 @@ class PowerShell(object):
907
964
  # CommandID that is created and we need to reference in the WSMan msgs
908
965
  self._command_id: typing.Optional[str] = None
909
966
 
910
- runspace_pool.pipelines[self.id] = self
967
+ def __enter__(self) -> "PowerShell":
968
+ return self
969
+
970
+ def __exit__(
971
+ self,
972
+ exc_type: typing.Optional[typing.Type[BaseException]],
973
+ value: typing.Optional[BaseException],
974
+ traceback: typing.Optional[types.TracebackType],
975
+ ) -> None:
976
+ self.close()
911
977
 
912
978
  def add_argument(
913
979
  self,
@@ -1042,6 +1108,33 @@ class PowerShell(object):
1042
1108
  self.commands = []
1043
1109
  return self
1044
1110
 
1111
+ def clear_streams(self) -> None:
1112
+ """
1113
+ Clears all the data streams of the current PowerShell object.
1114
+ """
1115
+ self.streams = PSDataStreams()
1116
+ self.output = []
1117
+
1118
+ def close(self) -> None:
1119
+ """
1120
+ Closes the PowerShell pipeline and cleans up server resources.
1121
+
1122
+ This method must be called to reuse a PowerShell object for multiple
1123
+ invocations. It sends a TERMINATE signal to the server and removes
1124
+ the pipeline from the runspace pool's pipeline registry.
1125
+
1126
+ :raises InvalidPipelineStateError: If the pipeline is not in a terminal
1127
+ state (COMPLETED, STOPPED, or FAILED)
1128
+ """
1129
+ valid_states = [PSInvocationState.COMPLETED, PSInvocationState.STOPPED, PSInvocationState.FAILED]
1130
+ if self.state == PSInvocationState.NOT_STARTED:
1131
+ return
1132
+ elif self.state not in valid_states:
1133
+ raise InvalidPipelineStateError(self.state, valid_states, "close a PowerShell pipeline")
1134
+
1135
+ self.runspace_pool.shell.signal(SignalCode.TERMINATE, command_id=self._command_id or self.id)
1136
+ self.runspace_pool.pipelines.pop(self.id, None)
1137
+
1045
1138
  def connect(self):
1046
1139
  """
1047
1140
  Connects to a running command on a remote server, waits until the
@@ -1124,8 +1217,14 @@ class PowerShell(object):
1124
1217
  values. Will default to returning the invocation info on all
1125
1218
  """
1126
1219
  log.info("Beginning remote Pipeline invocation")
1127
- if self.state != PSInvocationState.NOT_STARTED:
1128
- raise InvalidPipelineStateError(self.state, PSInvocationState.NOT_STARTED, "start a PowerShell pipeline")
1220
+ valid_states = [
1221
+ PSInvocationState.NOT_STARTED,
1222
+ PSInvocationState.STOPPED,
1223
+ PSInvocationState.COMPLETED,
1224
+ PSInvocationState.FAILED,
1225
+ ]
1226
+ if self.state not in valid_states:
1227
+ raise InvalidPipelineStateError(self.state, valid_states, "start a PowerShell pipeline")
1129
1228
 
1130
1229
  if len(self.commands) == 0:
1131
1230
  raise InvalidPSRPOperation("Cannot invoke PowerShell without any commands being set")
@@ -1414,7 +1513,7 @@ class PowerShell(object):
1414
1513
  self.state = PSInvocationState.STOPPING
1415
1514
  self.runspace_pool.shell.signal(SignalCode.PS_CTRL_C, str(self.id).upper())
1416
1515
  self.state = PSInvocationState.STOPPED
1417
- del self.runspace_pool.pipelines[self.id]
1516
+ self.runspace_pool.pipelines.pop(self.id, None)
1418
1517
 
1419
1518
  def _invoke(
1420
1519
  self,
@@ -1422,6 +1521,8 @@ class PowerShell(object):
1422
1521
  ) -> None:
1423
1522
  fragments = self.runspace_pool._fragmenter.fragment(msg, self.runspace_pool.id, self.id)
1424
1523
 
1524
+ self.runspace_pool.pipelines[self.id] = self
1525
+
1425
1526
  # send first fragment as Command message
1426
1527
  first_frag = base64.b64encode(fragments.pop(0)).decode("utf-8")
1427
1528
  resp = self.runspace_pool.shell.command("", arguments=[first_frag], command_id=self.id)
pypsrp/serializer.py CHANGED
@@ -62,7 +62,7 @@ class Serializer(object):
62
62
  self.cipher: typing.Any = None
63
63
  # Finds C0, C1 and surrogate pairs in a unicode string for us to
64
64
  # encode according to the PSRP rules
65
- self._serial_str = re.compile("[\u0000-\u001F\u007F-\u009F\U00010000-\U0010FFFF]")
65
+ self._serial_str = re.compile("[\u0000-\u001f\u007f-\u009f\U00010000-\U0010ffff]")
66
66
 
67
67
  # to support surrogate UTF-16 pairs we need to use a UTF-16 regex
68
68
  # so we can replace the UTF-16 string representation with the actual
@@ -615,7 +615,7 @@ class Serializer(object):
615
615
  element: ET.Element,
616
616
  metadata: ObjectMeta,
617
617
  ) -> typing.Any:
618
- obj = metadata.object() # type: ignore[misc] # Caller always sets object
618
+ obj = metadata.object()
619
619
  self.obj[element.attrib["RefId"]] = obj
620
620
 
621
621
  to_string_value = element.find("ToString")
@@ -669,7 +669,7 @@ class Serializer(object):
669
669
  element: ET.Element,
670
670
  metadata: ObjectMeta,
671
671
  ) -> typing.Any:
672
- obj = metadata.object() # type: ignore[misc] # Caller always sets object
672
+ obj = metadata.object()
673
673
  self.obj[element.attrib["RefId"]] = obj
674
674
 
675
675
  for obj_property in element:
@@ -703,13 +703,9 @@ class Serializer(object):
703
703
  element: ET.Element,
704
704
  metadata: typing.Optional[ObjectMeta] = None,
705
705
  ) -> typing.List:
706
- list_value = []
707
706
  value_meta = getattr(metadata, "list_value_meta", None)
708
707
 
709
- entries = element.find("LST")
710
- for entry in entries or []:
711
- entry_value = self.deserialize(entry, value_meta, clear=False)
712
- list_value.append(entry_value)
708
+ list_value = list(self._deserialize_list_values(element, "LST", value_meta=value_meta))
713
709
 
714
710
  return list_value
715
711
 
@@ -719,10 +715,8 @@ class Serializer(object):
719
715
  ) -> Queue:
720
716
  queue: Queue = Queue()
721
717
 
722
- entries = element.find("QUE")
723
- for entry in entries or []:
724
- entry_value = self.deserialize(entry, clear=False)
725
- queue.put(entry_value)
718
+ for entry in self._deserialize_list_values(element, "QUE"):
719
+ queue.put(entry)
726
720
 
727
721
  return queue
728
722
 
@@ -731,15 +725,24 @@ class Serializer(object):
731
725
  element: ET.Element,
732
726
  ) -> typing.List:
733
727
  # no native Stack object in Python so just use a list
734
- stack = []
735
-
736
- entries = element.find("STK")
737
- for entry in entries or []:
738
- entry_value = self.deserialize(entry, clear=False)
739
- stack.append(entry_value)
728
+ stack = list(self._deserialize_list_values(element, "STK"))
740
729
 
741
730
  return stack
742
731
 
732
+ def _deserialize_list_values(
733
+ self,
734
+ element: ET.Element,
735
+ list_type: str,
736
+ value_meta: typing.Optional[ObjectMeta] = None,
737
+ ) -> typing.Iterable[typing.Any]:
738
+ entries = element.find(list_type)
739
+ if entries is None:
740
+ return
741
+
742
+ for entry in entries:
743
+ entry_value = self.deserialize(entry, metadata=value_meta, clear=False)
744
+ yield entry_value
745
+
743
746
  def _deserialize_dct(
744
747
  self,
745
748
  element: ET.Element,
pypsrp/shell.py CHANGED
@@ -1,6 +1,8 @@
1
1
  # Copyright: (c) 2018, Jordan Borean (@jborean93) <jborean93@gmail.com>
2
2
  # MIT License (see LICENSE or https://opensource.org/licenses/MIT)
3
3
 
4
+ from __future__ import annotations
5
+
4
6
  import base64
5
7
  import logging
6
8
  import types
@@ -67,7 +69,7 @@ class WinRS(object):
67
69
  :param lifetime: The total lifetime of the shell
68
70
  :param name: The name (description) of the shell
69
71
  :param no_profile: Whether to create the shell with the user profile
70
- active or not
72
+ active or not. This may not work on newer hosts like Server 2012+.
71
73
  :param working_directory: The default working directory of the created
72
74
  shell
73
75
  """
@@ -197,8 +199,8 @@ class WinRS(object):
197
199
  # inherit the base options if it was passed in, otherwise use an empty
198
200
  # option set
199
201
  options = OptionSet() if base_options is None else base_options
200
- if self.no_profile is not None:
201
- options.add_option("WINRS_NOPROFILE", str(self.no_profile))
202
+ if self.no_profile:
203
+ options.add_option("WINRS_NOPROFILE", "TRUE", {"MustComply": "true"})
202
204
  if self.codepage is not None:
203
205
  options.add_option("WINRS_CODEPAGE", str(self.codepage))
204
206
 
@@ -315,6 +317,42 @@ class WinRS(object):
315
317
  ET.SubElement(signal, "{%s}Code" % rsp).text = code
316
318
  return self.wsman.signal(self.resource_uri, signal, selector_set=self._selector_set)
317
319
 
320
+ def _get_shell(
321
+ self,
322
+ *,
323
+ timeout: int | None = None,
324
+ ) -> dict[str, str | None]:
325
+ """Queries the WSMan host for the current shell state.
326
+
327
+ Returns the WSMan GetResponse fields as a dictionary.
328
+
329
+ The timeout parameter can be used to override the default WSMan timeout
330
+ for this operation. If set the HTTP connect and read timeout will be
331
+ set to this value + 2 seconds to ensure the request does not block for
332
+ a longer time.
333
+
334
+ :param timeout: Sets the WSMan operational timeout to this value in
335
+ seconds. Overrides the default timeout set on the WSMan instance.
336
+ :return: A dictionary containing the shell state fields from the raw XML
337
+ response.
338
+ """
339
+ rsp = NAMESPACES["rsp"]
340
+
341
+ resp = self.wsman.get(
342
+ "http://schemas.microsoft.com/wbem/wsman/1/windows/shell",
343
+ selector_set=self._selector_set,
344
+ timeout=timeout,
345
+ )
346
+
347
+ state = {}
348
+ shell_items = resp.find("rsp:Shell", namespaces=NAMESPACES)
349
+ if shell_items is not None:
350
+ for child in shell_items:
351
+ field_name = child.tag.replace(f"{{{rsp}}}", "")
352
+ state[field_name] = child.text
353
+
354
+ return state
355
+
318
356
  def _parse_shell_create(
319
357
  self,
320
358
  response: ET.Element,
pypsrp/wsman.py CHANGED
@@ -1,20 +1,21 @@
1
1
  # Copyright: (c) 2018, Jordan Borean (@jborean93) <jborean93@gmail.com>
2
2
  # MIT License (see LICENSE or https://opensource.org/licenses/MIT)
3
3
 
4
- from __future__ import division
4
+ from __future__ import annotations
5
5
 
6
6
  import ipaddress
7
7
  import logging
8
8
  import re
9
+ import time
9
10
  import typing
10
11
  import uuid
11
12
  import warnings
12
13
  import xml.etree.ElementTree as ET
13
- from xml.dom.minidom import Element
14
14
 
15
15
  import requests
16
16
  from requests.packages.urllib3.util.retry import Retry
17
17
 
18
+ from pypsrp import _pool_manager
18
19
  from pypsrp._utils import get_hostname, to_string, to_unicode
19
20
  from pypsrp.encryption import WinRMEncryption
20
21
  from pypsrp.exceptions import (
@@ -40,7 +41,7 @@ log = logging.getLogger(__name__)
40
41
  SUPPORTED_AUTHS = ["basic", "certificate", "credssp", "kerberos", "negotiate", "ntlm"]
41
42
 
42
43
  AUTH_KWARGS: typing.Dict[str, typing.List[str]] = {
43
- "certificate": ["certificate_key_pem", "certificate_pem"],
44
+ "certificate": ["certificate_key_pem", "certificate_pem", "certificate_key_password"],
44
45
  "credssp": ["credssp_auth_mechanism", "credssp_disable_tlsv1_2", "credssp_minimum_version"],
45
46
  "negotiate": ["negotiate_delegate", "negotiate_hostname_override", "negotiate_send_cbt", "negotiate_service"],
46
47
  }
@@ -364,7 +365,18 @@ class WSMan(object):
364
365
  selector_set: typing.Optional["SelectorSet"] = None,
365
366
  timeout: typing.Optional[int] = None,
366
367
  ) -> ET.Element:
367
- res = self.invoke(WSManAction.RECEIVE, resource_uri, resource, option_set, selector_set, timeout)
368
+ # Receiving data can sometimes timeout if the server has bounced the
369
+ # network adapter. Luckily just sending the exact same request again
370
+ # will return the same data so we can safely retry
371
+ res = self.invoke(
372
+ WSManAction.RECEIVE,
373
+ resource_uri,
374
+ resource,
375
+ option_set,
376
+ selector_set,
377
+ timeout,
378
+ retries_on_read_timeout=5,
379
+ )
368
380
  return res.find("s:Body", namespaces=NAMESPACES) # type: ignore[return-value] # WSMan always has this present
369
381
 
370
382
  def reconnect(
@@ -437,6 +449,8 @@ class WSMan(object):
437
449
  option_set: typing.Optional["OptionSet"] = None,
438
450
  selector_set: typing.Optional["SelectorSet"] = None,
439
451
  timeout: typing.Optional[int] = None,
452
+ *,
453
+ retries_on_read_timeout: int = 0,
440
454
  ) -> ET.Element:
441
455
  """
442
456
  Send a generic WSMan request to the host.
@@ -451,11 +465,21 @@ class WSMan(object):
451
465
  :param selector_set: a wsman.SelectorSet to add to the request
452
466
  :param timeout: Override the default wsman:OperationTimeout value for
453
467
  the request, this should be an int in seconds.
468
+ :param retries_on_read_timeout: The number of retries to attempt if the
469
+ request fails due to a read timeout.
454
470
  :return: The ET Element of the response XML from the server
455
471
  """
456
472
  s = NAMESPACES["s"]
457
473
  envelope = ET.Element("{%s}Envelope" % s)
458
474
 
475
+ http_timeout = None
476
+ if timeout:
477
+ # Failsafe if the operation timeout exceeds we don't want the
478
+ # request to hang indefinitely. The HTTP timeout needs to be more
479
+ # than the WSMan timeout as we cannot guarantee a WSMan response
480
+ # until the operation timeout is hit.
481
+ http_timeout = timeout + 2
482
+
459
483
  message_id, header = self._create_header(action, resource_uri, option_set, selector_set, timeout)
460
484
  message_id = f"uuid:{message_id}"
461
485
  envelope.append(header)
@@ -467,7 +491,11 @@ class WSMan(object):
467
491
  xml = ET.tostring(envelope, encoding="utf-8", method="xml")
468
492
 
469
493
  try:
470
- response = self.transport.send(xml)
494
+ response = self.transport.send(
495
+ xml,
496
+ retries_on_read_timeout=retries_on_read_timeout,
497
+ timeout=http_timeout,
498
+ )
471
499
  except WinRMTransportError as err:
472
500
  try:
473
501
  # try and parse the XML and get the WSManFault
@@ -570,9 +598,9 @@ class WSMan(object):
570
598
  ET.SubElement(header, "{%s}OperationTimeout" % wsman).text = "PT%sS" % str(timeout or self.operation_timeout)
571
599
 
572
600
  reply_to = ET.SubElement(header, "{%s}ReplyTo" % wsa)
573
- ET.SubElement(
574
- reply_to, "{%s}Address" % wsa, attrib={"{%s}mustUnderstand" % s: "true"}
575
- ).text = "http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous"
601
+ ET.SubElement(reply_to, "{%s}Address" % wsa, attrib={"{%s}mustUnderstand" % s: "true"}).text = (
602
+ "http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous"
603
+ )
576
604
 
577
605
  ET.SubElement(header, "{%s}ResourceURI" % wsman, attrib={"{%s}mustUnderstand" % s: "true"}).text = resource_uri
578
606
 
@@ -617,7 +645,7 @@ class WSMan(object):
617
645
  if reason_info is not None:
618
646
  reason = reason_info.text
619
647
 
620
- wsman_fault = fault.find("s:Detail/wsmanfault:WSManFault", namespaces=NAMESPACES) if fault else None
648
+ wsman_fault = fault.find("s:Detail/wsmanfault:WSManFault", namespaces=NAMESPACES) if fault is not None else None
621
649
  if wsman_fault is not None:
622
650
  code = wsman_fault.attrib.get("Code", code)
623
651
  machine = wsman_fault.attrib.get("Machine")
@@ -772,6 +800,7 @@ class _TransportHTTP(object):
772
800
 
773
801
  self.certificate_key_pem: typing.Optional[str] = None
774
802
  self.certificate_pem: typing.Optional[str] = None
803
+ self.certificate_key_password: typing.Optional[str] = None
775
804
  for kwarg_list in AUTH_KWARGS.values():
776
805
  for kwarg in kwarg_list:
777
806
  setattr(self, kwarg, kwargs.get(kwarg, None))
@@ -790,17 +819,35 @@ class _TransportHTTP(object):
790
819
  if self.session:
791
820
  self.session.close()
792
821
 
793
- def send(self, message: bytes) -> bytes:
822
+ def send(
823
+ self,
824
+ message: bytes,
825
+ *,
826
+ retries_on_read_timeout: int = 0,
827
+ timeout: int | float | tuple[int | float, int | float] | None = None,
828
+ ) -> bytes:
794
829
  hostname = get_hostname(self.endpoint)
795
830
  if self.session is None:
796
831
  self.session = self._build_session()
797
832
 
833
+ attempt = 0
834
+ while True:
798
835
  # need to send an initial blank message to setup the security
799
836
  # context required for encryption
800
- if self.wrap_required:
837
+ if self.wrap_required and not self.encryption:
801
838
  request = requests.Request("POST", self.endpoint, data=None)
802
839
  prep_request = self.session.prepare_request(request)
803
- self._send_request(prep_request)
840
+
841
+ try:
842
+ self._send_request(prep_request, timeout=timeout)
843
+ except (requests.ReadTimeout, requests.ConnectTimeout) as e:
844
+ log.exception("%s during initial authentication request - attempt %d", type(e).__name__, attempt)
845
+ if attempt == retries_on_read_timeout:
846
+ raise
847
+
848
+ attempt += 1
849
+ time.sleep(self.reconnection_backoff * (2**attempt))
850
+ continue
804
851
 
805
852
  protocol = WinRMEncryption.SPNEGO
806
853
  if isinstance(self.session.auth, HttpCredSSPAuth):
@@ -811,33 +858,55 @@ class _TransportHTTP(object):
811
858
 
812
859
  self.encryption = WinRMEncryption(self.session.auth.contexts[hostname], protocol) # type: ignore[union-attr] # This should not happen
813
860
 
814
- if log.isEnabledFor(logging.DEBUG):
815
- log.debug("Sending message: %s" % message.decode("utf-8"))
816
- # for testing, keep commented out
817
- # self._test_messages.append({"request": message.decode('utf-8'),
818
- # "response": None})
819
-
820
- headers = self.session.headers
821
- if self.wrap_required:
822
- content_type, payload = self.encryption.wrap_message(message) # type: ignore[union-attr] # This should not happen
823
- protocol = self.encryption.protocol if self.encryption else WinRMEncryption.SPNEGO
824
- type_header = '%s;protocol="%s";boundary="Encrypted Boundary"' % (content_type, protocol)
825
- headers.update(
826
- {
827
- "Content-Type": type_header,
828
- "Content-Length": str(len(payload)),
829
- }
830
- )
831
- else:
832
- payload = message
833
- headers["Content-Type"] = "application/soap+xml;charset=UTF-8"
861
+ if log.isEnabledFor(logging.DEBUG):
862
+ log.debug("Sending message on attempt %d: %s" % (attempt, message.decode("utf-8")))
863
+ # for testing, keep commented out
864
+ # self._test_messages.append({"request": message.decode('utf-8'),
865
+ # "response": None})
834
866
 
835
- request = requests.Request("POST", self.endpoint, data=payload, headers=headers)
836
- prep_request = self.session.prepare_request(request)
837
- return self._send_request(prep_request)
867
+ headers = self.session.headers.copy() # type: ignore[attr-defined] # We cannot use copy.copy as it still is a ref to the original.
868
+ if self.wrap_required:
869
+ content_type, payload = self.encryption.wrap_message(message) # type: ignore[union-attr] # This should not happen
870
+ protocol = self.encryption.protocol if self.encryption else WinRMEncryption.SPNEGO
871
+ type_header = '%s;protocol="%s";boundary="Encrypted Boundary"' % (content_type, protocol)
872
+ headers.update(
873
+ {
874
+ "Content-Type": type_header,
875
+ "Content-Length": str(len(payload)),
876
+ }
877
+ )
878
+ else:
879
+ payload = message
880
+ headers["Content-Type"] = "application/soap+xml;charset=UTF-8"
881
+
882
+ request = requests.Request("POST", self.endpoint, data=payload, headers=headers)
883
+ prep_request = self.session.prepare_request(request)
884
+ try:
885
+ return self._send_request(
886
+ prep_request,
887
+ timeout=timeout,
888
+ )
889
+ except (requests.ReadTimeout, requests.ConnectTimeout) as e:
890
+ log.exception("%s during WSMan request - attempt %d", type(e).__name__, attempt)
891
+ if attempt == retries_on_read_timeout:
892
+ raise
838
893
 
839
- def _send_request(self, request: requests.PreparedRequest) -> bytes:
840
- response = self.session.send(request, timeout=(self.connection_timeout, self.read_timeout)) # type: ignore[union-attr] # This should not happen
894
+ # On a failure the encryption state is invalidated and needs to
895
+ # be recreated after authentication is done.
896
+ self.encryption = None
897
+
898
+ attempt += 1
899
+ time.sleep(self.reconnection_backoff * (2**attempt - 1))
900
+
901
+ def _send_request(
902
+ self,
903
+ request: requests.PreparedRequest,
904
+ timeout: int | float | tuple[int | float, int | float] | None = None,
905
+ ) -> bytes:
906
+ response = self.session.send( # type: ignore[union-attr] # This should not happen
907
+ request,
908
+ timeout=timeout if timeout is not None else (self.connection_timeout, self.read_timeout),
909
+ )
841
910
 
842
911
  content_type = response.headers.get("content-type", "")
843
912
  if content_type.startswith("multipart/encrypted;") or content_type.startswith("multipart/x-multi-encrypted;"):
@@ -878,7 +947,7 @@ class _TransportHTTP(object):
878
947
 
879
948
  # get the env requests settings
880
949
  session.trust_env = True
881
- settings = session.merge_environment_settings( # type: ignore[no-untyped-call] # Not in types-requests
950
+ settings = session.merge_environment_settings(
882
951
  url=self.endpoint, proxies={}, stream=None, verify=None, cert=None
883
952
  )
884
953
 
@@ -915,8 +984,14 @@ class _TransportHTTP(object):
915
984
  del retry_kwargs["status"]
916
985
  retries = Retry(**retry_kwargs)
917
986
 
918
- session.mount("http://", requests.adapters.HTTPAdapter(max_retries=retries))
919
- session.mount("https://", requests.adapters.HTTPAdapter(max_retries=retries))
987
+ session.mount(
988
+ "http://",
989
+ _pool_manager.create_request_adapter(max_retries=retries),
990
+ )
991
+ session.mount(
992
+ "https://",
993
+ _pool_manager.create_request_adapter(max_retries=retries, key_password=self.certificate_key_password),
994
+ )
920
995
 
921
996
  # set cert validation config
922
997
  session.verify = self.cert_validation
@@ -954,6 +1029,9 @@ class _TransportHTTP(object):
954
1029
  if self.ssl is False:
955
1030
  raise ValueError("For certificate auth, SSL must be used")
956
1031
 
1032
+ # requests does not expose the password through the cert tuple. If set
1033
+ # it'll be passed to urllib3 through the custom adapter created for the
1034
+ # session.
957
1035
  session.cert = (self.certificate_pem, self.certificate_key_pem)
958
1036
  session.headers["Authorization"] = "http://schemas.dmtf.org/wbem/wsman/1/wsman/secprofile/https/mutual"
959
1037
 
@@ -1,30 +1,44 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: pypsrp
3
- Version: 0.8.1
3
+ Version: 0.9.0rc2
4
4
  Summary: PowerShell Remoting Protocol and WinRM for Python
5
- Home-page: https://github.com/jborean93/pypsrp
6
- Author: Jordan Borean
7
- Author-email: jborean93@gmail.com
8
- License: MIT
5
+ Author-email: Jordan Borean <jborean93@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: homepage, https://github.com/jborean93/pypsrp
9
8
  Keywords: winrm,psrp,winrs,windows,powershell
10
- Platform: UNKNOWN
11
9
  Classifier: Development Status :: 4 - Beta
12
- Classifier: License :: OSI Approved :: MIT License
13
10
  Classifier: Programming Language :: Python :: 3
14
- Classifier: Programming Language :: Python :: 3.6
15
- Classifier: Programming Language :: Python :: 3.7
16
- Classifier: Programming Language :: Python :: 3.8
17
- Classifier: Programming Language :: Python :: 3.9
18
11
  Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Requires-Python: >=3.10
19
17
  Description-Content-Type: text/markdown
20
18
  License-File: LICENSE
21
- Requires-Dist: cryptography
22
- Requires-Dist: pyspnego (<1.0.0)
23
- Requires-Dist: requests (>=2.9.1)
19
+ Requires-Dist: cryptography>=3.1
20
+ Requires-Dist: pyspnego<1.0.0,>=0.7.0
21
+ Requires-Dist: requests>=2.27.0
24
22
  Provides-Extra: credssp
25
- Requires-Dist: requests-credssp (>=2.0.0) ; extra == 'credssp'
23
+ Requires-Dist: requests-credssp>=2.0.0; extra == "credssp"
26
24
  Provides-Extra: kerberos
27
- Requires-Dist: pyspnego[kerberos] ; extra == 'kerberos'
25
+ Requires-Dist: pyspnego[kerberos]; extra == "kerberos"
26
+ Provides-Extra: dev
27
+ Requires-Dist: black==25.11.0; extra == "dev"
28
+ Requires-Dist: build; extra == "dev"
29
+ Requires-Dist: isort==6.1.0; extra == "dev"
30
+ Requires-Dist: mypy==1.19.0; extra == "dev"
31
+ Requires-Dist: pre-commit; extra == "dev"
32
+ Requires-Dist: pyspnego[kerberos]; extra == "dev"
33
+ Requires-Dist: pytest; extra == "dev"
34
+ Requires-Dist: pytest-cov; extra == "dev"
35
+ Requires-Dist: pytest-mock; extra == "dev"
36
+ Requires-Dist: PyYAML; extra == "dev"
37
+ Requires-Dist: requests-credssp; extra == "dev"
38
+ Requires-Dist: types-requests; extra == "dev"
39
+ Requires-Dist: types-PyYAML; extra == "dev"
40
+ Requires-Dist: xmldiff; extra == "dev"
41
+ Dynamic: license-file
28
42
 
29
43
  # pypsrp - Python PowerShell Remoting Protocol Client library
30
44
 
@@ -69,7 +83,7 @@ libraries to be installed.
69
83
 
70
84
  See `How to Install` for more details
71
85
 
72
- * CPython 3.6+
86
+ * CPython 3.10+
73
87
  * [cryptography](https://github.com/pyca/cryptography)
74
88
  * [pyspnego](https://github.com/jborean93/pyspnego)
75
89
  * [requests](https://github.com/requests/requests)
@@ -155,7 +169,19 @@ pip install pypsrp[credssp]
155
169
  ```
156
170
 
157
171
  If that fails you may need to update pip and setuptools to a newer version
158
- `pip install -U pip setuptools`.
172
+ `pip install -U pip setuptools`, otherwise the following system package may be
173
+ required;
174
+
175
+ ```bash
176
+ # For Debian/Ubuntu
177
+ apt-get install gcc python-dev
178
+
179
+ # For RHEL/Centos
180
+ yum install gcc python-devel
181
+
182
+ # For Fedora
183
+ dnf install gcc python-devel
184
+ ```
159
185
 
160
186
 
161
187
  ## How to Use
@@ -198,7 +224,8 @@ These are the options that can be used to setup `WSMan`;
198
224
  * `data_locale`: The `wsmv:DataLocale` value to set on each WSMan request. This specifies the format in which numerical data is presented in the response text, default is the value of `locale`
199
225
  * `reconnection_retries`: Number of retries on a connection problem, default is `0`
200
226
  * `reconnection_backoff`: Number of seconds to backoff in between reconnection attempts (first sleeps X, then sleeps 2*X, 4*X, 8*X, ...), default is `2.0`
201
- * `certificate_key_pem`: The path to the certificate key used in `certificate` authentication
227
+ * `certificate_key_pem`: The path to the certificate key used in `certificate` authentication. The key can be in either a `PKCS#1` or `PKCS#8` format
228
+ * `certificate_key_password`: The password for `certificate_key_pem` if it is encrypted
202
229
  * `certificate_pem`: The path to the certificate used in `certificate` authentication
203
230
  * `credssp_auth_mechanism`: The sub-auth mechanism used in CredSSP, default is `auto`, choices are `auto`, `ntlm`, or `kerberos`
204
231
  * `credssp_disable_tlsv1_2`: Whether to used CredSSP auth over the insecure TLSv1.0, default is `False`
@@ -235,7 +262,7 @@ configure a `WinRS` shell;
235
262
  * `idle_time_out`: THe idle timeout in seconds of the shell
236
263
  * `lifetime`: The total lifetime of the shell
237
264
  * `name`: The name (description only) of the shell
238
- * `no_profile`: Whether to create the shell with the user profile loaded or not
265
+ * `no_profile`: Whether to create the shell with the user profile loaded or not. This no longer works on Server 2012/Windows 8 or newer
239
266
  * `working_directory`: The default working directory of the created shell
240
267
 
241
268
  `RunspacePool` is a shell used by the PSRP protocol, it is designed to be a
@@ -252,6 +279,7 @@ Here are the options that can be used to configure a `RunspacePool` shell;
252
279
  * `min_runspaces`: The minimuum number of runspaces that a pool can hold, default is 1
253
280
  * `max_runspaces`: The maximum number of runspaces that a pool can hold. Each PowerShell pipeline is run in a single Runspace, default is 1
254
281
  * `session_key_timeout_ms`: The maximum time to wait for a session key transfer from the server
282
+ * `no_profile`: Do not load the user profile on the remote Runspace Pool
255
283
 
256
284
  ### Process
257
285
 
@@ -357,9 +385,8 @@ from pypsrp.wsman import WSMan
357
385
  # creates a https connection with explicit kerberos auth and implicit credentials
358
386
  wsman = WSMan("server", auth="kerberos", cert_validation=False))
359
387
 
360
- with wsman, RunspacePool(wsman) as pool:
388
+ with wsman, RunspacePool(wsman) as pool, PowerShell(pool) as ps:
361
389
  # execute 'Get-Process | Select-Object Name'
362
- ps = PowerShell(pool)
363
390
  ps.add_cmdlet("Get-Process").add_cmdlet("Select-Object").add_argument("Name")
364
391
  output = ps.invoke()
365
392
 
@@ -390,6 +417,19 @@ with wsman, RunspacePool(wsman) as pool:
390
417
  ps.invoke(["string", 1])
391
418
  print(ps.output)
392
419
  print(ps.streams.debug)
420
+
421
+ # It is possible to run the PowerShell pipeline again with invoke() but it
422
+ # needs to be explicitly closed first and the commands/streams optionally
423
+ # cleared if desired.
424
+ ps.close()
425
+
426
+ # Clears out ps.output and ps.streams to a blank value. Not required but
427
+ # nice if the output should be separate from a previous run
428
+ ps.clear_streams()
429
+
430
+ # Removes all existing commands. Not required but needed if re-using the
431
+ # same pipeline with a different set of commands
432
+ ps.clear_commands()
393
433
  ```
394
434
 
395
435
 
@@ -443,17 +483,18 @@ information and should only be used for debugging purposes._
443
483
  ## Testing
444
484
 
445
485
  Any changes are more than welcome in pull request form, you can run the current
446
- test suite with tox like so;
486
+ test suite with:
447
487
 
448
488
  ```bash
449
- # make sure tox is installed
450
- pip install tox
451
-
452
- # run the tox suite
453
- tox
454
-
455
- # or run the test manually for the current Python environment
456
- py.test -v --pep8 --cov pypsrp --cov-report term-missing
489
+ pip install -e .[dev]
490
+
491
+ python -m pytest \
492
+ tests/tests_pypsrp \
493
+ --verbose \
494
+ --junitxml junit/test-results.xml \
495
+ --cov pypsrp \
496
+ --cov-report xml \
497
+ --cov-report term-missing
457
498
  ```
458
499
 
459
500
  A lot of the tests either simulate a remote Windows host but you can also run a
@@ -523,5 +564,3 @@ tests.
523
564
  * Add Ansible playbook for better integration tests
524
565
  * Improved serialization between Python and .NET objects
525
566
  * Live interactive console for PSRP
526
-
527
-
@@ -0,0 +1,23 @@
1
+ pypsrp/__init__.py,sha256=9aButMuBNIEph0LfHsodNd8uSS7XKZj9IPHJsPd8hjY,958
2
+ pypsrp/_pool_manager.py,sha256=yKUzdzkE4hEo2mqIC-kKPL9uWpIk9mixCQ1AP5rgn_o,4222
3
+ pypsrp/_utils.py,sha256=y9sezXQPl0vXnr1beyZ8xcn-Q_zdg_7cg_lPk9QmjVQ,3540
4
+ pypsrp/client.py,sha256=iJERbEXwwE4wswpqAL0Dcz8ACqLvXUT9Dcl_Qqa3830,13892
5
+ pypsrp/complex_objects.py,sha256=PkJ1F1Byw5RI_Vpfy1KfcMFJdOfioOSsIbi50S_fZ3g,62666
6
+ pypsrp/encryption.py,sha256=4wOontH-eF8BBxTyHTX9iAF0uIM6m-z3N9poatspP1A,3870
7
+ pypsrp/exceptions.py,sha256=SWdQ9UAPOIwr7B4twGqDoXLCEtXFK_5u3PYIVLvEIbA,4034
8
+ pypsrp/host.py,sha256=XQgnkwzsZprNmAr1TPJpEBmj3xRSWFrYQFSIi870aNE,45065
9
+ pypsrp/messages.py,sha256=mCuie1ywOQmkXltISPvrb6ITS6GUA0oSld2jfNLdBf4,34169
10
+ pypsrp/negotiate.py,sha256=iVX2J-slABamesMajjTfmfqucDS4n5vVTTde_IDqpuE,10951
11
+ pypsrp/powershell.py,sha256=NRDoFtOsYjIrJHMQ6r6vRq6uL2EtPhhem5ZA_bWoDnw,69296
12
+ pypsrp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ pypsrp/serializer.py,sha256=49rBxfo3fh511mU3uzoK9vFvIOrBtyX3J-CUugbezc0,33767
14
+ pypsrp/shell.py,sha256=80mh35c-UXjsy_MFG9J3GGYLYqvk2FppUgrFHkWh6xg,18065
15
+ pypsrp/wsman.py,sha256=KkRg9UDDq1yxY4TKK9mGI-vS6px6ftQDPhmeojjItKg,48728
16
+ pypsrp/pwsh_scripts/__init__.py,sha256=VckRBWWDUdh_LTwPspE2FXSo8TaT50QnZ1aZ-yvxJmU,139
17
+ pypsrp/pwsh_scripts/copy.ps1,sha256=sTy4jcnh0cV5Y0l2-dd1znc-eM_kgE-okk51xZzdIQk,5374
18
+ pypsrp/pwsh_scripts/fetch.ps1,sha256=7gvPxURnksZetYgjWRjbIDIWSJYv2xGXZjRymvNUj6w,1989
19
+ pypsrp-0.9.0rc2.dist-info/licenses/LICENSE,sha256=8uJLgs8Wb_Us_8XaTvOOn7Yf0dwzFi9pcFHVxfgJKoE,1079
20
+ pypsrp-0.9.0rc2.dist-info/METADATA,sha256=_zCveXjKqq1_OiCmjLtge2Zr3v1G-S2EFrF33PTd6QA,23630
21
+ pypsrp-0.9.0rc2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
22
+ pypsrp-0.9.0rc2.dist-info/top_level.txt,sha256=9sFQD0PMIG07xbaR4j-1Fq7NaD9kwInn_HTc3Gqznq0,7
23
+ pypsrp-0.9.0rc2.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.37.1)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,23 +0,0 @@
1
- pypsrp/__init__.py,sha256=9aButMuBNIEph0LfHsodNd8uSS7XKZj9IPHJsPd8hjY,958
2
- pypsrp/_utils.py,sha256=BKXpmraKuyWt5N89ViuC91wsF7AB1XPyViE_1_Cl4w0,3541
3
- pypsrp/client.py,sha256=sp9rgNwAGJXXaMgdYdJ7yMd_q1FjYg1nlpVwT_yncSw,13941
4
- pypsrp/complex_objects.py,sha256=D2y7IaxN3_n72Er6vJxFFlui9yHORVopGxIVManlCDg,62744
5
- pypsrp/encryption.py,sha256=elgz6Fph6ZYsvN7ZT1FFSH7Q05UYS8Nh5kiJvSyWy-o,3871
6
- pypsrp/exceptions.py,sha256=SWdQ9UAPOIwr7B4twGqDoXLCEtXFK_5u3PYIVLvEIbA,4034
7
- pypsrp/host.py,sha256=XQgnkwzsZprNmAr1TPJpEBmj3xRSWFrYQFSIi870aNE,45065
8
- pypsrp/messages.py,sha256=GBKv2Ilm0oDV8lHVbYDkx41AckoOGIhQG-HovbLnvbA,34093
9
- pypsrp/negotiate.py,sha256=ZS1NL2U17CFiDE3B_U2AprRz_BfBO7iFC5jkSO_bi1E,11073
10
- pypsrp/powershell.py,sha256=10TpFRSvMi8VVtkkSklQCBftcagzLpN2ZginSbnyDVA,65595
11
- pypsrp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- pypsrp/serializer.py,sha256=KhT1l0ay2wQfncAXY-KY9ZQfVMKhjCWgjL7O1bywEhA,33759
13
- pypsrp/shell.py,sha256=DA-yj80PdtfxpOAmJrs6k5BmjqjE5-ZeDU2C9rgli6k,16697
14
- pypsrp/wsman.py,sha256=v2ZkKGfKY502VhA8XFdXKyEPbEoc3bTWJ486SRbW2do,45641
15
- pypsrp/pwsh_scripts/__init__.py,sha256=VckRBWWDUdh_LTwPspE2FXSo8TaT50QnZ1aZ-yvxJmU,139
16
- pypsrp/pwsh_scripts/copy.ps1,sha256=sTy4jcnh0cV5Y0l2-dd1znc-eM_kgE-okk51xZzdIQk,5374
17
- pypsrp/pwsh_scripts/fetch.ps1,sha256=7gvPxURnksZetYgjWRjbIDIWSJYv2xGXZjRymvNUj6w,1989
18
- pypsrp-0.8.1.dist-info/LICENSE,sha256=8uJLgs8Wb_Us_8XaTvOOn7Yf0dwzFi9pcFHVxfgJKoE,1079
19
- pypsrp-0.8.1.dist-info/METADATA,sha256=lB6x7GQTrp8L1flRqu7Np2XScvrS43pjj7mmR8LyEQU,22012
20
- pypsrp-0.8.1.dist-info/WHEEL,sha256=G16H4A3IeoQmnOrYV4ueZGKSjhipXx8zc8nu9FGlvMA,92
21
- pypsrp-0.8.1.dist-info/top_level.txt,sha256=9sFQD0PMIG07xbaR4j-1Fq7NaD9kwInn_HTc3Gqznq0,7
22
- pypsrp-0.8.1.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
23
- pypsrp-0.8.1.dist-info/RECORD,,
@@ -1 +0,0 @@
1
-