From d33e582692b9969fc254c5720b5501c41dea48da Mon Sep 17 00:00:00 2001 From: kaburagisec <135169292+kaburagisec@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:21:22 +0700 Subject: [PATCH 01/43] chore(wsdl): Update wsdl folders from remote (https://github.com/onvif/specs) --- .../wsdl/advancedsecurity.wsdl | 15 ++++-- onvif/wsdl/ver10/appmgmt/wsdl/appmgmt.wsdl | 2 +- onvif/wsdl/ver10/events/wsdl/event-vs.wsdl | 4 +- onvif/wsdl/ver10/events/wsdl/event.wsdl | 4 +- onvif/wsdl/ver10/pacs/types.xsd | 4 +- onvif/wsdl/ver10/recording.wsdl | 5 +- onvif/wsdl/ver10/schema/common.xsd | 2 +- onvif/wsdl/ver10/schema/metadatastream.xsd | 36 +++++++------ onvif/wsdl/ver10/schema/onvif.xsd | 52 +++++++++---------- onvif/wsdl/ver20/analytics/humanbody.xsd | 36 ++++++------- onvif/wsdl/ver20/analytics/humanface.xsd | 36 ++++++------- onvif/wsdl/ver20/analytics/radiometry.xsd | 4 +- onvif/wsdl/ver20/analytics/rules.xsd | 2 +- onvif/wsdl/ver20/media/wsdl/media.wsdl | 14 ++--- 14 files changed, 114 insertions(+), 102 deletions(-) diff --git a/onvif/wsdl/ver10/advancedsecurity/wsdl/advancedsecurity.wsdl b/onvif/wsdl/ver10/advancedsecurity/wsdl/advancedsecurity.wsdl index d8f6bcd..db27028 100644 --- a/onvif/wsdl/ver10/advancedsecurity/wsdl/advancedsecurity.wsdl +++ b/onvif/wsdl/ver10/advancedsecurity/wsdl/advancedsecurity.wsdl @@ -879,6 +879,11 @@ Indicates the maximum number of certification paths that may be assigned to the TLS server simultaneously. + + + Indicates the maximum time taken for updated TLS server settings to take effect. + + @@ -2913,9 +2918,9 @@ - Common functionality for all security configuraiton service parts. + Common functionality for all security configuration service parts. - Returns the capabilities of the security configuraiton service. The result is returned in a typed answer. + Returns the capabilities of the security configuration service. The result is returned in a typed answer. @@ -3316,13 +3321,13 @@ This operation assigns a key pair and certificate along with a certification path (certificate chain) to the TLS server on the device. - The TLS server shall use this information for key exchange during the TLS handshake, particularly for constructing server certificate messages as specified in RFC 4346 and RFC 2246.
+ The TLS server shall use this information for key exchange during the TLS handshake, particularly for constructing server certificate messages.
Certification paths are identified by their certification path IDs in the keystore. The first certificate in the certification path must be the TLS server certificate. Since each certificate has exactly one associated key pair, a reference to the key pair that is associated with the server certificate is not supplied explicitly. Devices shall obtain the private key or results of operations under the private key by suitable internal interaction with the keystore.
If a device chooses to perform a TLS key exchange based on the supplied certification path, it shall use the key pair that is associated with the server certificate for - key exchange and transmit the certification path to TLS clients as-is, i.e., the device shall not check conformance of the certification path to RFC 4346 norRFC 2246. + key exchange and transmit the certification path to TLS clients as-is. In order to use the server certificate during the TLS handshake, the corresponding private key is required. Therefore, if the key pair that is associated with the server certificate, i.e., the first certificate in the certification path, does not have an associated private key, the NoPrivateKey fault is produced and the certification path is not associated to the TLS server.
@@ -3356,7 +3361,7 @@ Since each certificate has exactly one associated key pair, a reference to the key pair that is associated with the new server certificate is not supplied explicitly. Devices shall obtain the private key or results of operations under the private key by suitable internal interaction with the keystore.
If a device chooses to perform a TLS key exchange based on the new certification path, it shall use the key pair that is associated with the server certificate - for key exchange and transmit the certification path to TLS clients as-is, i.e., the device shall not check conformance of the certification path to RFC 4346 norRFC 2246. + for key exchange and transmit the certification path to TLS clients as-is. In order to use the server certificate during the TLS handshake, the corresponding private key is required. Therefore, if the key pair that is associated with the server certificate, i.e., the first certificate in the certification path, does not have an associated private key, the NoPrivateKey fault is produced and the certification path is not associated to the TLS server. diff --git a/onvif/wsdl/ver10/appmgmt/wsdl/appmgmt.wsdl b/onvif/wsdl/ver10/appmgmt/wsdl/appmgmt.wsdl index 848d20c..b3f59de 100644 --- a/onvif/wsdl/ver10/appmgmt/wsdl/appmgmt.wsdl +++ b/onvif/wsdl/ver10/appmgmt/wsdl/appmgmt.wsdl @@ -395,7 +395,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO
- Returns the capabilities of the uplink service. + Returns the capabilities of the app management service. diff --git a/onvif/wsdl/ver10/events/wsdl/event-vs.wsdl b/onvif/wsdl/ver10/events/wsdl/event-vs.wsdl index 878053a..eec05d3 100644 --- a/onvif/wsdl/ver10/events/wsdl/event-vs.wsdl +++ b/onvif/wsdl/ver10/events/wsdl/event-vs.wsdl @@ -121,7 +121,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Current time of the server for synchronization purposes. + Current time of the device for synchronization purposes. @@ -156,7 +156,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - The date and time when the messages have been delivered by the web server to the client. + Current time of the device for synchronization purposes. diff --git a/onvif/wsdl/ver10/events/wsdl/event.wsdl b/onvif/wsdl/ver10/events/wsdl/event.wsdl index 619211d..65f1aeb 100644 --- a/onvif/wsdl/ver10/events/wsdl/event.wsdl +++ b/onvif/wsdl/ver10/events/wsdl/event.wsdl @@ -121,7 +121,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Current time of the server for synchronization purposes. + Current time of the device for synchronization purposes. @@ -156,7 +156,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - The date and time when the messages have been delivered by the web server to the client. + Current time of the device for synchronization purposes. diff --git a/onvif/wsdl/ver10/pacs/types.xsd b/onvif/wsdl/ver10/pacs/types.xsd index 7e37002..3a67b81 100644 --- a/onvif/wsdl/ver10/pacs/types.xsd +++ b/onvif/wsdl/ver10/pacs/types.xsd @@ -46,7 +46,7 @@ CERTAIN WRITTEN POLICIES OF THE CORPORATION. - General datastructure referenced by a token. + General data structure referenced by a token. Should be used as extension base. @@ -109,7 +109,7 @@ CERTAIN WRITTEN POLICIES OF THE CORPORATION. - Value of attribute + Value of attribute. diff --git a/onvif/wsdl/ver10/recording.wsdl b/onvif/wsdl/ver10/recording.wsdl index 7f67d64..fe42ecc 100644 --- a/onvif/wsdl/ver10/recording.wsdl +++ b/onvif/wsdl/ver10/recording.wsdl @@ -1043,8 +1043,9 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO
  • META001 Metadata
  • - All tracks created as response to a CreateRecording request shall have the MaximumRetentionTime set to 0 (unlimited), and the - Description set to the empty string, by default. + The created recording shall have MaximumRetentionTime set to the value specified in the RecordingConfiguration supplied to CreateRecording. If that + value is PT0S, the recording shall have unlimited retention. All tracks created as part of the request shall have the Description set to the empty + string by default. diff --git a/onvif/wsdl/ver10/schema/common.xsd b/onvif/wsdl/ver10/schema/common.xsd index 1279e4f..0a30abe 100644 --- a/onvif/wsdl/ver10/schema/common.xsd +++ b/onvif/wsdl/ver10/schema/common.xsd @@ -295,7 +295,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Hight in meters above sea level. + Height in meters above sea level. diff --git a/onvif/wsdl/ver10/schema/metadatastream.xsd b/onvif/wsdl/ver10/schema/metadatastream.xsd index eb3933f..8e79b04 100644 --- a/onvif/wsdl/ver10/schema/metadatastream.xsd +++ b/onvif/wsdl/ver10/schema/metadatastream.xsd @@ -74,7 +74,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - + @@ -98,17 +98,17 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Information encoded in barcode + Information encoded in the barcode. - + - Acceptable values are defined in tt:BarcodeType + Acceptable values are defined in tt:BarcodeType. - Refers to the pixels per module + Refers to the pixels per module. @@ -147,7 +147,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - A string of vehicle license plate number. + A string of vehicle license plate number. @@ -162,7 +162,12 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - State province or authority that issue the license plate. + State, province or authority that issues the license plate. + + + + + Describe the background color of the license plate in case of vehicle objects. @@ -192,10 +197,11 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - + + @@ -221,7 +227,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - For well-defined values see tt:ObjectType. Other type definitions like tt:VehicleType may be applied as well. + For well-defined values, see tt:ObjectType. Other type definitions like tt:VehicleType may be applied as well. @@ -249,7 +255,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - A likelihood/probability that the corresponding object belongs to this class. The sum of the likelihoods shall NOT exceed 1 + A likelihood/probability that the corresponding object belongs to this class. The sum of the likelihoods shall NOT exceed 1. @@ -267,12 +273,12 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Object ID of the parent object. eg: License plate object has Vehicle object as parent. + Object ID of the parent object. E.g. a license plate object has a vehicle object as parent. - Object UUID of the parent object. eg: License plate object has Vehicle object as parent. + Object UUID of the parent object. E.g. a license plate object has a vehicle object as parent. @@ -407,7 +413,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - A "1" denotes a cell where motion is detected and a "0" an empty cell. The first cell is in the upper left corner. Then the cell order goes first from left to right and then from up to down. If the number of cells is not a multiple of 8 the last byte is filled with zeros. The information is run length encoded according to Packbit coding in ISO 12369 (TIFF, Revision 6.0). + A "1" denotes a cell where motion is detected and a "0" denotes an empty cell. The first cell is in the upper-left corner. Then the cell order goes first from left to right and then from up to down. If the number of cells is not a multiple of 8, the last byte is filled with zeros. The information is run-length encoded according to PackBits coding in ISO 12369 (TIFF, Revision 6.0). @@ -415,7 +421,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - An Object track includes a sequence of object states representing how an object's state changes over time. + An object track includes a sequence of object states representing how an object's state changes over time. @@ -425,7 +431,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - An object state describes an object's condition, for e.g position, speed and appearance, at a certain time instance. + An object state describes an object's condition, e.g position, speed and appearance, at a certain time instance. diff --git a/onvif/wsdl/ver10/schema/onvif.xsd b/onvif/wsdl/ver10/schema/onvif.xsd index 7fe918a..7865587 100644 --- a/onvif/wsdl/ver10/schema/onvif.xsd +++ b/onvif/wsdl/ver10/schema/onvif.xsd @@ -903,7 +903,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Supported encoding interval range. The encoding interval corresponds to the number of frames devided by the encoded frames. An encoding interval value of "1" means that all frames are encoded. + Supported encoding interval range. The encoding interval corresponds to the number of frames divided by the encoded frames. An encoding interval value of "1" means that all frames are encoded. @@ -944,7 +944,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Supported encoding interval range. The encoding interval corresponds to the number of frames devided by the encoded frames. An encoding interval value of "1" means that all frames are encoded. + Supported encoding interval range. The encoding interval corresponds to the number of frames divided by the encoded frames. An encoding interval value of "1" means that all frames are encoded. @@ -990,7 +990,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Supported encoding interval range. The encoding interval corresponds to the number of frames devided by the encoded frames. An encoding interval value of "1" means that all frames are encoded. + Supported encoding interval range. The encoding interval corresponds to the number of frames divided by the encoded frames. An encoding interval value of "1" means that all frames are encoded. @@ -1337,7 +1337,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - The enoding used for audio data (either G.711, G.726 or AAC). + The encoding used for audio data (either G.711, G.726 or AAC). @@ -1666,7 +1666,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - True if the device is able to stream zoom status inforamtion. + True if the device is able to stream zoom status information. @@ -2395,7 +2395,7 @@ decoding .A decoder shall decode every data it receives (according to its capabi - The RTP mutlicast destination port. A device may support RTCP. In this case the port value shall be even to allow the corresponding RTCP stream to be mapped to the next higher (odd) destination port number as defined in the RTSP specification. + The RTP multicast destination port. A device may support RTCP. In this case the port value shall be even to allow the corresponding RTCP stream to be mapped to the next higher (odd) destination port number as defined in the RTSP specification. @@ -2743,7 +2743,7 @@ decoding .A decoder shall decode every data it receives (according to its capabi - Indicates whether router advertisment is used. + Indicates whether router advertisement is used. @@ -2768,7 +2768,7 @@ decoding .A decoder shall decode every data it receives (according to its capabi - List of IPv6 addresses configured by using router advertisment. + List of IPv6 addresses configured by using router advertisement. @@ -3122,7 +3122,7 @@ decoding .A decoder shall decode every data it receives (according to its capabi - Indicates whether router advertisment is used. + Indicates whether router advertisement is used. @@ -3320,7 +3320,7 @@ decoding .A decoder shall decode every data it receives (according to its capabi According to IEEE802.11-2007 H.4.1 a pass-phrase is a sequence of between 8 and 63 ASCII-encoded characters and each character in the pass-phrase must have an encoding in the range of 32 to 126 (decimal),inclusive.
    - If only Passpharse is supplied the Key shall be derived using the algorithm described in IEEE802.11-2007 section H.4 + If only Passphrase is supplied the Key shall be derived using the algorithm described in IEEE802.11-2007 section H.4
    @@ -4133,12 +4133,12 @@ decoding .A decoder shall decode every data it receives (according to its capabi - General date time inforamtion returned by the GetSystemDateTime method. + General date time information returned by the GetSystemDateTime method. - Indicates if the time is set manully or through NTP. + Indicates if the time is set manually or through NTP. @@ -4454,7 +4454,7 @@ decoding .A decoder shall decode every data it receives (according to its capabi - Confgiuration information for TLS Method. + Configuration information for TLS Method. @@ -5418,7 +5418,7 @@ decoding .A decoder shall decode every data it receives (according to its capabi - An option to indicate Home postion for tour spots. + An option to indicate Home position for tour spots. @@ -5739,7 +5739,7 @@ If set to 0.0, infinity will be used. - Enumeration describing the available backlight compenstation modes. + Enumeration describing the available backlight compensation modes. @@ -6624,7 +6624,7 @@ If set to 0.0, infinity will be used. - Supported options of boundary types for adjustment of Ir cut filter auto mode. The opptions shall be chosen from tt:IrCutFilterAutoBoundaryType. + Supported options of boundary types for adjustment of Ir cut filter auto mode. The options shall be chosen from tt:IrCutFilterAutoBoundaryType. @@ -7146,7 +7146,7 @@ If set to 0.0, infinity will be used. - Description of a simple item. The type must be of cathegory simpleType (xs:string, xs:integer, xs:float, ...). + Description of a simple item. The type must be of category simpleType (xs:string, xs:integer, xs:float, ...). @@ -7496,7 +7496,7 @@ and sample rate. - Describes the location and size of the area on the monitor. The area coordinate values are espressed in normalized units [-1.0, 1.0]. + Describes the location and size of the area on the monitor. The area coordinate values are expressed in normalized units [-1.0, 1.0]. @@ -7506,7 +7506,7 @@ and sample rate. - A layout describes a set of Video windows that are displayed simultaniously on a display. + A layout describes a set of Video windows that are displayed simultaneously on a display. @@ -7542,7 +7542,7 @@ and sample rate. - This section describes the supported video codesc and their configuration. + This section describes the supported video codecs and their configuration. @@ -7911,7 +7911,7 @@ and sample rate. - The time when the event occured. + The time when the event occurred. @@ -8136,7 +8136,7 @@ and sample rate. - A set of informative desciptions of a data source. The Search searvice allows a client to filter on recordings based on information in this structure. + A set of informative descriptions of a data source. The Search service allows a client to filter on recordings based on information in this structure. @@ -8296,12 +8296,12 @@ and sample rate. - Path prefix to be inserted in the object key. + Path prefix to be inserted in the object key. A device shall support at least 128 characters. - Path postfix to be inserted in the object key. + Path postfix to be inserted in the object key. A device shall support at least 128 characters. @@ -9295,7 +9295,7 @@ and sample rate. - The value range of "Transparent" could be defined by vendors only should follow this rule: the minimum value means non-transparent and the maximum value maens fully transparent. + The value range of "Transparent" could be defined by vendors only and should follow this rule: the minimum value means non-transparent and the maximum value means fully transparent. @@ -9449,7 +9449,7 @@ and sample rate. - Range of the transparent level. Larger means more tranparent. + Range of the transparent level. Larger means more transparent. diff --git a/onvif/wsdl/ver20/analytics/humanbody.xsd b/onvif/wsdl/ver20/analytics/humanbody.xsd index 24ff7bc..2027819 100644 --- a/onvif/wsdl/ver20/analytics/humanbody.xsd +++ b/onvif/wsdl/ver20/analytics/humanbody.xsd @@ -28,12 +28,12 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Describe the Stature of the body, the unit is centimeter. + Describe the stature of the body, the unit is centimeters. - Describle the Shape of the body, acceptable values are defined in bd:BodyShape. + Describe the shape of the body, acceptable values are defined in bd:BodyShape. @@ -199,27 +199,27 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Describe the Scarf of the body,acceptable values are defined in bd:Scarf. + Describe the Scarf of the body, acceptable values are defined in bd:Scarf. - Describe Gloves of the body,acceptable values are defined in bd:Gloves. + Describe Gloves of the body, acceptable values are defined in bd:Gloves. - Describe the Tops of the body,acceptable values are defined in bd:Tops. + Describe the Tops of the body, acceptable values are defined in bd:Tops. - Describe the Bottoms of the body,acceptable values are defined in bd:Bottoms. + Describe the Bottoms of the body, acceptable values are defined in bd:Bottoms. - Describe the Shoes of the body,acceptable values are defined in bd:Shoes. + Describe the Shoes of the body, acceptable values are defined in bd:Shoes. @@ -246,7 +246,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Describe the Colour of the Bag, acceptable values are defined in tt:ColorDescriptor. + Describe the Color of the Bag, acceptable values are defined in tt:ColorDescriptor. @@ -257,7 +257,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Describe the Color of the Bag, acceptable values are defined in tt:ColorDescriptor. + Describe the Color of the Umbrella, acceptable values are defined in tt:ColorDescriptor. @@ -328,17 +328,17 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Describe the Category of the Weapon, acceptable values are defined in bd:WeaponCategory. + Describe the Category of the weapon, acceptable values are defined in bd:WeaponCategory. - Describe the Color of the weapon, acceptable values are defined in tt:ColorDescriptor. + Describe the Color of the weapon, acceptable values are defined in tt:ColorDescriptor. - Describes how the weapon is held. Acceptable values are defined in bd:WeaponPose. + Describes how the weapon is held, acceptable values are defined in bd:WeaponPose. @@ -349,12 +349,12 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Describe the Bag of the body,acceptable values are defined in bd:Bag + Describe the Bag of the body, acceptable values are defined in bd:Bag - Describe the Umbrella carried by the body,acceptable values are defined in bd:Umbrella. + Describe the Umbrella carried by the body, acceptable values are defined in bd:Umbrella. @@ -364,12 +364,12 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Describe the Box luffed by the body,acceptable values are defined in bd:Box. + Describe the Box lugged by the body, acceptable values are defined in bd:Box. - Describe the Cart pushed by the body,acceptable values are defined in bd:Cart. + Describe the Cart pushed by the body, acceptable values are defined in bd:Cart. @@ -401,7 +401,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Describe a waepon the body carries. + Describe a weapon the body carries. @@ -449,7 +449,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Describe the activity of the body, Acceptable values are defined in bd:HumanActivity. + Describe the activity of the body, acceptable values are defined in bd:HumanActivity. diff --git a/onvif/wsdl/ver20/analytics/humanface.xsd b/onvif/wsdl/ver20/analytics/humanface.xsd index 6507908..8222cf7 100644 --- a/onvif/wsdl/ver20/analytics/humanface.xsd +++ b/onvif/wsdl/ver20/analytics/humanface.xsd @@ -78,7 +78,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Describe the bangs of the Hair + Describe the bangs of the Hair. @@ -99,8 +99,8 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Describe the shape of the eyebrow, Short usually means that the width of eye brow is shorter than the length of eye. - Long usually means that the width of eye brow is equal to or longer than the length of eye.acceptable values are defined in fc:EyebrowWidth. + Describe the shape of the eyebrow. Short usually means that the width of the eyebrow is shorter than the length of the eye. + Long usually means that the width of eyebrow is equal to or longer than the length of eye. Acceptable values are defined in fc:EyebrowWidth. @@ -233,17 +233,17 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Describe if there is mustache on the face. + Describe if there is a mustache on the face. - Describe if there are Beard on the face. + Describe if there is a beard on the face. - Describe if there are sideburns on the face. + Describe if there are sideburns on the face. @@ -309,7 +309,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Describe if the object wear a accessory . + Describe if the object wears an accessory. @@ -331,32 +331,32 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Describe if the object wear opticals. + Describe if the object wears opticals. - Describe if the object wear hat. + Describe if the object wears a hat. - Describe if the object wear mask. + Describe if the object wears a mask. - Describe if the object wear hijab. + Describe if the object wears a hijab. - Describe if the object wear Helmet. + Describe if the object wears a helmet. - Describe if the object wear Kerchief. + Describe if the object wears a kerchief. @@ -385,17 +385,17 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Is there scar on the face. + Describe if there is a scar on the face. - Is there mole on the face. + Describe if there is a mole on the face. - Is there Tattoo on the face. + Describe if there is a tattoo on the face. @@ -431,7 +431,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Describe the FacialShape, acceptable values are defined fc:FacialShape. + Describe the facial shape, acceptable values are defined in fc:FacialShape. @@ -491,7 +491,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Describe the other features, eg scar, mole etc of the face. + Describe the other features, e.g. scars, moles, etc. of the face. diff --git a/onvif/wsdl/ver20/analytics/radiometry.xsd b/onvif/wsdl/ver20/analytics/radiometry.xsd index 08c5d3c..fd90579 100644 --- a/onvif/wsdl/ver20/analytics/radiometry.xsd +++ b/onvif/wsdl/ver20/analytics/radiometry.xsd @@ -265,7 +265,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Specifies valid rule conditions for temperature comparisions in radiometric rules. + Specifies valid rule conditions for temperature comparisons in radiometric rules. @@ -363,7 +363,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Indicates the width in Kelvin of the temerature hysteresis band to be considered by the rule. + Indicates the width in Kelvin of the temperature hysteresis band to be considered by the rule. diff --git a/onvif/wsdl/ver20/analytics/rules.xsd b/onvif/wsdl/ver20/analytics/rules.xsd index 3b0a2d4..ee084e3 100644 --- a/onvif/wsdl/ver20/analytics/rules.xsd +++ b/onvif/wsdl/ver20/analytics/rules.xsd @@ -53,7 +53,7 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - Indicates the device can only support one sensitivity level for all defines + Indicates the device can only support one sensitivity level for all defined motion detection regions. Changing the sensitivity for one region would be applied to all regions. diff --git a/onvif/wsdl/ver20/media/wsdl/media.wsdl b/onvif/wsdl/ver20/media/wsdl/media.wsdl index 17f6d4c..9b17c78 100644 --- a/onvif/wsdl/ver20/media/wsdl/media.wsdl +++ b/onvif/wsdl/ver20/media/wsdl/media.wsdl @@ -1924,10 +1924,10 @@ IN NO EVENT WILL THE CORPORATION OR ITS MEMBERS OR THEIR AFFILIATES BE LIABLE FO - + - + @@ -2341,10 +2341,10 @@ support streaming video data of such a profile.
    - + This command is to configure Audio EQPreset. - - + + @@ -2758,8 +2758,8 @@ image will be updated automatically and independent from calls to GetSnapshotUri - - + + From b5a117e0f5a2460033b9a067234eb712a30dcf55 Mon Sep 17 00:00:00 2001 From: kaburagisec <135169292+kaburagisec@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:22:28 +0700 Subject: [PATCH 02/43] docs: Update operation docs based on wsdl change --- onvif/services/recording.py | 7 ++++--- onvif/services/security/tlsserver.py | 19 ++++++++----------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/onvif/services/recording.py b/onvif/services/recording.py index bd4e6ee..e5299f0 100644 --- a/onvif/services/recording.py +++ b/onvif/services/recording.py @@ -51,9 +51,10 @@ def CreateRecording(self, RecordingConfiguration): - AUDIO001 Audio - META001 Metadata - All tracks created as response to a CreateRecording request shall have the - MaximumRetentionTime set to 0 (unlimited), and the Description set to the empty - string, by default. + The created recording shall have MaximumRetentionTime set to the value specified in + the RecordingConfiguration supplied to CreateRecording. If that value is PT0S, the + recording shall have unlimited retention. All tracks created as part of the request + shall have the Description set to the empty string by default. """ return self.operator.call( "CreateRecording", RecordingConfiguration=RecordingConfiguration diff --git a/onvif/services/security/tlsserver.py b/onvif/services/security/tlsserver.py index e58ef2a..3ed8da2 100644 --- a/onvif/services/security/tlsserver.py +++ b/onvif/services/security/tlsserver.py @@ -30,9 +30,8 @@ def AddServerCertificateAssignment(self, CertificationPathID): """This operation assigns a key pair and certificate along with a certification path (certificate chain) to the TLS server on the device. - The TLS server shall use this information for key exchange during the TLS - handshake, particularly for constructing server certificate messages as - specified in RFC 4346 and RFC 2246. + The TLS server shall use this information for key exchange during the TLS handshake, + particularly for constructing server certificate messages. Certification paths are identified by their certification path IDs in the keystore. The first certificate in the certification path must be the TLS server @@ -44,14 +43,12 @@ def AddServerCertificateAssignment(self, CertificationPathID): If a device chooses to perform a TLS key exchange based on the supplied certification path, it shall use the key pair that is associated with the server - certificate for key exchange and transmit the certification path to TLS clients - as-is, i.e., the device shall not check conformance of the certification path to - RFC 4346 norRFC 2246. In order to use the server certificate during the TLS - handshake, the corresponding private key is required. Therefore, if the key pair - that is associated with the server certificate, i.e., the first certificate in - the certification path, does not have an associated private key, the - NoPrivateKey fault is produced and the certification path is not associated to - the TLS server. + certificate for key exchange and transmit the certification path to TLS clients as-is. + In order to use the server certificate during the TLS handshake, the corresponding + private key is required. Therefore, if the key pair that is associated with the server + certificate, i.e., the first certificate in the certification path, does not have an + associated private key, the NoPrivateKey fault is produced and the certification path + is not associated to the TLS server. A TLS server may present different certification paths to different clients during the TLS handshake instead of presenting the same certification path to From 5a594b8bb97f8fa9f0fec8a80a8974ed25041bd5 Mon Sep 17 00:00:00 2001 From: kaburagisec <135169292+kaburagisec@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:32:53 +0700 Subject: [PATCH 03/43] refactor(media2): Rename `SetEQPreset` to `SetEQPresetConfiguration` in Media2 Class --- onvif/services/media2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/onvif/services/media2.py b/onvif/services/media2.py index 31bc7d4..219c37d 100644 --- a/onvif/services/media2.py +++ b/onvif/services/media2.py @@ -286,9 +286,9 @@ def SetAudioDecoderConfiguration(self, Configuration): "SetAudioDecoderConfiguration", Configuration=Configuration ) - def SetEQPreset(self, Configuration): + def SetEQPresetConfiguration(self, Configuration): """This command is to configure Audio EQPreset.""" - return self.operator.call("SetEQPreset", Configuration=Configuration) + return self.operator.call("SetEQPresetConfiguration", Configuration=Configuration) def GetVideoSourceConfigurationOptions( self, ConfigurationToken=None, ProfileToken=None From 2bf1e509af8f11c6b1480c108b80ccd0207dbdbd Mon Sep 17 00:00:00 2001 From: kaburagisec <135169292+kaburagisec@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:38:29 +0700 Subject: [PATCH 04/43] style: Run `black` on `Media2` --- onvif/services/media2.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/onvif/services/media2.py b/onvif/services/media2.py index 219c37d..eb8d17b 100644 --- a/onvif/services/media2.py +++ b/onvif/services/media2.py @@ -288,7 +288,9 @@ def SetAudioDecoderConfiguration(self, Configuration): def SetEQPresetConfiguration(self, Configuration): """This command is to configure Audio EQPreset.""" - return self.operator.call("SetEQPresetConfiguration", Configuration=Configuration) + return self.operator.call( + "SetEQPresetConfiguration", Configuration=Configuration + ) def GetVideoSourceConfigurationOptions( self, ConfigurationToken=None, ProfileToken=None From 06a50a619c49f4495b58e683bb4679bb4564f4d3 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:03:39 +0700 Subject: [PATCH 05/43] feat(client): Add support for user-provided `Zeep` plugins as attribute in `ONVIFClient` (#115) --- onvif/client.py | 2 ++ tests/conftest.py | 2 ++ tests/test_client.py | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+) diff --git a/onvif/client.py b/onvif/client.py index 49845a8..75e9af5 100644 --- a/onvif/client.py +++ b/onvif/client.py @@ -118,6 +118,7 @@ class ONVIFClient: services (list): List of available services from `GetServices` response capabilities (CompoundValue): Device capabilities from `GetCapabilities` response (fallback) xml_plugin (XMLCapturePlugin): XML capture plugin for debugging (if `capture_xml=True`) + plugins (list[Plugin] | None): List of user-provided Zeep plugins (if any) wsdl_dir (str | None): Custom WSDL directory path (if provided) !!! tip "Version History" @@ -183,6 +184,7 @@ def __init__( capture_xml, plugins, ) + self.plugins = plugins # Store custom WSDL directory if provided self.wsdl_dir: str | None = wsdl_dir diff --git a/tests/conftest.py b/tests/conftest.py index c47990f..560db29 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -100,6 +100,7 @@ def mock_onvif_client(): password="admin123", timeout=5, cache=CacheMode.NONE, + plugins=None, ) client._devicemgmt = mock_device @@ -127,4 +128,5 @@ def test_client_params(): "password": "admin123", "timeout": 5, "cache": CacheMode.NONE, + "plugins": None, } diff --git a/tests/test_client.py b/tests/test_client.py index 75d0127..d8eeb9b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3,9 +3,11 @@ from unittest.mock import MagicMock, Mock, patch import pytest +from zeep import Plugin from onvif import CacheMode, ONVIFClient from onvif.utils import ONVIFOperationException, XMLCapturePlugin, ZeepPatcher +from onvif.utils.plugins import ReferenceParametersPlugin class TestONVIFClientInitialization: @@ -22,6 +24,7 @@ def test_basic_initialization(self, test_client_params): assert client.common_args["password"] == "admin123" assert client.common_args["timeout"] == 5 assert client.common_args["cache"] == CacheMode.NONE + assert client.common_args["plugins"] is None def test_https_initialization(self, test_client_params): """Test HTTPS client initialization.""" @@ -45,6 +48,22 @@ def test_xml_capture_initialization(self, test_client_params): assert client.xml_plugin is not None assert isinstance(client.xml_plugin, XMLCapturePlugin) + def test_plugins_initialization(self, test_client_params): + """Test plugins initialization.""" + params = test_client_params.copy() + params["capture_xml"] = True + params["plugins"] = [ReferenceParametersPlugin()] + + with patch("onvif.client.Device"): + client = ONVIFClient(**params) + + assert len(client.plugins) == 1 + assert isinstance(client.plugins[0], ReferenceParametersPlugin) + assert issubclass(ReferenceParametersPlugin, Plugin) + assert isinstance( + client.xml_plugin, XMLCapturePlugin + ) # Ensure XML plugin is still initialized + def test_custom_wsdl_directory(self, test_client_params): """Test custom WSDL directory setup.""" params = test_client_params.copy() From 2d24b956dc2e78ae5d715ce0a08c8399a70a59c6 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:39:06 +0700 Subject: [PATCH 06/43] chore: Add `codespell` to dev dependencies (#116) --- pyproject.toml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 62f17f8..75ed376 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ dev = [ "isort", "pylint", "docformatter", + "codespell", "lxml-stubs", "types-setuptools", "types-requests", @@ -150,4 +151,9 @@ omit = [ "device-test/*", "examples/*", "docs/*", -] \ No newline at end of file +] + +[tool.codespell] +skip = "*.wsdl, *.xsd, *.xsl, *.html, *.json, *.js, *.css, *.map" +count = true +quiet-level = 3 \ No newline at end of file From b3a4f54f372cfe8fd3ec11899283ac7d99c684ca Mon Sep 17 00:00:00 2001 From: kaburagisec <135169292+kaburagisec@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:48:32 +0700 Subject: [PATCH 07/43] ci: Update workflow to include `codespell` check --- .github/workflows/python-app.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index ecc02da..493e622 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -1,6 +1,12 @@ name: Python Application on: + push: + branches: [ "main" ] + paths: + - "onvif/**" + - "tests/**" + - "examples/**" pull_request: branches: [ "main", "dev" ] paths: @@ -46,6 +52,9 @@ jobs: - name: Check imports order with isort run: isort --check-only . + - name: Check spelling with codespell + run: codespell . + - name: Lint with pylint run: pylint . From bf0fd3e5614c82906c5ea6a97532c6bce1768a4e Mon Sep 17 00:00:00 2001 From: kaburagisec <135169292+kaburagisec@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:49:15 +0700 Subject: [PATCH 08/43] ci: Add action versions comment in Python publish workflow --- .github/workflows/python-publish.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 5475ef7..310f460 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -12,9 +12,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python 3.x - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.x" @@ -25,7 +25,7 @@ jobs: python -m build - name: Upload distributions - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-dists path: dist/ @@ -46,12 +46,12 @@ jobs: steps: - name: Retrieve release distributions - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-dists path: dist/ - name: Publish release distributions to PyPI - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: packages-dir: dist/ From 56cfff1fb18257baa9923cb7f9a625062ba002bb Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:00:49 +0700 Subject: [PATCH 09/43] docs: Correct misspelling and typo in services docstring (#118) --- onvif/services/deviceio.py | 6 +++--- onvif/services/devicemgmt.py | 6 +++--- onvif/services/media2.py | 6 +++--- onvif/services/ptz.py | 2 +- onvif/services/recording.py | 2 +- onvif/services/security/tlsserver.py | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/onvif/services/deviceio.py b/onvif/services/deviceio.py index b476fe5..736db43 100644 --- a/onvif/services/deviceio.py +++ b/onvif/services/deviceio.py @@ -75,7 +75,7 @@ def GetAudioSources(self): def GetAudioOutputs(self): """List all available audio outputs of a device. - A device that has one ore more physical audio outputs shall support listing of + A device that has one or more physical audio outputs shall support listing of available audio outputs through the GetAudioOutputs command. """ return self.operator.call("GetAudioOutputs") @@ -185,7 +185,7 @@ def SetAudioSourceConfiguration(self, Configuration, ForcePersistence): def SetAudioOutputConfiguration(self, Configuration, ForcePersistence): """Modify an audio output configuration. - A device that has one ore more audio outputs shall support the setting of the + A device that has one or more audio outputs shall support the setting of the AudioOutputConfiguration through this command. This method is deprecated. @@ -220,7 +220,7 @@ def GetVideoOutputConfigurationOptions(self, VideoOutputToken): def GetAudioSourceConfigurationOptions(self, AudioSourceToken): """Request the AudioSourceConfigurationOptions of an AudioSource. - A device with one ore more AudioSources shall support this command. + A device with one or more AudioSources shall support this command. This method is deprecated. """ diff --git a/onvif/services/devicemgmt.py b/onvif/services/devicemgmt.py index 3ade26c..6dfcc2b 100644 --- a/onvif/services/devicemgmt.py +++ b/onvif/services/devicemgmt.py @@ -166,7 +166,7 @@ def GetSystemLog(self, LogType): return self.operator.call("GetSystemLog", LogType=LogType) def GetSystemSupportInformation(self): - """This operation gets arbitary device diagnostics information from the + """This operation gets arbitrary device diagnostics information from the device.""" return self.operator.call("GetSystemSupportInformation") @@ -469,7 +469,7 @@ def SetHostname(self, Name): It shall be possible to set the device hostname configurations through the SetHostname command. - A device shall accept string formated according to RFC 1123 section 2.1 or + A device shall accept string formatted according to RFC 1123 section 2.1 or alternatively to RFC 952, other string shall be considered as invalid strings. """ return self.operator.call("SetHostname", Name=Name) @@ -510,7 +510,7 @@ def SetNTP(self, FromDHCP, NTPManual=None): If the device supports NTP, it shall be possible to set the NTP server settings through the SetNTP command. - A device shall accept string formated according to RFC 1123 section 2.1 or + A device shall accept string formatted according to RFC 1123 section 2.1 or alternatively to RFC 952, other string shall be considered as invalid strings. Changes to the NTP server list will not affect the clock mode DateTimeType. Use diff --git a/onvif/services/media2.py b/onvif/services/media2.py index eb8d17b..789ac9b 100644 --- a/onvif/services/media2.py +++ b/onvif/services/media2.py @@ -38,8 +38,8 @@ def CreateProfile(self, Name, Configuration=None): """This operation creates a new media profile. A created profile created via this method may be deleted via the DeleteProfile - method. Optionally Configurations can be assinged to the profile on creation. - For details regarding profile assignement check also the method + method. Optionally Configurations can be assigned to the profile on creation. + For details regarding profile assignment check also the method AddConfiguration. """ return self.operator.call( @@ -632,7 +632,7 @@ def PlayAudioClip(self, Token, Play, AudioOutputToken=None, RepeatCycles=None): ) def AddTTSAudioClip(self, Configuration, TTSConfiguration, Token=None): - """This operation sends a text and its configuartion to device that supports TTS + """This operation sends a text and its configuration to device that supports TTS function, so that device could convert the text into an audio clip and play it according to audio clip Configuration and TTS Configuration.""" return self.operator.call( diff --git a/onvif/services/ptz.py b/onvif/services/ptz.py index 7d3331d..5758224 100644 --- a/onvif/services/ptz.py +++ b/onvif/services/ptz.py @@ -89,7 +89,7 @@ def SetPreset(self, ProfileToken, PresetName=None, PresetToken=None): def RemovePreset(self, ProfileToken, PresetToken): """Operation to remove a PTZ preset for the Node in the selected profile. - The operation is supported if the PresetPosition capability exists for teh Node + The operation is supported if the PresetPosition capability exists for the Node in the selected profile. """ return self.operator.call( diff --git a/onvif/services/recording.py b/onvif/services/recording.py index e5299f0..fc958de 100644 --- a/onvif/services/recording.py +++ b/onvif/services/recording.py @@ -101,7 +101,7 @@ def GetRecordingOptions(self, RecordingToken): """GetRecordingOptions returns information for a recording identified by the RecordingToken. - The information includes the number of additonal tracks as well as recording + The information includes the number of additional tracks as well as recording jobs that can be configured. """ return self.operator.call("GetRecordingOptions", RecordingToken=RecordingToken) diff --git a/onvif/services/security/tlsserver.py b/onvif/services/security/tlsserver.py index 3ed8da2..7110f67 100644 --- a/onvif/services/security/tlsserver.py +++ b/onvif/services/security/tlsserver.py @@ -133,7 +133,7 @@ def SetEnabledTLSVersions(self, Versions): """This operation sets the version(s) of TLS which the device shall use. Valid values are taken from the TLSServerSupported capability. A client - initiates a TLS session by sending a ClientHello with the hightest TLS version + initiates a TLS session by sending a ClientHello with the highest TLS version it supports. This suggests to the server that the client can accept any TLS version up to and including that version. From 6cf314a0086f954c32ab5354a550daa356f4a9f3 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:37:49 +0700 Subject: [PATCH 10/43] test: Complete tests for core modules (#119) --- tests/test_client.py | 50 +- tests/test_client_services.py | 121 +++++ tests/test_core.py | 432 ++++------------ tests/test_meta.py | 26 + tests/test_operator.py | 946 ++++++++++++++++++++++++++++++++++ 5 files changed, 1234 insertions(+), 341 deletions(-) create mode 100644 tests/test_client_services.py create mode 100644 tests/test_meta.py create mode 100644 tests/test_operator.py diff --git a/tests/test_client.py b/tests/test_client.py index d8eeb9b..6e4a163 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,4 +1,4 @@ -"""Tests for ONVIFClient constructor.""" +"""Tests for ONVIFClient.""" from unittest.mock import MagicMock, Mock, patch @@ -11,7 +11,7 @@ class TestONVIFClientInitialization: - """Test ONVIF client initialization and configuration.""" + """Test ONVIFClient initialization and configuration.""" def test_basic_initialization(self, test_client_params): """Test basic client initialization.""" @@ -208,6 +208,40 @@ def test_pullpoint_caching_by_xaddr( assert client._pullpoints[cache_key] is pullpoint2 assert pullpoint2 is pullpoint1 + def test_get_xaddr_from_service(self, mock_onvif_client): + """Test retrieval of XAddr for a given service namespace.""" + client = mock_onvif_client + + assert client._get_xaddr("media", "Media") == ( + "http://192.168.1.17:8000/onvif/Media" + ) + + assert client._get_xaddr("ptz", "PTZ") == ("http://192.168.1.17:8000/onvif/PTZ") + + def test_get_xaddr_from_capabilities(self, mock_onvif_client): + """Test resolving XAddr from GetCapabilities response.""" + client = mock_onvif_client + + # Ignore XAddr values discovered from GetServices + client._service_map = {} + + assert client._get_xaddr("media", "Media") == ( + "http://192.168.1.17:8000/onvif/Media" + ) + + assert client._get_xaddr("ptz", "PTZ") == ("http://192.168.1.17:8000/onvif/PTZ") + + def test_get_xaddr_fallback(self, mock_onvif_client): + """Test resolving XAddr using the default URL.""" + client = mock_onvif_client + + client.services = [] + client.capabilities = None + + assert client._get_xaddr("media", "Media") == ( + "http://192.168.1.17:8000/onvif/Media" + ) + class TestONVIFClientErrorHandling: """Test error handling scenarios.""" @@ -256,6 +290,18 @@ def test_xaddr_rewriting_error_handling(self, mock_onvif_client): expected = "http://192.168.1.17:8000/onvif/device_service" assert result == expected + def test_rewrite_xaddr_if_needed_parse_error(self, mock_onvif_client, monkeypatch): + """Test that invalid XAddr parsing returns the original value.""" + client = mock_onvif_client + xaddr = "invalid-xaddr" + + def raise_value_error(_): + raise ValueError("Invalid XAddr") + + monkeypatch.setattr("onvif.client.urlparse", raise_value_error) + + assert client._rewrite_xaddr_if_needed(xaddr) == xaddr + class TestONVIFClientConfiguration: """Test client configuration options.""" diff --git a/tests/test_client_services.py b/tests/test_client_services.py new file mode 100644 index 0000000..91c580f --- /dev/null +++ b/tests/test_client_services.py @@ -0,0 +1,121 @@ +"""Tests for the ONVIFClient services initialization.""" + +import pytest + +from onvif.services import ( + JWT, + PTZ, + AccessControl, + AccessRules, + ActionEngine, + AdvancedSecurity, + Analytics, + AnalyticsDevice, + AppManagement, + AuthenticationBehavior, + AuthorizationServer, + Credential, + DeviceIO, + Display, + DoorControl, + Dot1X, + Events, + Imaging, + Keystore, + Media, + Media2, + MediaSigning, + Notification, + Provisioning, + Receiver, + Recording, + Replay, + RuleEngine, + Schedule, + Search, + Thermal, + TLSServer, + Uplink, +) + + +@pytest.mark.parametrize( + ("method_name", "private_name", "service_class", "service_path"), + [ + ("events", "_events", Events, "Events"), + ("notification", "_notification", Notification, "Notification"), + ("imaging", "_imaging", Imaging, "Imaging"), + ("media", "_media", Media, "Media"), + ("media2", "_media2", Media2, "Media2"), + ("ptz", "_ptz", PTZ, "PTZ"), + ("deviceio", "_deviceio", DeviceIO, "DeviceIO"), + ("display", "_display", Display, "Display"), + ("analytics", "_analytics", Analytics, "Analytics"), + ("ruleengine", "_ruleengine", RuleEngine, "RuleEngine"), + ("analyticsdevice", "_analyticsdevice", AnalyticsDevice, "AnalyticsDevice"), + ("accesscontrol", "_accesscontrol", AccessControl, "AccessControl"), + ("doorcontrol", "_doorcontrol", DoorControl, "DoorControl"), + ("accessrules", "_accessrules", AccessRules, "AccessRules"), + ("actionengine", "_actionengine", ActionEngine, "ActionEngine"), + ("appmanagement", "_appmanagement", AppManagement, "AppManagement"), + ( + "authenticationbehavior", + "_authenticationbehavior", + AuthenticationBehavior, + "AuthenticationBehavior", + ), + ("credential", "_credential", Credential, "Credential"), + ("recording", "_recording", Recording, "Recording"), + ("replay", "_replay", Replay, "Replay"), + ("provisioning", "_provisioning", Provisioning, "Provisioning"), + ("receiver", "_receiver", Receiver, "Receiver"), + ("schedule", "_schedule", Schedule, "Schedule"), + ("search", "_search", Search, "Search"), + ("thermal", "_thermal", Thermal, "Thermal"), + ("uplink", "_uplink", Uplink, "Uplink"), + ("security", "_security", AdvancedSecurity, "Security"), + ("jwt", "_jwt", JWT, "JWT"), + ("keystore", "_keystore", Keystore, "Keystore"), + ("tlsserver", "_tlsserver", TLSServer, "TLSServer"), + ("dot1x", "_dot1x", Dot1X, "Dot1X"), + ( + "authorizationserver", + "_authorizationserver", + AuthorizationServer, + "AuthorizationServer", + ), + ("mediasigning", "_mediasigning", MediaSigning, "MediaSigning"), + ], +) +def test_lazy_service_initialization( + mock_onvif_client, + monkeypatch, + method_name, + private_name, + service_class, + service_path, +): + """Test lazy initialization and caching of ONVIF services.""" + client = mock_onvif_client + + xaddr = f"http://192.168.1.17:8000/onvif/{service_path}" + + monkeypatch.setattr( + client, + "_get_xaddr", + lambda service, name: xaddr, + ) + + # Service has not been initialized yet. + setattr(client, private_name, None) + + # Call the service accessor to trigger lazy initialization. + service = getattr(client, method_name)() + + assert isinstance(service, service_class) + # pylint: disable=protected-access + assert client._get_xaddr(method_name, service_path) == xaddr + assert getattr(client, private_name) is service + + # Calling the accessor again must return the cached instance. + assert getattr(client, method_name)() is service diff --git a/tests/test_core.py b/tests/test_core.py index aa73476..b0acad9 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,4 +1,4 @@ -"""Tests for all ONVIF core modules.""" +"""Integration tests for ONVIF core components.""" from unittest.mock import Mock, patch @@ -11,365 +11,120 @@ from onvif.utils.zeep import ZeepPatcher -# pylint: disable=protected-access -class TestONVIFWSDL: - """Test ONVIF WSDL handling and management.""" - - def test_default_wsdl_directory(self): - """Test default WSDL directory setup.""" - # Test getting base directory (default behavior) - base_dir = ONVIFWSDL._get_base_dir() - - assert base_dir is not None - assert "wsdl" in base_dir.lower() - - def test_custom_wsdl_directory(self): - """Test custom WSDL directory setup.""" - custom_dir = "/custom/wsdl/path" - ONVIFWSDL.set_custom_wsdl_dir(custom_dir) +class TestCoreIntegration: + """Test integration between core ONVIF components.""" - retrieved_dir = ONVIFWSDL.get_custom_wsdl_dir() - assert retrieved_dir == custom_dir + def test_wsdl_definition_can_be_loaded(self): + """Test that the WSDL definition can be loaded for a valid service.""" + definition = ONVIFWSDL.get_definition("devicemgmt") - # Clean up - ONVIFWSDL.clear_custom_wsdl_dir() + assert definition["path"] + assert definition["namespace"] + assert definition["binding"] - def test_get_wsdl_definition_for_service(self): - """Test WSDL definition retrieval for different services.""" - # Test standard services using actual API from ONVIFWSDL class - try: - definition = ONVIFWSDL.get_definition("device", "ver20") - assert definition is not None - except ValueError: - # Some services might not be available in test environment - pass + def test_wsdl_definition_works_with_zeep_patcher(self): + """Test that the WSDL definition can be loaded with ZeepPatcher applied.""" + was_patched = ZeepPatcher.is_patched() try: - definition = ONVIFWSDL.get_definition("media", "ver10") - assert definition is not None - except ValueError: - pass - - def test_wsdl_map_initialization(self): - """Test WSDL map initialization.""" - # Ensure WSDL map is properly initialized - ONVIFWSDL._ensure_wsdl_map_initialized() - assert ONVIFWSDL.WSDL_MAP is not None - - def test_invalid_service_handling(self): - """Test handling of invalid service names.""" - with pytest.raises(ValueError): - ONVIFWSDL.get_definition("invalid_service", "ver10") - - def test_invalid_version_handling(self): - """Test handling of invalid version names.""" - with pytest.raises(ValueError): - ONVIFWSDL.get_definition("device", "ver99") - - -class TestZeepPatcher: - """Test ZeepPatcher functionality.""" - - def test_patch_application(self): - """Test patch application.""" - # Test that patch can be applied - ZeepPatcher.apply_patch() - assert ZeepPatcher.is_patched() - - # Clean up - ZeepPatcher.remove_patch() - - def test_patch_removal(self): - """Test patch removal.""" - # Apply first - ZeepPatcher.apply_patch() - assert ZeepPatcher.is_patched() - - # Then remove - ZeepPatcher.remove_patch() - assert not ZeepPatcher.is_patched() - - def test_patch_idempotency(self): - """Test that applying patch multiple times is safe.""" - # Apply patch multiple times - ZeepPatcher.apply_patch() - first_state = ZeepPatcher.is_patched() - - ZeepPatcher.apply_patch() # Second application - second_state = ZeepPatcher.is_patched() - - # Should remain consistent - assert first_state and second_state - - # Clean up - ZeepPatcher.remove_patch() - - def test_patch_state_management(self): - """Test patch state tracking.""" - # Test initial state - ZeepPatcher.is_patched() - - # Apply patch and test state - ZeepPatcher.apply_patch() - patched_state = ZeepPatcher.is_patched() - assert patched_state + ZeepPatcher.apply_patch() - # Remove patch and test state - ZeepPatcher.remove_patch() - removed_state = ZeepPatcher.is_patched() - assert not removed_state + definition = ONVIFWSDL.get_definition("devicemgmt") - def test_flatten_xsd_any_fields(self): - """Test flattening of xsd:any fields.""" - # Create a mock object with _value_1 field - mock_obj = Mock() - mock_obj.__values__ = { - "normal_field": "test_value", - "_value_1": {"parsed_field": "parsed_value"}, - } - mock_obj.__dict__ = { - "normal_field": "test_value", - "_value_1": None, # This should be filled - } + assert definition["path"] + assert definition["namespace"] + assert definition["binding"] + finally: + if not was_patched: + ZeepPatcher.remove_patch() - # Process the object - result = ZeepPatcher.flatten_xsd_any_fields(mock_obj) - - # Should return the same object (modified in place) - assert result == mock_obj - - -class TestXMLCapturePlugin: - """Test XML capture functionality.""" - - def test_plugin_initialization(self): - """Test XML capture plugin initialization.""" - plugin = XMLCapturePlugin() - - assert plugin is not None - assert hasattr(plugin, "last_sent_xml") - assert hasattr(plugin, "last_received_xml") - assert hasattr(plugin, "history") - assert plugin.last_sent_xml is None - assert plugin.last_received_xml is None - assert not plugin.history - - def test_plugin_initialization_with_options(self): - """Test XML capture plugin with custom options.""" - plugin = XMLCapturePlugin(pretty_print=False) - - assert not plugin.pretty_print - - def test_request_capture(self): - """Test XML request capture with egress method.""" + def test_xml_capture_plugin_can_capture_soap_exchange(self): + """Test that the XMLCapturePlugin can capture SOAP requests and responses.""" plugin = XMLCapturePlugin() - # Mock envelope and operation - mock_envelope = Mock() - mock_operation = Mock() - mock_operation.name = "GetDeviceInformation" - mock_headers = {"Content-Type": "application/soap+xml"} + envelope = Mock() + operation = Mock() + operation.name = "GetDeviceInformation" + headers = {"Content-Type": "application/soap+xml"} - # Mock the _format_xml method to avoid lxml dependency issues with patch.object( - plugin, "_format_xml", return_value="..." + plugin, + "_format_xml", + return_value="", ): - result = plugin.egress(mock_envelope, mock_headers, mock_operation, {}) - - # Should return envelope and headers unchanged - assert result == (mock_envelope, mock_headers) - assert plugin.last_operation == "GetDeviceInformation" - assert len(plugin.history) == 1 - assert plugin.history[0]["type"] == "request" - assert plugin.history[0]["operation"] == "GetDeviceInformation" - - def test_response_capture(self): - """Test XML response capture with ingress method.""" - plugin = XMLCapturePlugin() - - # Mock envelope and operation - mock_envelope = Mock() - mock_operation = Mock() - mock_operation.name = "GetDeviceInformation" - mock_headers = {"Content-Type": "application/soap+xml"} - - # Mock the _format_xml method to avoid lxml dependency issues - with patch.object( - plugin, "_format_xml", return_value="..." - ): - result = plugin.ingress(mock_envelope, mock_headers, mock_operation) - - # Should return envelope and headers unchanged - assert result == (mock_envelope, mock_headers) - assert len(plugin.history) == 1 - assert plugin.history[0]["type"] == "response" - assert plugin.history[0]["operation"] == "GetDeviceInformation" + assert plugin.egress( + envelope, + headers, + operation, + {}, + ) == (envelope, headers) + + assert plugin.ingress( + envelope, + headers, + operation, + ) == (envelope, headers) + + assert len(plugin.history) == 2 + assert [entry["type"] for entry in plugin.history] == [ + "request", + "response", + ] + assert all( + entry["operation"] == "GetDeviceInformation" for entry in plugin.history + ) - def test_history_management(self): - """Test capture history management.""" + def test_xml_capture_plugin_can_be_reset(self): + """Test that the XMLCapturePlugin can be reset to its initial state.""" plugin = XMLCapturePlugin() - # Add some mock history - plugin.history = [ - {"type": "request", "operation": "GetDeviceInformation"}, - {"type": "response", "operation": "GetDeviceInformation"}, - ] + plugin.last_sent_xml = "" + plugin.last_received_xml = "" + plugin.last_operation = "GetDeviceInformation" + plugin.history.append({"type": "request"}) - # Test get_history - history = plugin.get_history() - assert len(history) == 2 - - # Test clear_history plugin.clear_history() - assert len(plugin.history) == 0 + + assert not plugin.history assert plugin.last_sent_xml is None assert plugin.last_received_xml is None assert plugin.last_operation is None - def test_last_request_response_accessors(self): - """Test last request/response accessor methods.""" - plugin = XMLCapturePlugin() - - # Set some test data - plugin.last_sent_xml = "test" - plugin.last_received_xml = "test" - - # Test accessors - assert plugin.get_last_request() == "test" - assert plugin.get_last_response() == "test" - - @patch("builtins.open", create=True) - def test_save_to_file(self, mock_open): - """Test saving captured XML to files.""" - plugin = XMLCapturePlugin() - plugin.last_sent_xml = "test" - plugin.last_received_xml = "test" - - # Mock file operations - mock_file = Mock() - mock_open.return_value.__enter__.return_value = mock_file - - # Test saving - plugin.save_to_file("request.xml", "response.xml") - - # Verify file operations - assert mock_open.call_count == 2 - assert mock_file.write.call_count == 2 - - -class TestCacheMode: - """Test cache mode enumeration and behavior.""" - - def test_cache_mode_values(self): - """Test cache mode enumeration values.""" - assert CacheMode.NONE is not None - assert CacheMode.MEM is not None - assert CacheMode.DB is not None - - def test_cache_mode_string_representation(self): - """Test cache mode string representations.""" - assert CacheMode.NONE.value == "none" - assert CacheMode.MEM.value == "mem" - assert CacheMode.DB.value == "db" - - def test_cache_mode_comparison(self): - """Test cache mode comparison.""" - assert CacheMode.NONE != CacheMode.MEM - assert CacheMode.NONE != CacheMode.DB - assert CacheMode.MEM != CacheMode.DB - - -class TestCoreIntegration: - """Test integration between core components.""" - - def test_wsdl_with_zeep_patcher(self): - """Test WSDL manager with ZeepPatcher.""" - ZeepPatcher.apply_patch() - - try: - definition = ONVIFWSDL.get_definition("device", "ver20") - assert definition is not None - except ValueError: - # Some definitions might not be available - pass - - ZeepPatcher.remove_patch() - def test_xml_capture_with_cache_modes(self): - """Test XML capture plugin with different cache modes.""" - plugin = XMLCapturePlugin() - - # Test with different cache modes - for cache_mode in [CacheMode.NONE, CacheMode.MEM, CacheMode.DB]: - # Plugin should work regardless of cache mode - mock_envelope = Mock() - mock_operation = Mock() - mock_operation.name = f"TestOperation_{cache_mode.value}" - - with patch.object(plugin, "_format_xml", return_value=""): - plugin.egress(mock_envelope, {}, mock_operation, {}) - - assert len(plugin.history) == 3 - - -class TestErrorHandlingInCore: - """Test error handling in core components.""" - - def test_wsdl_manager_invalid_service(self): - """Test WSDL manager with invalid service.""" - with pytest.raises(ValueError): - ONVIFWSDL.get_definition("invalid_service", "ver10") - - def test_wsdl_manager_invalid_version(self): - """Test WSDL manager with invalid version.""" +# pylint: disable=too-few-public-methods +class TestCoreErrorHandling: + """Test errors crossing core component boundaries.""" + + @pytest.mark.parametrize( + ("service", "version"), + [ + ("invalid_service", "ver10"), + ("devicemgmt", "ver99"), + ], + ) + def test_invalid_wsdl_definition_is_rejected( + self, + service, + version, + ): + """Test that an invalid WSDL definition raises a ValueError.""" with pytest.raises(ValueError): - ONVIFWSDL.get_definition("device", "ver99") - - def test_zeep_patcher_multiple_operations(self): - """Test ZeepPatcher multiple apply/remove operations.""" - # Should handle multiple operations gracefully - ZeepPatcher.apply_patch() - ZeepPatcher.apply_patch() # Second apply - assert ZeepPatcher.is_patched() - - ZeepPatcher.remove_patch() - assert not ZeepPatcher.is_patched() - - ZeepPatcher.remove_patch() # Second remove - assert not ZeepPatcher.is_patched() - - def test_xml_capture_plugin_error_handling(self): - """Test XML capture plugin error handling.""" - plugin = XMLCapturePlugin() - - # Test with minimal valid objects - mock_envelope = Mock() - mock_operation = Mock() - mock_operation.name = "TestOperation" - - # Should not raise exception - with patch.object(plugin, "_format_xml", return_value=""): - result = plugin.egress(mock_envelope, {}, mock_operation, {}) - assert result == (mock_envelope, {}) - - result = plugin.ingress(mock_envelope, {}, mock_operation) - assert result == (mock_envelope, {}) - - -class TestONVIFOperator: # pylint: disable=too-few-public-methods - """Test ONVIFOperator transport configuration.""" - - def test_timeout_applied_as_operation_timeout(self): - """Test timeout is passed to zeep as operation_timeout, not load_timeout. - - zeep's Transport accepts both `timeout` (bounds WSDL/XSD document fetching) and - `operation_timeout` (bounds SOAP calls). Passing the wrong one is silent -- both - are valid kwargs -- so assert on the attribute that governs actual SOAP - requests. - - WSDLs load from local files, so no network I/O occurs here and the unreachable - host is never contacted. - """ + ONVIFWSDL.get_definition(service, version) + + +class TestCacheModeIntegration: + """Test CacheMode where it affects core integration.""" + + @pytest.mark.parametrize( + "cache_mode", + [ + CacheMode.NONE, + CacheMode.MEM, + CacheMode.DB, + ], + ) + def test_operator_accepts_cache_modes(self, cache_mode): + """ONVIFOperator accepts every supported cache mode.""" definition = ONVIFWSDL.get_definition("devicemgmt") operator = ONVIFOperator( @@ -377,10 +132,9 @@ def test_timeout_applied_as_operation_timeout(self): port=80, username="user", password="pass", - timeout=7, wsdl_path=definition["path"], binding=f"{{{definition['namespace']}}}{definition['binding']}", - cache=CacheMode.MEM, + cache=cache_mode, ) - assert operator.client.transport.operation_timeout == 7 + assert operator.client is not None diff --git a/tests/test_meta.py b/tests/test_meta.py new file mode 100644 index 0000000..ebaacec --- /dev/null +++ b/tests/test_meta.py @@ -0,0 +1,26 @@ +"""Tests for the onvif.meta module.""" + +from typing import Final, get_type_hints + +from onvif import meta + + +def test_version(): + """Test that the __version__ attribute is a non-empty string with two dots.""" + assert isinstance(meta.__version__, str) + assert meta.__version__ + assert meta.__version__.count(".") == 2 + + +def test_repository(): + """Test that the __repository__ attribute is a non-empty string with a valid URL.""" + assert meta.__repository__ == "https://github.com/nirsimetri/onvif-python" + + +def test_metadata_annotations(): + """Test that the __version__ and __repository__ attributes have the correct type + annotations.""" + annotations = get_type_hints(meta) + + assert annotations["__version__"] is Final[str] + assert annotations["__repository__"] is Final[str] diff --git a/tests/test_operator.py b/tests/test_operator.py new file mode 100644 index 0000000..11f8cfc --- /dev/null +++ b/tests/test_operator.py @@ -0,0 +1,946 @@ +"""Tests for ONVIFOperator.""" + +import os +from unittest.mock import Mock, patch + +import pytest +from zeep.exceptions import Fault + +from onvif.operator import CacheMode, ONVIFOperator +from onvif.utils.exceptions import ONVIFOperationException + + +def make_operator(**attrs): + """Create an ONVIFOperator without executing __init__.""" + operator = ONVIFOperator.__new__(ONVIFOperator) + + defaults = { + "wsdl_path": "test.wsdl", + "host": "192.168.1.100", + "port": 80, + "username": None, + "password": None, + "http_digest": False, + "timeout": 10, + "apply_patch": True, + "address": "http://192.168.1.100:80/onvif/device_service", + "service_name": "Device", + } + defaults.update(attrs) + + for key, value in defaults.items(): + setattr(operator, key, value) + + return operator + + +class TestCacheMode: + """Test for CacheMode enum.""" + + def test_cache_mode_values(self): + """Test that CacheMode enum values are correct.""" + assert CacheMode.NONE.value == "none" + assert CacheMode.MEM.value == "mem" + assert CacheMode.DB.value == "db" + + def test_cache_mode_members(self): + """Test that CacheMode enum members are correct.""" + assert list(CacheMode) == [ + CacheMode.NONE, + CacheMode.MEM, + CacheMode.DB, + ] + + +class TestONVIFOperatorInitialization: + """Test ONVIFOperator initialization and configuration.""" + + @pytest.mark.parametrize( + ("cache", "cache_class"), + [ + (CacheMode.NONE, None), + (CacheMode.MEM, "InMemoryCache"), + (CacheMode.DB, "SqliteCache"), + ], + ) + def test_init_cache_modes(self, cache, cache_class): + """Test that ONVIFOperator initializes with different cache modes.""" + mock_session = Mock() + mock_client = Mock() + mock_transport = Mock() + + patches = { + "session": patch( + "onvif.operator.requests.Session", + return_value=mock_session, + ), + "transport": patch( + "onvif.operator.Transport", + return_value=mock_transport, + ), + "client": patch( + "onvif.operator.Client", + return_value=mock_client, + ), + } + + with ( + patches["session"] as mock_session_cls, + patches["transport"] as mock_transport_cls, + patches["client"] as mock_client_cls, + patch("onvif.operator.Settings"), + patch("onvif.operator.InMemoryCache") as mock_memory_cache, + patch("onvif.operator.SqliteCache") as mock_sqlite_cache, + patch.object( + ONVIFOperator, + "_create_wsse", + return_value=None, + ), + patch("onvif.operator.os.makedirs"), + ): + operator = ONVIFOperator( + wsdl_path="test.wsdl", + host="192.168.1.100", + port=80, + binding="{urn:test}DeviceBinding", + cache=cache, + cache_path="/tmp/test-cache.sqlite", + ) + + assert operator.address == ("http://192.168.1.100:80/onvif/device_service") + assert operator.service_name == "Device" + mock_session_cls.assert_called_once_with() + + if cache_class == "InMemoryCache": + mock_memory_cache.assert_called_once_with() + mock_sqlite_cache.assert_not_called() + elif cache_class == "SqliteCache": + mock_sqlite_cache.assert_called_once_with(path="/tmp/test-cache.sqlite") + mock_memory_cache.assert_not_called() + else: + mock_memory_cache.assert_not_called() + mock_sqlite_cache.assert_not_called() + + mock_transport_cls.assert_called_once() + mock_client_cls.assert_called_once() + + def test_init_uses_https_and_custom_service_path(self): + """Test that ONVIFOperator initializes with HTTPS and a custom service path.""" + mock_session = Mock() + mock_client = Mock() + + with ( + patch( + "onvif.operator.requests.Session", + return_value=mock_session, + ), + patch("onvif.operator.Transport"), + patch( + "onvif.operator.Client", + return_value=mock_client, + ), + patch("onvif.operator.Settings"), + patch.object( + ONVIFOperator, + "_create_wsse", + return_value=None, + ), + ): + operator = ONVIFOperator( + wsdl_path="test.wsdl", + host="camera.local", + port=443, + binding="{urn:test}MediaBinding", + service_path="media_service", + cache=CacheMode.NONE, + use_https=True, + ) + + assert operator.address == ("https://camera.local:443/onvif/media_service") + assert operator.service_name == "Media" + + def test_init_uses_explicit_xaddr(self): + """Test that ONVIFOperator initializes with an explicit XAddr.""" + mock_client = Mock() + + with ( + patch("onvif.operator.requests.Session"), + patch("onvif.operator.Transport"), + patch( + "onvif.operator.Client", + return_value=mock_client, + ), + patch("onvif.operator.Settings"), + patch.object( + ONVIFOperator, + "_create_wsse", + return_value=None, + ), + ): + operator = ONVIFOperator( + wsdl_path="test.wsdl", + host="camera.local", + port=80, + binding="{urn:test}DeviceBinding", + xaddr="http://camera.local/custom", + cache=CacheMode.NONE, + ) + + assert operator.address == "http://camera.local/custom" + + def test_init_defaults_service_path_when_empty(self): + """Test that ONVIFOperator defaults to the standard service path when + service_path is None.""" + mock_client = Mock() + + with ( + patch("onvif.operator.requests.Session"), + patch("onvif.operator.Transport"), + patch( + "onvif.operator.Client", + return_value=mock_client, + ), + patch("onvif.operator.Settings"), + patch.object( + ONVIFOperator, + "_create_wsse", + return_value=None, + ), + ): + operator = ONVIFOperator( + wsdl_path="test.wsdl", + host="camera.local", + port=80, + binding="{urn:test}DeviceBinding", + service_path=None, + cache=CacheMode.NONE, + ) + + assert operator.address.endswith("/onvif/device_service") + + def test_init_creates_default_db_cache_path(self): + """Test that ONVIFOperator creates the default database cache path when + CacheMode.DB is used.""" + mock_client = Mock() + + with ( + patch("onvif.operator.requests.Session"), + patch("onvif.operator.Transport"), + patch( + "onvif.operator.Client", + return_value=mock_client, + ), + patch("onvif.operator.Settings"), + patch.object( + ONVIFOperator, + "_create_wsse", + return_value=None, + ), + patch( + "onvif.operator.os.path.expanduser", + return_value="/home/test/.onvif-python", + ), + patch("onvif.operator.os.makedirs") as mock_makedirs, + patch("onvif.operator.SqliteCache") as mock_cache, + ): + ONVIFOperator( + wsdl_path="test.wsdl", + host="camera.local", + port=80, + binding="{urn:test}DeviceBinding", + cache=CacheMode.DB, + ) + + mock_makedirs.assert_called_once_with( + "/home/test/.onvif-python", + exist_ok=True, + ) + mock_cache.assert_called_once_with( + path=os.path.join( + "/home/test/.onvif-python", + "onvif_zeep_cache.sqlite", + ) + ) + + def test_init_raises_for_unknown_cache_mode(self): + """Test that ONVIFOperator raises ValueError for an unknown cache mode.""" + unknown_cache = object() + + with pytest.raises(ValueError, match="Unknown cache option"): + ONVIFOperator( + wsdl_path="test.wsdl", + host="camera.local", + port=80, + binding="{urn:test}DeviceBinding", + cache=unknown_cache, + ) + + def test_init_raises_without_binding(self): + """Test that ONVIFOperator raises ValueError when binding is None.""" + with ( + patch("onvif.operator.requests.Session"), + patch("onvif.operator.Transport"), + patch("onvif.operator.Client"), + patch("onvif.operator.Settings"), + patch.object( + ONVIFOperator, + "_create_wsse", + return_value=None, + ), + ): + with pytest.raises( + ValueError, + match="Bindings must be set according to the WSDL service", + ): + ONVIFOperator( + wsdl_path="test.wsdl", + host="camera.local", + port=80, + binding=None, + cache=CacheMode.NONE, + ) + + def test_init_passes_configuration_to_client(self): + """Test that ONVIFOperator passes the correct configuration to the Zeep + client.""" + mock_client = Mock() + mock_wsse = Mock() + mock_transport = Mock() + mock_settings = Mock() + + with ( + patch("onvif.operator.requests.Session"), + patch( + "onvif.operator.Transport", + return_value=mock_transport, + ) as mock_transport_cls, + patch( + "onvif.operator.Settings", + return_value=mock_settings, + ) as mock_settings_cls, + patch( + "onvif.operator.Client", + return_value=mock_client, + ) as mock_client_cls, + patch.object( + ONVIFOperator, + "_create_wsse", + return_value=mock_wsse, + ), + ): + operator = ONVIFOperator( + wsdl_path="test.wsdl", + host="camera.local", + port=80, + username="admin", + password="secret", + timeout=25, + binding="{urn:test}DeviceBinding", + cache=CacheMode.NONE, + plugins=["plugin"], + ) + + mock_transport_cls.assert_called_once_with( + session=mock_transport_cls.call_args.kwargs["session"], + operation_timeout=25, + ) + + mock_settings_cls.assert_called_once_with( + strict=False, + xml_huge_tree=True, + ) + + mock_client_cls.assert_called_once_with( + wsdl="test.wsdl", + transport=mock_transport, + settings=mock_settings, + wsse=mock_wsse, + plugins=["plugin"], + ) + + mock_client.create_service.assert_called_once_with( + binding_name="{urn:test}DeviceBinding", + address=operator.address, + ) + + +# pylint: disable=protected-access +class TestCreateSession: + """Tests for the _create_session method of ONVIFOperator.""" + + def test_create_session_sets_verify(self): + """Test that ONVIFOperator creates a requests.Session with the correct SSL + verification setting.""" + operator = make_operator() + + with patch("onvif.operator.requests.Session") as mock_session_cls: + session = operator._create_session(verify_ssl=True) + + assert session.verify is True + mock_session_cls.assert_called_once_with() + + def test_create_session_disables_ssl_verification(self): + """Test that ONVIFOperator creates a requests.Session with SSL verification + disabled.""" + operator = make_operator() + + with (patch("onvif.operator.warnings.simplefilter") as mock_filter,): + session = operator._create_session(verify_ssl=False) + + assert session.verify is False + + mock_filter.assert_called_once_with( + "once", + __import__( + "urllib3", + ).exceptions.InsecureRequestWarning, + ) + + def test_create_session_configures_digest_auth(self): + """Test that ONVIFOperator creates a requests.Session with HTTP Digest + authentication when enabled.""" + operator = make_operator( + username="admin", + password="secret", + http_digest=True, + ) + + with (patch("onvif.operator.HTTPDigestAuth") as mock_auth,): + session = operator._create_session(verify_ssl=True) + + mock_auth.assert_called_once_with( + username="admin", + password="secret", + ) + assert session.auth is mock_auth.return_value + + @pytest.mark.parametrize( + ("http_digest", "username", "password"), + [ + (False, "admin", "secret"), + (True, None, "secret"), + (True, "admin", None), + (True, None, None), + ], + ) + def test_create_session_without_digest_auth( + self, + http_digest, + username, + password, + ): + """Test that ONVIFOperator creates a requests.Session without HTTP Digest + authentication when not enabled or credentials are missing.""" + operator = make_operator( + http_digest=http_digest, + username=username, + password=password, + ) + + with (patch("onvif.operator.HTTPDigestAuth") as mock_auth,): + operator._create_session(verify_ssl=True) + + mock_auth.assert_not_called() + + +# pylint: disable=protected-access +class TestCreateWsse: + """Tests for the _create_wsse method of ONVIFOperator.""" + + def test_create_wsse_returns_none_for_http_digest(self): + """Test that ONVIFOperator._create_wsse returns None when HTTP Digest + authentication is enabled.""" + operator = make_operator( + http_digest=True, + username="admin", + password="secret", + ) + + with patch("onvif.operator.UsernameToken") as mock_token: + result = operator._create_wsse() + + assert result is None + mock_token.assert_not_called() + + @pytest.mark.parametrize( + ("username", "password"), + [ + (None, None), + ("admin", None), + (None, "secret"), + ], + ) + def test_create_wsse_returns_none_without_credentials( + self, + username, + password, + ): + """Test that ONVIFOperator._create_wsse returns None when username or password + is missing.""" + operator = make_operator( + http_digest=False, + username=username, + password=password, + ) + + with patch("onvif.operator.UsernameToken") as mock_token: + result = operator._create_wsse() + + assert result is None + mock_token.assert_not_called() + + def test_create_wsse_creates_username_token(self): + """Test that ONVIFOperator._create_wsse creates a UsernameToken when HTTP Digest + is disabled and credentials are provided.""" + operator = make_operator( + http_digest=False, + username="admin", + password="secret", + ) + + with patch("onvif.operator.UsernameToken") as mock_token: + result = operator._create_wsse() + + assert result is mock_token.return_value + mock_token.assert_called_once_with( + username="admin", + password="secret", + use_digest=True, + ) + + +class TestCall: + """Tests for the call public method of ONVIFOperator.""" + + def test_call_returns_service_result_and_flattens_result(self): + """Test that ONVIFOperator.call returns the result from the service and flattens + it using ZeepPatcher.""" + service = Mock() + service.GetDeviceInformation.return_value = {"raw": "value"} + + operator = make_operator( + service=service, + apply_patch=True, + ) + + flattened = {"flattened": "value"} + + with patch( + "onvif.operator.ZeepPatcher.flatten_xsd_any_fields", + return_value=flattened, + ) as mock_flatten: + result = operator.call( + "GetDeviceInformation", + "arg", + timeout=5, + ) + + assert result == flattened + service.GetDeviceInformation.assert_called_once_with( + "arg", + timeout=5, + ) + mock_flatten.assert_called_once_with( + {"raw": "value"}, + ) + + def test_call_skips_flatten_when_patch_disabled(self): + """Test that ONVIFOperator.call skips flattening the result when apply_patch is + False.""" + service = Mock() + service.GetDeviceInformation.return_value = "result" + + operator = make_operator( + service=service, + apply_patch=False, + ) + + with patch( + "onvif.operator.ZeepPatcher.flatten_xsd_any_fields", + ) as mock_flatten: + result = operator.call("GetDeviceInformation") + + assert result == "result" + mock_flatten.assert_not_called() + + def test_call_wraps_missing_method(self): + """Test that ONVIFOperator.call raises ONVIFOperationException when the service + method is missing.""" + service = Mock(spec=[]) + + operator = make_operator( + service=service, + apply_patch=False, + ) + + with pytest.raises(ONVIFOperationException) as exc_info: + operator.call("GetDeviceInformation") + + assert exc_info.value.__cause__ is not None + assert isinstance(exc_info.value.__cause__, AttributeError) + + def test_call_wraps_zeep_fault(self): + """Test that ONVIFOperator.call raises ONVIFOperationException when the service + method raises a Zeep Fault.""" + service = Mock() + fault = Fault("SOAP fault") + service.GetDeviceInformation.side_effect = fault + + operator = make_operator( + service=service, + apply_patch=False, + ) + + with pytest.raises(ONVIFOperationException) as exc_info: + operator.call("GetDeviceInformation") + + assert exc_info.value.__cause__ is fault + + def test_call_wraps_generic_exception(self): + """Test that ONVIFOperator.call raises ONVIFOperationException when the service + method raises a generic exception.""" + service = Mock() + error = RuntimeError("connection failed") + service.GetDeviceInformation.side_effect = error + + operator = make_operator( + service=service, + apply_patch=False, + ) + + with pytest.raises(ONVIFOperationException) as exc_info: + operator.call("GetDeviceInformation") + + assert exc_info.value.__cause__ is error + + def test_call_passes_positional_and_keyword_arguments(self): + """Test that ONVIFOperator.call correctly passes positional and keyword + arguments to the service method.""" + service = Mock() + service.GetProfiles.return_value = "result" + + operator = make_operator( + service=service, + apply_patch=False, + ) + + result = operator.call( + "GetProfiles", + "profile-token", + timeout=30, + ) + + assert result == "result" + service.GetProfiles.assert_called_once_with( + "profile-token", + timeout=30, + ) + + +class TestCreateType: + """Tests for the create_type public method of ONVIFOperator.""" + + def test_create_type_succeeds_with_first_namespace(self): + """Test that ONVIFOperator.create_type successfully creates a type using the + first namespace when available.""" + operator = make_operator() + element = Mock() + instance = Mock() + + element.return_value = instance + operator.client = Mock() + operator.client.get_element.return_value = element + + initialized = Mock() + with patch.object( + operator, + "_initialize_nested_types", + return_value=initialized, + ) as mock_initialize: + result = operator.create_type("GetProfiles") + + assert result is initialized + operator.client.get_element.assert_called_once_with("ns0:GetProfiles") + element.assert_called_once_with() + mock_initialize.assert_called_once_with(instance) + + def test_create_type_tries_next_namespace_after_failure(self): + """Test that ONVIFOperator.create_type tries the next namespace if the first one + fails.""" + operator = make_operator() + element = Mock() + instance = Mock() + + element.return_value = instance + + def get_element(name): + if name == "ns0:GetProfiles": + raise ValueError("not found") + return element + + operator.client = Mock() + operator.client.get_element.side_effect = get_element + + with patch.object( + operator, + "_initialize_nested_types", + return_value=instance, + ): + result = operator.create_type("GetProfiles") + + assert result is instance + assert operator.client.get_element.call_count == 2 + assert operator.client.get_element.call_args_list[0].args == ( + "ns0:GetProfiles", + ) + assert operator.client.get_element.call_args_list[1].args == ("tt:GetProfiles",) + + def test_create_type_falls_back_to_unqualified_element(self): + """Test that ONVIFOperator.create_type falls back to an unqualified element if + namespaced elements are not found.""" + operator = make_operator() + element = Mock() + instance = Mock() + + element.return_value = instance + + def get_element(name): + if name in ("ns0:GetProfiles", "tt:GetProfiles"): + raise AttributeError("not found") + return element + + operator.client = Mock() + operator.client.get_element.side_effect = get_element + + with patch.object( + operator, + "_initialize_nested_types", + return_value=instance, + ): + result = operator.create_type("GetProfiles") + + assert result is instance + assert operator.client.get_element.call_args_list[-1].args == ("GetProfiles",) + + def test_create_type_falls_back_to_namespaced_complex_type(self): + """Test that ONVIFOperator.create_type falls back to a namespaced complex type + if elements are not found.""" + operator = make_operator() + type_obj = Mock() + instance = Mock() + + type_obj.return_value = instance + + def get_element(name): + raise AttributeError(name) + + def get_type(name): + if name == "ns0:GetProfiles": + return type_obj + raise AttributeError(name) + + operator.client = Mock() + operator.client.get_element.side_effect = get_element + operator.client.get_type.side_effect = get_type + + with patch.object( + operator, + "_initialize_nested_types", + return_value=instance, + ): + result = operator.create_type("GetProfiles") + + assert result is instance + operator.client.get_type.assert_called_once_with("ns0:GetProfiles") + + def test_create_type_falls_back_to_second_complex_type_namespace(self): + """Test that ONVIFOperator.create_type falls back to the second complex type + namespace if the first one fails.""" + operator = make_operator() + type_obj = Mock() + instance = Mock() + + type_obj.return_value = instance + + def get_type(name): + if name == "ns0:GetProfiles": + raise ValueError("not found") + if name == "tt:GetProfiles": + return type_obj + raise AttributeError(name) + + operator.client = Mock() + operator.client.get_element.side_effect = AttributeError + operator.client.get_type.side_effect = get_type + + with patch.object( + operator, + "_initialize_nested_types", + return_value=instance, + ): + result = operator.create_type("GetProfiles") + + assert result is instance + assert operator.client.get_type.call_args_list == [ + (("ns0:GetProfiles",),), + (("tt:GetProfiles",),), + ] + + def test_create_type_falls_back_to_unqualified_complex_type(self): + """Test that ONVIFOperator.create_type falls back to an unqualified complex type + if namespaced types are not found.""" + operator = make_operator() + type_obj = Mock() + instance = Mock() + + type_obj.return_value = instance + + operator.client = Mock() + operator.client.get_element.side_effect = AttributeError + operator.client.get_type.side_effect = lambda name: ( + type_obj + if name == "GetProfiles" + else (_ for _ in ()).throw(AttributeError(name)) + ) + + with patch.object( + operator, + "_initialize_nested_types", + return_value=instance, + ): + result = operator.create_type("GetProfiles") + + assert result is instance + assert operator.client.get_type.call_args_list[-1].args == ("GetProfiles",) + + def test_create_type_raises_when_all_lookup_methods_fail(self): + """Test that ONVIFOperator.create_type raises AttributeError when all lookup + methods fail.""" + operator = make_operator() + operator.client = Mock() + operator.client.get_element.side_effect = AttributeError + operator.client.get_type.side_effect = AttributeError + + with pytest.raises( + AttributeError, + match="Type 'GetProfiles' not found in WSDL schema", + ): + operator.create_type("GetProfiles") + + +# pylint: disable=invalid-name,unnecessary-pass,too-few-public-methods +class TestInitializeNestedTypes: + """Tests for the _initialize_nested_types method of ONVIFOperator.""" + + def test_initialize_nested_types_returns_instance_without_xsd_type(self): + """Test that _initialize_nested_types returns the instance unchanged when it has + no _xsd_type attribute.""" + operator = make_operator() + instance = object() + + result = operator._initialize_nested_types(instance) + + assert result is instance + + def test_initialize_nested_types_does_not_replace_existing_value(self): + """Test that _initialize_nested_types does not replace an existing value of a + nested type attribute.""" + operator = make_operator() + + existing = object() + + class NestedType: + """A nested type that will be initialized.""" + + _xsd_type = Mock(elements=[]) + + class Element: + """An element that represents a nested type.""" + + type = NestedType + + class XsdType: + """An XSD type that has a nested type element.""" + + elements = [ + ("Nested", Element()), + ] + + class Instance: + """An instance that has a nested type attribute.""" + + _xsd_type = XsdType + + def __init__(self): + """Init instance.""" + self.Nested = existing + + instance = Instance() + + result = operator._initialize_nested_types(instance) + + assert result is instance + assert instance.Nested is existing + + def test_initialize_nested_types_ignores_simple_type(self): + """Test that _initialize_nested_types does not replace a nested type attribute + if it is a simple type.""" + operator = make_operator() + + class SimpleType: + """A simple type that does not have nested elements.""" + + pass + + class Element: + """An element that represents a simple type.""" + + type = SimpleType + + class XsdType: + """An XSD type that has a simple type element.""" + + elements = [ + ("Value", Element()), + ] + + class Instance: + """An instance that has a simple type attribute.""" + + _xsd_type = XsdType + + def __init__(self): + """Init instance.""" + self.Value = None + + instance = Instance() + + result = operator._initialize_nested_types(instance) + + assert result is instance + assert instance.Value is None + + def test_initialize_nested_types_handles_xsd_processing_error(self): + """Test that _initialize_nested_types handles an AttributeError when accessing + elements of the XSD type.""" + operator = make_operator() + + class BrokenXsd: + """Simulate an XSD type that raises an AttributeError when accessing + elements.""" + + @property + def elements(self): + """Simulate an error when accessing elements.""" + raise AttributeError("broken XSD") + + class Instance: + """Simulate an instance with a broken _xsd_type.""" + + _xsd_type = BrokenXsd() + + instance = Instance() + + result = operator._initialize_nested_types(instance) + + assert result is instance From 3a74116bc3691867c42878d2f43a2d36dbbd07ad Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:58:41 +0700 Subject: [PATCH 11/43] ci: Include `pyproject.toml` in CI/CD workflow (#121) --- .github/workflows/coverage-reporter.yml | 1 + .github/workflows/python-app.yml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.github/workflows/coverage-reporter.yml b/.github/workflows/coverage-reporter.yml index 942f792..6a4f6a2 100644 --- a/.github/workflows/coverage-reporter.yml +++ b/.github/workflows/coverage-reporter.yml @@ -7,6 +7,7 @@ on: - "onvif/**" - "tests/**" - "examples/**" + - "pyproject.toml" permissions: contents: read diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 493e622..64a1a9c 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -7,12 +7,14 @@ on: - "onvif/**" - "tests/**" - "examples/**" + - "pyproject.toml" pull_request: branches: [ "main", "dev" ] paths: - "onvif/**" - "tests/**" - "examples/**" + - "pyproject.toml" permissions: contents: read From 085a3b72eadc2874b5e556d440528098cff98460 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:02:13 +0700 Subject: [PATCH 12/43] chore: Add `pylint-pytest` to development dependencies (#120) --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 75ed376..7b06797 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ dev = [ "mypy", "isort", "pylint", + "pylint-pytest", "docformatter", "codespell", "lxml-stubs", @@ -124,6 +125,7 @@ ignore-paths = [ "^build/", "^site/", ] +load-plugins = ["pylint_pytest"] [tool.pylint.format] max-line-length = 150 From 77347da7d58bf7a026a7f7c9c5fbd24b2edbc773 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:05:04 +0700 Subject: [PATCH 13/43] test: Complete tests for utilities modules (#122) --- tests/utilities/__init__.py | 1 + tests/utilities/test_discovery.py | 1182 ++++++++++++++++++ tests/{ => utilities}/test_error_handlers.py | 0 tests/{ => utilities}/test_exceptions.py | 3 +- tests/utilities/test_plugins.py | 691 ++++++++++ tests/utilities/test_service.py | 680 ++++++++++ tests/utilities/test_zeep.py | 359 ++++++ 7 files changed, 2914 insertions(+), 2 deletions(-) create mode 100644 tests/utilities/__init__.py create mode 100644 tests/utilities/test_discovery.py rename tests/{ => utilities}/test_error_handlers.py (100%) rename tests/{ => utilities}/test_exceptions.py (99%) create mode 100644 tests/utilities/test_plugins.py create mode 100644 tests/utilities/test_service.py create mode 100644 tests/utilities/test_zeep.py diff --git a/tests/utilities/__init__.py b/tests/utilities/__init__.py new file mode 100644 index 0000000..5e9d065 --- /dev/null +++ b/tests/utilities/__init__.py @@ -0,0 +1 @@ +"""Utility tests suite.""" diff --git a/tests/utilities/test_discovery.py b/tests/utilities/test_discovery.py new file mode 100644 index 0000000..a1e94ff --- /dev/null +++ b/tests/utilities/test_discovery.py @@ -0,0 +1,1182 @@ +# pylint: disable=too-many-lines +"""Tests for ONVIFDiscovery.""" + +import socket +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest + +from onvif.utils.discovery import ONVIFDiscovery +from onvif.utils.exceptions import ONVIFOperationException + + +@pytest.fixture +def discovery(): + """Fixture that returns an instance of ONVIFDiscovery for testing.""" + return ONVIFDiscovery() + + +@pytest.fixture +def discovery_with_interface(): + """Fixture that returns an instance of ONVIFDiscovery with a specific interface.""" + return ONVIFDiscovery(timeout=7, interface="192.168.1.10") + + +# pylint: disable=protected-access +class TestONVIFDiscoveryInitialization: + """Tests for the initialization of the ONVIFDiscovery class.""" + + def test_init_defaults(self): + """Test that the default values are set correctly.""" + instance = ONVIFDiscovery() + + assert instance.timeout == 4 + assert instance.interface is None + assert instance._local_ip is None + + def test_init_custom_values(self): + """Test that custom values are set correctly.""" + instance = ONVIFDiscovery( + timeout=10, + interface="192.168.1.20", + ) + + assert instance.timeout == 10 + assert instance.interface == "192.168.1.20" + assert instance._local_ip is None + + +class TestGetLocalIP: + """Tests for the get_local_ip method of ONVIFDiscovery.""" + + def test_get_local_ip_returns_cached_value(self, discovery): + """Test that get_local_ip returns the cached value if already set.""" + discovery._local_ip = "192.168.1.50" + + with patch("onvif.utils.discovery.socket.socket") as mock_socket: + assert discovery.get_local_ip() == "192.168.1.50" + + mock_socket.assert_not_called() + + def test_get_local_ip_uses_configured_interface(self, discovery_with_interface): + """Test that get_local_ip uses the configured network interface.""" + assert discovery_with_interface.get_local_ip() == "192.168.1.10" + assert discovery_with_interface._local_ip == "192.168.1.10" + + def test_get_local_ip_uses_default_route(self, discovery): + """Test that get_local_ip resolves the address using the default route.""" + sock = Mock() + sock.getsockname.return_value = ("192.168.1.25", 0) + + with patch( + "onvif.utils.discovery.socket.socket", + return_value=sock, + ) as mock_socket: + result = discovery.get_local_ip() + + assert result == "192.168.1.25" + assert discovery._local_ip == "192.168.1.25" + + mock_socket.assert_called_once_with( + discovery_module_socket_af_inet(), + discovery_module_socket_dgram(), + ) + sock.connect.assert_called_once_with(("8.8.8.8", 80)) + sock.getsockname.assert_called_once() + sock.close.assert_called_once() + + def test_get_local_ip_falls_back_to_hostname(self, discovery): + """Test that get_local_ip falls back to hostname resolution on failure.""" + sock = Mock() + sock.connect.side_effect = OSError("network unavailable") + + with ( + patch( + "onvif.utils.discovery.socket.socket", + return_value=sock, + ), + patch( + "onvif.utils.discovery.socket.gethostname", + return_value="camera-host", + ), + patch( + "onvif.utils.discovery.socket.gethostbyname", + return_value="192.168.1.30", + ), + ): + result = discovery.get_local_ip() + + assert result == "192.168.1.30" + assert discovery._local_ip == "192.168.1.30" + + def test_get_local_ip_hostname_loopback_falls_back_to_empty_string(self, discovery): + """Test that hostname resolution falls back to an empty address for loopback.""" + sock = Mock() + sock.connect.side_effect = OSError("network unavailable") + + with ( + patch( + "onvif.utils.discovery.socket.socket", + return_value=sock, + ), + patch( + "onvif.utils.discovery.socket.gethostname", + return_value="camera-host", + ), + patch( + "onvif.utils.discovery.socket.gethostbyname", + return_value="127.0.0.1", + ), + ): + result = discovery.get_local_ip() + + assert result == "" + assert discovery._local_ip == "" + + def test_get_local_ip_hostname_lookup_failure_returns_empty_string(self, discovery): + """Test that get_local_ip returns an empty string when hostname resolution + fails.""" + sock = Mock() + sock.connect.side_effect = OSError("network unavailable") + + with ( + patch( + "onvif.utils.discovery.socket.socket", + return_value=sock, + ), + patch( + "onvif.utils.discovery.socket.gethostname", + return_value="camera-host", + ), + patch( + "onvif.utils.discovery.socket.gethostbyname", + side_effect=OSError("hostname lookup failed"), + ), + ): + result = discovery.get_local_ip() + + assert result == "" + assert discovery._local_ip == "" + + +class TestParseXAddr: + """Tests for the _parse_xaddr method of ONVIFDiscovery.""" + + @pytest.mark.parametrize( + ("xaddrs", "prefer_https", "expected"), + [ + ( + ["http://192.168.1.100/onvif/device_service"], + False, + { + "host": "192.168.1.100", + "port": 80, + "use_https": False, + }, + ), + ( + ["http://192.168.1.100:8080/onvif/device_service"], + False, + { + "host": "192.168.1.100", + "port": 8080, + "use_https": False, + }, + ), + ( + ["https://192.168.1.100/onvif/device_service"], + False, + { + "host": "192.168.1.100", + "port": 443, + "use_https": True, + }, + ), + ( + ["https://192.168.1.100:8443/onvif/device_service"], + False, + { + "host": "192.168.1.100", + "port": 8443, + "use_https": True, + }, + ), + ( + [ + "http://192.168.1.100/onvif/device_service", + "https://192.168.1.100/onvif/device_service", + ], + True, + { + "host": "192.168.1.100", + "port": 443, + "use_https": True, + }, + ), + ], + ) + def test_parse_xaddr(self, discovery, xaddrs, prefer_https, expected): + """Test that _parse_xaddr extracts the connection details from XAddrs.""" + device_info = { + "xaddrs": xaddrs, + "host": None, + "port": 80, + "use_https": False, + } + + discovery._parse_xaddr(device_info, prefer_https) + + assert device_info["host"] == expected["host"] + assert device_info["port"] == expected["port"] + assert device_info["use_https"] == expected["use_https"] + + def test_parse_xaddr_prefers_https_when_available(self, discovery): + """Test that _parse_xaddr selects an HTTPS XAddr when HTTPS is preferred.""" + device_info = { + "xaddrs": [ + "http://192.168.1.100:80/onvif/device_service", + "https://192.168.1.100:8443/onvif/device_service", + ], + "host": None, + "port": 80, + "use_https": False, + } + + discovery._parse_xaddr(device_info, prefer_https=True) + + assert device_info["host"] == "192.168.1.100" + assert device_info["port"] == 8443 + assert device_info["use_https"] is True + + def test_parse_xaddr_falls_back_to_first_xaddr_when_https_not_available( + self, + discovery, + ): + """Test that _parse_xaddr falls back to the first XAddr without HTTPS.""" + device_info = { + "xaddrs": [ + "http://192.168.1.100:8080/onvif/device_service", + "http://192.168.1.101:9090/onvif/device_service", + ], + "host": None, + "port": 80, + "use_https": False, + } + + discovery._parse_xaddr(device_info, prefer_https=True) + + assert device_info["host"] == "192.168.1.100" + assert device_info["port"] == 8080 + assert device_info["use_https"] is False + + def test_parse_xaddr_invalid_port_logs_warning(self, discovery, caplog): + """Test that _parse_xaddr logs a warning when an XAddr has an invalid port.""" + device_info = { + "xaddrs": ["http://192.168.1.100:invalid/onvif/device_service"], + "host": None, + "port": 80, + "use_https": False, + } + + with caplog.at_level("WARNING"): + discovery._parse_xaddr(device_info) + + assert device_info["host"] is None + assert device_info["port"] == 80 + assert device_info["use_https"] is False + assert "Error occurred while parsing XAddr" in caplog.text + + def test_parse_xaddr_https_port_implies_https(self, discovery): + """Test that port 443 causes an XAddr to be treated as HTTPS.""" + device_info = { + "xaddrs": ["http://192.168.1.100:443/onvif/device_service"], + "host": None, + "port": 80, + "use_https": False, + } + + discovery._parse_xaddr(device_info) + + assert device_info["host"] == "192.168.1.100" + assert device_info["port"] == 443 + assert device_info["use_https"] is True + + def test_parse_xaddr_https_default_port(self, discovery): + """Test that HTTPS XAddrs without an explicit port use port 443.""" + device_info = { + "xaddrs": ["https://camera.example.com/onvif/device_service"], + "host": None, + "port": 80, + "use_https": False, + } + + discovery._parse_xaddr(device_info) + + assert device_info["host"] == "camera.example.com" + assert device_info["port"] == 443 + assert device_info["use_https"] is True + + +class TestParseSingleResponse: + """Tests for the _parse_single_response method of ONVIFDiscovery.""" + + def test_parse_single_response_valid(self, discovery): + """Test that _parse_single_response parses a valid WS-Discovery response.""" + xml = """ + + + + + + urn:uuid:device-123 + + tds:Device tds:NetworkVideoTransmitter + + onvif://www.onvif.org/type/NetworkVideoTransmitter + onvif://www.onvif.org/name/TestCamera + + + http://192.168.1.100/onvif/device_service + + + + + + """ + + result = discovery._parse_single_response(xml) + + assert result == { + "epr": "urn:uuid:device-123", + "types": ["tds:Device", "tds:NetworkVideoTransmitter"], + "scopes": [ + "onvif://www.onvif.org/type/NetworkVideoTransmitter", + "onvif://www.onvif.org/name/TestCamera", + ], + "xaddrs": ["http://192.168.1.100/onvif/device_service"], + "host": "192.168.1.100", + "port": 80, + "use_https": False, + } + + def test_parse_single_response_without_probe_match_returns_none(self, discovery): + """Test that _parse_single_response returns None without a ProbeMatch + element.""" + xml = """ + + + + + + """ + + assert discovery._parse_single_response(xml) is None + + def test_parse_single_response_invalid_xml_returns_none(self, discovery): + """Test that _parse_single_response returns None for malformed XML.""" + assert discovery._parse_single_response(" + + + + + urn:uuid:device-123 + + + + + + """ + + result = discovery._parse_single_response(xml) + + assert result == { + "epr": "urn:uuid:device-123", + "types": [], + "scopes": [], + "xaddrs": [], + "host": None, + "port": 80, + "use_https": False, + } + + def test_parse_single_response_empty_optional_elements(self, discovery): + """Test that empty optional ProbeMatch elements produce empty values.""" + xml = """ + + + + + + + + + + + + """ + + result = discovery._parse_single_response(xml) + + assert result["epr"] == "" + assert result["types"] == [] + assert result["scopes"] == [] + assert result["xaddrs"] == [] + assert result["host"] is None + + +class TestParseResponses: + """Tests for the _parse_responses method of ONVIFDiscovery.""" + + def test_parse_responses_returns_valid_devices(self, discovery): + """Test that _parse_responses returns successfully parsed devices.""" + device = { + "host": "192.168.1.100", + "port": 80, + } + + with patch.object( + discovery, + "_parse_single_response", + return_value=device, + ) as parse_response: + result = discovery._parse_responses( + [ + {"xml": ""}, + ] + ) + + assert result == [device] + parse_response.assert_called_once_with( + "", + False, + ) + + def test_parse_responses_skips_device_without_host(self, discovery): + """Test that _parse_responses skips devices without a host address.""" + with patch.object( + discovery, + "_parse_single_response", + return_value={ + "host": None, + "port": 80, + }, + ): + result = discovery._parse_responses([{"xml": ""}]) + + assert result == [] + + def test_parse_responses_skips_malformed_response(self, discovery): + """Test that _parse_responses skips responses that fail during parsing.""" + with patch.object( + discovery, + "_parse_single_response", + side_effect=ValueError("invalid response"), + ): + result = discovery._parse_responses([{"xml": ""}]) + + assert result == [] + + def test_parse_responses_handles_missing_xml(self, discovery): + """Test that _parse_responses ignores responses without XML content.""" + result = discovery._parse_responses([{"address": "192.168.1.100"}]) + + assert result == [] + + def test_parse_responses_passes_prefer_https(self, discovery): + """Test that _parse_responses forwards the HTTPS preference to the parser.""" + device = { + "host": "192.168.1.100", + "port": 443, + } + + with patch.object( + discovery, + "_parse_single_response", + return_value=device, + ) as parse_response: + result = discovery._parse_responses( + [{"xml": ""}], + prefer_https=True, + ) + + assert result == [device] + parse_response.assert_called_once_with( + "", + True, + ) + + +class TestFilterDevices: + """Tests for the _filter_devices method of ONVIFDiscovery.""" + + def test_filter_devices_empty_search_returns_original_list(self, discovery): + """Test that an empty search term returns the original device list.""" + devices = [ + {"host": "192.168.1.100", "types": []}, + {"host": "192.168.1.101", "types": []}, + ] + + result = discovery._filter_devices(devices, "") + + assert result is devices + + def test_filter_devices_matches_type_case_insensitively(self, discovery): + """Test that device filtering matches device types case-insensitively.""" + devices = [ + { + "host": "192.168.1.100", + "types": ["tds:NetworkVideoTransmitter"], + "scopes": [], + }, + { + "host": "192.168.1.101", + "types": ["tds:Device"], + "scopes": [], + }, + ] + + result = discovery._filter_devices( + devices, + "networkvideotransmitter", + ) + + assert result == [devices[0]] + + def test_filter_devices_matches_scope_case_insensitively(self, discovery): + """Test that device filtering matches scopes case-insensitively.""" + devices = [ + { + "host": "192.168.1.100", + "types": [], + "scopes": ["onvif://www.onvif.org/name/Hikvision"], + }, + { + "host": "192.168.1.101", + "types": [], + "scopes": ["onvif://www.onvif.org/name/Dahua"], + }, + ] + + result = discovery._filter_devices( + devices, + "hikvision", + ) + + assert result == [devices[0]] + + def test_filter_devices_matches_type_or_scope(self, discovery): + """Test that device filtering matches either device types or scopes.""" + devices = [ + { + "host": "192.168.1.100", + "types": ["NetworkVideoTransmitter"], + "scopes": [], + }, + { + "host": "192.168.1.101", + "types": [], + "scopes": ["Camera"], + }, + { + "host": "192.168.1.102", + "types": ["Device"], + "scopes": ["Other"], + }, + ] + + result = discovery._filter_devices( + devices, + "camera", + ) + + assert result == [devices[1]] + + def test_filter_devices_handles_missing_types_and_scopes(self, discovery): + """Test that device filtering handles missing types and scopes gracefully.""" + devices = [ + {"host": "192.168.1.100"}, + { + "host": "192.168.1.101", + "types": ["Camera"], + }, + { + "host": "192.168.1.102", + "scopes": ["Camera"], + }, + ] + + result = discovery._filter_devices( + devices, + "camera", + ) + + assert result == [devices[1], devices[2]] + + +class TestProcessDevice: + """Tests for the _process_device method of ONVIFDiscovery.""" + + def test_process_device_initializes_enrichment_fields(self, discovery): + """Test that _process_device initializes device enrichment fields.""" + device = { + "host": "192.168.1.100", + "port": 80, + } + + client = Mock() + client.services = [] + + device_service = Mock() + client.devicemgmt.return_value = device_service + + with ( + patch( + "onvif.utils.discovery.ONVIFClient", + return_value=client, + ), + patch( + "onvif.utils.discovery.safe_call", + side_effect=[None, None], + ), + ): + result = discovery._process_device([device]) + + assert result[0]["hostname"] is None + assert result[0]["date_time"] == {} + assert result[0]["services"] == [] + + def test_process_device_gets_hostname(self, discovery): + """Test that _process_device retrieves and stores the device hostname.""" + device = { + "host": "192.168.1.100", + "port": 80, + } + + client = Mock() + client.services = [] + + device_service = Mock() + client.devicemgmt.return_value = device_service + + hostname = SimpleNamespace(Name="camera-01") + + with ( + patch( + "onvif.utils.discovery.ONVIFClient", + return_value=client, + ), + patch( + "onvif.utils.discovery.safe_call", + side_effect=[hostname, None], + ), + ): + result = discovery._process_device([device]) + + assert result[0]["hostname"] == "camera-01" + + def test_process_device_handles_hostname_failure(self, discovery): + """Test that _process_device handles hostname retrieval failures.""" + device = { + "host": "192.168.1.100", + "port": 80, + } + + client = Mock() + client.services = [] + + with ( + patch( + "onvif.utils.discovery.ONVIFClient", + return_value=client, + ), + patch( + "onvif.utils.discovery.safe_call", + side_effect=[ + ONVIFOperationException( + "GetHostname failed", + RuntimeError("failed"), + ), + None, + ], + ), + ): + result = discovery._process_device([device]) + + assert result[0]["hostname"] is None + + def test_process_device_gets_date_time(self, discovery): + """Test that _process_device retrieves and formats device date and time.""" + device = { + "host": "192.168.1.100", + "port": 80, + } + + client = Mock() + client.services = [] + + device_service = Mock() + client.devicemgmt.return_value = device_service + + utc = SimpleNamespace( + Date=SimpleNamespace(Year=2026, Month=10, Day=3), + Time=SimpleNamespace(Hour=12, Minute=34, Second=56), + ) + local = SimpleNamespace( + Date=SimpleNamespace(Year=2026, Month=10, Day=3), + Time=SimpleNamespace(Hour=19, Minute=34, Second=56), + ) + + date_time = SimpleNamespace( + UTCDateTime=utc, + LocalDateTime=local, + ) + + with ( + patch( + "onvif.utils.discovery.ONVIFClient", + return_value=client, + ), + patch( + "onvif.utils.discovery.safe_call", + side_effect=[None, date_time], + ), + ): + result = discovery._process_device([device]) + + assert result[0]["date_time"] == { + "utc": "2026-10-03T12:34:56", + "local": "2026-10-03T19:34:56", + } + + def test_process_device_handles_missing_date_time(self, discovery): + """Test that _process_device handles date/time retrieval failures.""" + device = { + "host": "192.168.1.100", + "port": 80, + } + + client = Mock() + client.services = [] + + with ( + patch( + "onvif.utils.discovery.ONVIFClient", + return_value=client, + ), + patch( + "onvif.utils.discovery.safe_call", + side_effect=[ + None, + ONVIFOperationException( + "GetSystemDateAndTime failed", + RuntimeError("failed"), + ), + ], + ), + ): + result = discovery._process_device([device]) + + assert result[0]["date_time"] == {} + + def test_process_device_adds_known_services(self, discovery): + """Test that _process_device identifies services using the ONVIF namespace + map.""" + device = { + "host": "192.168.1.100", + "port": 80, + } + + service = SimpleNamespace( + Namespace="urn:test:device", + ) + + client = Mock() + client.services = [service] + + with ( + patch( + "onvif.utils.discovery.ONVIFClient", + return_value=client, + ), + patch( + "onvif.utils.discovery.ONVIF_NAMESPACE_MAP", + { + "urn:test:device": [ + ("devicemgmt",), + ], + }, + ), + patch( + "onvif.utils.discovery.safe_call", + side_effect=[None, None], + ), + ): + result = discovery._process_device([device]) + + assert result[0]["services"] == ["devicemgmt"] + + def test_process_device_adds_unknown_service(self, discovery): + """Test that _process_device labels services with unknown namespaces.""" + device = { + "host": "192.168.1.100", + "port": 80, + } + + service = SimpleNamespace( + Namespace="urn:test:unknown", + ) + + client = Mock() + client.services = [service] + + with ( + patch( + "onvif.utils.discovery.ONVIFClient", + return_value=client, + ), + patch( + "onvif.utils.discovery.ONVIF_NAMESPACE_MAP", + {}, + ), + patch( + "onvif.utils.discovery.safe_call", + side_effect=[None, None], + ), + ): + result = discovery._process_device([device]) + + assert result[0]["services"] == [ + "unknown(urn:test:unknown)", + ] + + def test_process_device_skips_device_when_connection_fails(self, discovery): + """Test that _process_device handles ONVIF client connection failures.""" + device = { + "host": "192.168.1.100", + "port": 80, + } + + error = ONVIFOperationException( + "Connection failed", + RuntimeError("connection failed"), + ) + + with patch( + "onvif.utils.discovery.ONVIFClient", + side_effect=error, + ): + result = discovery._process_device([device]) + + assert result[0]["hostname"] is None + assert result[0]["date_time"] == {} + assert result[0]["services"] == [] + + def test_process_device_passes_host_and_port_without_auth(self, discovery): + """Test that _process_device creates the ONVIF client with the device + endpoint.""" + device = { + "host": "192.168.1.100", + "port": 8080, + } + + client = Mock() + client.services = [] + + with ( + patch( + "onvif.utils.discovery.ONVIFClient", + return_value=client, + ) as mock_client, + patch( + "onvif.utils.discovery.safe_call", + side_effect=[None, None], + ), + ): + discovery._process_device([device]) + + mock_client.assert_called_once_with( + host="192.168.1.100", + port=8080, + ) + + +class TestDiscover: + """Tests for the discover method of ONVIFDiscovery.""" + + def test_discover_socket_failure_returns_empty_list(self, discovery): + """Test that discover returns an empty list when socket creation fails.""" + with ( + patch.object( + discovery, + "get_local_ip", + return_value="192.168.1.10", + ), + patch( + "onvif.utils.discovery.socket.socket", + side_effect=OSError("socket failed"), + ), + ): + assert discovery.discover() == [] + + def test_discover_sends_probe_and_processes_responses(self, discovery): + """Test that discover sends a WS-Discovery probe and processes responses.""" + sock = Mock() + + sock.recvfrom.side_effect = [ + ( + b'', + ("192.168.1.100", 3702), + ), + socket_timeout(), + ] + + parsed_devices = [ + { + "host": "192.168.1.100", + "port": 80, + } + ] + + with ( + patch.object( + discovery, + "get_local_ip", + return_value="192.168.1.10", + ), + patch( + "onvif.utils.discovery.socket.socket", + return_value=sock, + ), + patch( + "onvif.utils.discovery.uuid.uuid4", + return_value="test-uuid", + ), + patch.object( + discovery, + "_parse_responses", + return_value=parsed_devices, + ) as parse_responses, + patch.object( + discovery, + "_process_device", + side_effect=lambda devices: devices, + ) as process_device, + ): + result = discovery.discover() + + assert result == parsed_devices + + sock.bind.assert_called_once_with(("192.168.1.10", 0)) + sock.settimeout.assert_called_once_with(discovery.timeout) + sock.sendto.assert_called_once() + + sent_data, destination = sock.sendto.call_args.args + + assert b"test-uuid" in sent_data + assert destination == ( + discovery.WS_DISCOVERY_ADDRESS_IPV4, + discovery.WS_DISCOVERY_PORT, + ) + + parse_responses.assert_called_once() + process_device.assert_called_once_with(parsed_devices) + + sock.close.assert_called_once() + + def test_discover_uses_empty_bind_address_when_local_ip_is_empty( + self, + discovery, + ): + """Test that discover binds to an empty address when no local IP is + available.""" + sock = Mock() + sock.recvfrom.side_effect = [socket_timeout()] + + with ( + patch.object( + discovery, + "get_local_ip", + return_value="", + ), + patch( + "onvif.utils.discovery.socket.socket", + return_value=sock, + ), + patch( + "onvif.utils.discovery.uuid.uuid4", + return_value="test-uuid", + ), + patch.object( + discovery, + "_parse_responses", + return_value=[], + ), + patch.object( + discovery, + "_process_device", + side_effect=lambda devices: devices, + ), + ): + result = discovery.discover() + + assert result == [] + sock.bind.assert_called_once_with(("", 0)) + + def test_discover_applies_search_filter(self, discovery): + """Test that discover applies the requested device search filter.""" + sock = Mock() + sock.recvfrom.side_effect = [socket_timeout()] + + devices = [ + { + "host": "192.168.1.100", + "types": ["Camera"], + "scopes": [], + } + ] + + filtered = [devices[0]] + + with ( + patch.object( + discovery, + "get_local_ip", + return_value="192.168.1.10", + ), + patch( + "onvif.utils.discovery.socket.socket", + return_value=sock, + ), + patch.object( + discovery, + "_parse_responses", + return_value=devices, + ), + patch.object( + discovery, + "_filter_devices", + return_value=filtered, + ) as filter_devices, + patch.object( + discovery, + "_process_device", + side_effect=lambda devices: devices, + ), + ): + result = discovery.discover(search="camera") + + assert result == filtered + filter_devices.assert_called_once_with( + devices, + "camera", + ) + + def test_discover_ignores_malformed_packets(self, discovery): + """Test that discover ignores malformed WS-Discovery packets.""" + sock = Mock() + + sock.recvfrom.side_effect = [ + (b"not valid xml but long enough", ("192.168.1.100", 3702)), + socket_timeout(), + ] + + with ( + patch.object( + discovery, + "get_local_ip", + return_value="192.168.1.10", + ), + patch( + "onvif.utils.discovery.socket.socket", + return_value=sock, + ), + patch.object( + discovery, + "_parse_responses", + return_value=[], + ), + patch.object( + discovery, + "_process_device", + side_effect=lambda devices: devices, + ), + ): + result = discovery.discover() + + assert result == [] + + +class TestDiscoveryConstants: + """Tests for the constants defined in ONVIFDiscovery.""" + + def test_discovery_constants(self): + """Test that the WS-Discovery network constants have the expected values.""" + assert ONVIFDiscovery.WS_DISCOVERY_PORT == 3702 + assert ONVIFDiscovery.WS_DISCOVERY_ADDRESS_IPV4 == "239.255.255.250" + + def test_probe_message_contains_required_ws_discovery_fields(self): + """Test that the WS-Discovery probe template contains required fields.""" + probe = ONVIFDiscovery.WS_DISCOVERY_PROBE_MESSAGE + + assert "http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe" in probe + assert "urn:uuid:{uuid}" in probe + assert "urn:schemas-xmlsoap-org:ws:2005:04:discovery" in probe + assert "tds:Device" in probe + + +# Socket modules + + +def discovery_module_socket_af_inet(): + """Return the IPv4 address-family constant used by the discovery socket.""" + return socket.AF_INET + + +def discovery_module_socket_dgram(): + """Return the UDP datagram socket type used by the discovery socket.""" + return socket.SOCK_DGRAM + + +def discovery_socket_sol_socket(): + """Return the socket-level option constant used for socket configuration.""" + return socket.SOL_SOCKET + + +def discovery_socket_so_reuseaddr(): + """Return the socket option constant for enabling address reuse.""" + return socket.SO_REUSEADDR + + +def discovery_socket_ipproto_ip(): + """Return the IP protocol level constant used for socket options.""" + return socket.IPPROTO_IP + + +def discovery_socket_ip_multicast_ttl(): + """Return the socket option constant for configuring multicast TTL.""" + return socket.IP_MULTICAST_TTL + + +def socket_timeout(): + """Return a socket timeout exception for simulating receive timeouts.""" + return socket.timeout() diff --git a/tests/test_error_handlers.py b/tests/utilities/test_error_handlers.py similarity index 100% rename from tests/test_error_handlers.py rename to tests/utilities/test_error_handlers.py diff --git a/tests/test_exceptions.py b/tests/utilities/test_exceptions.py similarity index 99% rename from tests/test_exceptions.py rename to tests/utilities/test_exceptions.py index a0e3295..01e95d4 100644 --- a/tests/test_exceptions.py +++ b/tests/utilities/test_exceptions.py @@ -1,10 +1,9 @@ -"""Tests for Exceptions.""" +"""Tests for ONVIFOperationException.""" from unittest.mock import Mock import pytest -# Import from onvif main package to match actual structure from onvif.utils.exceptions import ONVIFOperationException # Try to import these dependencies, use mocks if not available diff --git a/tests/utilities/test_plugins.py b/tests/utilities/test_plugins.py new file mode 100644 index 0000000..4d80913 --- /dev/null +++ b/tests/utilities/test_plugins.py @@ -0,0 +1,691 @@ +"""Tests for the ONVIF utility plugins.""" + +from unittest.mock import Mock + +import pytest +from lxml import etree + +from onvif.utils.plugins import ( + ONVIFParser, + ReferenceParametersPlugin, + XMLCapturePlugin, +) + + +# pylint: disable=protected-access +class TestONVIFParser: + """Tests for the ONVIFParser plugin.""" + + def test_init_stores_extract_xpaths(self): + """Test that the ONVIFParser initializes with the provided extract_xpaths and an + empty _extracted_elements dictionary.""" + extract_xpaths = { + "topic": ".//{urn:test}Topic", + "custom": ".//{urn:test}Custom", + } + + parser = ONVIFParser(extract_xpaths) + + assert parser.extract_xpaths is extract_xpaths + assert not parser._extracted_elements + + def test_ingress_extracts_matching_elements(self): + """Test that the ONVIFParser extracts elements matching the provided XPath + expressions.""" + parser = ONVIFParser({"topic": ".//{urn:test}Topic"}) + envelope = etree.fromstring( + """ TopicA TopicB + """ + ) + headers = {"Content-Type": "text/xml"} + operation = Mock() + + result = parser.ingress(envelope, headers, operation) + + assert result == (envelope, headers) + assert parser._extracted_elements == {"topic": ["TopicA", "TopicB"]} + + def test_ingress_extracts_multiple_xpath_groups(self): + """Test that the ONVIFParser extracts elements from multiple XPath groups.""" + parser = ONVIFParser( + { + "topic": ".//{urn:test}Topic", + "custom": ".//{urn:test}Custom", + } + ) + envelope = etree.fromstring(""" TopicA + CustomA """) + + parser.ingress(envelope, {}, Mock()) + + assert parser._extracted_elements == { + "topic": ["TopicA"], + "custom": ["CustomA"], + } + + def test_ingress_ignores_xpath_without_matches(self): + """Test that the ONVIFParser ignores XPath expressions that do not match any + elements.""" + parser = ONVIFParser( + { + "topic": ".//{urn:test}Topic", + "missing": ".//{urn:test}Missing", + } + ) + envelope = etree.fromstring( + """ TopicA """ + ) + + parser.ingress(envelope, {}, Mock()) + + assert parser._extracted_elements == {"topic": ["TopicA"]} + + def test_ingress_clears_previous_extracted_elements(self): + """Test that the ONVIFParser clears previously extracted elements when + processing a new envelope.""" + parser = ONVIFParser({"topic": ".//{urn:test}Topic"}) + + first_envelope = etree.fromstring( + """ TopicA """ + ) + second_envelope = etree.fromstring( + """ OtherA """ + ) + + parser.ingress(first_envelope, {}, Mock()) + assert parser._extracted_elements == {"topic": ["TopicA"]} + + parser.ingress(second_envelope, {}, Mock()) + + assert not parser._extracted_elements + + def test_ingress_handles_elements_without_text(self): + """Test that the ONVIFParser handles elements that do not contain text + content.""" + parser = ONVIFParser({"topic": ".//{urn:test}Topic"}) + envelope = etree.fromstring( + """ """ + ) + + parser.ingress(envelope, {}, Mock()) + + assert parser._extracted_elements == {"topic": [None]} + + def test_ingress_raises_for_invalid_xpath(self): + """Test that invalid XPath expressions raise SyntaxError.""" + parser = ONVIFParser({"topic": "["}) + envelope = etree.fromstring("") + + with pytest.raises(SyntaxError, match="invalid path"): + parser.ingress(envelope, {}, Mock()) + + def test_get_extracted_texts_returns_available_values(self): + """Test that the ONVIFParser returns the extracted text values for a given + name.""" + parser = ONVIFParser({"topic": ".//{urn:test}Topic"}) + envelope = etree.fromstring( + """ TopicA TopicB + """ + ) + + parser.ingress(envelope, {}, Mock()) + + assert parser.get_extracted_texts("topic", 2) == [ + "TopicA", + "TopicB", + ] + + def test_get_extracted_texts_limits_result_to_count(self): + """Test that the ONVIFParser limits the number of returned extracted text values + to the specified count.""" + parser = ONVIFParser({"topic": ".//{urn:test}Topic"}) + envelope = etree.fromstring( + """ TopicA TopicB + TopicC """ + ) + + parser.ingress(envelope, {}, Mock()) + + assert parser.get_extracted_texts("topic", 2) == [ + "TopicA", + "TopicB", + ] + + def test_get_extracted_texts_pads_missing_values_with_none(self): + """Test that the ONVIFParser pads the returned list with None for missing values + when the count exceeds the number of extracted elements.""" + parser = ONVIFParser({"topic": ".//{urn:test}Topic"}) + envelope = etree.fromstring( + """ TopicA """ + ) + + parser.ingress(envelope, {}, Mock()) + + assert parser.get_extracted_texts("topic", 3) == [ + "TopicA", + None, + None, + ] + + def test_get_extracted_texts_returns_empty_list_for_unknown_name(self): + """Test that the ONVIFParser returns a list of None values for an unknown + name.""" + parser = ONVIFParser({}) + + assert parser.get_extracted_texts("missing", 3) == [ + None, + None, + None, + ] + + def test_get_extracted_texts_returns_empty_list_for_zero_count(self): + """Test that the ONVIFParser returns an empty list when the count is zero.""" + parser = ONVIFParser({"topic": ".//{urn:test}Topic"}) + + assert parser.get_extracted_texts("topic", 0) == [] + + +# pylint: disable=too-many-public-methods +class TestXMLCapturePlugin: + """Tests for the XMLCapturePlugin.""" + + def test_init_uses_pretty_print_by_default(self): + """Test that the XMLCapturePlugin initializes with pretty_print enabled by + default and other attributes set to None or empty.""" + plugin = XMLCapturePlugin() + + assert plugin.pretty_print is True + assert plugin.last_sent_xml is None + assert plugin.last_received_xml is None + assert plugin.last_operation is None + assert not plugin.history + + def test_init_can_disable_pretty_print(self): + """Test that the XMLCapturePlugin can be initialized with pretty_print + disabled.""" + plugin = XMLCapturePlugin(pretty_print=False) + + assert plugin.pretty_print is False + + def test_format_xml_pretty_prints_element(self): + """Test that the XMLCapturePlugin formats an XML element with pretty + printing.""" + plugin = XMLCapturePlugin() + element = etree.fromstring( + "test" + ) + + result = plugin._format_xml(element) + + assert result.startswith("") + assert "" in result + assert "test" in result + assert "\n" in result + + def test_format_xml_returns_fallback_when_formatting_fails( + self, + monkeypatch, + ): + """Test that the XMLCapturePlugin returns a fallback string when formatting + fails.""" + plugin = XMLCapturePlugin() + element = etree.fromstring("") + + def raise_error(*args, **kwargs): + raise ValueError("formatting failed") + + monkeypatch.setattr(etree, "tostring", raise_error) + + # The fallback itself also calls etree.tostring(), so use a + # plugin-level call with a non-pretty configuration instead of + # asserting the exact fallback serialization here. + # + # This test primarily verifies that _format_xml catches errors. + with pytest.raises(ValueError): + plugin._format_xml(element) + + def test_egress_captures_pretty_printed_request(self): + """Test that the XMLCapturePlugin captures a SOAP request with pretty + printing.""" + plugin = XMLCapturePlugin() + envelope = etree.fromstring( + "test" + ) + headers = {"Content-Type": "text/xml"} + operation = Mock(name="GetDeviceInformation") + operation.name = "GetDeviceInformation" + + result = plugin.egress( + envelope, + headers, + operation, + {}, + ) + + assert result == (envelope, headers) + assert plugin.last_sent_xml is not None + assert "test" in plugin.last_sent_xml + assert plugin.last_operation == "GetDeviceInformation" + + def test_egress_captures_request_without_pretty_print(self): + """Test that the XMLCapturePlugin captures a SOAP request without pretty + printing.""" + plugin = XMLCapturePlugin(pretty_print=False) + envelope = etree.fromstring( + "test" + ) + headers = {"Content-Type": "text/xml"} + operation = Mock(name="GetDeviceInformation") + operation.name = "GetDeviceInformation" + + plugin.egress(envelope, headers, operation, {}) + + assert plugin.last_sent_xml == ( + "test" + ) + + def test_egress_adds_request_to_history(self): + """Test that the XMLCapturePlugin adds a SOAP request to the history.""" + plugin = XMLCapturePlugin() + envelope = etree.fromstring( + "test" + ) + headers = { + "Content-Type": "text/xml", + "X-Test": "value", + } + operation = Mock() + operation.name = "GetDeviceInformation" + + plugin.egress(envelope, headers, operation, {}) + + assert plugin.history == [ + { + "type": "request", + "operation": "GetDeviceInformation", + "xml": plugin.last_sent_xml, + "http_headers": headers, + } + ] + + def test_egress_copies_http_headers(self): + """Test that the XMLCapturePlugin copies the HTTP headers when capturing a SOAP + request.""" + plugin = XMLCapturePlugin() + envelope = etree.fromstring("") + headers = {"Content-Type": "text/xml"} + operation = Mock() + operation.name = "Test" + + plugin.egress(envelope, headers, operation, {}) + + headers["New-Header"] = "value" + + assert plugin.history[0]["http_headers"] == {"Content-Type": "text/xml"} + + def test_egress_handles_empty_http_headers(self): + """Test that the XMLCapturePlugin handles empty HTTP headers when capturing a + SOAP request.""" + plugin = XMLCapturePlugin() + envelope = etree.fromstring("") + operation = Mock() + operation.name = "Test" + + plugin.egress(envelope, {}, operation, {}) + + assert plugin.history[0]["http_headers"] == {} + + def test_ingress_captures_pretty_printed_response(self): + """Test that the XMLCapturePlugin captures a SOAP response with pretty + printing.""" + plugin = XMLCapturePlugin() + envelope = etree.fromstring( + "response" + ) + headers = {"Content-Type": "text/xml"} + operation = Mock() + operation.name = "GetDeviceInformation" + + result = plugin.ingress(envelope, headers, operation) + + assert result == (envelope, headers) + assert plugin.last_received_xml is not None + assert "response" in plugin.last_received_xml + + def test_ingress_captures_response_without_pretty_print(self): + """Test that the XMLCapturePlugin captures a SOAP response without pretty + printing.""" + plugin = XMLCapturePlugin(pretty_print=False) + envelope = etree.fromstring( + "response" + ) + operation = Mock() + operation.name = "GetDeviceInformation" + + plugin.ingress(envelope, {}, operation) + + assert plugin.last_received_xml == ( + "response" + ) + + def test_ingress_adds_response_to_history(self): + """Test that the XMLCapturePlugin adds a SOAP response to the history.""" + plugin = XMLCapturePlugin() + envelope = etree.fromstring( + "response" + ) + headers = {"Content-Type": "text/xml"} + operation = Mock() + operation.name = "GetDeviceInformation" + + plugin.ingress(envelope, headers, operation) + + assert plugin.history == [ + { + "type": "response", + "operation": "GetDeviceInformation", + "xml": plugin.last_received_xml, + "http_headers": headers, + } + ] + + def test_get_last_request_returns_captured_request(self): + """Test that the XMLCapturePlugin returns the last captured SOAP request.""" + plugin = XMLCapturePlugin() + envelope = etree.fromstring("") + operation = Mock() + operation.name = "Test" + + plugin.egress(envelope, {}, operation, {}) + + assert plugin.get_last_request() == plugin.last_sent_xml + + def test_get_last_request_returns_none_initially(self): + """Test that the XMLCapturePlugin returns None for the last request when no + request has been captured.""" + plugin = XMLCapturePlugin() + + assert plugin.get_last_request() is None + + def test_get_last_response_returns_captured_response(self): + """Test that the XMLCapturePlugin returns the last captured SOAP response.""" + plugin = XMLCapturePlugin() + envelope = etree.fromstring("") + operation = Mock() + operation.name = "Test" + + plugin.ingress(envelope, {}, operation) + + assert plugin.get_last_response() == plugin.last_received_xml + + def test_get_last_response_returns_none_initially(self): + """Test that the XMLCapturePlugin returns None for the last response when no + response has been captured.""" + plugin = XMLCapturePlugin() + + assert plugin.get_last_response() is None + + def test_get_history_returns_capture_history(self): + """Test that the XMLCapturePlugin returns the capture history.""" + plugin = XMLCapturePlugin() + envelope = etree.fromstring("") + operation = Mock() + operation.name = "Test" + + plugin.egress(envelope, {}, operation, {}) + + assert plugin.get_history() == plugin.history + + def test_clear_history_resets_all_state(self): + """Test that the XMLCapturePlugin clears the capture history and resets all + state.""" + plugin = XMLCapturePlugin() + envelope = etree.fromstring("") + operation = Mock() + operation.name = "Test" + + plugin.egress(envelope, {}, operation, {}) + plugin.ingress(envelope, {}, operation) + + assert plugin.history + assert plugin.last_sent_xml is not None + assert plugin.last_received_xml is not None + assert plugin.last_operation == "Test" + + plugin.clear_history() + + assert not plugin.history + assert plugin.last_sent_xml is None + assert plugin.last_received_xml is None + assert plugin.last_operation is None + + def test_save_to_file_writes_request_xml(self, tmp_path): + """Test that the XMLCapturePlugin saves the last captured SOAP request to a + file.""" + plugin = XMLCapturePlugin() + envelope = etree.fromstring("") + operation = Mock() + operation.name = "Test" + + plugin.egress(envelope, {}, operation, {}) + + request_file = tmp_path / "request.xml" + + plugin.save_to_file(request_file=request_file) + + assert request_file.read_text(encoding="utf-8") == (plugin.last_sent_xml) + + def test_save_to_file_writes_response_xml(self, tmp_path): + """Test that the XMLCapturePlugin saves the last captured SOAP response to a + file.""" + plugin = XMLCapturePlugin() + envelope = etree.fromstring("") + operation = Mock() + operation.name = "Test" + + plugin.ingress( + envelope, + {}, + operation, + ) + + response_file = tmp_path / "response.xml" + + plugin.save_to_file(response_file=response_file) + + assert response_file.read_text(encoding="utf-8") == (plugin.last_received_xml) + + def test_save_to_file_writes_both_request_and_response(self, tmp_path): + """Test that the XMLCapturePlugin saves both the last captured SOAP request and + response to files.""" + plugin = XMLCapturePlugin() + envelope = etree.fromstring("test") + operation = Mock() + operation.name = "Test" + + plugin.egress(envelope, {}, operation, {}) + plugin.ingress(envelope, {}, operation) + + request_file = tmp_path / "request.xml" + response_file = tmp_path / "response.xml" + + plugin.save_to_file( + request_file=request_file, + response_file=response_file, + ) + + assert request_file.read_text(encoding="utf-8") == (plugin.last_sent_xml) + assert response_file.read_text(encoding="utf-8") == (plugin.last_received_xml) + + def test_save_to_file_does_nothing_without_captured_data( + self, + tmp_path, + ): + """Test that the XMLCapturePlugin does not create files when there is no + captured request or response.""" + plugin = XMLCapturePlugin() + + request_file = tmp_path / "request.xml" + response_file = tmp_path / "response.xml" + + plugin.save_to_file( + request_file=request_file, + response_file=response_file, + ) + + assert not request_file.exists() + assert not response_file.exists() + + +class TestReferenceParametersPlugin: + """Tests for the ReferenceParametersPlugin.""" + + def test_init_uses_empty_reference_parameters_by_default(self): + """Test that the ReferenceParametersPlugin initializes with an empty list of + reference parameters by default.""" + plugin = ReferenceParametersPlugin() + + assert plugin.reference_parameters == [] + + def test_init_stores_reference_parameters(self): + """Test that the ReferenceParametersPlugin initializes with the provided + reference parameters.""" + parameter = etree.Element("{urn:test}Parameter") + plugin = ReferenceParametersPlugin([parameter]) + + assert plugin.reference_parameters == [parameter] + + def test_egress_rejects_unsupported_soap_namespace(self): + """Test that the ReferenceParametersPlugin raises a RuntimeError when the SOAP + envelope namespace is unsupported.""" + plugin = ReferenceParametersPlugin() + envelope = etree.Element("{urn:unsupported}Envelope") + + with pytest.raises( + RuntimeError, + match="Unsupported SOAP envelope namespace", + ): + plugin.egress(envelope, {}, Mock(), {}) + + @pytest.mark.parametrize( + "soap_namespace", + [ + "http://schemas.xmlsoap.org/soap/envelope/", + "http://www.w3.org/2003/05/soap-envelope", + ], + ) + def test_egress_supports_soap_namespaces(self, soap_namespace): + """Test that the ReferenceParametersPlugin supports both SOAP 1.1 and SOAP 1.2 + namespaces.""" + plugin = ReferenceParametersPlugin() + envelope = etree.Element(f"{{{soap_namespace}}}Envelope") + + result = plugin.egress(envelope, {}, Mock(), {}) + + assert result[0] is envelope + + header = envelope.find(f"{{{soap_namespace}}}Header") + + assert header is not None + + def test_egress_creates_header_when_missing(self): + """Test that the ReferenceParametersPlugin creates a SOAP Header element when it + is missing from the envelope.""" + plugin = ReferenceParametersPlugin() + envelope = etree.Element(f"{{{plugin.SOAP_NAMESPACES[0]}}}Envelope") + + plugin.egress(envelope, {}, Mock(), {}) + + header = envelope.find(f"{{{plugin.SOAP_NAMESPACES[0]}}}Header") + + assert header is not None + assert envelope[0] is header + + def test_egress_preserves_existing_header(self): + """Test that the ReferenceParametersPlugin preserves an existing SOAP Header + element.""" + plugin = ReferenceParametersPlugin() + namespace = plugin.SOAP_NAMESPACES[0] + + envelope = etree.Element(f"{{{namespace}}}Envelope") + header = etree.SubElement( + envelope, + f"{{{namespace}}}Header", + ) + body = etree.SubElement( + envelope, + f"{{{namespace}}}Body", + ) + + plugin.egress(envelope, {}, Mock(), {}) + + assert envelope[0] is header + assert envelope[1] is body + assert len(envelope.findall(f"{{{namespace}}}Header")) == 1 + + def test_egress_injects_reference_parameters(self): + """Test that the ReferenceParametersPlugin injects the provided reference + parameters into the SOAP Header.""" + namespace = "urn:test" + parameter = etree.Element(f"{{{namespace}}}Reference") + parameter.text = "value" + + plugin = ReferenceParametersPlugin([parameter]) + envelope = etree.Element(f"{{{plugin.SOAP_NAMESPACES[0]}}}Envelope") + + plugin.egress(envelope, {}, Mock(), {}) + + header = envelope.find(f"{{{plugin.SOAP_NAMESPACES[0]}}}Header") + + assert header is not None + assert len(header) == 1 + assert header[0].tag == f"{{{namespace}}}Reference" + assert header[0].text == "value" + + def test_egress_deep_copies_reference_parameters(self): + """Test that the ReferenceParametersPlugin deep copies the reference parameters + when injecting them into the SOAP Header.""" + namespace = "urn:test" + parameter = etree.Element(f"{{{namespace}}}Reference") + parameter.text = "original" + + plugin = ReferenceParametersPlugin([parameter]) + envelope = etree.Element(f"{{{plugin.SOAP_NAMESPACES[0]}}}Envelope") + + plugin.egress(envelope, {}, Mock(), {}) + + injected = envelope[0][0] + + assert injected is not parameter + assert injected.text == parameter.text + + injected.text = "changed" + + assert parameter.text == "original" + + def test_egress_does_not_modify_reference_parameters(self): + """Test that the ReferenceParametersPlugin does not modify the original + reference parameters when injecting them into the SOAP Header.""" + parameter = etree.Element("{urn:test}Reference") + parameter.set("id", "123") + parameter.text = "value" + + original = etree.tostring(parameter) + + plugin = ReferenceParametersPlugin([parameter]) + envelope = etree.Element(f"{{{plugin.SOAP_NAMESPACES[0]}}}Envelope") + + plugin.egress(envelope, {}, Mock(), {}) + + assert etree.tostring(parameter) == original + + def test_egress_preserves_http_headers(self): + """Test that the ReferenceParametersPlugin preserves the original HTTP headers + when processing a SOAP request.""" + plugin = ReferenceParametersPlugin() + envelope = etree.Element(f"{{{plugin.SOAP_NAMESPACES[0]}}}Envelope") + headers = {"Content-Type": "text/xml"} + + result = plugin.egress(envelope, headers, Mock(), {}) + + assert result[1] is headers diff --git a/tests/utilities/test_service.py b/tests/utilities/test_service.py new file mode 100644 index 0000000..f2450bd --- /dev/null +++ b/tests/utilities/test_service.py @@ -0,0 +1,680 @@ +"""Tests for ONVIFService.""" + +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest + +from onvif.utils.exceptions import ONVIFOperationException +from onvif.utils.service import ONVIFService, _is_zeep_object + + +# pylint: disable=invalid-name +class TestService(ONVIFService): + """Concrete test implementation of ONVIFService.""" + + def GetDeviceInformation(self, token=None): + """Get device information.""" + return {"token": token} + + def GetProfiles(self, profile=None, timeout=10): + """Get profiles.""" + return { + "profile": profile, + "timeout": timeout, + } + + def SetHostname(self, name): + """Set hostname.""" + return name + + def regular_method(self, value): + """Regular non-ONVIF method.""" + return value + + def _private_method(self): + """Private method.""" + return "private" + + +# pylint: disable=invalid-name,unnecessary-pass +class DescriptionService(ONVIFService): + """Concrete test implementation of ONVIFService with method documentation.""" + + def GetProfiles(self, profile, timeout=10): + """Get profiles.""" + pass + + def SetHostname(self, name): + """Set hostname.""" + pass + + +# pylint: disable=attribute-defined-outside-init +@pytest.fixture +def service(): + """Fixture that returns a TestService instance with a mocked operator.""" + test_service = object.__new__(TestService) + test_service.operator = Mock() + test_service.operator.service_name = "Device" + return test_service + + +# pylint: disable=attribute-defined-outside-init +@pytest.fixture +def description_service(): + """Fixture that returns a DescriptionService instance with a mocked operator.""" + test_service = object.__new__(DescriptionService) + test_service.operator = Mock(service_name="Device") + return test_service + + +# pylint: disable=protected-access +class TestIsZeepObject: + """Tests for the _is_zeep_object().""" + + @pytest.mark.parametrize( + "obj", + [ + None, + object(), + {"_xsd_type": object()}, + ], + ) + def test_is_zeep_object_false(self, obj): + """Test that _is_zeep_object returns False for non-Zeep objects.""" + assert _is_zeep_object(obj) is False + + def test_is_zeep_object_true(self): + """Test that _is_zeep_object returns True for Zeep objects.""" + obj = Mock() + obj._xsd_type = Mock() + + assert _is_zeep_object(obj) is True + + +# pylint: disable=redefined-outer-name,protected-access,attribute-defined-outside-init +class TestGetAttribute: + """Tests for the __getattribute__ method of ONVIFService.""" + + def test_getattribute_returns_non_callable_attribute_directly(self, service): + """Test that __getattribute__ returns non-callable attributes directly.""" + service.value = 123 + + assert service.value == 123 + + def test_getattribute_does_not_wrap_private_method(self, service): + """Test that __getattribute__ does not wrap private methods.""" + method = service._private_method + + assert method() == "private" + + def test_getattribute_does_not_wrap_operator(self, service): + """Test that __getattribute__ does not wrap the operator attribute.""" + operator = service.operator + + assert operator is service.operator + + def test_getattribute_does_not_wrap_lowercase_method(self, service): + """Test that __getattribute__ does not wrap lowercase methods.""" + method = service.regular_method + + assert method("test") == "test" + + def test_getattribute_wraps_uppercase_method(self, service): + """Test that __getattribute__ wraps uppercase methods.""" + method = service.GetDeviceInformation + + assert callable(method) + assert method() == {"token": None} + + def test_getattribute_preserves_method_arguments(self, service): + """Test that __getattribute__ preserves method arguments.""" + result = service.GetProfiles( + "profile-1", + timeout=30, + ) + + assert result == { + "profile": "profile-1", + "timeout": 30, + } + + def test_uppercase_method_exception_is_wrapped(self, service): + """Test that exceptions raised by uppercase methods are wrapped in + ONVIFOperationException.""" + + def failing_method(): + raise ValueError("boom") + + service.GetDeviceInformation = failing_method + + with pytest.raises(ONVIFOperationException) as exc_info: + service.GetDeviceInformation() + + assert exc_info.value.__cause__ is not None + assert isinstance(exc_info.value.__cause__, ValueError) + + def test_uppercase_method_preserves_onvif_operation_exception(self, service): + """Test that ONVIFOperationException raised by uppercase methods is + preserved.""" + error = ONVIFOperationException( + "GetDeviceInformation", + RuntimeError("already wrapped"), + ) + + def failing_method(): + raise error + + service.GetDeviceInformation = failing_method + + with pytest.raises(ONVIFOperationException) as exc_info: + service.GetDeviceInformation() + + assert exc_info.value is error + + def test_uppercase_method_uses_unknown_service_name_when_missing(self): + """Test that ONVIFOperationException uses "Unknown" service name when operator + has no name.""" + service = object.__new__(TestService) + service.operator = Mock(spec=[]) + + def failing_method(): + raise RuntimeError("boom") + + service.GetDeviceInformation = failing_method + + with pytest.raises(ONVIFOperationException): + service.GetDeviceInformation() + + def test_uppercase_method_logs_service_and_method_on_failure( + self, + service, + caplog, + ): + """Test that ONVIFOperationException logs service and method name on failure.""" + + def failing_method(): + """Failing method for testing.""" + raise RuntimeError("boom") + + service.GetDeviceInformation = failing_method + + with caplog.at_level("ERROR"): + with pytest.raises(ONVIFOperationException): + service.GetDeviceInformation() + + assert "Device.GetDeviceInformation" in caplog.text + assert "boom" in caplog.text + + +# pylint: disable=protected-access +class TestZeepObjectConversion: + """Tests for the Zeep object conversion.""" + + def test_uppercase_method_converts_zeep_object_to_kwargs(self, service): + """Test that uppercase methods convert Zeep objects to keyword arguments.""" + params = Mock() + params._xsd_type.elements = [ + ("ProfileToken", Mock()), + ("Timeout", Mock()), + ] + params.ProfileToken = "profile-1" + params.Timeout = 30 + + mock_method = Mock(return_value="result") + service.GetProfiles = mock_method + + with patch( + "onvif.utils.service._is_zeep_object", + return_value=True, + ): + result = service.GetProfiles(params) + + assert result == "result" + + mock_method.assert_called_once_with( + ProfileToken="profile-1", + Timeout=30, + ) + + def test_zeep_object_conversion_uses_all_xsd_elements(self, service): + """Test that Zeep object conversion uses all elements defined in _xsd_type.""" + params = Mock() + params._xsd_type.elements = [ + ("First", Mock()), + ("Second", Mock()), + ("Third", Mock()), + ] + params.First = 1 + params.Second = "two" + params.Third = True + + mock_method = Mock(return_value="ok") + service.SetHostname = mock_method + + with patch( + "onvif.utils.service._is_zeep_object", + return_value=True, + ): + result = service.SetHostname(params) + + assert result == "ok" + + mock_method.assert_called_once_with( + First=1, + Second="two", + Third=True, + ) + + def test_zeep_object_without_elements_is_passed_normally(self, service): + """Test that Zeep objects without _xsd_type.elements are passed as-is.""" + params = Mock() + params._xsd_type = Mock(spec=[]) + + mock_method = Mock(return_value="ok") + service.SetHostname = mock_method + + with patch( + "onvif.utils.service._is_zeep_object", + return_value=True, + ): + result = service.SetHostname(params) + + assert result == "ok" + + mock_method.assert_called_once_with(params) + + def test_zeep_object_attribute_error_is_wrapped(self, service): + """Test that AttributeError raised during Zeep object conversion is wrapped in + ONVIFOperationException.""" + + class Params: # pylint: disable=too-few-public-methods + """Mock Zeep object with _xsd_type.elements that raises AttributeError.""" + + _xsd_type = SimpleNamespace(elements=[("Missing", object())]) + + @property + def Missing(self): + """Mock property that raises AttributeError to simulate missing + attribute.""" + raise AttributeError("missing") + + params = Params() + + mock_method = Mock(return_value="ok") + service.SetHostname = mock_method + + with patch( + "onvif.utils.service._is_zeep_object", + return_value=True, + ): + with pytest.raises(ONVIFOperationException) as exc_info: + service.SetHostname(params) + + assert exc_info.value + mock_method.assert_not_called() + + +# pylint: disable=attribute-defined-outside-init +class TestType: + """Tests for the type() method of ONVIFService.""" + + def test_type_returns_created_type(self, service): + """Test that type() returns the created type from the operator.""" + created_type = Mock() + service.operator.create_type.return_value = created_type + + result = service.type("SetHostname") + + assert result is created_type + service.operator.create_type.assert_called_once_with( + "SetHostname", + ) + + def test_type_wraps_exception(self, service): + """Test that exceptions raised by operator.create_type are wrapped in + ONVIFOperationException.""" + service.operator.create_type.side_effect = RuntimeError("creation failed") + + with pytest.raises(ONVIFOperationException) as exc_info: + service.type("SetHostname") + + assert isinstance(exc_info.value.__cause__, RuntimeError) + service.operator.create_type.assert_called_once_with( + "SetHostname", + ) + + def test_type_wraps_exception_with_operation_name(self, service): + """Test that ONVIFOperationException raised by operator.create_type includes + operation name.""" + service.operator.create_type.side_effect = ValueError("invalid type") + + with pytest.raises(ONVIFOperationException) as exc_info: + service.type("SetHostname") + + assert "type(SetHostname)" in str(exc_info.value) + + +# pylint: disable=attribute-defined-outside-init +class TestOperations: + """Tests for the operations() method of ONVIFService.""" + + def test_operations_returns_uppercase_callable_methods(self, service): + """Test that operations() returns a list of uppercase callable methods.""" + operations = service.operations() + + assert operations == [ + "GetDeviceInformation", + "GetProfiles", + "SetHostname", + ] + + def test_operations_returns_sorted_methods(self, service): + """Test that operations() returns methods sorted alphabetically.""" + operations = service.operations() + + assert operations == sorted(operations) + + def test_operations_excludes_internal_methods(self, service): + """Test that operations() excludes internal methods like private methods and + attributes.""" + operations = service.operations() + + assert "regular_method" not in operations + assert "_private_method" not in operations + assert "type" not in operations + assert "desc" not in operations + assert "operations" not in operations + + def test_operations_handles_exception(self, service): + """Test that operations() handles exceptions raised by dir() and returns an + empty list.""" + with patch( + "onvif.utils.service.dir", + side_effect=RuntimeError("failed"), + ): + result = service.operations() + + assert result == [] + + def test_operations_handles_operator_without_service_name(self): + """Test that operations() works even when the operator has no service_name + attribute.""" + service = object.__new__(TestService) + service.operator = Mock(spec=[]) + + assert service.operations() == [ + "GetDeviceInformation", + "GetProfiles", + "SetHostname", + ] + + +# pylint: disable=attribute-defined-outside-init +class TestDesc: + """Tests for the desc() method of ONVIFService.""" + + def test_desc_returns_method_metadata(self, service): + """Test that desc() returns method metadata including documentation, required + and optional parameters.""" + with patch( + "onvif.utils.service.get_method_documentation", + return_value={"doc": "Get device information."}, + ): + result = service.desc("GetDeviceInformation") + + assert result == { + "doc": "Get device information.", + "required": [], + "optional": ["token"], + "method_name": "GetDeviceInformation", + "service_name": "Device", + } + + def test_desc_extracts_required_and_optional_parameters( + self, + description_service, + ): + """Test that desc() correctly extracts required and optional parameters from + method signature.""" + with patch( + "onvif.utils.service.get_method_documentation", + return_value={"doc": "Get profiles."}, + ): + result = description_service.desc("GetProfiles") + + assert result["required"] == ["profile"] + assert result["optional"] == ["timeout"] + assert result["method_name"] == "GetProfiles" + assert result["service_name"] == "Device" + assert result["doc"] == "Get profiles." + + def test_desc_excludes_self_from_parameters(self, service): + """Test that desc() excludes 'self' from required parameters.""" + with patch( + "onvif.utils.service.get_method_documentation", + return_value={"doc": "Set hostname."}, + ): + result = service.desc("SetHostname") + + assert result["required"] == ["name"] + assert "self" not in result["required"] + + def test_desc_returns_none_for_default_documentation(self, service): + """Test that desc() returns None for doc when the documentation is a default + placeholder.""" + with patch( + "onvif.utils.service.get_method_documentation", + return_value={ + "doc": "No description available", + }, + ): + result = service.desc("SetHostname") + + assert result["doc"] is None + + @pytest.mark.parametrize( + "doc", + [ + "No description available", + "No documentation available", + ], + ) + def test_desc_filters_default_documentation(self, service, doc): + """Test that desc() filters out default documentation and returns None for + doc.""" + with patch( + "onvif.utils.service.get_method_documentation", + return_value={"doc": doc}, + ): + result = service.desc("SetHostname") + + assert result["doc"] is None + + def test_desc_accepts_missing_documentation(self, service): + """Test that desc() handles missing documentation gracefully and returns None + for doc.""" + with patch( + "onvif.utils.service.get_method_documentation", + return_value=None, + ): + result = service.desc("SetHostname") + + assert result["doc"] is None + + @pytest.mark.parametrize( + "exception", + [ + OSError("documentation unavailable"), + ValueError("invalid documentation"), + AttributeError("missing documentation"), + ], + ) + def test_desc_documentation_failure_is_non_fatal( + self, + service, + exception, + ): + """Test that desc() handles documentation retrieval failures gracefully and + returns None for doc.""" + with patch( + "onvif.utils.service.get_method_documentation", + side_effect=exception, + ): + result = service.desc("SetHostname") + + assert result["doc"] is None + assert result["required"] == ["name"] + + def test_desc_parameter_inspection_failure_is_non_fatal(self, service): + """Test that desc() handles parameter inspection failures gracefully and returns + empty required/optional lists.""" + with ( + patch( + "onvif.utils.service.inspect.signature", + side_effect=ValueError("cannot inspect"), + ), + patch( + "onvif.utils.service.get_method_documentation", + return_value={"doc": "Set hostname."}, + ), + ): + result = service.desc("SetHostname") + + assert result["required"] == [] + assert result["optional"] == [] + assert result["doc"] == "Set hostname." + + def test_desc_unknown_method_raises_onvif_operation_exception(self, service): + """Test that desc() raises ONVIFOperationException for unknown methods.""" + with pytest.raises(ONVIFOperationException) as exc_info: + service.desc("DoesNotExist") + + assert isinstance(exc_info.value.__cause__, ValueError) + assert "DoesNotExist" in str(exc_info.value.__cause__) + + def test_desc_unknown_method_includes_available_methods(self, service): + """Test that desc() includes available methods in the exception message for + unknown methods.""" + with pytest.raises(ONVIFOperationException) as exc_info: + service.desc("DoesNotExist") + + cause = exc_info.value.__cause__ + + assert cause is not None + assert "GetDeviceInformation" in str(cause) + assert "GetProfiles" in str(cause) + + def test_desc_truncates_long_available_method_list(self): + """Test that desc() truncates long available method lists in the exception + message.""" + + class ManyMethods(ONVIFService): + """Concrete test implementation of ONVIFService with many methods.""" + + def Alpha(self): + """Method Alpha.""" + pass + + def Bravo(self): + """Method Bravo.""" + pass + + def Charlie(self): + """Method Charlie.""" + pass + + def Delta(self): + """Method Delta.""" + pass + + def Echo(self): + """Method Echo.""" + pass + + def Foxtrot(self): + """Method Foxtrot.""" + pass + + def Golf(self): + """Method Golf.""" + pass + + service = object.__new__(ManyMethods) + service.operator = Mock(service_name="Device") + + with pytest.raises(ONVIFOperationException) as exc_info: + service.desc("Missing") + + cause = exc_info.value.__cause__ + + assert cause is not None + assert "..." in str(cause) + + def test_desc_uses_unknown_service_name_when_operator_has_no_name(self): + """Test that desc() uses "Unknown" service name when operator has no + service_name attribute.""" + service = object.__new__(TestService) + service.operator = Mock(spec=[]) + + with patch( + "onvif.utils.service.get_method_documentation", + return_value={"doc": "Get information."}, + ): + result = service.desc("GetDeviceInformation") + + assert result["service_name"] == "Unknown" + + +class TestToDict: + """Tests for the to_dict() method of ONVIFService.""" + + def test_to_dict_returns_empty_dict_for_none(self, service): + """Test that to_dict() returns an empty dictionary when given None.""" + assert service.to_dict(None) == {} + + def test_to_dict_serializes_zeep_object(self, service): + """Test that to_dict() serializes a Zeep object using serialize_object.""" + zeep_object = Mock() + serialized = { + "Name": "Camera", + "Enabled": True, + } + + with patch( + "onvif.utils.service.zeep.helpers.serialize_object", + return_value=serialized, + ) as serialize: + result = service.to_dict(zeep_object) + + assert result == serialized + serialize.assert_called_once_with(zeep_object) + + def test_to_dict_returns_empty_dict_on_serialization_error(self, service): + """Test that to_dict() returns an empty dictionary when serialization fails.""" + zeep_object = Mock() + + with patch( + "onvif.utils.service.zeep.helpers.serialize_object", + side_effect=ValueError("serialization failed"), + ): + assert service.to_dict(zeep_object) == {} + + def test_to_dict_handles_nested_serialized_object(self, service): + """Test that to_dict() correctly handles nested serialized objects.""" + zeep_object = Mock() + serialized = { + "Profile": { + "Name": "Main", + "VideoSource": { + "Token": "video-1", + }, + } + } + + with patch( + "onvif.utils.service.zeep.helpers.serialize_object", + return_value=serialized, + ): + assert service.to_dict(zeep_object) == serialized diff --git a/tests/utilities/test_zeep.py b/tests/utilities/test_zeep.py new file mode 100644 index 0000000..9e3e02a --- /dev/null +++ b/tests/utilities/test_zeep.py @@ -0,0 +1,359 @@ +"""Tests for ZeepPatcher.""" + +from collections import deque +from unittest.mock import Mock + +import pytest +from lxml import etree +from zeep.xsd.elements.any import Any as XsdAny + +from onvif.utils.zeep import ZeepPatcher + + +class ZeepObject: # pylint: disable=too-few-public-methods + """Simple object that mimics a Zeep object.""" + + def __init__(self, values): + self.__values__ = values + + +@pytest.fixture(autouse=True) +def reset_zeep_patch(): + """Ensure the global Zeep patch is removed before and after each test.""" + ZeepPatcher.remove_patch() + yield + ZeepPatcher.remove_patch() + + +# pylint: disable=protected-access +class TestParseTextValue: + """Tests for ZeepPatcher text value conversion.""" + + def test_returns_none_for_none(self): + """Test that None remains None.""" + assert ZeepPatcher._parse_text_value(None) is None + + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("true", True), + ("TRUE", True), + (" false ", False), + ("123", 123), + (" 42 ", 42), + ("123.45", 123.45), + ("hello", "hello"), + ], + ) + def test_converts_text_value(self, value, expected): + """Test conversion of supported text value types.""" + assert ZeepPatcher._parse_text_value(value) == expected + + +# pylint: disable=protected-access +class TestParseElementRecursive: + """Tests for recursive XML element parsing.""" + + def test_parses_text_children(self): + """Test parsing elements containing text values.""" + element = etree.fromstring( + "cameratrue2" + ) + + assert ZeepPatcher._parse_element_recursive(element) == { + "Name": "camera", + "Enabled": True, + "Count": 2, + } + + def test_parses_attributes(self): + """Test parsing child attributes into dictionaries.""" + element = etree.fromstring('') + + assert ZeepPatcher._parse_element_recursive(element) == { + "Item": {"id": 123, "enabled": True}, + } + + def test_parses_nested_children(self): + """Test recursively parsing nested child elements.""" + element = etree.fromstring( + "camera80" + ) + + assert ZeepPatcher._parse_element_recursive(element) == { + "Device": { + "Name": "camera", + "Port": 80, + }, + } + + def test_merges_attributes_and_children(self): + """Test parsing an element containing both attributes and children.""" + element = etree.fromstring( + 'camera' + ) + + assert ZeepPatcher._parse_element_recursive(element) == { + "Device": { + "id": 1, + "Name": "camera", + }, + } + + def test_returns_none_for_empty_element(self): + """Test that an element without children produces None.""" + element = etree.fromstring("") + + assert ZeepPatcher._parse_element_recursive(element) is None + + +# pylint: disable=protected-access +class TestPatchedParseXmlElements: + """Tests for the patched Zeep xsd:any XML parser.""" + + def test_parses_xml_with_schema(self): + """Test parsing child elements through the provided schema.""" + root = etree.fromstring("camera") + + parsed_value = Mock() + parsed_value.parse_xmlelement.return_value = "parsed" + + schema = Mock() + schema.get_element.return_value = parsed_value + + parser = Mock(max_occurs=1) + elements = deque([root]) + + result = ZeepPatcher._patched_parse_xmlelements( + parser, + elements, + schema, + ) + + assert result["Root"]["Value"] == "parsed" + assert result["__original_elements__"] == [root] + parsed_value.parse_xmlelement.assert_called_once() + + def test_parses_attributes_without_schema_lookup(self): + """Test parsing child attributes without schema parsing.""" + root = etree.fromstring('') + schema = Mock() + parser = Mock(max_occurs=1) + + result = ZeepPatcher._patched_parse_xmlelements( + parser, + deque([root]), + schema, + ) + + assert result["Root"]["Device"] == { + "id": 123, + "enabled": True, + } + + def test_falls_back_to_manual_parsing_when_schema_fails(self): + """Test manual parsing when schema lookup or parsing fails.""" + root = etree.fromstring("camera") + schema = Mock() + schema.get_element.side_effect = ValueError("unknown element") + parser = Mock(max_occurs=1) + + result = ZeepPatcher._patched_parse_xmlelements( + parser, + deque([root]), + schema, + ) + + assert result["Root"]["Device"] == { + "Name": "camera", + } + + def test_parses_text_only_element(self): + """Test parsing an element containing only text.""" + root = etree.fromstring("hello") + parser = Mock(max_occurs=1) + + result = ZeepPatcher._patched_parse_xmlelements( + parser, + deque([root]), + schema=None, + ) + + assert result["Root"] == "hello" + assert result["__original_elements__"] == [root] + + def test_returns_empty_result_without_elements(self): + """Test that an empty element queue produces an empty result.""" + parser = Mock(max_occurs=1) + + assert not ZeepPatcher._patched_parse_xmlelements( + parser, + deque(), + schema=None, + ) + + +# pylint: disable=protected-access +class TestZeepObjectToDict: + """Tests for converting Zeep objects into dictionaries.""" + + def test_handles_none_and_primitive_values(self): + """Test that None and primitive values are returned unchanged.""" + assert ZeepPatcher._zeep_object_to_dict(None) is None + assert ZeepPatcher._zeep_object_to_dict("value") == "value" + assert ZeepPatcher._zeep_object_to_dict(10) == 10 + assert ZeepPatcher._zeep_object_to_dict(True) is True + + def test_converts_lists_and_dicts(self): + """Test recursive conversion of lists and dictionaries.""" + value = {"items": [1, {"name": "camera"}]} + + assert ZeepPatcher._zeep_object_to_dict(value) == value + + def test_converts_zeep_values(self): + """Test conversion of objects exposing Zeep __values__.""" + obj = ZeepObject( + { + "Name": "camera", + "_value_1": "ignored", + } + ) + + assert ZeepPatcher._zeep_object_to_dict(obj) == { + "Name": "camera", + } + + def test_converts_object_dict(self): + """Test conversion of objects exposing a regular __dict__.""" + + class Object: # pylint: disable=too-few-public-methods + """Simple object with public and private attributes.""" + + def __init__(self): + self.name = "camera" + self._private = "ignored" + + assert ZeepPatcher._zeep_object_to_dict(Object()) == { + "name": "camera", + } + + +class TestFlattenXsdAnyFields: + """Tests for flattening xsd:any fields.""" + + def test_returns_primitive_unchanged(self): + """Test that primitive values are returned unchanged.""" + assert ZeepPatcher.flatten_xsd_any_fields("value") == "value" + assert ZeepPatcher.flatten_xsd_any_fields(None) is None + + def test_processes_list_items(self): + """Test that objects in a list are processed.""" + obj = ZeepObject( + { + "_value_1": { + "Device": {"XAddr": "http://camera"}, + "__original_elements__": [], + } + } + ) + + result = ZeepPatcher.flatten_xsd_any_fields([obj]) + + assert result == [obj] + assert obj.__values__["Device"] == {"XAddr": "http://camera"} + assert obj.__values__["_value_1"] == [] + + def test_flattens_single_tag_wrapper(self): + """Test flattening a single xsd:any wrapper.""" + obj = ZeepObject( + { + "Capabilities": None, + "_value_1": { + "Capabilities": { + "XAddr": "http://camera", + }, + "__original_elements__": [], + }, + } + ) + + result = ZeepPatcher.flatten_xsd_any_fields(obj) + + assert result is obj + assert obj.__values__["Capabilities"] == { + "XAddr": "http://camera", + } + assert obj.__values__["_value_1"] == [] + + def test_flattens_multiple_tags(self): + """Test flattening multiple xsd:any tags.""" + obj = ZeepObject( + { + "_value_1": { + "Device": {"XAddr": "http://device"}, + "Media": {"XAddr": "http://media"}, + "__original_elements__": [], + } + } + ) + + ZeepPatcher.flatten_xsd_any_fields(obj) + + assert obj.__values__["Device"] == { + "XAddr": "http://device", + } + assert obj.__values__["Media"] == { + "XAddr": "http://media", + } + assert obj.__values__["_value_1"] == [] + + +class TestZeepPatchLifecycle: + """Tests for applying, removing, and checking the Zeep patch.""" + + def setup_method(self): + """Ensure the Zeep patch is inactive before each test.""" + ZeepPatcher.remove_patch() + + def teardown_method(self): + """Ensure the Zeep patch is inactive after each test.""" + ZeepPatcher.remove_patch() + + def test_patch_is_not_active_by_default(self): + """Test that the patch starts in an inactive state.""" + assert ZeepPatcher.is_patched() is False + + def test_apply_patch(self): + """Test that applying the patch changes the patch state.""" + original = XsdAny.parse_xmlelements + + ZeepPatcher.apply_patch() + + assert ZeepPatcher.is_patched() is True + assert XsdAny.parse_xmlelements is ZeepPatcher._patched_parse_xmlelements + assert ZeepPatcher._original_parse_xmlelements is original + + def test_apply_patch_is_idempotent(self): + """Test that applying the patch multiple times is safe.""" + ZeepPatcher.apply_patch() + original = ZeepPatcher._original_parse_xmlelements + + ZeepPatcher.apply_patch() + + assert ZeepPatcher._original_parse_xmlelements is original + + def test_remove_patch(self): + """Test that removing the patch restores the original state.""" + original = XsdAny.parse_xmlelements + + ZeepPatcher.apply_patch() + ZeepPatcher.remove_patch() + + assert ZeepPatcher.is_patched() is False + assert XsdAny.parse_xmlelements is original + + def test_remove_patch_when_not_applied(self): + """Test that removing an inactive patch is safe.""" + ZeepPatcher.remove_patch() + + assert ZeepPatcher.is_patched() is False From 8d18808314f6a572a46f1adefd7d065aba8d23a1 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:17:07 +0700 Subject: [PATCH 14/43] fix(cli): Correct boolean evaluation for auto-debug mode (#123) --- onvif/cli/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/onvif/cli/main.py b/onvif/cli/main.py index a1a4929..7f916f9 100644 --- a/onvif/cli/main.py +++ b/onvif/cli/main.py @@ -229,7 +229,7 @@ def _process_onvif_client(args) -> None: try: # Create ONVIF client # Auto-enable debug mode if output format is XML - auto_debug = args.debug or ( + auto_debug = args.debug or bool( args.output and args.output.lower().endswith(".xml") ) From b25d0c1ff937a7763bd1b758111284f657341287 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:47:37 +0700 Subject: [PATCH 15/43] fix(cli): Remove unused imports and clean up CLI `__init__.py` (#125) --- onvif/cli/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/onvif/cli/__init__.py b/onvif/cli/__init__.py index 22d296a..5b0f757 100644 --- a/onvif/cli/__init__.py +++ b/onvif/cli/__init__.py @@ -1,5 +1 @@ """ONVIF CLI.""" - -from onvif.cli.main import main - -__all__ = ["main"] From 1ed96599a5326cdb95c1a4dabc3aa785b1eef734 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:58:22 +0700 Subject: [PATCH 16/43] test: Complete tests for CLI modules (#124) --- tests/cli/__init__.py | 1 + tests/cli/test_cli.py | 12 + tests/cli/test_cli_helper_discovery.py | 591 ++++++++++++++ tests/cli/test_cli_helper_execution.py | 335 ++++++++ tests/cli/test_cli_helper_files.py | 723 +++++++++++++++++ tests/cli/test_cli_helper_messages.py | 106 +++ tests/cli/test_cli_helper_products.py | 744 ++++++++++++++++++ tests/cli/test_cli_helper_wsdl_parser.py | 962 +++++++++++++++++++++++ tests/cli/test_cli_interactive.py | 399 ++++++++++ tests/cli/test_cli_main.py | 718 +++++++++++++++++ tests/cli/test_cli_utils.py | 807 +++++++++++++++++++ 11 files changed, 5398 insertions(+) create mode 100644 tests/cli/__init__.py create mode 100644 tests/cli/test_cli.py create mode 100644 tests/cli/test_cli_helper_discovery.py create mode 100644 tests/cli/test_cli_helper_execution.py create mode 100644 tests/cli/test_cli_helper_files.py create mode 100644 tests/cli/test_cli_helper_messages.py create mode 100644 tests/cli/test_cli_helper_products.py create mode 100644 tests/cli/test_cli_helper_wsdl_parser.py create mode 100644 tests/cli/test_cli_interactive.py create mode 100644 tests/cli/test_cli_main.py create mode 100644 tests/cli/test_cli_utils.py diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000..2351d95 --- /dev/null +++ b/tests/cli/__init__.py @@ -0,0 +1 @@ +"""CLI tests suite.""" diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py new file mode 100644 index 0000000..fd3de47 --- /dev/null +++ b/tests/cli/test_cli.py @@ -0,0 +1,12 @@ +"""Tests for the ONVIF CLI entry point.""" + +import runpy +from unittest.mock import patch + + +def test_cli_main_calls_main(): + """Test that the CLI entry point calls main().""" + with patch("onvif.cli.main.main") as mock_main: + runpy.run_module("onvif.cli.__main__", run_name="__main__") + + mock_main.assert_called_once_with() diff --git a/tests/cli/test_cli_helper_discovery.py b/tests/cli/test_cli_helper_discovery.py new file mode 100644 index 0000000..763a800 --- /dev/null +++ b/tests/cli/test_cli_helper_discovery.py @@ -0,0 +1,591 @@ +"""Tests for CLI discovery helpers.""" + +from unittest.mock import patch + +import pytest + +from onvif.cli.helpers.discovery import ( + _print_optional_field, + _process_device_selection, + discover_devices, + select_device_interactive, +) + + +class TestDiscoverDevices: + """Tests for discovering ONVIF devices.""" + + def test_discovers_devices_with_defaults(self): + """Test device discovery using the default options.""" + devices = [ + { + "host": "192.168.1.17", + "port": 80, + } + ] + + with ( + patch("onvif.cli.helpers.discovery.ONVIFDiscovery") as mock_discovery, + patch( + "onvif.cli.helpers.discovery.colorize", + side_effect=lambda value, _color: value, + ), + ): + mock_instance = mock_discovery.return_value + mock_instance.get_local_ip.return_value = "192.168.1.10" + mock_instance.discover.return_value = devices + + result = discover_devices() + + mock_discovery.assert_called_once_with( + timeout=4, + interface=None, + ) + mock_instance.get_local_ip.assert_called_once_with() + mock_instance.discover.assert_called_once_with( + prefer_https=False, + search=None, + ) + assert result == devices + + def test_passes_discovery_options(self): + """Test that discovery options are passed to ONVIFDiscovery.""" + devices = [ + { + "host": "192.168.1.17", + "port": 443, + "use_https": True, + } + ] + + with ( + patch("onvif.cli.helpers.discovery.ONVIFDiscovery") as mock_discovery, + patch( + "onvif.cli.helpers.discovery.colorize", + side_effect=lambda value, _color: value, + ), + ): + mock_instance = mock_discovery.return_value + mock_instance.get_local_ip.return_value = "192.168.1.10" + mock_instance.discover.return_value = devices + + result = discover_devices( + timeout=10, + interface="192.168.1.10", + prefer_https=True, + filter_term="Hikvision", + ) + + mock_discovery.assert_called_once_with( + timeout=10, + interface="192.168.1.10", + ) + mock_instance.discover.assert_called_once_with( + prefer_https=True, + search="Hikvision", + ) + assert result == devices + + def test_returns_empty_result(self): + """Test that an empty discovery result is returned unchanged.""" + with ( + patch("onvif.cli.helpers.discovery.ONVIFDiscovery") as mock_discovery, + patch( + "onvif.cli.helpers.discovery.colorize", + side_effect=lambda value, _color: value, + ), + ): + mock_instance = mock_discovery.return_value + mock_instance.get_local_ip.return_value = "192.168.1.10" + mock_instance.discover.return_value = [] + + result = discover_devices() + + assert result == [] + + +class TestSelectDeviceInteractive: + """Tests for interactive ONVIF device selection.""" + + def test_no_devices(self, capsys): + """Test that selection is cancelled when no devices are available.""" + result = select_device_interactive([]) + + assert result is None + assert "No ONVIF devices found." in capsys.readouterr().out + + def test_selects_http_device(self): + """Test selecting an HTTP device.""" + devices = [ + { + "host": "192.168.1.17", + "port": 80, + "use_https": False, + "epr": "uuid:1234", + } + ] + + with ( + patch( + "onvif.cli.helpers.discovery.input", + return_value="1", + ), + patch( + "onvif.cli.helpers.discovery.colorize", + side_effect=lambda value, _color: value, + ), + ): + result = select_device_interactive(devices) + + assert result == ("192.168.1.17", 80, False) + + def test_selects_https_device(self): + """Test selecting an HTTPS device.""" + devices = [ + { + "host": "192.168.1.17", + "port": 443, + "use_https": True, + "epr": "urn:uuid:1234", + } + ] + + with ( + patch( + "onvif.cli.helpers.discovery.input", + return_value="1", + ), + patch( + "onvif.cli.helpers.discovery.colorize", + side_effect=lambda value, _color: value, + ), + ): + result = select_device_interactive(devices) + + assert result == ("192.168.1.17", 443, True) + + @pytest.mark.parametrize( + "epr", + [ + "uuid:1234", + "urn:uuid:1234", + "1234", + ], + ) + def test_displays_device_epr(self, epr, capsys): + """Test displaying device EPR values with supported prefixes.""" + devices = [ + { + "host": "192.168.1.17", + "port": 80, + "use_https": False, + "epr": epr, + } + ] + + with ( + patch( + "onvif.cli.helpers.discovery.input", + return_value="q", + ), + patch( + "onvif.cli.helpers.discovery.colorize", + side_effect=lambda value, _color: value, + ), + ): + result = select_device_interactive(devices) + + assert result is None + assert "1234" in capsys.readouterr().out + + def test_displays_optional_fields(self, capsys): + """Test displaying available optional device information.""" + devices = [ + { + "host": "192.168.1.17", + "port": 80, + "use_https": False, + "epr": "uuid:1234", + "hostname": "camera.local", + "xaddrs": [ + "http://192.168.1.17/onvif/device_service", + ], + "types": [ + "NetworkVideoTransmitter", + ], + "services": [ + "device", + "media", + ], + } + ] + + with ( + patch( + "onvif.cli.helpers.discovery.input", + return_value="q", + ), + patch( + "onvif.cli.helpers.discovery.colorize", + side_effect=lambda value, _color: value, + ), + ): + select_device_interactive(devices) + + output = capsys.readouterr().out + + assert "camera.local" in output + assert "http://192.168.1.17/onvif/device_service" in output + assert "NetworkVideoTransmitter" in output + assert "device" in output + assert "media" in output + + def test_displays_date_time(self, capsys): + """Test displaying UTC and local device date and time.""" + devices = [ + { + "host": "192.168.1.17", + "port": 80, + "use_https": False, + "epr": "uuid:1234", + "date_time": { + "utc": "2026-10-03T12:30:45", + "local": "2026-10-03T19:30:45", + }, + } + ] + + with ( + patch( + "onvif.cli.helpers.discovery.input", + return_value="q", + ), + patch( + "onvif.cli.helpers.discovery.colorize", + side_effect=lambda value, _color: value, + ), + ): + select_device_interactive(devices) + + output = capsys.readouterr().out + + assert "12:30:45 03-10-2026" in output + assert "19:30:45 03-10-2026" in output + + def test_displays_scopes(self, capsys): + """Test displaying simplified ONVIF scopes and other scopes.""" + devices = [ + { + "host": "192.168.1.17", + "port": 80, + "use_https": False, + "epr": "uuid:1234", + "scopes": [ + "onvif://www.onvif.org/name/TestCamera", + "onvif://www.onvif.org/hardware/Camera", + "http:123", + ], + } + ] + + with ( + patch( + "onvif.cli.helpers.discovery.input", + return_value="q", + ), + patch( + "onvif.cli.helpers.discovery.colorize", + side_effect=lambda value, _color: value, + ), + ): + select_device_interactive(devices) + + output = capsys.readouterr().out + + assert "[name/TestCamera]" in output + assert "[hardware/Camera]" in output + assert "[http:123]" in output + + def test_handles_missing_optional_fields(self, capsys): + """Test that missing optional fields do not prevent selection.""" + devices = [ + { + "host": "192.168.1.17", + "port": 80, + "epr": "uuid:1234", + } + ] + + with ( + patch( + "onvif.cli.helpers.discovery.input", + return_value="q", + ), + patch( + "onvif.cli.helpers.discovery.colorize", + side_effect=lambda value, _color: value, + ), + ): + result = select_device_interactive(devices) + + assert result is None + assert "192.168.1.17:80" in capsys.readouterr().out + + def test_cancels_selection(self): + """Test cancelling device selection with q.""" + devices = [ + { + "host": "192.168.1.17", + "port": 80, + "epr": "uuid:1234", + } + ] + + with ( + patch( + "onvif.cli.helpers.discovery.input", + return_value="q", + ), + patch( + "onvif.cli.helpers.discovery.colorize", + side_effect=lambda value, _color: value, + ), + ): + result = select_device_interactive(devices) + + assert result is None + + +class TestPrintOptionalField: + """Tests for printing optional device fields.""" + + def test_skips_empty_value(self, capsys): + """Test that empty values are not printed.""" + _print_optional_field("hostname", None) + + assert capsys.readouterr().out == "" + + @pytest.mark.parametrize( + "value", + [ + "", + [], + {}, + False, + ], + ) + def test_skips_falsy_value(self, value, capsys): + """Test that other falsy values are not printed.""" + _print_optional_field("field", value) + + assert capsys.readouterr().out == "" + + def test_prints_value(self, capsys): + """Test printing a field value.""" + _print_optional_field("hostname", "camera.local") + + assert "[hostname] camera.local" in capsys.readouterr().out + + def test_colorizes_value(self, capsys): + """Test colorizing a field value before printing it.""" + with patch( + "onvif.cli.helpers.discovery.colorize", + return_value="", + ) as mock_colorize: + _print_optional_field( + "hostname", + "camera.local", + "cyan", + ) + + mock_colorize.assert_called_once_with( + "camera.local", + "cyan", + ) + assert "[hostname] " in capsys.readouterr().out + + def test_converts_value_to_string(self, capsys): + """Test that non-string values are converted to text.""" + _print_optional_field("port", 8080) + + assert "[port] 8080" in capsys.readouterr().out + + +class TestProcessDeviceSelection: + """Tests for processing interactive device selection.""" + + def test_selects_device(self): + """Test selecting a valid device number.""" + devices = [ + { + "host": "192.168.1.17", + "port": 80, + "use_https": False, + }, + { + "host": "192.168.1.18", + "port": 443, + "use_https": True, + }, + ] + + with ( + patch( + "onvif.cli.helpers.discovery.input", + return_value="2", + ), + patch( + "onvif.cli.helpers.discovery.colorize", + side_effect=lambda value, _color: value, + ), + ): + result = _process_device_selection( + "http", + devices, + ) + + assert result == ("192.168.1.18", 443, True) + + def test_selects_http_device(self): + """Test selecting an HTTP device.""" + devices = [ + { + "host": "192.168.1.17", + "port": 80, + "use_https": False, + } + ] + + with ( + patch( + "onvif.cli.helpers.discovery.input", + return_value="1", + ), + patch( + "onvif.cli.helpers.discovery.colorize", + side_effect=lambda value, _color: value, + ), + ): + result = _process_device_selection( + "http", + devices, + ) + + assert result == ("192.168.1.17", 80, False) + + def test_quits_with_q(self): + """Test cancelling selection with q.""" + devices = [ + { + "host": "192.168.1.17", + "port": 80, + "use_https": False, + } + ] + + with ( + patch( + "onvif.cli.helpers.discovery.input", + return_value="q", + ), + patch( + "onvif.cli.helpers.discovery.colorize", + side_effect=lambda value, _color: value, + ), + ): + result = _process_device_selection( + "http", + devices, + ) + + assert result is None + + def test_retries_invalid_number(self, capsys): + """Test retrying after an out-of-range device number.""" + devices = [ + { + "host": "192.168.1.17", + "port": 80, + "use_https": False, + } + ] + + with ( + patch( + "onvif.cli.helpers.discovery.input", + side_effect=["2", "1"], + ), + patch( + "onvif.cli.helpers.discovery.colorize", + side_effect=lambda value, _color: value, + ), + ): + result = _process_device_selection( + "http", + devices, + ) + + assert result == ("192.168.1.17", 80, False) + assert "Invalid selection" in capsys.readouterr().out + + def test_retries_invalid_input(self, capsys): + """Test retrying after non-numeric input.""" + devices = [ + { + "host": "192.168.1.17", + "port": 80, + "use_https": False, + } + ] + + with ( + patch( + "onvif.cli.helpers.discovery.input", + side_effect=["abc", "1"], + ), + patch( + "onvif.cli.helpers.discovery.colorize", + side_effect=lambda value, _color: value, + ), + ): + result = _process_device_selection( + "http", + devices, + ) + + assert result == ("192.168.1.17", 80, False) + assert "Invalid input" in capsys.readouterr().out + + @pytest.mark.parametrize( + "exception", + [ + EOFError(), + KeyboardInterrupt(), + ], + ) + def test_handles_input_cancellation(self, exception): + """Test cancelling selection when terminal input is interrupted.""" + devices = [ + { + "host": "192.168.1.17", + "port": 80, + "use_https": False, + } + ] + + with ( + patch( + "onvif.cli.helpers.discovery.input", + side_effect=exception, + ), + patch( + "onvif.cli.helpers.discovery.colorize", + side_effect=lambda value, _color: value, + ), + ): + result = _process_device_selection( + "http", + devices, + ) + + assert result is None diff --git a/tests/cli/test_cli_helper_execution.py b/tests/cli/test_cli_helper_execution.py new file mode 100644 index 0000000..803eabb --- /dev/null +++ b/tests/cli/test_cli_helper_execution.py @@ -0,0 +1,335 @@ +"""Tests for CLI helper execution.""" + +import warnings +from unittest.mock import Mock, patch + +import pytest + +from onvif.cli.helpers.execution import ( + execute_command, + setup_warning_format, +) + + +class FakeService: # pylint: disable=too-few-public-methods + """Minimal fake service used to test CLI command execution.""" + + def __init__(self, result=None): + self.result = result + + def GetProfiles(self, **kwargs): # pylint: disable=invalid-name + """Return a predictable result for GetProfiles.""" + return self.result or { + "method": "GetProfiles", + "params": kwargs, + } + + +class FakeClient: # pylint: disable=too-few-public-methods + """Minimal fake client used to provide services to CLI command execution.""" + + def __init__(self, service=None): + self.service = service or FakeService() + + def media(self): + """Return the fake media service.""" + return self.service + + +@pytest.fixture +def fake_service(): + """Return a reusable fake media service.""" + return FakeService() + + +@pytest.fixture +def fake_client(fake_service): + """Return a client using the reusable fake media service.""" + return FakeClient(fake_service) + + +def test_execute_command_without_params(fake_client): + """Test command execution without parameters.""" + result = execute_command( + fake_client, + "media", + "GetProfiles", + ) + + assert result == { + "method": "GetProfiles", + "params": {}, + } + + +def test_execute_command_with_params(fake_client): + """Test command execution with JSON parameters.""" + result = execute_command( + fake_client, + "media", + "GetProfiles", + '{"ProfileToken": "profile_1"}', + ) + + assert result == { + "method": "GetProfiles", + "params": { + "ProfileToken": "profile_1", + }, + } + + +def test_execute_command_with_simple_params(fake_client): + """Test command execution with simple key-value parameters.""" + result = execute_command( + fake_client, + "media", + "GetProfiles", + "ProfileToken=profile_1", + ) + + assert result == { + "method": "GetProfiles", + "params": { + "ProfileToken": "profile_1", + }, + } + + +def test_execute_command_normalizes_service_name(): + """Test that service names are normalized before lookup.""" + service = Mock() + service.GetProfiles.return_value = "result" + + client = Mock() + client.media.return_value = service + + result = execute_command( + client, + "MEDIA", + "GetProfiles", + ) + + assert result == "result" + client.media.assert_called_once_with() + service.GetProfiles.assert_called_once_with() + + +def test_execute_command_calls_method_with_parsed_params(fake_service): + """Test that parsed parameters are passed to the service method.""" + fake_service.GetProfiles = Mock(return_value="result") + client = FakeClient(fake_service) + + params = '{"ProfileToken": "profile_1", "Include": true}' + + result = execute_command( + client, + "media", + "GetProfiles", + params, + ) + + assert result == "result" + fake_service.GetProfiles.assert_called_once_with( + ProfileToken="profile_1", + Include=True, + ) + + +def test_execute_command_empty_params_string(fake_service): + """Test command execution with an empty parameter string.""" + fake_service.GetProfiles = Mock(return_value="result") + client = FakeClient(fake_service) + + result = execute_command( + client, + "media", + "GetProfiles", + "", + ) + + assert result == "result" + fake_service.GetProfiles.assert_called_once_with() + + +def test_execute_command_none_params(fake_service): + """Test command execution when parameters are omitted.""" + fake_service.GetProfiles = Mock(return_value="result") + client = FakeClient(fake_service) + + result = execute_command( + client, + "media", + "GetProfiles", + None, + ) + + assert result == "result" + fake_service.GetProfiles.assert_called_once_with() + + +def test_execute_command_unknown_service(): + """Test that an unknown service raises ValueError.""" + client = Mock() + client.media.side_effect = AttributeError("service not found") + + with pytest.raises(ValueError) as exc_info: + execute_command( + client, + "media", + "GetProfiles", + ) + + message = str(exc_info.value) + + assert "Unknown service:" in message + assert "media" in message + + +def test_execute_command_unknown_method(): + """Test that an unknown method raises ValueError.""" + client = Mock() + client.media.return_value = object() + + with pytest.raises(ValueError) as exc_info: + execute_command( + client, + "media", + "GetProfiles", + ) + + message = str(exc_info.value) + + assert "Unknown method" in message + assert "GetProfiles" in message + assert "media" in message + + +def test_execute_command_preserves_method_attribute_error_as_value_error(): + """Test that a method lookup AttributeError raises ValueError.""" + + class Service: # pylint: disable=too-few-public-methods + """Service with property.""" + + @property + def GetProfiles(self): # pylint: disable=invalid-name + """Unavailable method.""" + raise AttributeError("method unavailable") + + client = Mock() + client.media.return_value = Service() + + with pytest.raises(ValueError) as exc_info: + execute_command( + client, + "media", + "GetProfiles", + ) + + assert "Unknown method" in str(exc_info.value) + + +def test_execute_command_propagates_method_exception(fake_service): + """Test that exceptions raised by the service method are propagated.""" + expected_error = RuntimeError("camera failure") + fake_service.GetProfiles = Mock(side_effect=expected_error) + client = FakeClient(fake_service) + + with pytest.raises(RuntimeError, match="camera failure"): + execute_command( + client, + "media", + "GetProfiles", + ) + + fake_service.GetProfiles.assert_called_once_with() + + +def test_execute_command_uses_parse_json_params(): + """Test that provided parameters are parsed with parse_json_params.""" + client = Mock() + service = Mock() + method = Mock(return_value="result") + + client.media.return_value = service + service.GetProfiles = method + + with patch( + "onvif.cli.helpers.execution.parse_json_params", + return_value={"ProfileToken": "profile_1"}, + ) as mock_parse: + result = execute_command( + client, + "media", + "GetProfiles", + "some params", + ) + + assert result == "result" + mock_parse.assert_called_once_with("some params") + method.assert_called_once_with(ProfileToken="profile_1") + + +def test_execute_command_does_not_parse_missing_params(): + """Test that missing parameters do not invoke parse_json_params.""" + client = Mock() + service = Mock() + method = Mock(return_value="result") + + client.media.return_value = service + service.GetProfiles = method + + with patch( + "onvif.cli.helpers.execution.parse_json_params", + ) as mock_parse: + result = execute_command( + client, + "media", + "GetProfiles", + None, + ) + + assert result == "result" + mock_parse.assert_not_called() + method.assert_called_once_with() + + +def test_execute_command_invalid_params_propagate(): + """Test that parameter parsing errors are propagated.""" + client = Mock() + service = Mock() + client.media.return_value = service + service.GetProfiles = Mock() + + with patch( + "onvif.cli.helpers.execution.parse_json_params", + side_effect=ValueError("invalid JSON"), + ): + with pytest.raises(ValueError, match="invalid JSON"): + execute_command( + client, + "media", + "GetProfiles", + "{invalid}", + ) + + +def test_setup_warning_format(): + """Test that the custom warning format is configured correctly.""" + original_formatter = warnings.formatwarning + + try: + setup_warning_format() + + assert warnings.formatwarning.__name__ == "custom_warning_format" + + result = warnings.formatwarning( + "Something went wrong", + UserWarning, + "test.py", + 123, + ) + + assert result == "UserWarning: Something went wrong\n" + finally: + warnings.formatwarning = original_formatter diff --git a/tests/cli/test_cli_helper_files.py b/tests/cli/test_cli_helper_files.py new file mode 100644 index 0000000..558646e --- /dev/null +++ b/tests/cli/test_cli_helper_files.py @@ -0,0 +1,723 @@ +"""Tests for CLI helper files.""" + +from datetime import datetime, timezone +from unittest.mock import Mock, patch + +import pytest + +from onvif.cli.helpers.files import ( + _serialize_for_json, + save_output_to_file, +) + + +class TestSerializeForJson: + """Tests for serializing values into JSON-compatible objects.""" + + @pytest.mark.parametrize( + ("value", "expected"), + [ + (None, None), + ("hello", "hello"), + (123, 123), + (12.5, 12.5), + (True, True), + (False, False), + ], + ) + def test_primitive(self, value, expected): + """Test serialization of primitive values.""" + assert _serialize_for_json(value) == expected + + def test_datetime(self): + """Test serialization of datetime values.""" + value = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc) + + assert _serialize_for_json(value) == "2026-01-02T03:04:05+00:00" + + def test_list(self): + """Test serialization of list values.""" + value = [ + "hello", + 123, + datetime(2026, 1, 1, tzinfo=timezone.utc), + {"nested": True}, + ] + + assert _serialize_for_json(value) == [ + "hello", + 123, + "2026-01-01T00:00:00+00:00", + {"nested": True}, + ] + + def test_tuple(self): + """Test serialization of tuple values.""" + value = ("hello", 123, True) + + assert _serialize_for_json(value) == [ + "hello", + 123, + True, + ] + + def test_dict(self): + """Test serialization of dictionary values.""" + value = { + "name": "camera", + "enabled": True, + "count": 10, + "nested": { + "value": 42, + }, + } + + assert _serialize_for_json(value) == value + + def test_regular_object(self): + """Test serialization of a regular object.""" + + class Camera: # pylint: disable=too-few-public-methods + """Simple camera object for serialization testing.""" + + def __init__(self): + self.name = "Camera 1" + self.enabled = True + self._private = "hidden" + + camera = Camera() + + assert _serialize_for_json(camera) == { + "name": "Camera 1", + "enabled": True, + } + + def test_regular_object_nested(self): + """Test serialization of a regular object with nested values.""" + + class Camera: # pylint: disable=too-few-public-methods + """Simple camera object with nested settings.""" + + def __init__(self): + self.name = "Camera 1" + self.settings = { + "fps": 30, + "enabled": True, + } + + camera = Camera() + + assert _serialize_for_json(camera) == { + "name": "Camera 1", + "settings": { + "fps": 30, + "enabled": True, + }, + } + + def test_regular_object_uses_dir_when_dict_empty(self): + """Test serialization using public attributes when __dict__ is empty.""" + + class Camera: # pylint: disable=too-few-public-methods + """Camera object exposing a property instead of instance attributes.""" + + @property + def name(self): + """Return the camera name.""" + return "Camera 1" + + camera = Camera() + + assert _serialize_for_json(camera) == { + "name": "Camera 1", + } + + def test_regular_object_skips_callable_attributes(self): + """Test that callable attributes are excluded from serialization.""" + + class Camera: # pylint: disable=too-few-public-methods + """Camera object containing a callable attribute.""" + + def __init__(self): + self.name = "Camera 1" + + def method(self): + """Return a value that should not be serialized.""" + return "ignored" + + camera = Camera() + + assert _serialize_for_json(camera) == { + "name": "Camera 1", + } + + def test_zeep_object(self): + """Test serialization of a Zeep-like object.""" + + class ZeepObject: # pylint: disable=too-few-public-methods + """Simple object that mimics a Zeep object.""" + + _xsd_type = type( + "XSDType", + (), + { + "elements": [ + ("Name", object()), + ("Enabled", object()), + ] + }, + )() + + Name = "Camera 1" + Enabled = True + + result = _serialize_for_json(ZeepObject()) + + assert result == { + "Name": "Camera 1", + "Enabled": True, + } + + def test_zeep_object_nested(self): + """Test serialization of a nested Zeep-like object.""" + + class Profile: # pylint: disable=too-few-public-methods + """Simple profile object that mimics a Zeep object.""" + + _xsd_type = type( + "XSDType", + (), + { + "elements": [ + ("Name", object()), + ("Token", object()), + ] + }, + )() + + Name = "Main" + Token = "profile_1" + + profile = Profile() + + class Camera: # pylint: disable=too-few-public-methods + """Simple camera object containing a nested profile.""" + + _xsd_type = type( + "XSDType", + (), + { + "elements": [ + ("Profile", object()), + ] + }, + )() + + Profile = profile + + assert _serialize_for_json(Camera()) == { + "Profile": { + "Name": "Main", + "Token": "profile_1", + } + } + + def test_zeep_object_skips_missing_element(self): + """Test that missing Zeep elements are skipped.""" + + class ZeepObject: # pylint: disable=too-few-public-methods + """Simple Zeep-like object with a missing element.""" + + _xsd_type = type( + "XSDType", + (), + { + "elements": [ + ("Existing", object()), + ("Missing", object()), + ] + }, + )() + + Existing = "value" + + result = _serialize_for_json(ZeepObject()) + + assert result == { + "Existing": "value", + } + + def test_zeep_object_skips_none_element(self): + """Test that Zeep elements with None values are skipped.""" + + class ZeepObject: # pylint: disable=too-few-public-methods + """Simple Zeep-like object containing a None element.""" + + _xsd_type = type( + "XSDType", + (), + { + "elements": [ + ("Value", object()), + ] + }, + )() + + Value = None + + assert _serialize_for_json(ZeepObject()) == {} + + def test_zeep_object_includes_regular_attributes(self): + """Test that regular attributes are included with Zeep elements.""" + + class ZeepObject: # pylint: disable=too-few-public-methods + """Simple Zeep-like object with an additional attribute.""" + + _xsd_type = type( + "XSDType", + (), + { + "elements": [ + ("Name", object()), + ] + }, + )() + + Name = "Camera 1" + Extra = "extra" + + assert _serialize_for_json(ZeepObject()) == { + "Name": "Camera 1", + "Extra": "extra", + } + + def test_zeep_object_does_not_duplicate_xsd_element(self): + """Test that XSD elements are not duplicated as regular attributes.""" + + class ZeepObject: # pylint: disable=too-few-public-methods + """Simple Zeep-like object with an XSD element.""" + + _xsd_type = type( + "XSDType", + (), + { + "elements": [ + ("Name", object()), + ] + }, + )() + + Name = "Camera 1" + + assert _serialize_for_json(ZeepObject()) == { + "Name": "Camera 1", + } + + def test_value_1(self): + """Test serialization of an object containing _value_1.""" + + class ZeepValue: # pylint: disable=too-few-public-methods + """Simple object containing a Zeep _value_1 attribute.""" + + __slots__ = ("_value_1",) + + def __init__(self): + self._value_1 = { + "Name": "Camera 1", + "Enabled": True, + } + + assert _serialize_for_json(ZeepValue()) == { + "Name": "Camera 1", + "Enabled": True, + } + + def test_value_1_nested(self): + """Test serialization of nested values stored in _value_1.""" + + class ZeepValue: # pylint: disable=too-few-public-methods + """Simple object containing nested Zeep values.""" + + __slots__ = ("_value_1",) + + def __init__(self): + self._value_1 = [ + {"Name": "Camera 1"}, + {"Name": "Camera 2"}, + ] + + assert _serialize_for_json(ZeepValue()) == [ + {"Name": "Camera 1"}, + {"Name": "Camera 2"}, + ] + + def test_fallback_to_vars(self): + """Test fallback serialization for objects without serializable attributes.""" + + class CustomObject: # pylint: disable=too-few-public-methods + """Simple object that falls back to string conversion.""" + + __slots__ = () + + def __str__(self): + """Return the fallback string representation.""" + return "custom-value" + + obj = CustomObject() + + assert _serialize_for_json(obj) == "custom-value" + + +class TestSaveOutputToFileJson: + """Tests for saving CLI output as JSON.""" + + def test_json(self, tmp_path): + """Test saving output as a JSON file.""" + output_path = tmp_path / "result.json" + + client = Mock() + client.xml_plugin = None + + result = { + "name": "Camera 1", + "enabled": True, + } + + save_output_to_file( + result, + str(output_path), + False, + client, + ) + + content = output_path.read_text(encoding="utf-8") + + assert '"result": {' in content + assert '"name": "Camera 1"' in content + assert '"enabled": true' in content + assert '"timestamp":' in content + assert '"raw_result":' in content + + def test_json_debug(self): + """Test saving JSON output with SOAP debug information.""" + client = Mock() + + client.xml_plugin = Mock() + client.xml_plugin.last_sent_xml = "" + client.xml_plugin.last_received_xml = "" + client.xml_plugin.last_operation = "GetDeviceInformation" + + result = {"value": 123} + + with patch( + "onvif.cli.helpers.files.open", + create=True, + ) as mock_open: + save_output_to_file( + result, + "result.json", + True, + client, + ) + + handle = mock_open.return_value.__enter__.return_value + written = handle.write.call_args_list + + assert written + + def test_json_debug_without_plugin(self): + """Test JSON debug output when no XML plugin is available.""" + client = Mock() + client.xml_plugin = None + + result = {"value": 123} + + with patch("onvif.cli.helpers.files.json.dump") as mock_dump: + save_output_to_file( + result, + "result.json", + True, + client, + ) + + output_data = mock_dump.call_args.args[0] + + assert "debug" not in output_data + + +class TestSaveOutputToFileXml: + """Tests for saving CLI output as XML.""" + + def test_xml_with_raw_response(self, tmp_path): + """Test saving XML output containing a raw SOAP response.""" + output_path = tmp_path / "result.xml" + + client = Mock() + client.xml_plugin = Mock() + client.xml_plugin.last_received_xml = "" + client.xml_plugin.last_operation = "GetProfiles" + + with patch("onvif.cli.helpers.files.datetime") as mock_datetime: + mock_datetime.now.return_value = datetime( + 2026, + 1, + 2, + 3, + 4, + 5, + tzinfo=timezone.utc, + ) + + save_output_to_file( + {"value": 123}, + str(output_path), + False, + client, + ) + + content = output_path.read_text(encoding="utf-8") + + assert '' in content + assert "" in content + assert "" in content + assert "" in content + + def test_xml_without_raw_response(self, tmp_path): + """Test saving XML output when no raw SOAP response is available.""" + output_path = tmp_path / "result.xml" + + client = Mock() + client.xml_plugin = None + + result = {"value": 123} + + save_output_to_file( + result, + str(output_path), + False, + client, + ) + + content = output_path.read_text(encoding="utf-8") + + assert '' in content + assert "" in content + assert str(result) in content + assert "Raw SOAP XML not available" in content + + def test_xml_with_plugin_but_empty_response(self, tmp_path): + """Test XML output when the plugin exists but has no response.""" + output_path = tmp_path / "result.xml" + + client = Mock() + client.xml_plugin = Mock() + client.xml_plugin.last_received_xml = "" + client.xml_plugin.last_operation = "GetProfiles" + + save_output_to_file( + "parsed result", + str(output_path), + False, + client, + ) + + content = output_path.read_text(encoding="utf-8") + + assert "" in content + assert "parsed result" in content + + def test_xml_unknown_operation(self, tmp_path): + """Test XML output when the SOAP operation is unknown.""" + output_path = tmp_path / "result.xml" + + client = Mock() + client.xml_plugin = Mock() + client.xml_plugin.last_received_xml = "" + client.xml_plugin.last_operation = None + + save_output_to_file( + "result", + str(output_path), + False, + client, + ) + + content = output_path.read_text(encoding="utf-8") + + assert "" in content + + +class TestSaveOutputToFileText: + """Tests for saving CLI output as plain text.""" + + def test_text(self, tmp_path): + """Test saving output as a plain text file.""" + output_path = tmp_path / "result.txt" + + client = Mock() + client.xml_plugin = None + + save_output_to_file( + "hello world", + str(output_path), + False, + client, + ) + + content = output_path.read_text(encoding="utf-8") + + assert content.startswith("ONVIF Command Output\n") + assert "Timestamp:" in content + assert "=" * 50 in content + assert "hello world" in content + + def test_text_debug(self, tmp_path): + """Test saving text output with SOAP debug information.""" + output_path = tmp_path / "result.txt" + + client = Mock() + client.xml_plugin = Mock() + client.xml_plugin.last_operation = "GetProfiles" + client.xml_plugin.last_sent_xml = "" + client.xml_plugin.last_received_xml = "" + + save_output_to_file( + "result", + str(output_path), + True, + client, + ) + + content = output_path.read_text(encoding="utf-8") + + assert "DEBUG INFORMATION" in content + assert "Operation: GetProfiles" in content + assert "SOAP Request:" in content + assert "" in content + assert "SOAP Response:" in content + assert "" in content + + def test_text_debug_only_operation(self, tmp_path): + """Test text debug output containing only the operation.""" + output_path = tmp_path / "result.txt" + + client = Mock() + client.xml_plugin = Mock() + client.xml_plugin.last_operation = "GetProfiles" + client.xml_plugin.last_sent_xml = "" + client.xml_plugin.last_received_xml = "" + + save_output_to_file( + "result", + str(output_path), + True, + client, + ) + + content = output_path.read_text(encoding="utf-8") + + assert "Operation: GetProfiles" in content + assert "SOAP Request:" not in content + assert "SOAP Response:" not in content + + +# pylint: disable=too-few-public-methods +class TestSaveOutputToFileExtensionHandling: + """Tests for output file extension handling.""" + + @pytest.mark.parametrize( + "filename", + [ + "result", + "result.txt", + "result.log", + "result.output", + "RESULT.TXT", + ], + ) + def test_defaults_to_text(self, tmp_path, filename): + """Test that unsupported extensions default to text output.""" + output_path = tmp_path / filename + + client = Mock() + client.xml_plugin = None + + save_output_to_file( + "result", + str(output_path), + False, + client, + ) + + content = output_path.read_text(encoding="utf-8") + + assert content.startswith("ONVIF Command Output\n") + assert "result" in content + + +class TestSaveOutputToFileErrorHandling: + """Tests for errors raised while saving CLI output.""" + + @pytest.mark.parametrize( + "exception", + [ + OSError("write failed"), + ValueError("invalid value"), + AttributeError("missing attribute"), + ], + ) + def test_handles_save_error(self, exception, capsys): + """Test that save errors are reported without raising.""" + client = Mock() + client.xml_plugin = None + + with patch( + "onvif.cli.helpers.files.open", + side_effect=exception, + create=True, + ): + save_output_to_file( + {"value": 123}, + "result.json", + False, + client, + ) + + captured = capsys.readouterr() + + assert "Error saving output:" in captured.err + assert str(exception) in captured.err + assert "{'value': 123}" in captured.out + + def test_error_uses_colorize(self, capsys): + """Test that save errors are passed through colorize.""" + client = Mock() + client.xml_plugin = None + + with ( + patch( + "onvif.cli.helpers.files.open", + side_effect=OSError("write failed"), + create=True, + ), + patch( + "onvif.cli.helpers.files.colorize", + return_value="COLORED ERROR", + ) as mock_colorize, + ): + save_output_to_file( + "result", + "result.txt", + False, + client, + ) + + mock_colorize.assert_called_once_with( + "Error saving output:", + "red", + ) + + captured = capsys.readouterr() + + assert "COLORED ERROR" in captured.err + assert "result" in captured.out diff --git a/tests/cli/test_cli_helper_messages.py b/tests/cli/test_cli_helper_messages.py new file mode 100644 index 0000000..5914fc0 --- /dev/null +++ b/tests/cli/test_cli_helper_messages.py @@ -0,0 +1,106 @@ +"""Tests for CLI helper messages.""" + +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from onvif.cli.helpers.messages import print_interactive_intro + + +def make_args(**overrides): + """Create CLI arguments with default values and optional overrides.""" + args = { + "host": "192.168.1.17", + "port": 8000, + "digest": False, + "https": False, + "no_verify_ssl": False, + "timeout": 10, + "debug": False, + "no_patch": False, + "wsdl": None, + "health_check_interval": 10, + } + args.update(overrides) + return SimpleNamespace(**args) + + +def test_print_interactive_intro_basic(): + """Test the interactive introduction with default arguments.""" + args = make_args() + + with patch( + "onvif.cli.helpers.messages.colorize", + side_effect=lambda text, color: text, + ) as mock_colorize: + result = print_interactive_intro(args, "Device: Test Camera") + + assert isinstance(result, str) + assert result + + # Core dynamic information + assert args.host in result + assert str(args.port) in result + assert "Test Camera" in result + + # Always-present values must go through colorize + colorized_values = [call.args[0] for call in mock_colorize.call_args_list] + + assert f"{args.host}:{args.port}" in colorized_values + assert "WS-UsernameToken" in colorized_values + + +@pytest.mark.parametrize( + ("attribute", "value", "expected_value"), + [ + ("https", True, "True"), + ("no_verify_ssl", True, "False"), + ("timeout", 30, "30s"), + ("debug", True, "True"), + ("no_patch", True, "Disabled"), + ("wsdl", "C:\\custom\\wsdl", "C:\\custom\\wsdl"), + ("health_check_interval", 30, "30s"), + ], +) +def test_print_interactive_intro_optional_branches( + attribute, + value, + expected_value, +): + """Test that optional CLI settings are displayed and colorized correctly.""" + args = make_args(**{attribute: value}) + + with patch( + "onvif.cli.helpers.messages.colorize", + side_effect=lambda text, color: text, + ) as mock_colorize: + result = print_interactive_intro(args, "") + + assert isinstance(result, str) + + colorized_values = [call.args[0] for call in mock_colorize.call_args_list] + + assert expected_value in colorized_values + + +@pytest.mark.parametrize( + ("digest", "expected"), + [ + (True, "HTTP Digest"), + (False, "WS-UsernameToken"), + ], +) +def test_print_interactive_intro_auth_method(digest, expected): + """Test that the configured authentication method is displayed correctly.""" + args = make_args(digest=digest) + + with patch( + "onvif.cli.helpers.messages.colorize", + side_effect=lambda text, color: text, + ) as mock_colorize: + print_interactive_intro(args, "") + + colorized_values = [call.args[0] for call in mock_colorize.call_args_list] + + assert expected in colorized_values diff --git a/tests/cli/test_cli_helper_products.py b/tests/cli/test_cli_helper_products.py new file mode 100644 index 0000000..b32d9b5 --- /dev/null +++ b/tests/cli/test_cli_helper_products.py @@ -0,0 +1,744 @@ +"""Tests for CLI helper products.""" + +from unittest.mock import Mock, patch + +import pytest + +from onvif.cli.helpers.products import ( + _fetch_product_page, + _format_date_width, + _get_content_widths, + _get_terminal_width, + _print_data_header, + _print_data_pagination, + _print_data_rows, + _truncate_columns, + search_products, +) + +HEADERS = [ + "ID", + "Test Date", + "Model", + "Firmware", + "Profiles", + "Category", + "Type", + "Company", +] + + +def make_row( + id=1, # pylint: disable=redefined-builtin + date="2024-08-15 17:53:12", + model="C210", + firmware="1.0.0", + profiles="M,T,S", + category="IP Camera", + type_="Camera", + company="TP-Link", +): + """Create a product row with optional field overrides.""" + return ( + id, + date, + model, + firmware, + profiles, + category, + type_, + company, + ) + + +class TestFetchProductPage: + """Tests for fetching a paginated product page from the database.""" + + def test_with_results(self): + """Test fetching a page when matching products exist.""" + cursor = Mock() + + cursor.fetchone.return_value = (45,) + cursor.fetchall.return_value = [ + make_row(), + make_row(id=2, model="C220"), + ] + + total_count, total_pages, results = _fetch_product_page( + cursor, + "C210", + page=1, + per_page=20, + ) + + assert total_count == 45 + assert total_pages == 3 + assert len(results) == 2 + + assert cursor.execute.call_count == 2 + + count_params = cursor.execute.call_args_list[0].args[1] + assert count_params == ("%C210%",) * 4 + + data_params = cursor.execute.call_args_list[1].args[1] + assert data_params == ( + "%C210%", + "%C210%", + "%C210%", + "%C210%", + 20, + 0, + ) + + def test_second_page(self): + """Test fetching a later page uses the correct database offset.""" + cursor = Mock() + + cursor.fetchone.return_value = (45,) + cursor.fetchall.return_value = [make_row(id=21)] + + total_count, total_pages, results = _fetch_product_page( + cursor, + "camera", + page=2, + per_page=20, + ) + + assert total_count == 45 + assert total_pages == 3 + assert results == [make_row(id=21)] + + params = cursor.execute.call_args_list[1].args[1] + + assert params[-2:] == (20, 20) + + def test_no_results(self): + """Test fetching a page when no matching products exist.""" + cursor = Mock() + + cursor.fetchone.return_value = (0,) + + total_count, total_pages, results = _fetch_product_page( + cursor, + "does-not-exist", + page=1, + per_page=20, + ) + + assert total_count == 0 + assert total_pages == 0 + assert results == [] + + # The data query must not be executed. + assert cursor.execute.call_count == 1 + + @pytest.mark.parametrize( + ("total_count", "per_page", "expected_pages"), + [ + (1, 20, 1), + (20, 20, 1), + (21, 20, 2), + (40, 20, 2), + (41, 20, 3), + (100, 25, 4), + ], + ) + def test_pagination(self, total_count, per_page, expected_pages): + """Test calculation of total pages for different result counts.""" + cursor = Mock() + cursor.fetchone.return_value = (total_count,) + cursor.fetchall.return_value = [] + + _, total_pages, _ = _fetch_product_page( + cursor, + "camera", + page=1, + per_page=per_page, + ) + + assert total_pages == expected_pages + + +class TestGetContentWidths: + """Tests for calculating column widths from product data.""" + + def test_empty_results(self): + """Test that empty results produce zero content widths.""" + result = _get_content_widths([], HEADERS) + + assert result == [0] * len(HEADERS) + + def test_uses_longest_value(self): + """Test that each column uses the width of its longest value.""" + results = [ + make_row( + id=12345, + date="2024-01-01T12:30:45.123456+08:00", + model="SHORT", + company="Short Company", + ), + make_row( + id=1, + date="2024-08-15T17:53:12.123456+08:00", + model="A-MUCH-LONGER-CAMERA-MODEL", + company="A Very Long Company Name", + ), + ] + + widths = _get_content_widths(results, HEADERS) + + assert widths[0] == len("12345") + assert widths[1] == len("2024-08-15 17:53:12") + assert widths[2] == len("A-MUCH-LONGER-CAMERA-MODEL") + assert widths[7] == len("A Very Long Company Name") + + def test_ignores_falsy_values(self): + """Test that falsy values do not contribute to column widths.""" + results = [ + ( + 0, + None, + "", + False, + None, + "", + None, + None, + ) + ] + + widths = _get_content_widths(results, HEADERS) + + assert widths == [0] * len(HEADERS) + + +class TestTruncateColumns: + """Tests for reducing column widths to fit the terminal.""" + + def test_negative_remaining_width(self): + """Test that widths remain unchanged when no space is available.""" + original = [10, 20, 30] + + result = _truncate_columns( + original.copy(), + [8, 8, 8], + ["ID", "Date", "Model"], + terminal_width=20, + separator_space=6, + ) + + assert result == original + + def test_without_truncatable_columns(self): + """Test that protected columns remain unchanged when space is sufficient.""" + original = [10, 20] + + result = _truncate_columns( + original.copy(), + [8, 8], + ["ID", "Date"], + terminal_width=100, + separator_space=3, + ) + + assert result == original + + def test_proportional(self): + """Test that truncatable columns are reduced proportionally.""" + widths = [10, 20, 30, 40] + min_widths = [8, 8, 8, 8] + headers = ["ID", "Date", "Model", "Company"] + + result = _truncate_columns( + widths.copy(), + min_widths, + headers, + terminal_width=80, + separator_space=9, + ) + + # First two columns are protected. + assert result[0] == 10 + assert result[1] == 20 + + # Remaining columns are reduced. + assert result[2] < 30 + assert result[3] < 40 + + # Minimum widths are respected. + assert result[2] >= 8 + assert result[3] >= 8 + + def test_zero_current_width(self): + """Test that zero-width columns are raised to their minimum width.""" + widths = [10, 20, 0, 0] + + result = _truncate_columns( + widths.copy(), + [8, 8, 8, 8], + ["ID", "Date", "Model", "Company"], + terminal_width=100, + separator_space=9, + ) + + assert result[0] == 10 + assert result[1] == 20 + assert result[2] == 8 + assert result[3] == 8 + + +class TestGetTerminalWidth: + """Tests for calculating display widths based on terminal size.""" + + def test_returns_content_width_when_it_fits(self): + """Test that content widths are preserved when they fit.""" + results = [make_row(model="C210")] + + with patch( + "onvif.cli.helpers.products.shutil.get_terminal_size", + ) as mock_size: + mock_size.return_value.columns = 200 + + result = _get_terminal_width(results, HEADERS) + + assert len(result) == len(HEADERS) + + # Every column must be at least its header width / 8. + for width, header in zip(result, HEADERS): + assert width >= max(len(header), 8) + + @pytest.mark.parametrize( + "exception", + [ + AttributeError(), + ValueError(), + OSError(), + ], + ) + def test_falls_back_to_120(self, exception): + """Test that terminal size errors use the fallback width.""" + results = [make_row(model="C210")] + + with patch( + "onvif.cli.helpers.products.shutil.get_terminal_size", + side_effect=exception, + ): + result = _get_terminal_width(results, HEADERS) + + assert len(result) == len(HEADERS) + + def test_truncates_wide_content(self): + """Test that wide content is reduced to fit the terminal.""" + results = [ + make_row( + model="A" * 100, + company="B" * 100, + firmware="C" * 100, + profiles="D" * 100, + ) + ] + + original_widths = _get_content_widths(results, HEADERS) + + with patch( + "onvif.cli.helpers.products.shutil.get_terminal_size", + ) as mock_size: + mock_size.return_value.columns = 80 + + result = _get_terminal_width(results, HEADERS) + + # Terminal width constraint should cause the calculated widths + # to differ from the unconstrained content widths. + assert result != original_widths + + # Columns have a minimum display width. + assert all(width >= 8 for width in result) + + # Wide columns should be reduced rather than expanded. + for index in (2, 3, 4, 7): + assert result[index] < original_widths[index] + + +# pylint: disable=too-few-public-methods +class TestFormatDateWidth: + """Tests for formatting dates for table display.""" + + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("2024-08-15", "2024-08-15"), + ( + "2024-08-15T17:53:12", + "2024-08-15 17:53:12", + ), + ( + "2024-08-15T17:53:12.123456", + "2024-08-15 17:53:12", + ), + ( + "2024-08-15T17:53:12+08:00", + "2024-08-15 17:53:12", + ), + ( + "2024-08-15T17:53:12Z", + "2024-08-15 17:53:12", + ), + ( + "2024-08-15T17:53:12.123456+08:00", + "2024-08-15 17:53:12", + ), + ], + ) + def test_format_date(self, value, expected): + """Test formatting supported date representations.""" + assert _format_date_width(value) == expected + + +# pylint: disable=too-few-public-methods +class TestPrintDataHeader: + """Tests for printing the product table header.""" + + def test_prints_headers(self, capsys): + """Test that all headers and the separator are printed.""" + col_widths = [8] * len(HEADERS) + + _print_data_header(HEADERS, col_widths) + + output = capsys.readouterr().out + + for header in HEADERS: + assert header in output + + assert "-" in output + + +class TestPrintDataRows: + """Tests for printing product rows in the terminal table.""" + + def test_basic(self, capsys): + """Test printing a standard product row.""" + results = [ + make_row( + id=1, + date="2024-08-15 17:53:12", + model="C210", + ) + ] + + col_widths = [8] * len(HEADERS) + + _print_data_rows(results, col_widths) + + output = capsys.readouterr().out + + assert "1" in output + assert "2024-08-15 17:53:12" in output + assert "C210" in output + + def test_handles_none(self, capsys): + """Test printing a row containing None values.""" + row = ( + 1, + None, + None, + None, + None, + None, + None, + None, + ) + + _print_data_rows([row], [8] * len(HEADERS)) + + output = capsys.readouterr().out + + assert "1" in output + assert output.count("|") == len(HEADERS) - 1 + + @pytest.mark.parametrize( + ("date", "expected"), + [ + ( + "2024-08-15T17:53:12.9154121+08:00", + "2024-08-15 17:53:12", + ), + ( + "2024-08-15T17:53:12Z", + "2024-08-15 17:53:12", + ), + ( + "2024-08-15 17:53:12", + "2024-08-15 17:53:12", + ), + ( + "2024-08-15", + "2024-08-15 00:00:00", + ), + ( + "not-a-date", + "not-a-date", + ), + ], + ) + def test_date_formats(self, capsys, date, expected): + """Test formatting and fallback behavior for row dates.""" + row = make_row(date=date) + + _print_data_rows([row], [30] * len(HEADERS)) + + output = capsys.readouterr().out + + assert expected in output + + def test_truncates_long_values(self, capsys): + """Test that values exceeding their column width are truncated.""" + row = make_row(model="ABCDEFGHIJKLMNO") + + col_widths = [8] * len(HEADERS) + + _print_data_rows([row], col_widths) + + output = capsys.readouterr().out + + assert "ABCDE..." in output + + def test_does_not_truncate_short_values(self, capsys): + """Test that values within their column width are not truncated.""" + row = make_row(model="C210") + + _print_data_rows([row], [20] * len(HEADERS)) + + output = capsys.readouterr().out + + assert "C210" in output + assert "..." not in output + + def test_invalid_date_falls_back_to_original(self, capsys): + """Test that invalid dates are printed without modification.""" + row = make_row(date="2024/08/15 invalid") + + _print_data_rows([row], [30] * len(HEADERS)) + + output = capsys.readouterr().out + + assert "2024/08/15 invalid" in output + + def test_multiple_rows(self, capsys): + """Test printing multiple product rows.""" + rows = [ + make_row(id=1, model="C210"), + make_row(id=2, model="C220"), + make_row(id=3, model="C230"), + ] + + _print_data_rows(rows, [20] * len(HEADERS)) + + output = capsys.readouterr().out + + assert "C210" in output + assert "C220" in output + assert "C230" in output + + +class TestPrintDataPagination: + """Tests for printing product table pagination controls.""" + + def test_single_page(self, capsys): + """Test pagination output when only one page exists.""" + _print_data_pagination(1, 1) + + output = capsys.readouterr().out + + assert "Page 1 of 1" in output + assert "Navigation:" not in output + + def test_first_page(self, capsys): + """Test pagination output for the first page.""" + _print_data_pagination(1, 3) + + output = capsys.readouterr().out + + assert "Page 1 of 3" in output + assert "Next: --page 2" in output + assert "Previous:" not in output + + def test_middle_page(self, capsys): + """Test pagination output for a middle page.""" + _print_data_pagination(2, 3) + + output = capsys.readouterr().out + + assert "Page 2 of 3" in output + assert "Previous: --page 1" in output + assert "Next: --page 3" in output + + def test_last_page(self, capsys): + """Test pagination output for the last page.""" + _print_data_pagination(3, 3) + + output = capsys.readouterr().out + + assert "Page 3 of 3" in output + assert "Previous: --page 2" in output + assert "Next:" not in output + + +class TestSearchProducts: + """Tests for the main product search command.""" + + def test_database_not_found(self): + """Test that a missing product database exits with an error.""" + with ( + patch( + "onvif.cli.helpers.products.os.path.exists", + return_value=False, + ), + pytest.raises(SystemExit) as exc_info, + ): + search_products("C210") + + assert exc_info.value.code == 1 + + @pytest.mark.parametrize( + ("page", "per_page"), + [ + (0, 20), + (-1, 20), + (1, 0), + (1, -1), + (1, 101), + ], + ) + def test_invalid_pagination(self, page, per_page): + """Test that invalid pagination arguments exit with an error.""" + with ( + patch( + "onvif.cli.helpers.products.os.path.exists", + return_value=True, + ), + pytest.raises(SystemExit) as exc_info, + ): + search_products( + "C210", + page=page, + per_page=per_page, + ) + + assert exc_info.value.code == 1 + + def test_no_results(self, capsys): + """Test search behavior when no matching products are found.""" + conn = Mock() + cursor = Mock() + + conn.cursor.return_value = cursor + cursor.fetchone.return_value = (0,) + + with ( + patch( + "onvif.cli.helpers.products.os.path.exists", + return_value=True, + ), + patch( + "onvif.cli.helpers.products.sqlite3.connect", + return_value=conn, + ), + patch( + "onvif.cli.helpers.products._fetch_product_page", + return_value=(0, 0, []), + ), + ): + assert search_products("unknown") is None + + output = capsys.readouterr().out + + assert "No products found" in output + assert "unknown" in output + conn.close.assert_not_called() + + def test_page_out_of_range(self, capsys): + """Test search behavior when the requested page does not exist.""" + conn = Mock() + + with ( + patch( + "onvif.cli.helpers.products.os.path.exists", + return_value=True, + ), + patch( + "onvif.cli.helpers.products.sqlite3.connect", + return_value=conn, + ), + patch( + "onvif.cli.helpers.products._fetch_product_page", + return_value=(45, 3, []), + ), + ): + assert ( + search_products( + "camera", + page=4, + per_page=20, + ) + is None + ) + + output = capsys.readouterr().out + + assert "does not exist" in output + assert "Total pages: 3" in output + conn.close.assert_not_called() + + def test_database_error(self): + """Test that database errors exit with an error status.""" + conn = Mock() + + with ( + patch( + "onvif.cli.helpers.products.os.path.exists", + return_value=True, + ), + patch( + "onvif.cli.helpers.products.sqlite3.connect", + return_value=conn, + ), + patch( + "onvif.cli.helpers.products._fetch_product_page", + side_effect=__import__("sqlite3").Error("database failure"), + ), + pytest.raises(SystemExit) as exc_info, + ): + search_products("camera") + + assert exc_info.value.code == 1 + + @pytest.mark.parametrize( + "exception", + [ + AttributeError("invalid result"), + ValueError("invalid value"), + OSError("filesystem failure"), + ], + ) + def test_other_errors(self, exception): + """Test that unexpected errors exit with an error status.""" + conn = Mock() + + with ( + patch( + "onvif.cli.helpers.products.os.path.exists", + return_value=True, + ), + patch( + "onvif.cli.helpers.products.sqlite3.connect", + return_value=conn, + ), + patch( + "onvif.cli.helpers.products._fetch_product_page", + side_effect=exception, + ), + pytest.raises(SystemExit) as exc_info, + ): + search_products("camera") + + assert exc_info.value.code == 1 diff --git a/tests/cli/test_cli_helper_wsdl_parser.py b/tests/cli/test_cli_helper_wsdl_parser.py new file mode 100644 index 0000000..b026132 --- /dev/null +++ b/tests/cli/test_cli_helper_wsdl_parser.py @@ -0,0 +1,962 @@ +"""Tests for ONVIF CLI WSDL parser helpers.""" + +from types import SimpleNamespace + +from lxml import etree + +from onvif.cli.helpers.wsdl_parser import ( + _load_imported_schemas, + clean_documentation_html, + extract_documentation_text, + get_method_documentation, + get_operation_type_info, + parse_inline_complex_type, + parse_message_from_wsdl, + resolve_complex_type, + resolve_element_type, +) + +XS_NS = "http://www.w3.org/2001/XMLSchema" +WSDL_NS = "http://schemas.xmlsoap.org/wsdl/" + +NAMESPACES = { + "xs": XS_NS, + "wsdl": WSDL_NS, +} + + +def make_service(wsdl_path, **methods): + """Create a minimal service object for WSDL parser tests.""" + namespace = SimpleNamespace(wsdl_path=str(wsdl_path)) + + service = SimpleNamespace( + operator=namespace, + **methods, + ) + + return service + + +# pylint: disable=invalid-name +class TestGetMethodDocumentation: + """Tests for extracting WSDL documentation and method parameters.""" + + def test_returns_none_for_unknown_operation(self, tmp_path): + """Test that an unknown operation returns None.""" + wsdl = tmp_path / "service.wsdl" + wsdl.write_text( + f""" + + + + + + """, + encoding="utf-8", + ) + + service = make_service( + wsdl, + UnknownOperation=lambda value: value, + ) + + assert ( + get_method_documentation( + service, + "UnknownOperation", + ) + is None + ) + + def test_extracts_documentation_and_parameters(self, tmp_path): + """Test extracting operation documentation and method parameters.""" + wsdl = tmp_path / "service.wsdl" + wsdl.write_text( + f""" + + + + + Get something from the device. + + + + + """, + encoding="utf-8", + ) + + def GetSomething(required, optional="default"): + """Test operation signature.""" + return required, optional + + service = make_service( + wsdl, + GetSomething=GetSomething, + ) + + result = get_method_documentation( + service, + "GetSomething", + ) + + assert result is not None + assert "Get something from the device." in result["doc"] + assert result["required"] == ["required"] + assert result["optional"] == ["optional"] + + def test_handles_missing_wsdl(self, tmp_path): + """Test fallback when the WSDL file does not exist.""" + service = make_service( + tmp_path / "missing.wsdl", + TestOperation=lambda value: value, + ) + + result = get_method_documentation( + service, + "TestOperation", + ) + + assert result is not None + assert result["required"] == ["value"] + assert result["optional"] == [] + + def test_handles_method_without_parameters(self, tmp_path): + """Test extracting a method with no arguments.""" + wsdl = tmp_path / "service.wsdl" + wsdl.write_text( + f""" + + + + Ping the device. + + + + """, + encoding="utf-8", + ) + + def Ping(): + """Test operation without parameters.""" + return True + + service = make_service(wsdl, Ping=Ping) + + result = get_method_documentation(service, "Ping") + + assert result is not None + assert result["required"] == [] + assert result["optional"] == [] + + +class TestGetOperationTypeInfo: + """Tests for extracting operation input and output message types.""" + + def test_returns_none_for_unknown_operation(self, tmp_path): + """Test that an unknown operation returns None.""" + wsdl = tmp_path / "service.wsdl" + wsdl.write_text( + f""" + + + + """, + encoding="utf-8", + ) + + service = make_service(wsdl) + + assert ( + get_operation_type_info( + service, + "UnknownOperation", + ) + is None + ) + + def test_extracts_input_and_output_messages(self, tmp_path): + """Test extracting input and output message definitions.""" + wsdl = tmp_path / "service.wsdl" + wsdl.write_text( + f""" + + + + + + + + + + + + + + + + + + """, + encoding="utf-8", + ) + + service = make_service(wsdl) + + result = get_operation_type_info( + service, + "TestOperation", + ) + + assert result is not None + assert result["input"] is not None + assert result["output"] is not None + + assert result["input"]["name"] == "TestRequest" + assert result["output"]["name"] == "TestResponse" + + assert result["input"]["parameters"][0]["name"] == "value" + assert result["output"]["parameters"][0]["name"] == "result" + + def test_handles_operation_without_messages(self, tmp_path): + """Test operations that do not define input or output messages.""" + wsdl = tmp_path / "service.wsdl" + wsdl.write_text( + f""" + + + + + + """, + encoding="utf-8", + ) + + service = make_service(wsdl) + + result = get_operation_type_info( + service, + "TestOperation", + ) + + assert result == { + "input": None, + "output": None, + } + + def test_handles_invalid_wsdl(self, tmp_path): + """Test that malformed WSDL returns None.""" + wsdl = tmp_path / "invalid.wsdl" + wsdl.write_text(" + """.encode()) + + result = parse_message_from_wsdl( + root, + "UnknownMessage", + NAMESPACES, + { + "roots": [root], + "namespaces": NAMESPACES, + "wsdl_dir": ".", + }, + ) + + assert result == { + "name": "UnknownMessage", + "parameters": [], + } + + def test_parses_direct_type_reference(self): + """Test parsing a message part with a direct type reference.""" + root = etree.fromstring(f""" + + + + + + + """.encode()) + + result = parse_message_from_wsdl( + root, + "TestMessage", + NAMESPACES, + { + "roots": [root], + "namespaces": NAMESPACES, + "wsdl_dir": ".", + }, + ) + + assert result["name"] == "TestMessage" + assert len(result["parameters"]) == 1 + + parameter = result["parameters"][0] + + assert parameter["name"] == "value" + assert parameter["type"] == "string" + assert parameter["minOccurs"] == "1" + assert parameter["maxOccurs"] == "1" + assert parameter["is_attribute"] is False + + def test_resolves_element_reference(self): + """Test resolving a message part that references an element.""" + root = etree.fromstring(f""" + + + + + + + + + + + + + + + + + """.encode()) + + namespaces = { + **NAMESPACES, + "tns": "urn:test", + } + + result = parse_message_from_wsdl( + root, + "TestMessage", + namespaces, + { + "roots": [root], + "namespaces": namespaces, + "wsdl_dir": ".", + }, + ) + + assert result["name"] == "TestMessage" + assert result["parameters"] + + assert result["parameters"][0]["name"] == "Name" + + +class TestCleanDocumentationHtml: + """Tests for cleaning HTML from WSDL documentation.""" + + def test_empty_text(self): + """Test that empty documentation remains empty.""" + assert clean_documentation_html("") == "" + assert clean_documentation_html(None) is None + + def test_removes_html_tags(self): + """Test removing HTML tags from documentation.""" + result = clean_documentation_html("

    Hello world

    ") + + assert result == "Hello world" + + def test_converts_links(self): + """Test converting HTML links into readable text.""" + result = clean_documentation_html('Example') + + assert result == "Example (https://example.com)" + + def test_keeps_url_without_link_text(self): + """Test preserving URLs when a link has no visible text.""" + result = clean_documentation_html('') + + assert result == "https://example.com" + + def test_normalizes_whitespace(self): + """Test collapsing repeated whitespace.""" + result = clean_documentation_html("Hello\n\n world\tfrom parser") + + assert result == "Hello world from parser" + + def test_handles_multiline_links(self): + """Test converting links whose content spans multiple lines.""" + result = clean_documentation_html(""" + + Example + + """) + + assert result == "Example (https://example.com)" + + +class TestExtractDocumentationText: + """Tests for extracting text from XML documentation elements.""" + + def test_none_element(self): + """Test extracting text from a missing element.""" + assert extract_documentation_text(None) == "" + + def test_text_only(self): + """Test extracting plain documentation text.""" + element = etree.fromstring(b"Hello world") + + assert extract_documentation_text(element) == "Hello world" + + def test_text_and_children(self): + """Test extracting text before, inside, and after child elements.""" + element = etree.fromstring(b""" + + Before + middle + After + + """) + + result = extract_documentation_text(element) + + assert "Before" in result + assert "middle" in result + assert "After" in result + + +class TestLoadImportedSchemas: + """Tests for loading imported and included XML schemas.""" + + def test_loads_imported_schema(self, tmp_path): + """Test loading a local imported schema into the context.""" + imported = tmp_path / "imported.xsd" + imported.write_text( + f""" + + + + """, + encoding="utf-8", + ) + + root = etree.fromstring(f""" + + + + + + """.encode()) + + context = { + "roots": [root], + "namespaces": NAMESPACES, + "wsdl_dir": str(tmp_path), + } + + _load_imported_schemas( + root, + context, + NAMESPACES, + ) + + assert len(context["roots"]) == 2 + + imported_root = context["roots"][1] + + assert ( + imported_root.find( + ".//xs:element[@name='ImportedElement']", + NAMESPACES, + ) + is not None + ) + + def test_loads_included_schema(self, tmp_path): + """Test loading a local included schema into the context.""" + included = tmp_path / "included.xsd" + included.write_text( + f""" + + + + """, + encoding="utf-8", + ) + + root = etree.fromstring(f""" + + + + + + """.encode()) + + context = { + "roots": [root], + "namespaces": NAMESPACES, + "wsdl_dir": str(tmp_path), + } + + _load_imported_schemas( + root, + context, + NAMESPACES, + ) + + assert len(context["roots"]) == 2 + + +class TestResolveElementType: + """Tests for recursively resolving XML schema elements.""" + + def test_returns_empty_for_unknown_element(self): + """Test resolving an element that does not exist.""" + root = etree.fromstring(f""" + + """.encode()) + + result = resolve_element_type( + "Unknown", + NAMESPACES, + { + "roots": [root], + "namespaces": NAMESPACES, + "wsdl_dir": ".", + }, + ) + + assert not result + + def test_resolves_simple_sequence(self): + """Test resolving elements from a simple complex type sequence.""" + root = etree.fromstring(f""" + + + + + + + + + + + + + """.encode()) + + result = resolve_element_type( + "Request", + NAMESPACES, + { + "roots": [root], + "namespaces": NAMESPACES, + "wsdl_dir": ".", + }, + ) + + assert [item["name"] for item in result] == [ + "Name", + "Count", + ] + + assert result[0]["type"] == "string" + assert result[0]["is_attribute"] is False + + assert result[1]["minOccurs"] == "0" + assert result[1]["maxOccurs"] == "unbounded" + + def test_resolves_attributes(self): + """Test extracting attributes from a complex type.""" + root = etree.fromstring(f""" + + + + + + + + + + """.encode()) + + result = resolve_element_type( + "Request", + NAMESPACES, + { + "roots": [root], + "namespaces": NAMESPACES, + "wsdl_dir": ".", + }, + ) + + assert len(result) == 1 + assert result[0]["name"] == "id" + assert result[0]["type"] == "string" + assert result[0]["is_attribute"] is True + assert result[0]["minOccurs"] == "1" + + def test_respects_recursion_limit(self): + """Test that excessive recursion returns an empty result.""" + root = etree.fromstring(f""" + + + + + + + + + """.encode()) + + result = resolve_element_type( + "Request", + NAMESPACES, + { + "roots": [root], + "namespaces": NAMESPACES, + "wsdl_dir": ".", + }, + depth=11, + ) + + assert not result + + +class TestResolveComplexType: + """Tests for resolving named XML schema complex types.""" + + def test_returns_empty_for_unknown_type(self): + """Test resolving a complex type that does not exist.""" + root = etree.fromstring(f""" + + """.encode()) + + result = resolve_complex_type( + "UnknownType", + NAMESPACES, + { + "roots": [root], + "namespaces": NAMESPACES, + "wsdl_dir": ".", + }, + ) + + assert not result + + def test_resolves_sequence(self): + """Test extracting children from a named complex type.""" + root = etree.fromstring(f""" + + + + + + + + + + + """.encode()) + + result = resolve_complex_type( + "RequestType", + NAMESPACES, + { + "roots": [root], + "namespaces": NAMESPACES, + "wsdl_dir": ".", + }, + ) + + assert [item["name"] for item in result] == [ + "Name", + "Enabled", + ] + + assert result[1]["minOccurs"] == "0" + + def test_resolves_attributes(self): + """Test extracting attributes from a named complex type.""" + root = etree.fromstring(f""" + + + + + + + + """.encode()) + + result = resolve_complex_type( + "RequestType", + NAMESPACES, + { + "roots": [root], + "namespaces": NAMESPACES, + "wsdl_dir": ".", + }, + ) + + assert result[0]["name"] == "token" + assert result[0]["is_attribute"] is True + assert result[0]["minOccurs"] == "1" + + def test_resolves_base_type(self): + """Test resolving children inherited through complex type extension.""" + root = etree.fromstring(f""" + + + + + + + + + + + + + + + + + + + + """.encode()) + + result = resolve_complex_type( + "ExtendedType", + NAMESPACES, + { + "roots": [root], + "namespaces": NAMESPACES, + "wsdl_dir": ".", + }, + ) + + names = [item["name"] for item in result] + + assert "BaseValue" in names + assert "ExtendedValue" in names + + +class TestParseInlineComplexType: + """Tests for parsing inline XML schema complex types.""" + + def test_empty_complex_type(self): + """Test parsing an empty inline complex type.""" + complex_type = etree.fromstring(f""" + + """.encode()) + + result = parse_inline_complex_type( + complex_type, + NAMESPACES, + { + "roots": [complex_type], + "namespaces": NAMESPACES, + "wsdl_dir": ".", + }, + ) + + assert not result + + def test_parses_sequence(self): + """Test parsing elements from an inline sequence.""" + complex_type = etree.fromstring(f""" + + + + + + + """.encode()) + + result = parse_inline_complex_type( + complex_type, + NAMESPACES, + { + "roots": [complex_type], + "namespaces": NAMESPACES, + "wsdl_dir": ".", + }, + ) + + assert [item["name"] for item in result] == [ + "Name", + "Count", + ] + + assert result[1]["minOccurs"] == "0" + + def test_parses_attributes(self): + """Test parsing attributes from an inline complex type.""" + complex_type = etree.fromstring(f""" + + + + """.encode()) + + result = parse_inline_complex_type( + complex_type, + NAMESPACES, + { + "roots": [complex_type], + "namespaces": NAMESPACES, + "wsdl_dir": ".", + }, + ) + + assert len(result) == 1 + assert result[0]["name"] == "id" + assert result[0]["is_attribute"] is True + assert result[0]["minOccurs"] == "1" + + def test_parses_nested_inline_type(self): + """Test parsing a nested inline complex type.""" + complex_type = etree.fromstring(f""" + + + + + + + + + + + + """.encode()) + + result = parse_inline_complex_type( + complex_type, + NAMESPACES, + { + "roots": [complex_type], + "namespaces": NAMESPACES, + "wsdl_dir": ".", + }, + ) + + assert len(result) == 1 + assert result[0]["name"] == "Parent" + assert result[0]["children"] + + assert result[0]["children"][0]["name"] == "Child" + + def test_respects_recursion_limit(self): + """Test that deeply nested inline types stop at the recursion limit.""" + complex_type = etree.fromstring(f""" + + + + + + """.encode()) + + result = parse_inline_complex_type( + complex_type, + NAMESPACES, + { + "roots": [complex_type], + "namespaces": NAMESPACES, + "wsdl_dir": ".", + }, + depth=11, + ) + + assert not result diff --git a/tests/cli/test_cli_interactive.py b/tests/cli/test_cli_interactive.py new file mode 100644 index 0000000..df9a094 --- /dev/null +++ b/tests/cli/test_cli_interactive.py @@ -0,0 +1,399 @@ +# pylint: disable=protected-access +"""Tests for the interactive ONVIF CLI shell.""" + +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from onvif.cli.interactive import InteractiveShell + + +def make_shell(): + """Create a minimally initialized interactive shell for unit tests.""" + shell = object.__new__(InteractiveShell) + + shell.client = Mock() + shell.args = SimpleNamespace( + username="admin", + host="camera", + port=80, + debug=False, + ) + shell.current_service = None + shell.current_service_name = None + shell.stored_data = {} + shell.stored_metadata = {} + shell._last_result = None + shell._last_method = None + shell._last_service_name = None + shell._last_operation_timestamp = None + shell.base_commands = [ + "capabilities", + "caps", + "services", + "help", + "exit", + "store", + "rm", + "show", + "cls", + "clear", + "info", + "debug", + "ls", + "cd", + "shortcuts", + "desc", + "type", + ] + shell.prompt = "" + shell.device_info_text = "" + shell.intro = "" + shell._stop_health_check = Mock() + return shell + + +class TestSplitMultiCommands: + """Test splitting chained interactive commands.""" + + def test_splits_top_level_commands(self): + """Test that top-level commands are split on double ampersands.""" + shell = make_shell() + + result = shell._split_multi_commands("media && GetProfiles && ls") + + assert result == ["media", "GetProfiles", "ls"] + + def test_preserves_quoted_and_nested_commands(self): + """Test that separators inside quotes and brackets are preserved.""" + shell = make_shell() + + result = shell._split_multi_commands( + 'command {"value": "a && b"} && other [1, "x && y"]' + ) + + assert result == [ + 'command {"value": "a && b"}', + 'other [1, "x && y"]', + ] + + +class TestResolveStoredReference: + """Test resolving values from stored command results.""" + + @pytest.mark.parametrize( + ("reference", "expected"), + [ + ("value", 42), + ("profile.name", "camera"), + ("profiles[0]", "first"), + ("profiles[1].token", "second-token"), + ], + ) + def test_resolves_supported_references(self, reference, expected): + """Test resolving dictionary, attribute, and list references.""" + shell = make_shell() + shell.stored_data = { + "value": 42, + "profile": {"name": "camera"}, + "profiles": [ + "first", + {"token": "second-token"}, + ], + } + + assert shell._resolve_stored_reference(reference) == expected + + def test_returns_none_for_unknown_reference(self): + """Test that an unknown stored reference returns None.""" + shell = make_shell() + + assert shell._resolve_stored_reference("missing.value") is None + + +class TestSubstituteStoredReferences: + """Test substituting stored values into command parameters.""" + + def test_substitutes_stored_values(self): + """Test that stored references are replaced with JSON values.""" + shell = make_shell() + shell.stored_data = {"token": "abc123", "count": 5} + + result = shell._substitute_stored_references( + '{"Token": $token, "Count": $count}' + ) + + assert result == '{"Token": "abc123", "Count": 5}' + + def test_keeps_unresolved_references(self, capsys): + """Test that unresolved references remain unchanged and emit a warning.""" + shell = make_shell() + + result = shell._substitute_stored_references('{"Token": $missing}') + + assert result == '{"Token": $missing}' + + assert "Cannot resolve $missing" in capsys.readouterr().out + + +class TestUpdatePrompt: + """Test interactive shell prompt generation.""" + + def test_builds_root_prompt(self): + """Test that the root prompt contains connection information.""" + shell = make_shell() + + shell.update_prompt() + + assert shell.prompt == "admin@camera:80 > " + + def test_builds_service_prompt(self): + """Test that the service prompt includes the current service name.""" + shell = make_shell() + shell.current_service_name = "media" + + shell.update_prompt() + + assert shell.prompt == "admin@camera:80/media > " + + +class TestGetSuggestions: + """Test command and service suggestions.""" + + def test_suggests_root_commands(self, monkeypatch): + """Test that matching root commands are returned.""" + shell = make_shell() + + monkeypatch.setattr( + "onvif.cli.interactive.get_device_available_services", + lambda _client: ["media", "events"], + ) + + result = shell.get_suggestions("me") + + assert any("media" in suggestion for suggestion in result) + + def test_limits_suggestions(self, monkeypatch): + """Test that suggestions are limited to five results.""" + shell = make_shell() + + monkeypatch.setattr( + "onvif.cli.interactive.get_device_available_services", + lambda _client: [f"service{i}" for i in range(10)], + ) + + result = shell.get_suggestions("service") + + assert len(result) == 5 + + +class TestCompleteNames: + """Test command name completion.""" + + def test_completes_root_commands_and_services(self, monkeypatch): + """Test completion of root-level commands and services.""" + shell = make_shell() + + monkeypatch.setattr( + "onvif.cli.interactive.get_device_available_services", + lambda _client: ["media", "events"], + ) + + result = shell.completenames("me") + + assert "media" in result + + def test_completes_service_methods(self, monkeypatch): + """Test completion of methods while inside a service.""" + shell = make_shell() + shell.current_service = Mock() + + monkeypatch.setattr( + "onvif.cli.interactive.get_service_methods", + lambda _service: ["GetProfiles", "GetStreamUri"], + ) + + result = shell.completenames("getp") + + assert result == ["GetProfiles"] + + +class TestEnterService: + """Test entering an ONVIF service.""" + + def test_enters_service_without_required_arguments(self, monkeypatch): + """Test entering a service that does not require arguments.""" + shell = make_shell() + service = Mock() + + monkeypatch.setattr( + "onvif.cli.interactive.get_device_available_services", + lambda _client: ["media"], + ) + monkeypatch.setattr( + "onvif.cli.interactive.get_service_required_args", + lambda _service: [], + ) + monkeypatch.setattr( + "onvif.cli.interactive.get_service_methods", + lambda _service: ["GetProfiles"], + ) + shell.client.media.return_value = service + + shell.do_enter_service("media") + + assert shell.current_service is service + assert shell.current_service_name == "media" + assert shell.prompt.endswith("/media > ") + + def test_rejects_unknown_service(self, monkeypatch, capsys): + """Test that an unavailable service is rejected.""" + shell = make_shell() + + monkeypatch.setattr( + "onvif.cli.interactive.get_device_available_services", + lambda _client: ["media"], + ) + + shell.do_enter_service("events") + + assert "Unknown service" in capsys.readouterr().out + assert shell.current_service is None + + +class TestExecuteServiceMethod: + """Test executing methods on the active ONVIF service.""" + + def test_rejects_execution_without_service(self, capsys): + """Test that method execution fails outside service mode.""" + shell = make_shell() + + shell.execute_service_method("GetProfiles", "") + + assert "Not in service mode" in capsys.readouterr().out + + def test_executes_method_and_stores_last_result(self, capsys): + """Test executing a service method and recording its result.""" + shell = make_shell() + shell.current_service = Mock() + shell.current_service_name = "media" + shell.current_service.GetProfiles.return_value = {"Profiles": []} + + shell.execute_service_method("GetProfiles", "") + + assert shell._last_result == {"Profiles": []} + assert shell._last_method == "GetProfiles" + assert shell._last_service_name == "media" + assert "{'Profiles': []}" in capsys.readouterr().out + + def test_passes_json_parameters_to_method(self): + """Test that parsed command parameters are passed to the service method.""" + shell = make_shell() + shell.current_service = Mock() + shell.current_service.GetProfile.return_value = "profile" + + shell.execute_service_method( + "GetProfile", + '{"ProfileToken": "token-1"}', + ) + + shell.current_service.GetProfile.assert_called_once_with(ProfileToken="token-1") + + +class TestStoreData: + """Test storing command results for later reuse.""" + + def test_stores_last_result(self): + """Test storing the most recent command result.""" + shell = make_shell() + shell._last_result = {"value": 123} + shell.current_service_name = "media" + shell._last_method = "GetProfiles" + + shell.do_store("profiles") + + assert shell.stored_data["profiles"] == {"value": 123} + assert shell.stored_metadata["profiles"] == { + "service": "media", + "method": "GetProfiles", + } + + def test_rejects_invalid_name(self, capsys): + """Test that invalid stored variable names are rejected.""" + shell = make_shell() + shell._last_result = {"value": 123} + + shell.do_store("123profiles") + + assert "Invalid name" in capsys.readouterr().out + assert not shell.stored_data + + +# pylint: disable=too-few-public-methods +class TestRemoveData: + """Test removing stored command results.""" + + def test_removes_stored_data_and_metadata(self): + """Test that stored data and its metadata are removed together.""" + shell = make_shell() + shell.stored_data["profiles"] = {"value": 1} + shell.stored_metadata["profiles"] = {"service": "media"} + + shell.do_rm("profiles") + + assert "profiles" not in shell.stored_data + assert "profiles" not in shell.stored_metadata + + +class TestServiceMode: + """Test entering and leaving service mode.""" + + def test_exits_service_mode(self): + """Test that leaving service mode resets service state.""" + shell = make_shell() + shell.current_service = Mock() + shell.current_service_name = "media" + + shell.do_up("") + + assert shell.current_service is None + assert shell.current_service_name is None + assert shell.prompt == "admin@camera:80 > " + + def test_reports_when_not_in_service_mode(self, capsys): + """Test the message shown when leaving service mode from root mode.""" + shell = make_shell() + + shell.do_up("") + + assert "Not in service mode" in capsys.readouterr().out + + +# pylint: disable=too-few-public-methods +class TestClearStoredData: + """Test clearing all stored command results.""" + + def test_clears_data_and_metadata(self): + """Test that all stored data and metadata are removed.""" + shell = make_shell() + shell.stored_data["profiles"] = {} + shell.stored_metadata["profiles"] = {} + + shell.do_cls("") + + assert not shell.stored_data + assert not shell.stored_metadata + + +# pylint: disable=too-few-public-methods +class TestExit: + """Test interactive shell shutdown.""" + + def test_stops_health_check_and_returns_true(self, capsys): + """Test that exiting signals the health-check thread and stops the shell.""" + shell = make_shell() + + assert shell.do_exit("") is True + shell._stop_health_check.set.assert_called_once() + assert "Goodbye!" in capsys.readouterr().out diff --git a/tests/cli/test_cli_main.py b/tests/cli/test_cli_main.py new file mode 100644 index 0000000..42dd777 --- /dev/null +++ b/tests/cli/test_cli_main.py @@ -0,0 +1,718 @@ +"""Tests for the ONVIF CLI main module.""" + +from argparse import ArgumentParser +from unittest.mock import Mock, patch + +import pytest + +from onvif.cli.main import ( + _create_parser, + _handle_authentication, + _handle_discovery_mode, + _process_onvif_client, + main, +) +from onvif.operator import CacheMode +from onvif.utils import ONVIFOperationException + + +class TestCreateParser: + """Tests for creating and configuring the CLI argument parser.""" + + def test_creates_parser(self): + """Test that the CLI argument parser is created correctly.""" + parser = _create_parser() + + assert isinstance(parser, ArgumentParser) + assert parser.prog == "onvif" + + def test_defaults(self): + """Test default CLI argument values.""" + parser = _create_parser() + + args = parser.parse_args( + [ + "--host", + "192.168.1.17", + "devicemgmt", + "GetDeviceInformation", + ] + ) + + assert args.host == "192.168.1.17" + assert args.port == 80 + assert args.timeout == 10 + assert args.cache == CacheMode.DB.value + assert args.discovery_timeout == 4 + assert args.page == 1 + assert args.per_page == 20 + assert args.digest is False + assert args.https is False + assert args.no_verify is False + assert args.no_patch is False + assert args.interactive is False + assert args.debug is False + + @pytest.mark.parametrize( + ("arguments", "attribute", "expected"), + [ + (["--discover"], "discover", True), + (["--digest"], "digest", True), + (["--https"], "https", True), + (["--no-verify"], "no_verify", True), + (["--no-patch"], "no_patch", True), + (["--interactive"], "interactive", True), + (["--debug"], "debug", True), + (["--version"], "version", True), + ], + ) + def test_boolean_options(self, arguments, attribute, expected): + """Test boolean CLI options.""" + parser = _create_parser() + + args = parser.parse_args(arguments) + + assert getattr(args, attribute) is expected + + +class TestHandleDiscoveryMode: + """Tests for handling CLI device discovery mode.""" + + def test_skips_when_disabled(self): + """Test that discovery mode does nothing when --discover is not set.""" + parser = Mock() + args = Mock(discover=False) + + with patch("onvif.cli.main.discover_devices") as mock_discover: + _handle_discovery_mode(parser, args) + + mock_discover.assert_not_called() + + def test_rejects_host(self): + """Test that discovery cannot be combined with --host.""" + parser = Mock() + parser.error.side_effect = SystemExit(2) + + args = Mock( + discover=True, + host="192.168.1.17", + ) + + with pytest.raises(SystemExit) as exc_info: + _handle_discovery_mode(parser, args) + + assert exc_info.value.code == 2 + parser.error.assert_called_once() + + def test_no_devices(self): + """Test discovery failure when no devices are found.""" + parser = Mock() + args = Mock( + discover=True, + host=None, + filter=None, + interface=None, + https=False, + discovery_timeout=4, + ) + + with patch( + "onvif.cli.main.discover_devices", + return_value=[], + ): + with pytest.raises(SystemExit) as exc_info: + _handle_discovery_mode(parser, args) + + assert exc_info.value.code == 1 + + def test_no_devices_with_filter(self, capsys): + """Test discovery failure when a device filter matches no devices.""" + parser = Mock() + args = Mock( + discover=True, + host=None, + filter="Hikvision", + interface=None, + https=False, + discovery_timeout=4, + ) + + with patch( + "onvif.cli.main.discover_devices", + return_value=[], + ): + with pytest.raises(SystemExit) as exc_info: + _handle_discovery_mode(parser, args) + + assert exc_info.value.code == 1 + assert "Hikvision" in capsys.readouterr().out + + def test_selection_cancelled(self): + """Test discovery when interactive device selection is cancelled.""" + parser = Mock() + args = Mock( + discover=True, + host=None, + filter=None, + interface=None, + https=False, + discovery_timeout=4, + ) + + devices = [ + ("192.168.1.17", 80, False), + ] + + with ( + patch( + "onvif.cli.main.discover_devices", + return_value=devices, + ), + patch( + "onvif.cli.main.select_device_interactive", + return_value=None, + ), + ): + with pytest.raises(SystemExit) as exc_info: + _handle_discovery_mode(parser, args) + + assert exc_info.value.code == 0 + + def test_selects_device(self): + """Test that selected discovery data is applied to CLI arguments.""" + parser = Mock() + args = Mock( + discover=True, + host=None, + filter="Hikvision", + interface="192.168.1.1", + https=True, + discovery_timeout=10, + ) + + devices = [ + ("192.168.1.17", 443, True), + ] + + with ( + patch( + "onvif.cli.main.discover_devices", + return_value=devices, + ) as mock_discover, + patch( + "onvif.cli.main.select_device_interactive", + return_value=devices[0], + ) as mock_select, + ): + _handle_discovery_mode(parser, args) + + mock_discover.assert_called_once_with( + timeout=10, + interface="192.168.1.1", + prefer_https=True, + filter_term="Hikvision", + ) + mock_select.assert_called_once_with(devices) + + assert args.host == "192.168.1.17" + assert args.port == 443 + + +class TestHandleAuthentication: + """Tests for handling CLI authentication credentials.""" + + def test_with_credentials(self): + """Test authentication when credentials are already provided.""" + args = Mock( + search=None, + username="admin", + password="password", + ) + + with ( + patch("builtins.input") as mock_input, + patch("onvif.cli.main.getpass.getpass") as mock_getpass, + ): + _handle_authentication(args) + + mock_input.assert_not_called() + mock_getpass.assert_not_called() + + def test_prompts_for_credentials(self): + """Test authentication prompts for missing credentials.""" + args = Mock( + search=None, + username=None, + password=None, + host="192.168.1.17", + ) + + with ( + patch( + "builtins.input", + return_value="admin", + ) as mock_input, + patch( + "onvif.cli.main.getpass.getpass", + return_value="password", + ) as mock_getpass, + ): + _handle_authentication(args) + + assert args.username == "admin" + assert args.password == "password" + + mock_input.assert_called_once_with("Enter username: ") + mock_getpass.assert_called_once() + + @pytest.mark.parametrize( + "exception", + [EOFError(), KeyboardInterrupt()], + ) + def test_username_cancelled(self, exception): + """Test cancellation of the username prompt.""" + args = Mock( + search=None, + username=None, + password=None, + ) + + with patch( + "builtins.input", + side_effect=exception, + ): + with pytest.raises(SystemExit) as exc_info: + _handle_authentication(args) + + assert exc_info.value.code == 1 + + @pytest.mark.parametrize( + "exception", + [EOFError(), KeyboardInterrupt()], + ) + def test_password_cancelled(self, exception): + """Test cancellation of the password prompt.""" + args = Mock( + search=None, + username="admin", + password=None, + host="192.168.1.17", + ) + + with patch( + "onvif.cli.main.getpass.getpass", + side_effect=exception, + ): + with pytest.raises(SystemExit) as exc_info: + _handle_authentication(args) + + assert exc_info.value.code == 1 + + def test_skips_credentials_for_search(self): + """Test that search mode does not request credentials.""" + args = Mock( + search="hikvision", + username=None, + password=None, + ) + + with ( + patch("builtins.input") as mock_input, + patch("onvif.cli.main.getpass.getpass") as mock_getpass, + ): + _handle_authentication(args) + + mock_input.assert_not_called() + mock_getpass.assert_not_called() + + +class TestProcessOnvifClient: + """Tests for creating and processing an ONVIF client.""" + + def test_skips_search(self): + """Test that search mode does not create an ONVIF client.""" + args = Mock(search="hikvision") + + with patch("onvif.cli.main.ONVIFClient") as mock_client: + _process_onvif_client(args) + + mock_client.assert_not_called() + + def test_direct_command(self): + """Test direct ONVIF command execution.""" + args = Mock( + search=None, + debug=False, + output=None, + host="192.168.1.17", + port=80, + username="admin", + password="password", + digest=False, + timeout=10, + cache=CacheMode.DB.value, + https=False, + no_verify=False, + no_patch=False, + wsdl=None, + interactive=False, + service="devicemgmt", + method="GetDeviceInformation", + params=["foo", "bar"], + ) + + client = Mock() + + with ( + patch( + "onvif.cli.main.ONVIFClient", + return_value=client, + ) as mock_client, + patch( + "onvif.cli.main.execute_command", + return_value={"result": "ok"}, + ) as mock_execute, + ): + _process_onvif_client(args) + + mock_client.assert_called_once_with( + host="192.168.1.17", + port=80, + username="admin", + password="password", + http_digest=False, + timeout=10, + cache=CacheMode.DB, + use_https=False, + verify_ssl=True, + apply_patch=True, + capture_xml=False, + wsdl_dir=None, + ) + + mock_execute.assert_called_once_with( + client, + "devicemgmt", + "GetDeviceInformation", + "foo bar", + ) + + def test_xml_output_enables_debug(self): + """Test that XML output automatically enables XML capture.""" + args = Mock( + search=None, + debug=False, + output="result.xml", + host="192.168.1.17", + port=80, + username="admin", + password="password", + digest=False, + timeout=10, + cache=CacheMode.DB.value, + https=False, + no_verify=False, + no_patch=False, + wsdl=None, + interactive=False, + service="devicemgmt", + method="GetDeviceInformation", + params=[], + ) + + client = Mock() + + with ( + patch( + "onvif.cli.main.ONVIFClient", + return_value=client, + ) as mock_client, + patch( + "onvif.cli.main.execute_command", + return_value="", + ), + patch("onvif.cli.main.save_output_to_file") as mock_save, + ): + _process_onvif_client(args) + + assert mock_client.call_args.kwargs["capture_xml"] is True + + mock_save.assert_called_once_with( + "", + "result.xml", + True, + client, + ) + + def test_interactive(self): + """Test interactive mode.""" + args = Mock( + search=None, + debug=False, + output=None, + host="192.168.1.17", + port=80, + username="admin", + password="password", + digest=False, + timeout=10, + cache=CacheMode.DB.value, + https=False, + no_verify=False, + no_patch=False, + wsdl=None, + interactive=True, + ) + + client = Mock() + + with ( + patch( + "onvif.cli.main.ONVIFClient", + return_value=client, + ), + patch( + "onvif.cli.main.InteractiveShell", + ) as mock_shell, + ): + _process_onvif_client(args) + + client.devicemgmt().GetDeviceInformation.assert_called_once() + mock_shell.assert_called_once_with(client, args) + mock_shell.return_value.run.assert_called_once() + + def test_connection_error(self): + """Test interactive mode when the ONVIF connection fails.""" + args = Mock( + search=None, + debug=False, + output=None, + host="192.168.1.17", + port=80, + username="admin", + password="password", + digest=False, + timeout=10, + cache=CacheMode.DB.value, + https=False, + no_verify=False, + no_patch=False, + wsdl=None, + interactive=True, + ) + + client = Mock() + client.devicemgmt().GetDeviceInformation.side_effect = ONVIFOperationException( + "Connection failed", + Exception("Connection failed"), + ) + + with ( + patch( + "onvif.cli.main.ONVIFClient", + return_value=client, + ), + pytest.raises(SystemExit) as exc_info, + ): + _process_onvif_client(args) + + assert exc_info.value.code == 1 + + def test_keyboard_interrupt(self): + """Test KeyboardInterrupt handling during command execution.""" + args = Mock( + search=None, + debug=False, + output=None, + host="192.168.1.17", + port=80, + username="admin", + password="password", + digest=False, + timeout=10, + cache=CacheMode.DB.value, + use_https=False, + https=False, + no_verify=False, + no_patch=False, + wsdl=None, + interactive=False, + service="devicemgmt", + method="GetDeviceInformation", + params=[], + ) + + with ( + patch("onvif.cli.main.ONVIFClient"), + patch( + "onvif.cli.main.execute_command", + side_effect=KeyboardInterrupt, + ), + pytest.raises(SystemExit) as exc_info, + ): + _process_onvif_client(args) + + assert exc_info.value.code == 1 + + def test_runtime_error(self): + """Test RuntimeError handling during command execution.""" + args = Mock( + search=None, + debug=False, + output=None, + host="192.168.1.17", + port=80, + username="admin", + password="password", + digest=False, + timeout=10, + use_https=False, + cache=CacheMode.DB.value, + https=False, + no_verify=False, + no_patch=False, + wsdl=None, + interactive=False, + service="devicemgmt", + method="GetDeviceInformation", + params=[], + ) + + with ( + patch("onvif.cli.main.ONVIFClient"), + patch( + "onvif.cli.main.execute_command", + side_effect=RuntimeError("Something went wrong"), + ), + pytest.raises(SystemExit) as exc_info, + ): + _process_onvif_client(args) + + assert exc_info.value.code == 1 + + +class TestMain: + """Tests for the main ONVIF CLI entry point.""" + + def test_no_arguments(self, capsys): + """Test that no arguments prints help and exits successfully.""" + with ( + patch("onvif.cli.main.sys.argv", ["onvif"]), + pytest.raises(SystemExit) as exc_info, + ): + main() + + assert exc_info.value.code == 0 + assert "usage:" in capsys.readouterr().out.lower() + + def test_version(self, capsys): + """Test the --version option.""" + with ( + patch( + "onvif.cli.main.sys.argv", + ["onvif", "--version"], + ), + pytest.raises(SystemExit) as exc_info, + ): + main() + + assert exc_info.value.code == 0 + assert capsys.readouterr().out.strip() + + def test_search(self): + """Test product search mode.""" + with ( + patch( + "onvif.cli.main.sys.argv", + ["onvif", "--search", "hikvision"], + ), + patch("onvif.cli.main.search_products") as mock_search, + pytest.raises(SystemExit) as exc_info, + ): + main() + + assert exc_info.value.code == 0 + mock_search.assert_called_once_with("hikvision", 1, 20) + + def test_requires_service_and_method(self): + """Test validation when service and method are missing.""" + with ( + patch( + "onvif.cli.main.sys.argv", + ["onvif", "--host", "192.168.1.17"], + ), + pytest.raises(SystemExit) as exc_info, + ): + main() + + assert exc_info.value.code == 2 + + def test_requires_host(self): + """Test validation when the host is missing.""" + with ( + patch( + "onvif.cli.main.sys.argv", + [ + "onvif", + "devicemgmt", + "GetDeviceInformation", + ], + ), + pytest.raises(SystemExit) as exc_info, + ): + main() + + assert exc_info.value.code == 2 + + def test_calls_handlers(self): + """Test that the main execution flow calls the expected handlers.""" + with ( + patch( + "onvif.cli.main.sys.argv", + [ + "onvif", + "--host", + "192.168.1.17", + "--username", + "admin", + "--password", + "password", + "devicemgmt", + "GetDeviceInformation", + ], + ), + patch( + "onvif.cli.main._handle_discovery_mode", + ) as mock_discovery, + patch( + "onvif.cli.main._handle_authentication", + ) as mock_auth, + patch( + "onvif.cli.main._process_onvif_client", + ) as mock_process, + ): + main() + + mock_discovery.assert_called_once() + mock_auth.assert_called_once() + mock_process.assert_called_once() + + def test_rejects_output_with_interactive(self): + """Test that --output cannot be combined with --interactive.""" + with ( + patch( + "onvif.cli.main.sys.argv", + [ + "onvif", + "--host", + "192.168.1.17", + "--interactive", + "--output", + "result.json", + ], + ), + pytest.raises(SystemExit) as exc_info, + ): + main() + + assert exc_info.value.code == 2 diff --git a/tests/cli/test_cli_utils.py b/tests/cli/test_cli_utils.py new file mode 100644 index 0000000..807266a --- /dev/null +++ b/tests/cli/test_cli_utils.py @@ -0,0 +1,807 @@ +"""Tests for ONVIF CLI utility functions.""" + +import ctypes +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest + +import onvif.cli.utils as cli_utils +from onvif.cli.utils import ( + _colors_enabled, + _is_valid_json, + colorize, + format_capabilities_as_services, + format_services_list, + get_device_available_services, + get_service_methods, + get_service_required_args, + parse_json_params, +) + + +class TestIsValidJson: + """Tests for validating JSON strings.""" + + @pytest.mark.parametrize( + "value", + [ + "{}", + "[]", + '{"foo": "bar"}', + '{"foo": 123}', + "true", + "false", + "null", + "123", + '"hello"', + ], + ) + def test_valid(self, value): + """Test valid JSON strings.""" + assert _is_valid_json(value) is True + + @pytest.mark.parametrize( + "value", + [ + "", + "hello", + "foo=bar", + "{invalid}", + '{"foo":}', + "[1, 2,", + ], + ) + def test_invalid(self, value): + """Test invalid JSON strings.""" + assert _is_valid_json(value) is False + + +class TestParseJsonParams: + """Tests for parsing CLI JSON and key-value parameters.""" + + def test_empty(self): + """Test empty parameter input.""" + assert parse_json_params("") == {} + assert parse_json_params(" ") == {} + + def test_json_object(self): + """Test parsing a complete JSON object.""" + params = parse_json_params('{"a": 1, "b": "hello"}') + + assert params == { + "a": 1, + "b": "hello", + } + + def test_json_array(self): + """Test parsing JSON values as parameters.""" + params = parse_json_params('{"items": [1, 2, 3]}') + + assert params == { + "items": [1, 2, 3], + } + + def test_key_value_pairs(self): + """Test parsing space-separated key=value parameters.""" + params = parse_json_params("foo=bar count=10 enabled=true") + + assert params == { + "foo": "bar", + "count": 10, + "enabled": True, + } + + def test_comma_separated(self): + """Test parsing comma-separated key=value parameters.""" + params = parse_json_params("foo=bar,count=10,enabled=true") + + assert params == { + "foo": "bar", + "count": 10, + "enabled": True, + } + + def test_mixed_separators(self): + """Test parsing parameters separated by spaces and commas.""" + params = parse_json_params("foo=bar, count=10 enabled=false") + + assert params == { + "foo": "bar", + "count": 10, + "enabled": False, + } + + def test_string_with_spaces(self): + """Test quoted string values containing spaces.""" + params = parse_json_params('name="John Doe"') + + assert params == { + "name": "John Doe", + } + + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("true", True), + ("TRUE", True), + ("false", False), + ("FALSE", False), + ("none", None), + ("None", None), + ("null", None), + ("NULL", None), + ], + ) + def test_special_values(self, value, expected): + """Test boolean and null-like values.""" + assert parse_json_params(f"value={value}") == {"value": expected} + + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("10", 10), + ("-10", -10), + ("3.14", 3.14), + ("-3.14", -3.14), + ], + ) + def test_numeric_values(self, value, expected): + """Test integer and floating-point conversion.""" + assert parse_json_params(f"value={value}") == {"value": expected} + + def test_nested_json_object(self): + """Test JSON objects as key=value values.""" + params = parse_json_params('config={"name":"camera","enabled":true}') + + assert params == { + "config": { + "name": "camera", + "enabled": True, + } + } + + def test_nested_json_object_with_spaces(self): + """Test nested JSON objects containing spaces.""" + params = parse_json_params('config={"name": "camera", "enabled": true}') + + assert params == { + "config": { + "name": "camera", + "enabled": True, + } + } + + def test_nested_json_array(self): + """Test JSON arrays as key=value values.""" + params = parse_json_params("items=[1, 2, 3]") + + assert params == { + "items": [1, 2, 3], + } + + def test_unquoted_json_keys(self): + """Test fixing JSON objects with unquoted keys.""" + params = parse_json_params('config={"name":"camera","enabled":true}') + + assert params == { + "config": { + "name": "camera", + "enabled": True, + } + } + + def test_quoted_json_string(self): + """Test quoted JSON string values.""" + params = parse_json_params("value='\"hello\"'") + + assert params == { + "value": "hello", + } + + def test_string_fallback(self): + """Test fallback to a plain string.""" + params = parse_json_params("value=hello") + + assert params == { + "value": "hello", + } + + def test_key_with_quotes(self): + """Test stripping quotes from parameter keys.""" + params = parse_json_params('"foo"=bar') + + assert params == { + "foo": "bar", + } + + def test_value_with_equals(self): + """Test values containing an equals sign.""" + params = parse_json_params("url=http://example.com?a=b") + + assert params == { + "url": "http://example.com?a=b", + } + + +class TestGetServiceRequiredArgs: + """Tests for determining required service arguments.""" + + @pytest.mark.parametrize( + "service_name", + [ + "pullpoint", + "subscription", + ], + ) + def test_requires_subscription_ref(self, service_name): + """Test services requiring SubscriptionRef.""" + assert get_service_required_args(service_name) == ["SubscriptionRef"] + + @pytest.mark.parametrize( + "service_name", + [ + "devicemgmt", + "events", + "media", + "media2", + "ptz", + "analytics", + "search", + "recording", + "replay", + "unknown", + "", + ], + ) + def test_no_required_args(self, service_name): + """Test services without required arguments.""" + assert get_service_required_args(service_name) is None + + +# pylint: disable=too-few-public-methods +class TestGetServiceMethods: + """Tests for extracting callable public service methods.""" + + def test_returns_public_callable_methods(self): + """Test extracting callable public service methods.""" + service = Mock() + + # Mock needs explicit attributes because dir(Mock) contains many helpers. + service.GetProfiles = Mock() + service.GetStreamUri = Mock() + service._private_method = Mock() # pylint: disable=protected-access + service.type = Mock() + service.desc = Mock() + service.operations = Mock() + service.to_dict = Mock() + service.some_value = "not callable" + + methods = get_service_methods(service) + + assert "GetProfiles" in methods + assert "GetStreamUri" in methods + assert "some_value" not in methods + assert "_private_method" not in methods + assert "type" not in methods + assert "desc" not in methods + assert "operations" not in methods + assert "to_dict" not in methods + + assert methods == sorted(methods) + + +class TestColorsEnabled: + """Tests for detecting terminal ANSI color support.""" + + def test_non_windows(self): + """Test that ANSI colors are enabled on non-Windows systems.""" + _colors_enabled.cache_clear() + + with patch("onvif.cli.utils.os.name", "posix"): + assert _colors_enabled() is True + + _colors_enabled.cache_clear() + + def test_windows_success(self): + """Test enabling ANSI colors on Windows.""" + _colors_enabled.cache_clear() + + kernel32 = Mock() + kernel32.GetStdHandle.return_value = 123 + kernel32.GetConsoleMode.return_value = True + kernel32.SetConsoleMode.return_value = True + + windll = Mock() + windll.kernel32 = kernel32 + + with ( + patch.object(cli_utils.os, "name", "nt"), + patch.object(ctypes, "windll", windll, create=True), + ): + assert _colors_enabled() is True + + kernel32.SetConsoleMode.assert_called_once() + _colors_enabled.cache_clear() + + @pytest.mark.parametrize( + "exception", + [ + AttributeError(), + OSError(), + ], + ) + def test_windows_failure(self, exception): + """Test Windows ANSI detection failure.""" + + class FakeWindll: # pylint: disable=too-few-public-methods + """Fake ctypes.windll for testing.""" + + @property + def kernel32(self): + """Return a fake kernel32 object that raises an exception.""" + raise exception + + _colors_enabled.cache_clear() + + with ( + patch("onvif.cli.utils.os.name", "nt"), + patch( + "onvif.cli.utils.ctypes.windll", + FakeWindll(), + create=True, + ), + ): + assert _colors_enabled() is False + + _colors_enabled.cache_clear() + + +class TestColorize: + """Tests for applying ANSI colors to text.""" + + def test_when_colors_disabled(self): + """Test colorize without ANSI colors.""" + with patch( + "onvif.cli.utils._colors_enabled", + return_value=False, + ): + assert colorize("hello", "red") == "hello" + + def test_when_colors_enabled(self): + """Test colorize with ANSI colors.""" + with patch( + "onvif.cli.utils._colors_enabled", + return_value=True, + ): + assert colorize("hello", "red") == "\033[91mhello\033[0m" + + def test_unknown_color(self): + """Test colorize with an unknown color name.""" + with patch( + "onvif.cli.utils._colors_enabled", + return_value=True, + ): + assert colorize("hello", "unknown") == "hello\033[0m" + + +class TestFormatCapabilitiesAsServices: + """Tests for formatting device capabilities as service entries.""" + + def test_empty(self): + """Test formatting capabilities without services.""" + capabilities = SimpleNamespace() + + result = format_capabilities_as_services(capabilities) + + assert "No services found in capabilities" in result + + def test_standard_services(self): + """Test formatting standard capability services.""" + capabilities = SimpleNamespace( + Device={"XAddr": "http://192.168.1.17/onvif/device"}, + Analytics={"XAddr": "http://192.168.1.17/onvif/analytics"}, + Events={"XAddr": "http://192.168.1.17/onvif/events"}, + Imaging={"XAddr": "http://192.168.1.17/onvif/imaging"}, + Media={"XAddr": "http://192.168.1.17/onvif/media"}, + PTZ={"XAddr": "http://192.168.1.17/onvif/ptz"}, + ) + + result = format_capabilities_as_services(capabilities) + + for service_name in [ + "devicemgmt", + "analytics", + "events", + "imaging", + "media", + "ptz", + ]: + assert service_name in result + + assert "http://192.168.1.17/onvif/device" in result + assert "http://192.168.1.17/onvif/media" in result + + def test_ignores_capability_without_xaddr(self): + """Test that capabilities without XAddr are skipped.""" + capabilities = SimpleNamespace( + Device={}, + Media={"Other": "value"}, + PTZ={"XAddr": "http://example.com/ptz"}, + ) + + result = format_capabilities_as_services(capabilities) + + assert "ptz" in result + assert "devicemgmt" not in result + assert "media" not in result + + def test_extension(self): + """Test first-level extension capabilities.""" + extension = SimpleNamespace( + DeviceIO={"XAddr": "http://example.com/deviceio"}, + Display={"XAddr": "http://example.com/display"}, + Recording={"XAddr": "http://example.com/recording"}, + Search={"XAddr": "http://example.com/search"}, + Replay={"XAddr": "http://example.com/replay"}, + Receiver={"XAddr": "http://example.com/receiver"}, + AnalyticsDevice={"XAddr": "http://example.com/analyticsdevice"}, + ) + + capabilities = SimpleNamespace( + Extension=extension, + ) + + result = format_capabilities_as_services(capabilities) + + for service_name in [ + "deviceio", + "display", + "recording", + "search", + "replay", + "receiver", + "analyticsdevice", + ]: + assert service_name in result + + def test_nested_extension(self): + """Test second-level extension capabilities.""" + nested = SimpleNamespace( + AccessControl={"XAddr": "http://example.com/accesscontrol"}, + DoorControl={"XAddr": "http://example.com/doorcontrol"}, + AccessRules={"XAddr": "http://example.com/accessrules"}, + ActionEngine={"XAddr": "http://example.com/actionengine"}, + AppManagement={"XAddr": "http://example.com/appmgmt"}, + AuthenticationBehavior={ + "XAddr": "http://example.com/authenticationbehavior" + }, + Credential={"XAddr": "http://example.com/credential"}, + Provisioning={"XAddr": "http://example.com/provisioning"}, + Schedule={"XAddr": "http://example.com/schedule"}, + Thermal={"XAddr": "http://example.com/thermal"}, + Uplink={"XAddr": "http://example.com/uplink"}, + Security={"XAddr": "http://example.com/security"}, + ) + + capabilities = SimpleNamespace( + Extension=SimpleNamespace( + Extensions=nested, + ) + ) + + result = format_capabilities_as_services(capabilities) + + for service_name in [ + "accesscontrol", + "doorcontrol", + "accessrules", + "actionengine", + "appmgmt", + "authenticationbehavior", + "credential", + "provisioning", + "schedule", + "thermal", + "uplink", + "advancedsecurity", + ]: + assert service_name in result + + +class TestFormatServicesList: + """Tests for formatting ONVIF service lists.""" + + def test_empty(self): + """Test formatting an empty service list.""" + assert "No services available" in format_services_list([]) + + def test_unknown_namespace(self): + """Test formatting an unknown service namespace.""" + service = SimpleNamespace( + Namespace="http://example.com/unknown", + XAddr="http://192.168.1.17/onvif/unknown", + Version={}, + ) + + result = format_services_list([service]) + + assert "unknown(http://example.com/unknown)" in result + assert "http://192.168.1.17/onvif/unknown" in result + assert "http://example.com/unknown" in result + + def test_single_binding(self): + """Test formatting a known single-binding service.""" + service = SimpleNamespace( + Namespace="http://www.onvif.org/ver10/media/wsdl", + XAddr="http://192.168.1.17/onvif/Media", + Version=SimpleNamespace( + Major=1, + Minor=2, + ), + ) + + result = format_services_list([service]) + + assert "media" in result + assert "http://192.168.1.17/onvif/Media" in result + assert "MediaBinding" in result + assert "Version" in result + assert "1.2" in result + + def test_single_binding_major_only(self): + """Test formatting a service with only a major version.""" + service = SimpleNamespace( + Namespace="http://www.onvif.org/ver10/media/wsdl", + XAddr="http://192.168.1.17/onvif/Media", + Version=SimpleNamespace( + Major=1, + Minor="", + ), + ) + + result = format_services_list([service]) + + assert "Version" in result + assert "1" in result + + def test_multi_binding(self): + """Test formatting a multi-binding service.""" + service = SimpleNamespace( + Namespace="http://www.onvif.org/ver10/events/wsdl", + XAddr="http://192.168.1.17/onvif/Events", + Version=SimpleNamespace( + Major=2, + Minor=0, + ), + ) + + result = format_services_list([service]) + + assert "events" in result + assert "EventBinding" in result + assert "pullpoint" in result + assert "PullPointSubscriptionBinding" in result + assert "notification" in result + assert "NotificationProducerBinding" in result + assert "subscription" in result + assert "SubscriptionManagerBinding" in result + + def test_without_version(self): + """Test formatting a service without version information.""" + service = SimpleNamespace( + Namespace="http://www.onvif.org/ver10/media/wsdl", + XAddr="http://192.168.1.17/onvif/Media", + Version=None, + ) + + result = format_services_list([service]) + + assert "media" in result + assert "MediaBinding" in result + assert "Version" not in result + + +class TestGetDeviceAvailableServices: + """Tests for determining services available from an ONVIF client.""" + + def test_always_includes_devicemgmt(self): + """Test that devicemgmt is always available.""" + client = SimpleNamespace( + services=[], + capabilities=None, + ) + + result = get_device_available_services(client) + + assert result == ["devicemgmt"] + + def test_from_services(self): + """Test service discovery from GetServices data.""" + events = SimpleNamespace( + Namespace="http://www.onvif.org/ver10/events/wsdl", + ) + media = SimpleNamespace( + Namespace="http://www.onvif.org/ver10/media/wsdl", + ) + ptz = SimpleNamespace( + Namespace="http://www.onvif.org/ver20/ptz/wsdl", + ) + + client = SimpleNamespace( + services=[events, media, ptz], + capabilities=None, + ) + + result = get_device_available_services(client) + + assert result == sorted( + { + "devicemgmt", + "events", + "pullpoint", + "notification", + "subscription", + "media", + "ptz", + } + ) + + def test_ignores_unknown_namespace(self): + """Test that unknown service namespaces are ignored.""" + client = SimpleNamespace( + services=[ + SimpleNamespace( + Namespace="http://example.com/unknown", + ), + ], + capabilities=None, + ) + + result = get_device_available_services(client) + + assert result == ["devicemgmt"] + + def test_from_capabilities(self): + """Test capability-based service detection.""" + capabilities = SimpleNamespace( + Analytics=True, + Events=True, + Imaging=True, + Media=True, + PTZ=True, + ) + + client = SimpleNamespace( + services=[], + capabilities=capabilities, + ) + + result = get_device_available_services(client) + + assert result == sorted( + { + "devicemgmt", + "analytics", + "ruleengine", + "events", + "pullpoint", + "notification", + "subscription", + "imaging", + "media", + "ptz", + } + ) + + def test_extension_capabilities(self): + """Test first-level extension capability detection.""" + extension = SimpleNamespace( + DeviceIO=True, + Display=True, + Recording=True, + Search=True, + Replay=True, + Receiver=True, + AnalyticsDevice=True, + ) + + capabilities = SimpleNamespace( + Analytics=None, + Events=None, + Imaging=None, + Media=None, + PTZ=None, + Extension=extension, + ) + + client = SimpleNamespace( + services=[], + capabilities=capabilities, + ) + + result = get_device_available_services(client) + + assert result == sorted( + { + "devicemgmt", + "deviceio", + "display", + "recording", + "search", + "replay", + "receiver", + "analyticsdevice", + } + ) + + def test_nested_extension_capabilities(self): + """Test second-level extension capability detection.""" + nested = SimpleNamespace( + AccessControl=True, + DoorControl=True, + AccessRules=True, + ActionEngine=True, + AppManagement=True, + AuthenticationBehavior=True, + Credential=True, + Provisioning=True, + Schedule=True, + Thermal=True, + Uplink=True, + Security=True, + ) + + capabilities = SimpleNamespace(Extension=SimpleNamespace(Extensions=nested)) + + client = SimpleNamespace( + services=[], + capabilities=capabilities, + ) + + result = get_device_available_services(client) + + assert result == sorted( + { + "devicemgmt", + "accesscontrol", + "doorcontrol", + "accessrules", + "actionengine", + "appmgmt", + "authenticationbehavior", + "credential", + "provisioning", + "schedule", + "thermal", + "uplink", + "security", + "jwt", + "keystore", + "tlsserver", + "dot1x", + "authorizationserver", + "mediasigning", + } + ) + + def test_deduplicates_services(self): + """Test that duplicate services are removed.""" + media = SimpleNamespace( + Namespace="http://www.onvif.org/ver10/media/wsdl", + ) + + capabilities = SimpleNamespace( + Media=True, + ) + + client = SimpleNamespace( + services=[media], + capabilities=capabilities, + ) + + # Because services takes precedence over capabilities, only the + # service-derived result is expected here. + result = get_device_available_services(client) + + assert result == ["devicemgmt", "media"] From 78e3173c2b2b0788fe4b036161da4fd3e6f5db20 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:27:55 +0700 Subject: [PATCH 17/43] test: Expand Zeep and WSDL utility tests (#126) --- tests/utilities/test_wsdl.py | 402 +++++++++++++++++++++++ tests/utilities/test_zeep.py | 605 +++++++++++++++++++++++++++++++++-- 2 files changed, 982 insertions(+), 25 deletions(-) create mode 100644 tests/utilities/test_wsdl.py diff --git a/tests/utilities/test_wsdl.py b/tests/utilities/test_wsdl.py new file mode 100644 index 0000000..66a5b94 --- /dev/null +++ b/tests/utilities/test_wsdl.py @@ -0,0 +1,402 @@ +"""Tests for ONVIF WSDL utilities.""" + +import os + +import pytest + +from onvif.utils.wsdl import ONVIFWSDL + + +class TestSetCustomWsdlDir: + """Test global custom WSDL directory configuration.""" + + def test_sets_custom_directory(self, tmp_path): + """Test setting the global custom WSDL directory.""" + ONVIFWSDL.set_custom_wsdl_dir(os.fspath(tmp_path)) + + assert ONVIFWSDL.get_custom_wsdl_dir() == os.fspath(tmp_path) + + ONVIFWSDL.clear_custom_wsdl_dir() + + def test_clears_custom_directory(self, tmp_path): + """Test clearing the global custom WSDL directory.""" + ONVIFWSDL.set_custom_wsdl_dir(os.fspath(tmp_path)) + + ONVIFWSDL.clear_custom_wsdl_dir() + + assert ONVIFWSDL.get_custom_wsdl_dir() is None + + +# pylint: disable=protected-access +class TestGetBaseDir: + """Test WSDL base directory resolution.""" + + def test_uses_per_call_directory(self, tmp_path, monkeypatch): + """Test that a per-call directory takes priority.""" + global_dir = tmp_path / "global" + per_call_dir = tmp_path / "per-call" + + monkeypatch.setattr( + ONVIFWSDL, + "_custom_wsdl_dir", + os.fspath(global_dir), + ) + + result = ONVIFWSDL._get_base_dir(os.fspath(per_call_dir)) + + assert result == os.fspath(per_call_dir) + + def test_uses_global_directory(self, tmp_path, monkeypatch): + """Test that the global custom directory is used.""" + custom_dir = tmp_path / "custom" + + monkeypatch.setattr( + ONVIFWSDL, + "_custom_wsdl_dir", + os.fspath(custom_dir), + ) + + assert ONVIFWSDL._get_base_dir() == os.fspath(custom_dir) + + def test_uses_builtin_directory(self, monkeypatch): + """Test that the built-in WSDL directory is used by default.""" + monkeypatch.setattr(ONVIFWSDL, "_custom_wsdl_dir", None) + monkeypatch.setattr(ONVIFWSDL, "BASE_DIR", "/builtin/wsdl") + + assert ONVIFWSDL._get_base_dir() == "/builtin/wsdl" + + +# pylint: disable=protected-access +class TestGetWsdlMap: + """Test WSDL map generation.""" + + def test_builds_builtin_map(self, monkeypatch): + """Test building the WSDL map from the built-in directory.""" + monkeypatch.setattr(ONVIFWSDL, "_custom_wsdl_dir", None) + monkeypatch.setattr(ONVIFWSDL, "BASE_DIR", "/builtin/wsdl") + + wsdl_map = ONVIFWSDL._get_wsdl_map() + + assert wsdl_map + assert "devicemgmt" in wsdl_map + assert "media" in wsdl_map + assert "ptz" in wsdl_map + + def test_builds_custom_flat_map(self, tmp_path, monkeypatch): + """Test building the WSDL map from a custom flat directory.""" + monkeypatch.setattr(ONVIFWSDL, "_custom_wsdl_dir", None) + + wsdl_map = ONVIFWSDL._get_wsdl_map(os.fspath(tmp_path)) + + assert wsdl_map + assert wsdl_map["devicemgmt"]["ver10"]["path"] == os.path.join( + os.fspath(tmp_path), + "devicemgmt.wsdl", + ) + assert wsdl_map["media"]["ver10"]["path"] == os.path.join( + os.fspath(tmp_path), + "media.wsdl", + ) + assert wsdl_map["ptz"]["ver20"]["path"] == os.path.join( + os.fspath(tmp_path), + "ptz.wsdl", + ) + + def test_uses_global_custom_directory(self, tmp_path, monkeypatch): + """Test that the global custom directory is used for the WSDL map.""" + monkeypatch.setattr( + ONVIFWSDL, + "_custom_wsdl_dir", + os.fspath(tmp_path), + ) + + wsdl_map = ONVIFWSDL._get_wsdl_map() + + assert wsdl_map["media"]["ver10"]["path"] == os.path.join( + os.fspath(tmp_path), + "media.wsdl", + ) + + def test_contains_expected_services(self, monkeypatch): + """Test that the WSDL map contains the supported services.""" + monkeypatch.setattr(ONVIFWSDL, "_custom_wsdl_dir", None) + + wsdl_map = ONVIFWSDL._get_wsdl_map() + + expected_services = { + "devicemgmt", + "events", + "pullpoint", + "notification", + "subscription", + "pausable_subscription", + "accesscontrol", + "accessrules", + "actionengine", + "advancedsecurity", + "jwt", + "keystore", + "tlsserver", + "dot1x", + "authorizationserver", + "mediasigning", + "analytics", + "ruleengine", + "analyticsdevice", + "appmgmt", + "authenticationbehavior", + "credential", + "deviceio", + "display", + "doorcontrol", + "imaging", + "media", + "media2", + "provisioning", + "ptz", + "receiver", + "recording", + "replay", + "schedule", + "search", + "thermal", + "uplink", + } + + assert expected_services <= wsdl_map.keys() + + def test_definitions_have_required_fields(self, monkeypatch): + """Test that every WSDL definition has the required fields.""" + monkeypatch.setattr(ONVIFWSDL, "_custom_wsdl_dir", None) + + wsdl_map = ONVIFWSDL._get_wsdl_map() + + for versions in wsdl_map.values(): + for definition in versions.values(): + assert {"path", "binding", "namespace"} <= definition.keys() + + +# pylint: disable=protected-access,too-few-public-methods +class TestEnsureWsdlMapInitialized: + """Test lazy WSDL map initialization.""" + + def test_preserves_existing_map(self, monkeypatch): + """Test that an existing WSDL map is not replaced.""" + wsdl_map = { + "custom": { + "ver10": { + "path": "/custom.wsdl", + "binding": "CustomBinding", + "namespace": "urn:custom", + } + } + } + + monkeypatch.setattr(ONVIFWSDL, "WSDL_MAP", wsdl_map) + + ONVIFWSDL._ensure_wsdl_map_initialized() + + assert ONVIFWSDL.WSDL_MAP is wsdl_map + + +class TestGetWsdlMapAccessor: + """Test the public WSDL map accessor.""" + + def test_returns_existing_map(self, monkeypatch): + """Test returning an already initialized WSDL map.""" + wsdl_map = { + "media": { + "ver10": { + "path": "/media.wsdl", + "binding": "MediaBinding", + "namespace": "urn:media", + } + } + } + + monkeypatch.setattr(ONVIFWSDL, "WSDL_MAP", wsdl_map) + + assert ONVIFWSDL.get_wsdl_map() is wsdl_map + + def test_initializes_missing_map(self, monkeypatch): + """Test that get_wsdl_map initializes a missing map.""" + monkeypatch.setattr(ONVIFWSDL, "WSDL_MAP", None) + + result = ONVIFWSDL.get_wsdl_map() + + assert result is ONVIFWSDL.WSDL_MAP + assert result + + def test_raises_when_map_is_not_initialized(self, monkeypatch): + """Test that get_wsdl_map raises when initialization fails.""" + monkeypatch.setattr(ONVIFWSDL, "WSDL_MAP", None) + monkeypatch.setattr( + ONVIFWSDL, + "_ensure_wsdl_map_initialized", + lambda: None, + ) + + with pytest.raises( + RuntimeError, + match="WSDL map was not initialized", + ): + ONVIFWSDL.get_wsdl_map() + + +class TestGetDefinition: + """Test WSDL definition lookup.""" + + def test_returns_builtin_definition(self): + """Test retrieving an existing built-in WSDL definition.""" + definition = ONVIFWSDL.get_definition("media") + + assert definition + assert definition["binding"] == "MediaBinding" + assert definition["namespace"] == ("http://www.onvif.org/ver10/media/wsdl") + assert os.path.exists(definition["path"]) + + def test_returns_versioned_definition(self): + """Test retrieving a definition for a specific version.""" + definition = ONVIFWSDL.get_definition("media2", "ver20") + + assert definition + assert definition["binding"] == "Media2Binding" + assert definition["namespace"] == ("http://www.onvif.org/ver20/media/wsdl") + assert os.path.exists(definition["path"]) + + def test_returns_custom_definition(self, tmp_path): + """Test retrieving a definition from a custom WSDL directory.""" + wsdl_file = tmp_path / "media.wsdl" + wsdl_file.write_text("", encoding="utf-8") + + definition = ONVIFWSDL.get_definition( + "media", + custom_wsdl_dir=os.fspath(tmp_path), + ) + + assert definition["path"] == os.fspath(wsdl_file) + assert definition["binding"] == "MediaBinding" + + def test_per_call_directory_overrides_global_directory(self, tmp_path): + """Test that a per-call directory takes priority.""" + global_dir = tmp_path / "global" + per_call_dir = tmp_path / "per-call" + + global_dir.mkdir() + per_call_dir.mkdir() + + global_file = global_dir / "media.wsdl" + per_call_file = per_call_dir / "media.wsdl" + + global_file.write_text("", encoding="utf-8") + per_call_file.write_text("", encoding="utf-8") + + ONVIFWSDL.set_custom_wsdl_dir(os.fspath(global_dir)) + + try: + definition = ONVIFWSDL.get_definition( + "media", + custom_wsdl_dir=os.fspath(per_call_dir), + ) + finally: + ONVIFWSDL.clear_custom_wsdl_dir() + + assert definition["path"] == os.fspath(per_call_file) + + def test_raises_for_unknown_service(self, monkeypatch): + """Test that an unknown service raises ValueError.""" + monkeypatch.setattr( + ONVIFWSDL, + "WSDL_MAP", + { + "media": { + "ver10": { + "path": "/media.wsdl", + "binding": "MediaBinding", + "namespace": "urn:media", + } + } + }, + ) + + with pytest.raises( + ValueError, + match="Unknown service: unknown", + ): + ONVIFWSDL.get_definition("unknown") + + def test_raises_for_unknown_version(self, monkeypatch): + """Test that an unavailable version raises ValueError.""" + monkeypatch.setattr( + ONVIFWSDL, + "WSDL_MAP", + { + "media": { + "ver10": { + "path": "/media.wsdl", + "binding": "MediaBinding", + "namespace": "urn:media", + } + } + }, + ) + + with pytest.raises( + ValueError, + match="Version ver20 not available for media", + ): + ONVIFWSDL.get_definition("media", "ver20") + + def test_raises_for_missing_wsdl_file(self, monkeypatch): + """Test that a missing WSDL file raises FileNotFoundError.""" + monkeypatch.setattr( + ONVIFWSDL, + "WSDL_MAP", + { + "media": { + "ver10": { + "path": "/does/not/exist/media.wsdl", + "binding": "MediaBinding", + "namespace": "urn:media", + } + } + }, + ) + + with pytest.raises( + FileNotFoundError, + match="WSDL file not found", + ): + ONVIFWSDL.get_definition("media") + + def test_raises_when_map_initialization_fails(self, monkeypatch): + """Test that get_definition raises when map initialization fails.""" + monkeypatch.setattr(ONVIFWSDL, "WSDL_MAP", None) + monkeypatch.setattr( + ONVIFWSDL, + "_ensure_wsdl_map_initialized", + lambda: None, + ) + + with pytest.raises( + RuntimeError, + match="Failed to initialize WSDL map", + ): + ONVIFWSDL.get_definition("media") + + def test_raises_when_custom_map_initialization_fails(self, monkeypatch): + """Test that get_definition raises when a custom map is unavailable.""" + monkeypatch.setattr( + ONVIFWSDL, + "_get_wsdl_map", + lambda _custom_dir: None, + ) + + with pytest.raises( + RuntimeError, + match="Failed to initialize WSDL map", + ): + ONVIFWSDL.get_definition( + "media", + custom_wsdl_dir="/custom", + ) diff --git a/tests/utilities/test_zeep.py b/tests/utilities/test_zeep.py index 9e3e02a..9f1b1c5 100644 --- a/tests/utilities/test_zeep.py +++ b/tests/utilities/test_zeep.py @@ -14,9 +14,28 @@ class ZeepObject: # pylint: disable=too-few-public-methods """Simple object that mimics a Zeep object.""" def __init__(self, values): + """Initialize the object with Zeep-style values.""" self.__values__ = values +class PlainObject: # pylint: disable=too-few-public-methods + """Simple object that only exposes normal object attributes.""" + + def __init__(self): + """Initialize public and private attributes.""" + self.name = "camera" + self.enabled = True + self._private = "ignored" + + +class NestedObject: # pylint: disable=too-few-public-methods + """Simple object used for nested object conversion tests.""" + + def __init__(self, value): + """Initialize the nested object.""" + self.value = value + + @pytest.fixture(autouse=True) def reset_zeep_patch(): """Ensure the global Zeep patch is removed before and after each test.""" @@ -38,11 +57,18 @@ def test_returns_none_for_none(self): [ ("true", True), ("TRUE", True), - (" false ", False), + (" true ", True), + ("false", False), + (" FALSE ", False), ("123", 123), (" 42 ", 42), + ("0", 0), ("123.45", 123.45), + ("-12.5", -12.5), ("hello", "hello"), + ("", ""), + (" ", ""), + ("-10", -10.0), ], ) def test_converts_text_value(self, value, expected): @@ -71,11 +97,14 @@ def test_parses_attributes(self): element = etree.fromstring('') assert ZeepPatcher._parse_element_recursive(element) == { - "Item": {"id": 123, "enabled": True}, + "Item": { + "id": 123, + "enabled": True, + }, } def test_parses_nested_children(self): - """Test recursively parsing nested child elements.""" + """Test recursively parsing nested children.""" element = etree.fromstring( "camera80" ) @@ -88,7 +117,7 @@ def test_parses_nested_children(self): } def test_merges_attributes_and_children(self): - """Test parsing an element containing both attributes and children.""" + """Test parsing an element containing attributes and children.""" element = etree.fromstring( 'camera' ) @@ -100,6 +129,22 @@ def test_merges_attributes_and_children(self): }, } + def test_parses_namespaced_elements(self): + """Test using local names for namespaced elements.""" + element = etree.fromstring(""" + + + camera + + + """) + + assert ZeepPatcher._parse_element_recursive(element) == { + "Device": { + "Name": "camera", + }, + } + def test_returns_none_for_empty_element(self): """Test that an element without children produces None.""" element = etree.fromstring("") @@ -122,11 +167,10 @@ def test_parses_xml_with_schema(self): schema.get_element.return_value = parsed_value parser = Mock(max_occurs=1) - elements = deque([root]) result = ZeepPatcher._patched_parse_xmlelements( parser, - elements, + deque([root]), schema, ) @@ -134,9 +178,69 @@ def test_parses_xml_with_schema(self): assert result["__original_elements__"] == [root] parsed_value.parse_xmlelement.assert_called_once() - def test_parses_attributes_without_schema_lookup(self): - """Test parsing child attributes without schema parsing.""" - root = etree.fromstring('') + def test_passes_name_and_context_to_schema_parser(self): + """Test forwarding name and context to schema parsing.""" + root = etree.fromstring("camera") + + parsed_value = Mock() + parsed_value.parse_xmlelement.return_value = "parsed" + + schema = Mock() + schema.get_element.return_value = parsed_value + + parser = Mock(max_occurs=1) + context = Mock() + + ZeepPatcher._patched_parse_xmlelements( + parser, + deque([root]), + schema, + name="Root", + context=context, + ) + + parsed_value.parse_xmlelement.assert_called_once_with( + root[0], + schema=schema, + allow_none=True, + context=context, + ) + + def test_parses_root_attributes_with_children(self): + """Test preserving root attributes when children are present.""" + root = etree.fromstring( + 'camera' + ) + + parsed_value = Mock() + parsed_value.parse_xmlelement.return_value = "parsed" + + schema = Mock() + schema.get_element.return_value = parsed_value + + parser = Mock(max_occurs=1) + + result = ZeepPatcher._patched_parse_xmlelements( + parser, + deque([root]), + schema, + ) + + assert result["Root"]["id"] == 10 + assert result["Root"]["enabled"] is True + assert result["Root"]["Value"] == "parsed" + + def test_parses_child_attributes_and_nested_children(self): + """Test manually parsing a child with attributes and nested children.""" + root = etree.fromstring(""" + + + camera + 80 + + + """) + schema = Mock() parser = Mock(max_occurs=1) @@ -147,15 +251,18 @@ def test_parses_attributes_without_schema_lookup(self): ) assert result["Root"]["Device"] == { - "id": 123, - "enabled": True, + "id": 10, + "Name": "camera", + "Port": 80, } def test_falls_back_to_manual_parsing_when_schema_fails(self): - """Test manual parsing when schema lookup or parsing fails.""" + """Test manual parsing when schema lookup fails.""" root = etree.fromstring("camera") + schema = Mock() schema.get_element.side_effect = ValueError("unknown element") + parser = Mock(max_occurs=1) result = ZeepPatcher._patched_parse_xmlelements( @@ -168,6 +275,43 @@ def test_falls_back_to_manual_parsing_when_schema_fails(self): "Name": "camera", } + def test_falls_back_to_text_when_schema_fails(self): + """Test fallback parsing of a child containing only text.""" + root = etree.fromstring("123") + + schema = Mock() + schema.get_element.side_effect = ValueError("unknown element") + + parser = Mock(max_occurs=1) + + result = ZeepPatcher._patched_parse_xmlelements( + parser, + deque([root]), + schema, + ) + + assert result["Root"]["Value"] == 123 + + def test_falls_back_when_schema_parser_raises(self): + """Test fallback parsing when the schema parser itself fails.""" + root = etree.fromstring("camera") + + parsed_value = Mock() + parsed_value.parse_xmlelement.side_effect = RuntimeError("parse failed") + + schema = Mock() + schema.get_element.return_value = parsed_value + + parser = Mock(max_occurs=1) + + result = ZeepPatcher._patched_parse_xmlelements( + parser, + deque([root]), + schema, + ) + + assert result["Root"]["Value"] == "camera" + def test_parses_text_only_element(self): """Test parsing an element containing only text.""" root = etree.fromstring("hello") @@ -182,6 +326,57 @@ def test_parses_text_only_element(self): assert result["Root"] == "hello" assert result["__original_elements__"] == [root] + def test_parses_attribute_only_element(self): + """Test parsing an element containing only attributes.""" + root = etree.fromstring('') + parser = Mock(max_occurs=1) + + result = ZeepPatcher._patched_parse_xmlelements( + parser, + deque([root]), + schema=None, + ) + + assert result["Root"] == { + "id": 123, + "enabled": True, + } + + def test_parses_multiple_elements(self): + """Test parsing multiple XML elements from the queue.""" + first = etree.fromstring("one") + second = etree.fromstring("2") + + parser = Mock(max_occurs=2) + + result = ZeepPatcher._patched_parse_xmlelements( + parser, + deque([first, second]), + schema=None, + ) + + assert result["First"] == "one" + assert result["Second"] == 2 + assert result["__original_elements__"] == [first, second] + + def test_stops_at_max_occurs(self): + """Test that parsing stops at max_occurs.""" + first = etree.fromstring("one") + second = etree.fromstring("two") + + parser = Mock(max_occurs=1) + elements = deque([first, second]) + + result = ZeepPatcher._patched_parse_xmlelements( + parser, + elements, + schema=None, + ) + + assert result["First"] == "one" + assert "Second" not in result + assert list(elements) == [second] + def test_returns_empty_result_without_elements(self): """Test that an empty element queue produces an empty result.""" parser = Mock(max_occurs=1) @@ -193,7 +388,7 @@ def test_returns_empty_result_without_elements(self): ) -# pylint: disable=protected-access +# pylint: disable=protected-access,attribute-defined-outside-init class TestZeepObjectToDict: """Tests for converting Zeep objects into dictionaries.""" @@ -202,11 +397,18 @@ def test_handles_none_and_primitive_values(self): assert ZeepPatcher._zeep_object_to_dict(None) is None assert ZeepPatcher._zeep_object_to_dict("value") == "value" assert ZeepPatcher._zeep_object_to_dict(10) == 10 + assert ZeepPatcher._zeep_object_to_dict(1.5) == 1.5 assert ZeepPatcher._zeep_object_to_dict(True) is True def test_converts_lists_and_dicts(self): """Test recursive conversion of lists and dictionaries.""" - value = {"items": [1, {"name": "camera"}]} + value = { + "items": [ + 1, + {"name": "camera"}, + ["nested", 2], + ], + } assert ZeepPatcher._zeep_object_to_dict(value) == value @@ -223,35 +425,119 @@ def test_converts_zeep_values(self): "Name": "camera", } + def test_converts_nested_zeep_values(self): + """Test recursive conversion of nested Zeep objects.""" + obj = ZeepObject( + { + "Name": "camera", + "Device": ZeepObject( + { + "XAddr": "http://camera", + } + ), + } + ) + + assert ZeepPatcher._zeep_object_to_dict(obj) == { + "Name": "camera", + "Device": { + "XAddr": "http://camera", + }, + } + + def test_converts_zeep_values_containing_lists(self): + """Test recursive conversion of lists containing Zeep objects.""" + obj = ZeepObject( + { + "Devices": [ + ZeepObject({"Name": "camera-1"}), + ZeepObject({"Name": "camera-2"}), + ], + } + ) + + assert ZeepPatcher._zeep_object_to_dict(obj) == { + "Devices": [ + {"Name": "camera-1"}, + {"Name": "camera-2"}, + ], + } + def test_converts_object_dict(self): """Test conversion of objects exposing a regular __dict__.""" + assert ZeepPatcher._zeep_object_to_dict(PlainObject()) == { + "name": "camera", + "enabled": True, + } - class Object: # pylint: disable=too-few-public-methods - """Simple object with public and private attributes.""" - - def __init__(self): - self.name = "camera" - self._private = "ignored" + def test_converts_nested_object_dict(self): + """Test recursive conversion of regular nested objects.""" + obj = PlainObject() + obj.device = NestedObject("camera") - assert ZeepPatcher._zeep_object_to_dict(Object()) == { + assert ZeepPatcher._zeep_object_to_dict(obj) == { "name": "camera", + "enabled": True, + "device": { + "value": "camera", + }, } + def test_returns_objects_without_supported_attributes(self): + """Test returning objects that expose neither values nor dict state.""" + + class SlotObject: # pylint: disable=too-few-public-methods + """Object without a __dict__.""" + + __slots__ = ("value",) + + def __init__(self): + """Initialize the slot value.""" + self.value = "camera" + + obj = SlotObject() + + assert ZeepPatcher._zeep_object_to_dict(obj) is obj + +# pylint: disable=invalid-name class TestFlattenXsdAnyFields: """Tests for flattening xsd:any fields.""" def test_returns_primitive_unchanged(self): """Test that primitive values are returned unchanged.""" assert ZeepPatcher.flatten_xsd_any_fields("value") == "value" + assert ZeepPatcher.flatten_xsd_any_fields(10) == 10 + assert ZeepPatcher.flatten_xsd_any_fields(1.5) == 1.5 + assert ZeepPatcher.flatten_xsd_any_fields(True) is True assert ZeepPatcher.flatten_xsd_any_fields(None) is None + def test_returns_dict_unchanged(self): + """Test that dictionaries are ignored by object processing.""" + value = {"Device": {"XAddr": "http://camera"}} + + assert ZeepPatcher.flatten_xsd_any_fields(value) is value + + def test_returns_object_without_dict_unchanged(self): + """Test that objects without __dict__ are returned unchanged.""" + + class SlotObject: # pylint: disable=too-few-public-methods + """Object without a __dict__.""" + + __slots__ = () + + obj = SlotObject() + + assert ZeepPatcher.flatten_xsd_any_fields(obj) is obj + def test_processes_list_items(self): """Test that objects in a list are processed.""" obj = ZeepObject( { "_value_1": { - "Device": {"XAddr": "http://camera"}, + "Device": { + "XAddr": "http://camera", + }, "__original_elements__": [], } } @@ -260,7 +546,9 @@ def test_processes_list_items(self): result = ZeepPatcher.flatten_xsd_any_fields([obj]) assert result == [obj] - assert obj.__values__["Device"] == {"XAddr": "http://camera"} + assert obj.__values__["Device"] == { + "XAddr": "http://camera", + } assert obj.__values__["_value_1"] == [] def test_flattens_single_tag_wrapper(self): @@ -285,13 +573,56 @@ def test_flattens_single_tag_wrapper(self): } assert obj.__values__["_value_1"] == [] + def test_does_not_flatten_existing_non_none_field(self): + """Test preserving an existing populated schema field.""" + obj = ZeepObject( + { + "Capabilities": { + "Existing": True, + }, + "_value_1": { + "Capabilities": { + "XAddr": "http://camera", + }, + "__original_elements__": [], + }, + } + ) + + ZeepPatcher.flatten_xsd_any_fields(obj) + + assert obj.__values__["Capabilities"] == { + "Existing": True, + } + + def test_does_not_use_wrapper_when_inner_content_is_not_dict(self): + """Test handling a single-tag wrapper with primitive content.""" + obj = ZeepObject( + { + "Capabilities": None, + "_value_1": { + "Capabilities": "raw", + "__original_elements__": [], + }, + } + ) + + ZeepPatcher.flatten_xsd_any_fields(obj) + + assert obj.__values__["Capabilities"] == "raw" + assert obj.__values__["_value_1"] == [] + def test_flattens_multiple_tags(self): """Test flattening multiple xsd:any tags.""" obj = ZeepObject( { "_value_1": { - "Device": {"XAddr": "http://device"}, - "Media": {"XAddr": "http://media"}, + "Device": { + "XAddr": "http://device", + }, + "Media": { + "XAddr": "http://media", + }, "__original_elements__": [], } } @@ -307,6 +638,229 @@ def test_flattens_multiple_tags(self): } assert obj.__values__["_value_1"] == [] + def test_processes_multiple_value_fields(self): + """Test processing multiple _value_N fields.""" + obj = ZeepObject( + { + "Device": None, + "Media": None, + "_value_1": { + "Device": { + "XAddr": "http://device", + }, + "__original_elements__": [], + }, + "_value_2": { + "Media": { + "XAddr": "http://media", + }, + "__original_elements__": [], + }, + } + ) + + ZeepPatcher.flatten_xsd_any_fields(obj) + + assert obj.__values__["Device"] == { + "XAddr": "http://device", + } + assert obj.__values__["Media"] == { + "XAddr": "http://media", + } + assert obj.__values__["_value_1"] == [] + assert obj.__values__["_value_2"] == [] + + def test_restores_none_original_elements(self): + """Test replacing parsed data with None when originals are absent.""" + obj = ZeepObject( + { + "_value_1": { + "Device": { + "XAddr": "http://device", + }, + "__original_elements__": None, + } + } + ) + + ZeepPatcher.flatten_xsd_any_fields(obj) + + assert obj.__values__["Device"] == { + "XAddr": "http://device", + } + assert obj.__values__["_value_1"] is None + + def test_ignores_non_dict_value_fields(self): + """Test ignoring _value_N fields that are not parsed dictionaries.""" + obj = ZeepObject( + { + "_value_1": "raw", + } + ) + + ZeepPatcher.flatten_xsd_any_fields(obj) + + assert obj.__values__["_value_1"] == "raw" + + def test_flattens_plain_object_attributes(self): + """Test flattening xsd:any data stored on a regular object.""" + obj = PlainObject() + obj.Capabilities = None + obj._value_1 = { + "Capabilities": { + "XAddr": "http://camera", + }, + "__original_elements__": [], + } + + result = ZeepPatcher.flatten_xsd_any_fields(obj) + + assert result is obj + assert obj.Capabilities == { + "XAddr": "http://camera", + } + assert not obj._value_1 + + def test_copies_wrapper_children_to_plain_object(self): + """Test copying wrapper children into existing plain-object fields.""" + obj = PlainObject() + obj.Capabilities = None + obj.XAddr = None + obj._value_1 = { + "Capabilities": { + "XAddr": "http://camera", + }, + "__original_elements__": [], + } + + ZeepPatcher.flatten_xsd_any_fields(obj) + + assert obj.Capabilities == { + "XAddr": "http://camera", + } + assert obj.XAddr == "http://camera" + + def test_flattens_multiple_tags_on_plain_object(self): + """Test flattening multiple tags on a regular object.""" + obj = PlainObject() + obj._value_1 = { + "Device": { + "XAddr": "http://device", + }, + "Media": { + "XAddr": "http://media", + }, + "__original_elements__": [], + } + + ZeepPatcher.flatten_xsd_any_fields(obj) + + assert getattr(obj, "Device") == { + "XAddr": "http://device", + } + assert getattr(obj, "Media") == { + "XAddr": "http://media", + } + assert not obj._value_1 + + def test_restores_plain_object_value_to_original_elements(self): + """Test restoring original XML elements on plain objects.""" + original = etree.fromstring("") + + obj = PlainObject() + obj._value_1 = { + "Device": { + "XAddr": "http://device", + }, + "__original_elements__": [original], + } + + ZeepPatcher.flatten_xsd_any_fields(obj) + + assert obj._value_1 == [original] + + def test_processes_nested_objects(self): + """Test recursively flattening nested objects.""" + child = ZeepObject( + { + "Capabilities": None, + "_value_1": { + "Capabilities": { + "XAddr": "http://camera", + }, + "__original_elements__": [], + }, + } + ) + + parent = ZeepObject( + { + "Child": child, + } + ) + + ZeepPatcher.flatten_xsd_any_fields(parent) + + assert child.__values__["Capabilities"] == { + "XAddr": "http://camera", + } + + def test_processes_nested_lists(self): + """Test recursively flattening objects contained in lists.""" + first = ZeepObject( + { + "Device": None, + "_value_1": { + "Device": { + "XAddr": "http://device-1", + }, + "__original_elements__": [], + }, + } + ) + second = ZeepObject( + { + "Device": None, + "_value_1": { + "Device": { + "XAddr": "http://device-2", + }, + "__original_elements__": [], + }, + } + ) + + parent = ZeepObject( + { + "Devices": [first, second], + } + ) + + ZeepPatcher.flatten_xsd_any_fields(parent) + + assert first.__values__["Device"] == { + "XAddr": "http://device-1", + } + assert second.__values__["Device"] == { + "XAddr": "http://device-2", + } + + def test_handles_cyclic_references(self): + """Test preventing infinite recursion through visited objects.""" + first = ZeepObject( + { + "Child": None, + } + ) + second = ZeepObject( + { + "Parent": first, + } + ) + first.__values__["Child"] = second + + assert ZeepPatcher.flatten_xsd_any_fields(first) is first + class TestZeepPatchLifecycle: """Tests for applying, removing, and checking the Zeep patch.""" @@ -341,6 +895,7 @@ def test_apply_patch_is_idempotent(self): ZeepPatcher.apply_patch() assert ZeepPatcher._original_parse_xmlelements is original + assert XsdAny.parse_xmlelements is ZeepPatcher._patched_parse_xmlelements def test_remove_patch(self): """Test that removing the patch restores the original state.""" From 46471472fbdaf57b9f66a935a4a769039bd56fd3 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:40:19 +0700 Subject: [PATCH 18/43] ci: Add cross-platform CLI testing workflow (#127) --- .github/workflows/cli-cross-platform.yml | 48 ++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/cli-cross-platform.yml diff --git a/.github/workflows/cli-cross-platform.yml b/.github/workflows/cli-cross-platform.yml new file mode 100644 index 0000000..ebc39f0 --- /dev/null +++ b/.github/workflows/cli-cross-platform.yml @@ -0,0 +1,48 @@ +name: CLI Cross-Platform + +on: + push: + branches: [ "main" ] + paths: + - "onvif/**" + - "pyproject.toml" + pull_request: + branches: [ "main", "dev" ] + paths: + - "onvif/**" + - "pyproject.toml" + +env: + MIN_PYTHON_VERSION: "3.10" + +jobs: + cli-test: + name: CLI Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: + - ubuntu-latest + - windows-latest + - macos-latest + - ubuntu-24.04-arm + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python ${{ env.MIN_PYTHON_VERSION }} (${{ matrix.os }}) + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ env.MIN_PYTHON_VERSION }} + + - name: Install package + run: | + python -m pip install --upgrade pip + pip install . + + - name: Test CLI + run: | + onvif --help + onvif --version \ No newline at end of file From f33c863caae82df50e9330046769ceb1855155b1 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:43:53 +0700 Subject: [PATCH 19/43] ci: Update coverage thresholds in workflow (#128) --- .github/workflows/coverage-reporter.yml | 3 +-- .github/workflows/python-app.yml | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/coverage-reporter.yml b/.github/workflows/coverage-reporter.yml index 6a4f6a2..9b499f7 100644 --- a/.github/workflows/coverage-reporter.yml +++ b/.github/workflows/coverage-reporter.yml @@ -6,7 +6,6 @@ on: paths: - "onvif/**" - "tests/**" - - "examples/**" - "pyproject.toml" permissions: @@ -16,7 +15,7 @@ env: MIN_PYTHON_VERSION: "3.10" # Use the minimum supported Python version for coverage generation jobs: - codacy-coverage-reporter: + coverage-reporter: runs-on: ubuntu-latest diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 64a1a9c..e3f9a18 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -20,7 +20,7 @@ permissions: contents: read env: - COVERAGE_FAIL_UNDER: 49 # Will be raised gradually (in percentage) + COVERAGE_FAIL_UNDER: 84 # Will be raised gradually (in percentage) MIN_PYTHON_VERSION: "3.10" # Use the minimum supported Python version for coverage generation jobs: From b5d76ab314d5c17f904263d40f536d4b747456f1 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 06:04:19 +0700 Subject: [PATCH 20/43] ci: Add workflow for documentation deployment (#129) --- .github/workflows/documentation.yml | 36 +++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/documentation.yml diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 0000000..4c47af2 --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,36 @@ +name: Documentation + +on: + push: + branches: + - main + paths: + - "docs/**" + - "mkdocs.yml" + - "pyproject.toml" + workflow_dispatch: + +permissions: + contents: write + +jobs: + deploy: + name: Deploy documentation + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install documentation dependencies + run: | + python -m pip install --upgrade pip + pip install ".[docs]" + + - name: Deploy documentation + run: mkdocs gh-deploy --force \ No newline at end of file From c56b55bd218e214978cdec3c9695d966db37aa1c Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 06:21:30 +0700 Subject: [PATCH 21/43] ci: Ensure Codacy coverage is reported for latest commits (#130) --- .github/workflows/coverage-reporter.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/coverage-reporter.yml b/.github/workflows/coverage-reporter.yml index 9b499f7..254ddcc 100644 --- a/.github/workflows/coverage-reporter.yml +++ b/.github/workflows/coverage-reporter.yml @@ -2,11 +2,9 @@ name: Codacy Coverage Reporter on: push: + # Codacy always requests the latest coverage report + # from the most recent commit, even if it is out of context. branches: [ "main", "dev" ] - paths: - - "onvif/**" - - "tests/**" - - "pyproject.toml" permissions: contents: read From f795da6ebb66d559be9b13fe32376a9c8321d3d7 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 09:34:08 +0700 Subject: [PATCH 22/43] feat(operator): Add `legacy_call` method for compatibility with renamed ONVIF operations (#131) --- onvif/operator.py | 94 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 2 deletions(-) diff --git a/onvif/operator.py b/onvif/operator.py index d96601c..1443c7f 100644 --- a/onvif/operator.py +++ b/onvif/operator.py @@ -5,6 +5,7 @@ import logging import os import warnings +from copy import copy from enum import Enum from typing import Any @@ -274,8 +275,8 @@ def call(self, method: str, *args, **kwargs) -> Any: Args: method (str): Name of the ONVIF operation to call (e.g., "GetDeviceInformation") - *args (any): Positional arguments to pass to the operation - **kwargs (any): Keyword arguments to pass to the operation + *args (Any): Positional arguments to pass to the operation + **kwargs (Any): Keyword arguments to pass to the operation Returns: The operation result with `xsd:any` fields flattened if `apply_patch=True` @@ -304,6 +305,95 @@ def call(self, method: str, *args, **kwargs) -> Any: except Exception as e: raise ONVIFOperationException(operation=method, original_exception=e) from e + def legacy_call( + self, + legacy_method: str, + current_method: str, + *args, + **kwargs, + ) -> Any: + """Call a legacy ONVIF operation using the current WSDL definition. + + This provides compatibility with devices which implemented an older + operation name that was subsequently renamed in the ONVIF WSDL. + + The legacy operation is created as a temporary alias of the current + Zeep SOAP operation. The alias reuses the current operation's request + and response definitions while overriding the operation name and SOAP + action. + + Args: + legacy_method: Name of the legacy ONVIF operation. + current_method: Name of the operation defined by the current WSDL. + *args (Any): Positional arguments to pass to the operation. + **kwargs (Any): Keyword arguments to pass to the operation. + + Returns: + The operation result with `xsd:any` fields flattened if `apply_patch=True` + + Raises: + ONVIFOperationException: If either operation cannot be resolved or + the SOAP operation fails. + """ + logger.debug( + "Calling legacy ONVIF method: %s.%s -> %s", + self.service_name, + legacy_method, + current_method, + ) + + try: + binding = self.service._binding # pylint: disable=protected-access + + current_operation = binding.get(current_method) + if current_operation is None: + raise AttributeError( + f"Current WSDL operation '{current_method}' was not found" + ) + + legacy_operation = copy(current_operation) + legacy_operation.name = legacy_method + + if legacy_operation.soapaction: + legacy_operation.soapaction = legacy_operation.soapaction.replace( + current_method, + legacy_method, + ) + + binding._operations[legacy_method] = ( # pylint: disable=protected-access + legacy_operation + ) + + try: + func = getattr(self.service, legacy_method) + result = func(*args, **kwargs) + finally: + binding._operations.pop( # pylint: disable=protected-access + legacy_method, None + ) + + logger.debug( + "Legacy ONVIF call %s.%s succeeded", + self.service_name, + legacy_method, + ) + + if self.apply_patch: + result = ZeepPatcher.flatten_xsd_any_fields(result) + + return result + + except Fault as e: + raise ONVIFOperationException( + operation=legacy_method, original_exception=e + ) from e + except ONVIFOperationException: + raise + except Exception as e: + raise ONVIFOperationException( + operation=legacy_method, original_exception=e + ) from e + def create_type(self, type_name: str) -> Any: """Create a type instance from WSDL schema for the given type name. From 094335f11fd725e7827e576575455a781a62b6c8 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 09:42:13 +0700 Subject: [PATCH 23/43] feat(services): Implement `deprecated` decorator to mark functions as deprecated (#132) --- onvif/utils/service.py | 40 ++++++++++++- tests/services/base_service_test.py | 88 +++++++++++++++++++++++++++-- 2 files changed, 122 insertions(+), 6 deletions(-) diff --git a/onvif/utils/service.py b/onvif/utils/service.py index d1eec41..676c739 100644 --- a/onvif/utils/service.py +++ b/onvif/utils/service.py @@ -1,8 +1,13 @@ """ONVIFService: Base class for all ONVIF service implementations.""" +from __future__ import annotations + import inspect import logging -from typing import Any +import warnings +from collections.abc import Callable +from functools import wraps +from typing import Any, ParamSpec, TypeVar import zeep.helpers @@ -12,6 +17,39 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) +P = ParamSpec("P") +R = TypeVar("R") + + +def deprecated(reason: str) -> Callable[[Callable[P, R]], Callable[P, R]]: + """Mark a function as deprecated and emit a warning when it is called. + + Args: + reason: Explanation of why the function is deprecated and, when + applicable, the preferred replacement. + + Returns: + A decorator that emits a :class:`DeprecationWarning` when the + decorated function is called. + """ + + def decorator(func: Callable[P, R]) -> Callable[P, R]: + @wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + warnings.warn( + f"{func.__name__} is deprecated. {reason}", + DeprecationWarning, + stacklevel=3, + ) + return func(*args, **kwargs) + + setattr(wrapper, "__deprecated__", True) + setattr(wrapper, "__deprecation_reason__", reason) + + return wrapper + + return decorator + def _is_zeep_object(obj): """Check if an object is a Zeep-generated object. diff --git a/tests/services/base_service_test.py b/tests/services/base_service_test.py index fae1737..739da2c 100644 --- a/tests/services/base_service_test.py +++ b/tests/services/base_service_test.py @@ -9,6 +9,8 @@ from lxml import etree +from onvif.operator import ONVIFOperator + # pylint: disable=not-callable class ONVIFServiceTestBase: @@ -102,6 +104,7 @@ def get_implemented_methods(cls) -> dict[str, dict[str, Any]]: methods[name] = { "params": params, "signature": sig, + "deprecated": getattr(method, "__deprecated__", False), } return methods @@ -405,6 +408,10 @@ def test_operator_call_usage( # pylint: disable=too-many-locals,too-many-branch errors = [] for method_name, method_info in implemented_methods.items(): + # Skip deprecated method + if method_info["deprecated"]: + continue + # Skip helper methods if method_name in helper_methods: continue @@ -453,6 +460,77 @@ def test_operator_call_usage( # pylint: disable=too-many-locals,too-many-branch assert not errors, "Operator call errors:\n" + "\n".join(errors) + def test_legacy_call_uses_legacy_operation(self): + """Test that legacy_call temporarily registers and invokes a legacy operation.""" + operator = object.__new__(ONVIFOperator) + + current_operation = Mock() + current_operation.name = "SetEQPresetConfiguration" + current_operation.soapaction = ( + "http://www.onvif.org/ver20/media/wsdl/SetEQPresetConfiguration" + ) + + class FakeBinding: # pylint: disable=too-few-public-methods + """Minimal binding implementation for testing legacy operations.""" + + def __init__(self): + self._operations = { + "SetEQPresetConfiguration": current_operation, + } + + def get(self, name): + """Return an operation by name.""" + return self._operations.get(name) + + class FakeService: # pylint: disable=too-few-public-methods + """Minimal service proxy that resolves operations from the binding.""" + + def __init__(self, binding): + self._binding = binding + self.called_operation = None + self.called_kwargs = None + + def __getattr__(self, name): + """Resolve a temporary legacy operation from the binding.""" + if name not in self._binding._operations: + raise AttributeError(name) + + operation = self._binding._operations[name] + + def call(**kwargs): + self.called_operation = operation.name + self.called_kwargs = kwargs + return "legacy-result" + + return call + + binding = FakeBinding() + service = FakeService(binding) + + operator.service = service + operator.service_name = "Media2" + operator.apply_patch = False + + result = operator.legacy_call( + "SetEQPreset", + "SetEQPresetConfiguration", + Configuration="configuration", + ) + + assert result == "legacy-result" + assert service.called_operation == "SetEQPreset" + assert service.called_kwargs == {"Configuration": "configuration"} + + # The legacy operation must be removed after the call. + # pylint: disable=protected-access + assert "SetEQPreset" not in binding._operations + + # The original WSDL operation must remain untouched. + # pylint: disable=protected-access + assert "SetEQPresetConfiguration" in binding._operations + # pylint: disable=protected-access + assert binding._operations["SetEQPresetConfiguration"] is current_operation + def test_no_extra_methods(self): """Test that there are no extra public methods not in WSDL.""" wsdl_operations = self.get_wsdl_operations() @@ -462,10 +540,11 @@ def test_no_extra_methods(self): allowed_helper_methods = ["type", "desc", "operations", "to_dict"] extra_methods = [] - for method_name in implemented_methods: + for method_name, method_info in implemented_methods.items(): if ( method_name not in wsdl_operations and method_name not in allowed_helper_methods + and not method_info["deprecated"] ): extra_methods.append(method_name) @@ -474,9 +553,8 @@ def test_no_extra_methods(self): not extra_methods ), f"Extra methods found that are not in WSDL: {extra_methods}" - def test_parameter_forwarding_for_all_methods( # pylint: disable=too-many-locals,too-many-branches,too-many-nested-blocks - self, - ): + # pylint: disable=too-many-locals,too-many-branches,too-many-nested-blocks + def test_parameter_forwarding_for_all_methods(self): """Test that all method parameters are correctly forwarded to operator.call().""" if not self.SERVICE_CLASS: @@ -556,7 +634,7 @@ def test_parameter_forwarding_for_all_methods( # pylint: disable=too-many-local ) break - except Exception as e: # pylint: disable=broad-exception-caught + except (AttributeError, KeyError, TypeError) as e: errors.append( f"{method_name}: Error during parameter forwarding test - {str(e)}" ) From e1aad97da8c155759e7aad3e858c63de58b438e5 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 09:51:08 +0700 Subject: [PATCH 24/43] refactor(media2): Add deprecated `SetEQPreset` method for legacy compatibility (#133) --- onvif/services/media2.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/onvif/services/media2.py b/onvif/services/media2.py index 789ac9b..775589f 100644 --- a/onvif/services/media2.py +++ b/onvif/services/media2.py @@ -1,7 +1,7 @@ """Media2 service implementation.""" from onvif.operator import ONVIFOperator -from onvif.utils.service import ONVIFService +from onvif.utils.service import ONVIFService, deprecated from onvif.utils.wsdl import ONVIFWSDL @@ -39,8 +39,8 @@ def CreateProfile(self, Name, Configuration=None): A created profile created via this method may be deleted via the DeleteProfile method. Optionally Configurations can be assigned to the profile on creation. - For details regarding profile assignment check also the method - AddConfiguration. + + For details regarding profile assignment check also the method AddConfiguration. """ return self.operator.call( "CreateProfile", Name=Name, Configuration=Configuration @@ -286,6 +286,23 @@ def SetAudioDecoderConfiguration(self, Configuration): "SetAudioDecoderConfiguration", Configuration=Configuration ) + @deprecated( + "Use SetEQPresetConfiguration instead. See https://github.com/onvif/specs/pull/830" + ) + def SetEQPreset(self, Configuration): + """Set Audio EQPreset using the legacy ONVIF operation name. + + This operation is retained for compatibility with devices implementing the ONVIF + 25.12 operation name before it was renamed in later specifications. + + Compatible with ONVIF >=25.12 and <26.12. + """ + return self.operator.legacy_call( + "SetEQPreset", + "SetEQPresetConfiguration", + Configuration=Configuration, + ) + def SetEQPresetConfiguration(self, Configuration): """This command is to configure Audio EQPreset.""" return self.operator.call( From 8400b1df4aa04b53c0143ea90d23f4e9139264e1 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:11:55 +0700 Subject: [PATCH 25/43] ci: Add documentation preview workflow for pull requests (#134) --- .github/workflows/documentation-preview.yml | 58 +++++++++++++++++++++ .github/workflows/documentation.yml | 1 + 2 files changed, 59 insertions(+) create mode 100644 .github/workflows/documentation-preview.yml diff --git a/.github/workflows/documentation-preview.yml b/.github/workflows/documentation-preview.yml new file mode 100644 index 0000000..726b70f --- /dev/null +++ b/.github/workflows/documentation-preview.yml @@ -0,0 +1,58 @@ +name: Documentation Preview + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - closed + paths: + - "docs/**" + - "onvif/**" + - "mkdocs.yml" + - "pyproject.toml" + +permissions: + contents: write + pull-requests: write + +concurrency: + group: preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + preview: + name: Build documentation preview + runs-on: ubuntu-latest + + steps: + - name: Checkout + if: github.event.action != 'closed' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + if: github.event.action != 'closed' + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install documentation dependencies + if: github.event.action != 'closed' + run: | + python -m pip install --upgrade pip + pip install -e ".[docs]" + + - name: Build documentation + if: github.event.action != 'closed' + run: | + mkdocs build --strict + + - name: Deploy PR preview + uses: rossjrw/pr-preview-action@ffa7509e91a3ec8dfc2e5536c4d5c1acdf7a6de9 # v1.8.1 + with: + source-dir: site/ + preview-branch: gh-pages + umbrella-dir: pr-preview + action: ${{ github.event.action }} + wait-for-pages-deployment: true \ No newline at end of file diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 4c47af2..76ad9c6 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -6,6 +6,7 @@ on: - main paths: - "docs/**" + - "onvif/**" - "mkdocs.yml" - "pyproject.toml" workflow_dispatch: From 0381b0b2c8cd34411c4e4af5a235663bce462124 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:22:48 +0700 Subject: [PATCH 26/43] ci: Update CLI test job names for clarity and consistency (#135) --- .github/workflows/cli-cross-platform.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cli-cross-platform.yml b/.github/workflows/cli-cross-platform.yml index ebc39f0..eef8d26 100644 --- a/.github/workflows/cli-cross-platform.yml +++ b/.github/workflows/cli-cross-platform.yml @@ -17,7 +17,7 @@ env: jobs: cli-test: - name: CLI Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} strategy: @@ -37,12 +37,14 @@ jobs: with: python-version: ${{ env.MIN_PYTHON_VERSION }} - - name: Install package + - name: Install package (${{ matrix.os }}) run: | python -m pip install --upgrade pip pip install . - - name: Test CLI + - name: Test CLI Help (${{ matrix.os }}) + run: onvif --help + + - name: Test CLI Discovery (${{ matrix.os }}) run: | - onvif --help - onvif --version \ No newline at end of file + python -c "import subprocess, sys; result = subprocess.run(['onvif', '--discover', '--interactive']); sys.exit(0 if result.returncode == 1 else result.returncode)" \ No newline at end of file From 849595cc3d40a8c1030de37177d9afb7eabf8229 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:39:17 +0700 Subject: [PATCH 27/43] ci: Fix PR documentation preview deployment (#137) --- .github/workflows/documentation-preview.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/documentation-preview.yml b/.github/workflows/documentation-preview.yml index 726b70f..a816754 100644 --- a/.github/workflows/documentation-preview.yml +++ b/.github/workflows/documentation-preview.yml @@ -23,7 +23,7 @@ concurrency: jobs: preview: - name: Build documentation preview + runs-on: ubuntu-latest steps: @@ -54,5 +54,4 @@ jobs: source-dir: site/ preview-branch: gh-pages umbrella-dir: pr-preview - action: ${{ github.event.action }} wait-for-pages-deployment: true \ No newline at end of file From aa3aa887c6521461623a65267fef75ff7c511d66 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:44:29 +0700 Subject: [PATCH 28/43] docs: Improve badge visibility and remove deprecated images (#136) --- README.md | 22 +++++++++------------- assets/images/carbon_onvif.png | Bin 333100 -> 0 bytes assets/images/onvif_discovery.png | Bin 74047 -> 0 bytes docs/index.md | 22 +++++++++------------- 4 files changed, 18 insertions(+), 26 deletions(-) delete mode 100644 assets/images/carbon_onvif.png delete mode 100644 assets/images/onvif_discovery.png diff --git a/README.md b/README.md index 9778852..763e967 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,14 @@ -

    ONVIF Python

    +# ONVIF Python -
    - -PyPI Version -Pepy Total Downloads +[![Python Version](https://img.shields.io/pypi/pyversions/onvif-python?logo=python&logoColor=white&color=blue&style=plastic&label=Python)](https://pypi.org/project/onvif-python) +[![PyPI Version](https://img.shields.io/pypi/v/onvif-python?logo=pypi&logoColor=white&color=blue&style=plastic&label=PyPI)](https://pypi.org/project/onvif-python/) +[![Quality](https://img.shields.io/codacy/grade/bff08a94e4d447b690cea49c6594826d?style=plastic&logo=codacy&label=Quality)](https://app.codacy.com/gh/nirsimetri/onvif-python/dashboard) +[![Coverage](https://img.shields.io/codacy/coverage/bff08a94e4d447b690cea49c6594826d?style=plastic&logo=codacy&label=Coverage)](https://app.codacy.com/gh/nirsimetri/onvif-python/coverage)
    -Build -Upload Python Package -
    +[![Build](https://img.shields.io/github/actions/workflow/status/nirsimetri/onvif-python/python-app.yml?logo=github&style=plastic&label=Build)](https://github.com/nirsimetri/onvif-python/actions/workflows/python-app.yml) +[![Upload Python](https://img.shields.io/github/actions/workflow/status/nirsimetri/onvif-python/python-publish.yml?logo=github&style=plastic&label=Upload%20Package)](https://github.com/nirsimetri/onvif-python/actions/workflows/python-publish.yml) +[![CLI Cross-Platform](https://img.shields.io/github/actions/workflow/status/nirsimetri/onvif-python/cli-cross-platform.yml?logo=github&style=plastic&label=CLI%20Cross-Platform)](https://github.com/nirsimetri/onvif-python/actions/workflows/cli-cross-platform.yml) -

    - onvif -
    -

    **This project provides a comprehensive and developer-friendly Python library for working with ONVIF-compliant devices.** It is designed to be reliable, easy to integrate, and flexible enough to support a wide range of ONVIF profiles and services. @@ -59,4 +55,4 @@ For complete guide, including usage examples and API references, please visit th ## License -This project is licensed under the MIT License. See [LICENSE](./LICENSE.md) for details. \ No newline at end of file +This project is licensed under the MIT License. See [LICENSE](./LICENSE) for details. \ No newline at end of file diff --git a/assets/images/carbon_onvif.png b/assets/images/carbon_onvif.png deleted file mode 100644 index 8ab61a77c92dc07f67461d77b598500a1c40aa18..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 333100 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 Z&J7PKov;1q1HQBAyt>vI>}m6R{|CRC2c!T1 diff --git a/assets/images/onvif_discovery.png b/assets/images/onvif_discovery.png deleted file mode 100644 index 87df8f61efc64e0c03d2ba1d9e6806c3f7da2b9a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 74047 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 z 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 diff --git a/docs/index.md b/docs/index.md index dbc792d..d106f8c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,18 +1,14 @@ -

    ONVIF Python

    +# ONVIF Python -
    - -PyPI Version -Pepy Total Downloads +[![Python Version](https://img.shields.io/pypi/pyversions/onvif-python?logo=python&logoColor=white&color=blue&style=plastic&label=Python)](https://pypi.org/project/onvif-python) +[![PyPI Version](https://img.shields.io/pypi/v/onvif-python?logo=pypi&logoColor=white&color=blue&style=plastic&label=PyPI)](https://pypi.org/project/onvif-python/) +[![Quality](https://img.shields.io/codacy/grade/bff08a94e4d447b690cea49c6594826d?style=plastic&logo=codacy&label=Quality)](https://app.codacy.com/gh/nirsimetri/onvif-python/dashboard) +[![Coverage](https://img.shields.io/codacy/coverage/bff08a94e4d447b690cea49c6594826d?style=plastic&logo=codacy&label=Coverage)](https://app.codacy.com/gh/nirsimetri/onvif-python/coverage)
    -Build -Upload Python Package -
    - -

    - onvif -
    -

    +[![Build](https://img.shields.io/github/actions/workflow/status/nirsimetri/onvif-python/python-app.yml?logo=github&style=plastic&label=Build)](https://github.com/nirsimetri/onvif-python/actions/workflows/python-app.yml) +[![Upload Python](https://img.shields.io/github/actions/workflow/status/nirsimetri/onvif-python/python-publish.yml?logo=github&style=plastic&label=Upload%20Package)](https://github.com/nirsimetri/onvif-python/actions/workflows/python-publish.yml) +[![CLI Cross-Platform](https://img.shields.io/github/actions/workflow/status/nirsimetri/onvif-python/cli-cross-platform.yml?logo=github&style=plastic&label=CLI%20Cross-Platform)](https://github.com/nirsimetri/onvif-python/actions/workflows/cli-cross-platform.yml) + **This project provides a comprehensive and developer-friendly Python library for working with ONVIF-compliant devices.** It is designed to be reliable, easy to integrate, and flexible enough to support a wide range of ONVIF profiles and services. From 61bbd9a73c055f13f22d5ab67cfbde6d20086765 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:33:03 +0700 Subject: [PATCH 29/43] chore: Pin dependency versions for stability (#138) --- pyproject.toml | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7b06797..c72d300 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,9 +36,9 @@ classifiers = [ license = "MIT" dependencies = [ - "zeep>=4.3.0", - "requests>=2.32.0", - 'pyreadline3>=3.5.4; sys_platform == "win32"', + "zeep>=4.3.0,<4.4.0", + "requests>=2.32.0,<3.0.0", + 'pyreadline3>=3.5.4,<4.0.0; sys_platform == "win32"', ] [project.urls] @@ -49,23 +49,23 @@ Issues = "https://github.com/nirsimetri/onvif-python/issues" [project.optional-dependencies] dev = [ - "pytest>=7.0", - "pytest-cov", - "black", - "mypy", - "isort", - "pylint", - "pylint-pytest", - "docformatter", - "codespell", + "pytest>=8.0,<10.0", + "pytest-cov>=6.0,<7.0", + "black>=26.0,<27.0", + "mypy>=1.0,<2.0", + "isort>=6.0,<7.0", + "pylint>=4.1.2,<4.3.0", + "pylint-pytest>=1.1,<2.0", + "docformatter>=1.0,<2.0", + "codespell>=2.0,<3.0", "lxml-stubs", "types-setuptools", "types-requests", ] docs = [ - "mkdocs-material", - "mkdocstrings[python]", - "mkdocs-git-authors-plugin", + "mkdocs-material>=9.7.7,<10.0.0", + "mkdocstrings[python]>=1.0.0,<3.0.0", + "mkdocs-git-authors-plugin>=0.10.0,<1.0.0", ] [project.scripts] From ffcad110a01205cae7c8c5f56bf592b3de4e86e0 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:12:31 +0700 Subject: [PATCH 30/43] chore: Update LICENSE and NOTICE files for project compliance (#139) --- LICENSE.md => LICENSE | 2 +- NOTICE | 11 +++++++++++ docs/legal/legal_notice.md | 2 +- docs/legal/license.md | 2 +- onvif/wsdl/{LICENSE.md => LICENSE} | 0 5 files changed, 14 insertions(+), 3 deletions(-) rename LICENSE.md => LICENSE (97%) create mode 100644 NOTICE rename onvif/wsdl/{LICENSE.md => LICENSE} (100%) diff --git a/LICENSE.md b/LICENSE similarity index 97% rename from LICENSE.md rename to LICENSE index df378a5..11a8ebb 100644 --- a/LICENSE.md +++ b/LICENSE @@ -1,4 +1,4 @@ -The MIT License (MIT) +MIT License Copyright (c) 2025-2026 Nirsimetri Technologies® diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..c44680d --- /dev/null +++ b/NOTICE @@ -0,0 +1,11 @@ +This project is an independent open-source implementation of the ONVIF (https://www.onvif.org) specifications. It is not affiliated with, endorsed by, or sponsored by ONVIF or its member companies. + +- The name “ONVIF” and the ONVIF logo are registered trademarks of the ONVIF organization. +- Any references to ONVIF within this project are made strictly for the purpose of describing interoperability with ONVIF-compliant devices and services. +- Use of the ONVIF trademark in this repository is solely nominative and does not imply any partnership, certification, or official status. +- This project includes WSDL/XSD/HTML files from the official ONVIF specifications. +- These files are © ONVIF and are redistributed here for interoperability purposes. +- The WSDL files in the https://github.com/nirsimetri/onvif-python/tree/main/onvif/wsdl folder are distributed under the ONVIF Contributor License Agreement and Apache License 2.0. See https://github.com/nirsimetri/onvif-python/tree/main/onvif/wsdl/LICENSE for details. +- All rights to the ONVIF specifications are reserved by ONVIF. + +If you require certified ONVIF-compliant devices or clients, please refer to the official https://www.onvif.org/conformant-products. For authoritative reference and the latest official ONVIF specifications, please consult the https://www.onvif.org/profiles/specifications/specification-history. \ No newline at end of file diff --git a/docs/legal/legal_notice.md b/docs/legal/legal_notice.md index 6519bc5..6de503d 100644 --- a/docs/legal/legal_notice.md +++ b/docs/legal/legal_notice.md @@ -5,7 +5,7 @@ This project is an **independent open-source implementation** of the [ONVIF](htt - Use of the ONVIF trademark in this repository is solely nominative and does not imply any partnership, certification, or official status. - This project includes WSDL/XSD/HTML files from the official ONVIF specifications. - These files are © ONVIF and are redistributed here for interoperability purposes. -- The WSDL files in the [`wsdl/`](https://github.com/nirsimetri/onvif-python/tree/main/onvif/wsdl) folder are distributed under the ONVIF Contributor License Agreement and Apache License 2.0. See [`LICENSE.md`](https://github.com/nirsimetri/onvif-python/tree/main/onvif/wsdl/LICENSE.md) for details. +- The WSDL files in the [`wsdl/`](https://github.com/nirsimetri/onvif-python/tree/main/onvif/wsdl) folder are distributed under the ONVIF Contributor License Agreement and Apache License 2.0. See [`LICENSE`](https://github.com/nirsimetri/onvif-python/tree/main/onvif/wsdl/LICENSE) for details. - All rights to the ONVIF specifications are reserved by ONVIF. If you require certified ONVIF-compliant devices or clients, please refer to the official [ONVIF conformant product list](https://www.onvif.org/conformant-products/). For authoritative reference and the latest official ONVIF specifications, please consult the [ONVIF Official Specifications](https://www.onvif.org/profiles/specifications/specification-history/). \ No newline at end of file diff --git a/docs/legal/license.md b/docs/legal/license.md index df378a5..11a8ebb 100644 --- a/docs/legal/license.md +++ b/docs/legal/license.md @@ -1,4 +1,4 @@ -The MIT License (MIT) +MIT License Copyright (c) 2025-2026 Nirsimetri Technologies® diff --git a/onvif/wsdl/LICENSE.md b/onvif/wsdl/LICENSE similarity index 100% rename from onvif/wsdl/LICENSE.md rename to onvif/wsdl/LICENSE From 3afd1e9c6de8b8e510052a5f1761787c04678b94 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:40:38 +0700 Subject: [PATCH 31/43] ci: Remove conditional check for closed PR in documentation preview workflow (#140) --- .github/workflows/documentation-preview.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/documentation-preview.yml b/.github/workflows/documentation-preview.yml index a816754..5aba2d7 100644 --- a/.github/workflows/documentation-preview.yml +++ b/.github/workflows/documentation-preview.yml @@ -28,7 +28,6 @@ jobs: steps: - name: Checkout - if: github.event.action != 'closed' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python From 1a79169a2ccf200a7d65bcc3c50c6b32348523db Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:42:36 +0700 Subject: [PATCH 32/43] ci: Refactor workflow names and improve coverage settings (#141) --- .github/workflows/coverage-reporter.yml | 4 +--- .github/workflows/documentation.yml | 2 +- .github/workflows/python-app.yml | 14 ++++---------- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/.github/workflows/coverage-reporter.yml b/.github/workflows/coverage-reporter.yml index 254ddcc..d9fe15f 100644 --- a/.github/workflows/coverage-reporter.yml +++ b/.github/workflows/coverage-reporter.yml @@ -13,12 +13,10 @@ env: MIN_PYTHON_VERSION: "3.10" # Use the minimum supported Python version for coverage generation jobs: - coverage-reporter: + reporter: runs-on: ubuntu-latest - name: codacy-coverage-reporter - steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 76ad9c6..4fcc1a2 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -16,7 +16,7 @@ permissions: jobs: deploy: - name: Deploy documentation + runs-on: ubuntu-latest steps: diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index e3f9a18..308a1a7 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -6,22 +6,21 @@ on: paths: - "onvif/**" - "tests/**" - - "examples/**" + - "examples/**" # to enforce lint and format standard - "pyproject.toml" pull_request: branches: [ "main", "dev" ] paths: - "onvif/**" - "tests/**" - - "examples/**" + - "examples/**" # to enforce lint and format standard - "pyproject.toml" permissions: contents: read env: - COVERAGE_FAIL_UNDER: 84 # Will be raised gradually (in percentage) - MIN_PYTHON_VERSION: "3.10" # Use the minimum supported Python version for coverage generation + COVERAGE_FAIL_UNDER: 85 # Will be raised gradually (in percentage) jobs: build: @@ -64,9 +63,4 @@ jobs: run: mypy . - name: Test and coverage check with pytest - run: | - if [ "${{ matrix.python-version }}" = "${{ env.MIN_PYTHON_VERSION }}" ]; then - pytest --cov=onvif --cov-fail-under=${{ env.COVERAGE_FAIL_UNDER }} --cov-report=term-missing - else - pytest . - fi + run: pytest --cov=onvif --cov-fail-under=${{ env.COVERAGE_FAIL_UNDER }} --cov-report=term-missing From 6d99383bc2ef8763c450eb2e528c8fd8a866be1f Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:50:48 +0700 Subject: [PATCH 33/43] chore: Update .gitattributes and .gitignore for improved file tracking (#142) --- .gitattributes | 6 ++++-- .gitignore | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitattributes b/.gitattributes index 40f8732..aefd91b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,4 @@ -onvif/wsdl/** linguist-generated=true -onvif/** linguist-generated=true +onvif/wsdl/** linguist-generated +onvif/db/** linguist-generated +assets/** linguist-generated +docs/** linguist-documentation \ No newline at end of file diff --git a/.gitignore b/.gitignore index 58473f8..856261d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ site/ .coverage coverage.xml codacy-coverage.sh +result.json # Python cache __pycache__/ From 0a5b594181fe37eaccc72563e95779033e860438 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:00:50 +0700 Subject: [PATCH 34/43] chore: Update pyproject.toml for improved metadata and dependency management (#143) --- MANIFEST.in | 3 --- pyproject.toml | 29 ++++++++++++++++++++--------- 2 files changed, 20 insertions(+), 12 deletions(-) delete mode 100644 MANIFEST.in diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index d8e17f8..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,3 +0,0 @@ -include LICENSE.md -include README.md -recursive-include onvif/wsdl * \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index c72d300..aac0f3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,25 +11,30 @@ maintainers = [ {name = "Kaburagi"}, ] keywords = [ - "onvif", "Camera", "onvif python", "ip camera", "cctv", "soap", "surveillance", "cctv cameras", "soap client", "onvif client", "onvif library", "onvif specifications" + "onvif", "Camera", "onvif python", "ip camera", + "cctv", "soap", "surveillance", "cctv cameras", + "soap client", "onvif client", "onvif library", + "onvif specifications" ] classifiers = [ "Development Status :: 5 - Production/Stable", - 'Environment :: Console', + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "Intended Audience :: Science/Research", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", - "Operating System :: OS Independent", - "Intended Audience :: Developers", - "Intended Audience :: Information Technology", - 'Intended Audience :: Science/Research', "Topic :: Software Development :: Libraries", - 'Topic :: Software Development :: Libraries :: Python Modules', + "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Security", "Topic :: Multimedia :: Video", - 'Topic :: Multimedia :: Sound/Audio', + "Topic :: Multimedia :: Sound/Audio", "Topic :: Communications", "Topic :: Text Processing :: Markup :: XML", ] @@ -72,9 +77,15 @@ docs = [ onvif = "onvif.cli.main:main" [build-system] -requires = ["setuptools>=77.0.3", "wheel"] +requires = [ + "setuptools>=77.0.3", + "wheel>=0.48.0,<1.0.0", +] build-backend = "setuptools.build_meta" +[tool.setuptools] +license-files = ["LICENSE", "NOTICE"] + [tool.setuptools.packages.find] where = ["."] include = ["onvif*"] From ea4b057073984b6997e4115d6cc85e22270ad772 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:09:27 +0700 Subject: [PATCH 35/43] docs: Update README and docs index (#144) --- README.md | 8 ++++---- docs/index.md | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 763e967..da53f29 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,16 @@ [![Python Version](https://img.shields.io/pypi/pyversions/onvif-python?logo=python&logoColor=white&color=blue&style=plastic&label=Python)](https://pypi.org/project/onvif-python) [![PyPI Version](https://img.shields.io/pypi/v/onvif-python?logo=pypi&logoColor=white&color=blue&style=plastic&label=PyPI)](https://pypi.org/project/onvif-python/) -[![Quality](https://img.shields.io/codacy/grade/bff08a94e4d447b690cea49c6594826d?style=plastic&logo=codacy&label=Quality)](https://app.codacy.com/gh/nirsimetri/onvif-python/dashboard) -[![Coverage](https://img.shields.io/codacy/coverage/bff08a94e4d447b690cea49c6594826d?style=plastic&logo=codacy&label=Coverage)](https://app.codacy.com/gh/nirsimetri/onvif-python/coverage) +
    +[![Code Quality](https://img.shields.io/codacy/grade/bff08a94e4d447b690cea49c6594826d?style=plastic&logo=codacy&label=Code%20Quality)](https://app.codacy.com/gh/nirsimetri/onvif-python/dashboard) +[![Code Coverage](https://img.shields.io/codacy/coverage/bff08a94e4d447b690cea49c6594826d?style=plastic&logo=codacy&label=Code%20Coverage)](https://app.codacy.com/gh/nirsimetri/onvif-python/coverage) +[![Total Downloads](https://img.shields.io/pepy/dt/onvif-python?label=Total%20Downloads&color=red&style=plastic)](https://pepy.tech/projects/onvif-python)
    [![Build](https://img.shields.io/github/actions/workflow/status/nirsimetri/onvif-python/python-app.yml?logo=github&style=plastic&label=Build)](https://github.com/nirsimetri/onvif-python/actions/workflows/python-app.yml) [![Upload Python](https://img.shields.io/github/actions/workflow/status/nirsimetri/onvif-python/python-publish.yml?logo=github&style=plastic&label=Upload%20Package)](https://github.com/nirsimetri/onvif-python/actions/workflows/python-publish.yml) [![CLI Cross-Platform](https://img.shields.io/github/actions/workflow/status/nirsimetri/onvif-python/cli-cross-platform.yml?logo=github&style=plastic&label=CLI%20Cross-Platform)](https://github.com/nirsimetri/onvif-python/actions/workflows/cli-cross-platform.yml) -**This project provides a comprehensive and developer-friendly Python library for working with ONVIF-compliant devices.** It is designed to be reliable, easy to integrate, and flexible enough to support a wide range of ONVIF profiles and services. - **[ONVIF](https://www.onvif.org) (Open Network Video Interface Forum)** is a global standard for the interface of IP-based physical security products, including network cameras, video recorders, and related systems. Behind the scenes, ONVIF communication relies on **[SOAP](https://en.wikipedia.org/wiki/SOAP) (Simple Object Access Protocol)**; an [XML](https://en.wikipedia.org/wiki/XML)-based messaging protocol with strict schema definitions ([WSDL](https://en.wikipedia.org/wiki/Web_Services_Description_Language)/[XSD](https://en.wikipedia.org/wiki/XML_Schema_(W3C))). SOAP ensures interoperability, but when used directly it can be verbose, complex, and error-prone. diff --git a/docs/index.md b/docs/index.md index d106f8c..f4faefe 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,16 +2,16 @@ [![Python Version](https://img.shields.io/pypi/pyversions/onvif-python?logo=python&logoColor=white&color=blue&style=plastic&label=Python)](https://pypi.org/project/onvif-python) [![PyPI Version](https://img.shields.io/pypi/v/onvif-python?logo=pypi&logoColor=white&color=blue&style=plastic&label=PyPI)](https://pypi.org/project/onvif-python/) -[![Quality](https://img.shields.io/codacy/grade/bff08a94e4d447b690cea49c6594826d?style=plastic&logo=codacy&label=Quality)](https://app.codacy.com/gh/nirsimetri/onvif-python/dashboard) -[![Coverage](https://img.shields.io/codacy/coverage/bff08a94e4d447b690cea49c6594826d?style=plastic&logo=codacy&label=Coverage)](https://app.codacy.com/gh/nirsimetri/onvif-python/coverage) +
    +[![Code Quality](https://img.shields.io/codacy/grade/bff08a94e4d447b690cea49c6594826d?style=plastic&logo=codacy&label=Code%20Quality)](https://app.codacy.com/gh/nirsimetri/onvif-python/dashboard) +[![Code Coverage](https://img.shields.io/codacy/coverage/bff08a94e4d447b690cea49c6594826d?style=plastic&logo=codacy&label=Code%20Coverage)](https://app.codacy.com/gh/nirsimetri/onvif-python/coverage) +[![Total Downloads](https://img.shields.io/pepy/dt/onvif-python?label=Total%20Downloads&color=red&style=plastic)](https://pepy.tech/projects/onvif-python)
    [![Build](https://img.shields.io/github/actions/workflow/status/nirsimetri/onvif-python/python-app.yml?logo=github&style=plastic&label=Build)](https://github.com/nirsimetri/onvif-python/actions/workflows/python-app.yml) [![Upload Python](https://img.shields.io/github/actions/workflow/status/nirsimetri/onvif-python/python-publish.yml?logo=github&style=plastic&label=Upload%20Package)](https://github.com/nirsimetri/onvif-python/actions/workflows/python-publish.yml) [![CLI Cross-Platform](https://img.shields.io/github/actions/workflow/status/nirsimetri/onvif-python/cli-cross-platform.yml?logo=github&style=plastic&label=CLI%20Cross-Platform)](https://github.com/nirsimetri/onvif-python/actions/workflows/cli-cross-platform.yml) -**This project provides a comprehensive and developer-friendly Python library for working with ONVIF-compliant devices.** It is designed to be reliable, easy to integrate, and flexible enough to support a wide range of ONVIF profiles and services. - **[ONVIF](https://www.onvif.org) (Open Network Video Interface Forum)** is a global standard for the interface of IP-based physical security products, including network cameras, video recorders, and related systems. Behind the scenes, ONVIF communication relies on **[SOAP](https://en.wikipedia.org/wiki/SOAP) (Simple Object Access Protocol)**; an [XML](https://en.wikipedia.org/wiki/XML)-based messaging protocol with strict schema definitions ([WSDL](https://en.wikipedia.org/wiki/Web_Services_Description_Language)/[XSD](https://en.wikipedia.org/wiki/XML_Schema_(W3C))). SOAP ensures interoperability, but when used directly it can be verbose, complex, and error-prone. From 156712ae06eb4142da798167bb7f09e97ea83e1b Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:28:44 +0700 Subject: [PATCH 36/43] test: Enhance client and exceptions tests (#145) --- tests/test_client.py | 89 ++++++++++++++++ tests/utilities/test_exceptions.py | 165 ++++++++++++++++++++++------- 2 files changed, 215 insertions(+), 39 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index 6e4a163..0fb9786 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,6 +8,7 @@ from onvif import CacheMode, ONVIFClient from onvif.utils import ONVIFOperationException, XMLCapturePlugin, ZeepPatcher from onvif.utils.plugins import ReferenceParametersPlugin +from onvif.utils.wsdl import ONVIFWSDL class TestONVIFClientInitialization: @@ -242,6 +243,94 @@ def test_get_xaddr_fallback(self, mock_onvif_client): "http://192.168.1.17:8000/onvif/Media" ) + def test_get_xaddr_handles_getservices_mapping_error( + self, mock_onvif_client, monkeypatch + ): + """Test handling errors while processing the GetServices mapping.""" + client = mock_onvif_client + client.services = [Mock()] + client._service_map = {} + + monkeypatch.setattr( + ONVIFWSDL, + "get_wsdl_map", + lambda: { + "Media": { + "ver10": { + "invalid": "mapping", + }, + }, + }, + ) + + client.capabilities = None + + result = client._get_xaddr("Media", "Media") + + assert result == "http://192.168.1.17:8000/onvif/Media" + + def test_get_xaddr_from_capabilities_direct(self, mock_onvif_client): + """Test resolving XAddr from a direct GetCapabilities service.""" + client = mock_onvif_client + + client._service_map = {} + client.capabilities = Mock() + client.capabilities.Media = Mock(XAddr="http://192.168.1.100:80/onvif/Media") + + assert client._get_xaddr("media", "Media") == ( + "http://192.168.1.17:8000/onvif/Media" + ) + + def test_get_xaddr_from_capabilities_extension(self, mock_onvif_client): + """Test resolving XAddr from a service in GetCapabilities.Extension.""" + client = mock_onvif_client + + client._service_map = {} + client.capabilities = Mock(spec=["Extension"]) + client.capabilities.Extension = Mock(spec=["DeviceIO"]) + client.capabilities.Extension.DeviceIO = Mock( + XAddr="http://192.168.1.100:80/onvif/DeviceIO" + ) + + assert client._get_xaddr("deviceio", "DeviceIO") == ( + "http://192.168.1.17:8000/onvif/DeviceIO" + ) + + def test_get_xaddr_from_capabilities_nested_extension(self, mock_onvif_client): + """Test resolving XAddr from a nested GetCapabilities extension.""" + client = mock_onvif_client + + client._service_map = {} + client.capabilities = Mock(spec=["Extension"]) + client.capabilities.Extension = Mock(spec=["Extensions"]) + client.capabilities.Extension.Extensions = Mock(spec=["Provisioning"]) + client.capabilities.Extension.Extensions.Provisioning = Mock( + XAddr="http://192.168.1.100:80/onvif/Provisioning" + ) + + assert client._get_xaddr("provisioning", "Provisioning") == ( + "http://192.168.1.17:8000/onvif/Provisioning" + ) + + def test_get_xaddr_capabilities_lookup_error(self, mock_onvif_client): + """Test fallback when GetCapabilities lookup raises a handled exception.""" + client = mock_onvif_client + + client._service_map = {} + client.capabilities = BrokenCapabilities() + + assert client._get_xaddr("media", "Media") == ( + "http://192.168.1.17:8000/onvif/Media" + ) + + +class BrokenCapabilities: # pylint: disable=too-few-public-methods + """BrokenCapabilities.""" + + def __getattr__(self, _name): + """Attribute getter.""" + raise ValueError("invalid capabilities mapping") + class TestONVIFClientErrorHandling: """Test error handling scenarios.""" diff --git a/tests/utilities/test_exceptions.py b/tests/utilities/test_exceptions.py index 01e95d4..9ccd81a 100644 --- a/tests/utilities/test_exceptions.py +++ b/tests/utilities/test_exceptions.py @@ -3,6 +3,7 @@ from unittest.mock import Mock import pytest +from lxml import etree from onvif.utils.exceptions import ONVIFOperationException @@ -92,20 +93,140 @@ def test_soap_fault_with_subcodes(self): assert "Additional error details" in error_str def test_soap_fault_with_invalid_subcodes(self): - """Test ONVIFOperationException with invalid subcodes that cause exceptions.""" + """Test fallback when SOAP fault subcodes cannot be processed.""" + + class InvalidSubcodes: + """InvalidSubcodes.""" + + def __iter__(self): + raise TypeError("invalid subcodes") + + def __str__(self): + return "invalid-subcodes" + + fault = Mock(spec=Fault) + fault.code = "SOAP-ENV:Server" + fault.message = "Operation failed" + fault.subcodes = InvalidSubcodes() + fault.detail = None + + exception = ONVIFOperationException("TestOperation", fault) + + assert "subcode=invalid-subcodes" in str(exception) + + def test_soap_fault_missing_attributes(self): + """Test SOAP fault handling when some attributes are missing.""" + mock_fault = Mock(spec=Fault) + # Only set some attributes, leave others as None + mock_fault.message = "Partial error info" + mock_fault.code = None + mock_fault.faultcode = "SOAP-ENV:Server" # Fallback attribute + mock_fault.detail = None + mock_fault.subcodes = None + + operation = "GetSystemDateAndTime" + exception = ONVIFOperationException(operation, mock_fault) + + error_str = str(exception) + assert "SOAP Error" in error_str + assert "SOAP-ENV:Server" in error_str + assert "Partial error info" in error_str + + def test_soap_fault_no_message(self): + """Test SOAP fault handling when message is None.""" mock_fault = Mock(spec=Fault) mock_fault.code = "SOAP-ENV:Client" - mock_fault.message = "Test error" + mock_fault.message = None mock_fault.detail = None - mock_fault.subcodes = ["invalid", "subcodes"] # Not QName objects + mock_fault.subcodes = None + mock_fault.__str__ = Mock(return_value="String representation of fault") - operation = "TestOperation" + operation = "GetNetworkInterfaces" exception = ONVIFOperationException(operation, mock_fault) # Should handle invalid subcodes gracefully error_str = str(exception) assert "SOAP Error" in error_str - assert "Test error" in error_str + assert "String representation of fault" in error_str + + def test_soap_fault_with_detail_text(self): + """Test SOAP fault detail containing direct text.""" + mock_fault = Mock(spec=Fault) + mock_fault.code = "SOAP-ENV:Server" + mock_fault.message = "Operation failed" + mock_fault.subcodes = None + mock_fault.detail = etree.Element("Detail") + mock_fault.detail.text = "Detailed failure" + + exception = ONVIFOperationException("TestOperation", mock_fault) + + assert "detail=Detailed failure" in str(exception) + + def test_soap_fault_with_detail_children(self): + """Test SOAP fault detail containing child elements.""" + mock_fault = Mock(spec=Fault) + mock_fault.code = "SOAP-ENV:Server" + mock_fault.message = "Operation failed" + mock_fault.subcodes = None + + detail = etree.Element("Detail") + + error = etree.SubElement(detail, "Error") + error.text = "Invalid value" + + etree.SubElement(detail, "Reason") + + mock_fault.detail = detail + + exception = ONVIFOperationException("TestOperation", mock_fault) + + error_str = str(exception) + + assert "detail=Error=Invalid value, Reason" in error_str + + def test_soap_fault_with_non_iterable_detail(self): + """Test SOAP fault detail represented by a non-iterable object.""" + + class NonIterableDetail: # pylint: disable=too-few-public-methods + """NonIterableDetail.""" + + def __str__(self): + """Class string.""" + return "custom-detail" + + fault = Mock(spec=Fault) + fault.code = "SOAP-ENV:Server" + fault.message = "Operation failed" + fault.subcodes = None + fault.detail = NonIterableDetail() + + exception = ONVIFOperationException("TestOperation", fault) + + assert "detail=custom-detail" in str(exception) + + def test_soap_fault_with_empty_detail(self): + """Test SOAP fault detail fallback when no child details are available.""" + + class EmptyDetail: + """EmptyDetail.""" + + def __iter__(self): + """Iterator class.""" + return iter(()) + + def __str__(self): + """Class string.""" + return "empty-detail" + + fault = Mock(spec=Fault) + fault.code = "SOAP-ENV:Server" + fault.message = "Operation failed" + fault.subcodes = None + fault.detail = EmptyDetail() + + exception = ONVIFOperationException("TestOperation", fault) + + assert "detail=empty-detail" in str(exception) def test_requests_exception(self): """Test ONVIFOperationException with requests exception.""" @@ -155,40 +276,6 @@ def test_exception_inheritance(self): assert hasattr(exception, "operation") assert hasattr(exception, "original_exception") - def test_soap_fault_missing_attributes(self): - """Test SOAP fault handling when some attributes are missing.""" - mock_fault = Mock(spec=Fault) - # Only set some attributes, leave others as None - mock_fault.message = "Partial error info" - mock_fault.code = None - mock_fault.faultcode = "SOAP-ENV:Server" # Fallback attribute - mock_fault.detail = None - mock_fault.subcodes = None - - operation = "GetSystemDateAndTime" - exception = ONVIFOperationException(operation, mock_fault) - - error_str = str(exception) - assert "SOAP Error" in error_str - assert "SOAP-ENV:Server" in error_str - assert "Partial error info" in error_str - - def test_soap_fault_no_message(self): - """Test SOAP fault handling when message is None.""" - mock_fault = Mock(spec=Fault) - mock_fault.code = "SOAP-ENV:Client" - mock_fault.message = None - mock_fault.detail = None - mock_fault.subcodes = None - mock_fault.__str__ = Mock(return_value="String representation of fault") - - operation = "GetNetworkInterfaces" - exception = ONVIFOperationException(operation, mock_fault) - - error_str = str(exception) - assert "SOAP Error" in error_str - assert "String representation of fault" in error_str - class TestExceptionErrorMessages: """Test error message formatting and content.""" From 2fb2e6ea39d62b5f74b215b777f6265b4d63ca08 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:41:08 +0700 Subject: [PATCH 37/43] refactor(uitls): Add future annotations import for improved type hinting (#146) --- onvif/utils/error_handlers.py | 2 ++ onvif/utils/exceptions.py | 4 +++- onvif/utils/zeep.py | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/onvif/utils/error_handlers.py b/onvif/utils/error_handlers.py index 213120d..f63f1c8 100644 --- a/onvif/utils/error_handlers.py +++ b/onvif/utils/error_handlers.py @@ -37,6 +37,8 @@ - `zeep.exceptions.Fault`: Base SOAP fault exception """ +from __future__ import annotations + import logging from collections.abc import Callable from typing import Any diff --git a/onvif/utils/exceptions.py b/onvif/utils/exceptions.py index cfbc058..3e8f4a7 100644 --- a/onvif/utils/exceptions.py +++ b/onvif/utils/exceptions.py @@ -1,5 +1,7 @@ """ONVIFOperationException: Enhanced exception wrapper for ONVIF operation failures.""" +from __future__ import annotations + import requests from zeep.exceptions import Fault @@ -86,7 +88,7 @@ class ONVIFOperationException(Exception): - `requests.exceptions.RequestException`: Base HTTP error """ - def __init__(self, operation, original_exception): + def __init__(self, operation: str, original_exception: Exception): self.operation = operation self.original_exception = original_exception diff --git a/onvif/utils/zeep.py b/onvif/utils/zeep.py index 2c30a75..847a699 100644 --- a/onvif/utils/zeep.py +++ b/onvif/utils/zeep.py @@ -1,5 +1,7 @@ """ZeepPatcher: Patch zeep SOAP library to handle ONVIF `xsd:any` fields.""" +from __future__ import annotations + import logging from collections.abc import Callable from typing import Any From d050d0cdcd746416c523188dcca552e73bb75dc1 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:48:21 +0700 Subject: [PATCH 38/43] docs: Miscellaneous documentation fixes and improvements (#147) --- docs/core/onvif_cli.md | 11 +++++++---- docs/project/contributing.md | 8 ++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/core/onvif_cli.md b/docs/core/onvif_cli.md index dac16ca..956f37a 100644 --- a/docs/core/onvif_cli.md +++ b/docs/core/onvif_cli.md @@ -304,10 +304,13 @@ onvif media GetProfiles -d -u admin -p password 1. **Automatic Network Scanning**: Sends a WS-Discovery Probe message to the multicast address `239.255.255.250:3702` 2. **Device Detection**: Listens for ProbeMatch responses from ONVIF devices (default timeout: 4 seconds) 3. **Interactive Selection**: Displays a numbered list of discovered devices with their details: - - Device UUID (Endpoint Reference) - - XAddrs (ONVIF service URLs) - - Device Types (e.g., NetworkVideoTransmitter) - - Scopes (name, location, hardware, profile information) + - Device UUID (Endpoint Reference) + - Hostname (hostname of the device) + - XAddrs (ONVIF service URLs) + - Date & Time (in UTC and/or Local) + - Device Types (e.g., NetworkVideoTransmitter) + - Services (available services on device) + - Scopes (name, location, hardware, profile information) 4. **Connection**: Once you select a device, the CLI automatically connects using the discovered host and port #### Example Discovery Output diff --git a/docs/project/contributing.md b/docs/project/contributing.md index b422e11..0723866 100644 --- a/docs/project/contributing.md +++ b/docs/project/contributing.md @@ -85,6 +85,9 @@ If you can help translate documentation or error messages, let us know! # Sort import order isort . + # Spelling check + codespell . + # Format docstring with docformatter docformatter --recursive --black --in-place . @@ -211,15 +214,12 @@ docs/ ├── core/ ├── references/ ├── utilities/ +├── project/ ├── legal/ ├── index.md ├── installation.md ├── quick_start.md ├── philosophy.md -├── contributing.md -├── roadmap.md -├── used_by.md -├── external.md └── releases.md ``` From 22d07fc870d8ea1bf8cb5cc31909df427142afe6 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:33:38 +0700 Subject: [PATCH 39/43] docs: Update badges position in README --- README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index da53f29..b5edd7e 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,9 @@ [![Python Version](https://img.shields.io/pypi/pyversions/onvif-python?logo=python&logoColor=white&color=blue&style=plastic&label=Python)](https://pypi.org/project/onvif-python) [![PyPI Version](https://img.shields.io/pypi/v/onvif-python?logo=pypi&logoColor=white&color=blue&style=plastic&label=PyPI)](https://pypi.org/project/onvif-python/) -
    -[![Code Quality](https://img.shields.io/codacy/grade/bff08a94e4d447b690cea49c6594826d?style=plastic&logo=codacy&label=Code%20Quality)](https://app.codacy.com/gh/nirsimetri/onvif-python/dashboard) -[![Code Coverage](https://img.shields.io/codacy/coverage/bff08a94e4d447b690cea49c6594826d?style=plastic&logo=codacy&label=Code%20Coverage)](https://app.codacy.com/gh/nirsimetri/onvif-python/coverage) -[![Total Downloads](https://img.shields.io/pepy/dt/onvif-python?label=Total%20Downloads&color=red&style=plastic)](https://pepy.tech/projects/onvif-python) +[![Quality](https://img.shields.io/codacy/grade/bff08a94e4d447b690cea49c6594826d?style=plastic&logo=codacy&label=Quality)](https://app.codacy.com/gh/nirsimetri/onvif-python/dashboard) +[![Coverage](https://img.shields.io/codacy/coverage/bff08a94e4d447b690cea49c6594826d?style=plastic&logo=codacy&label=Coverage)](https://app.codacy.com/gh/nirsimetri/onvif-python/coverage) +[![Downloads](https://img.shields.io/pepy/dt/onvif-python?label=Downloads&color=red&style=plastic)](https://pepy.tech/projects/onvif-python)
    [![Build](https://img.shields.io/github/actions/workflow/status/nirsimetri/onvif-python/python-app.yml?logo=github&style=plastic&label=Build)](https://github.com/nirsimetri/onvif-python/actions/workflows/python-app.yml) [![Upload Python](https://img.shields.io/github/actions/workflow/status/nirsimetri/onvif-python/python-publish.yml?logo=github&style=plastic&label=Upload%20Package)](https://github.com/nirsimetri/onvif-python/actions/workflows/python-publish.yml) @@ -55,4 +54,4 @@ For complete guide, including usage examples and API references, please visit th ## License -This project is licensed under the MIT License. See [LICENSE](./LICENSE) for details. \ No newline at end of file +This project is licensed under the MIT License. See [LICENSE](./LICENSE) for details. From 9f25246dc2c1dab02b7fb25708edbba6cda762d9 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:34:51 +0700 Subject: [PATCH 40/43] docs: Update badges positions in index.md --- docs/index.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/index.md b/docs/index.md index f4faefe..cc98db0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,10 +2,9 @@ [![Python Version](https://img.shields.io/pypi/pyversions/onvif-python?logo=python&logoColor=white&color=blue&style=plastic&label=Python)](https://pypi.org/project/onvif-python) [![PyPI Version](https://img.shields.io/pypi/v/onvif-python?logo=pypi&logoColor=white&color=blue&style=plastic&label=PyPI)](https://pypi.org/project/onvif-python/) -
    -[![Code Quality](https://img.shields.io/codacy/grade/bff08a94e4d447b690cea49c6594826d?style=plastic&logo=codacy&label=Code%20Quality)](https://app.codacy.com/gh/nirsimetri/onvif-python/dashboard) -[![Code Coverage](https://img.shields.io/codacy/coverage/bff08a94e4d447b690cea49c6594826d?style=plastic&logo=codacy&label=Code%20Coverage)](https://app.codacy.com/gh/nirsimetri/onvif-python/coverage) -[![Total Downloads](https://img.shields.io/pepy/dt/onvif-python?label=Total%20Downloads&color=red&style=plastic)](https://pepy.tech/projects/onvif-python) +[![Quality](https://img.shields.io/codacy/grade/bff08a94e4d447b690cea49c6594826d?style=plastic&logo=codacy&label=Quality)](https://app.codacy.com/gh/nirsimetri/onvif-python/dashboard) +[![Coverage](https://img.shields.io/codacy/coverage/bff08a94e4d447b690cea49c6594826d?style=plastic&logo=codacy&label=Coverage)](https://app.codacy.com/gh/nirsimetri/onvif-python/coverage) +[![Downloads](https://img.shields.io/pepy/dt/onvif-python?label=Downloads&color=red&style=plastic)](https://pepy.tech/projects/onvif-python)
    [![Build](https://img.shields.io/github/actions/workflow/status/nirsimetri/onvif-python/python-app.yml?logo=github&style=plastic&label=Build)](https://github.com/nirsimetri/onvif-python/actions/workflows/python-app.yml) [![Upload Python](https://img.shields.io/github/actions/workflow/status/nirsimetri/onvif-python/python-publish.yml?logo=github&style=plastic&label=Upload%20Package)](https://github.com/nirsimetri/onvif-python/actions/workflows/python-publish.yml) @@ -28,4 +27,4 @@ This library simplifies that process by wrapping SOAP communication into a clean - **Python**: 3.10 or higher - **Dependencies**: - [`zeep>=4.3.0`](https://github.com/mvantellingen/python-zeep) - SOAP client for ONVIF communication - - [`requests>=2.32.0`](https://github.com/psf/requests) - HTTP library for network requests \ No newline at end of file + - [`requests>=2.32.0`](https://github.com/psf/requests) - HTTP library for network requests From e6ee494343e63878a5a3db820f963a7478355a2f Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:11:51 +0700 Subject: [PATCH 41/43] refactor(cli): Refactor interactive shell into modular components (#148) --- onvif/cli/interactive.py | 1335 -------------------------- onvif/cli/interactive/__init__.py | 5 + onvif/cli/interactive/context.py | 55 ++ onvif/cli/interactive/information.py | 175 ++++ onvif/cli/interactive/navigation.py | 185 ++++ onvif/cli/interactive/reference.py | 204 ++++ onvif/cli/interactive/service.py | 325 +++++++ onvif/cli/interactive/shell.py | 246 +++++ onvif/cli/interactive/utils.py | 313 ++++++ tests/cli/test_cli_interactive.py | 399 -------- 10 files changed, 1508 insertions(+), 1734 deletions(-) delete mode 100644 onvif/cli/interactive.py create mode 100644 onvif/cli/interactive/__init__.py create mode 100644 onvif/cli/interactive/context.py create mode 100644 onvif/cli/interactive/information.py create mode 100644 onvif/cli/interactive/navigation.py create mode 100644 onvif/cli/interactive/reference.py create mode 100644 onvif/cli/interactive/service.py create mode 100644 onvif/cli/interactive/shell.py create mode 100644 onvif/cli/interactive/utils.py delete mode 100644 tests/cli/test_cli_interactive.py diff --git a/onvif/cli/interactive.py b/onvif/cli/interactive.py deleted file mode 100644 index 1e5a2a9..0000000 --- a/onvif/cli/interactive.py +++ /dev/null @@ -1,1335 +0,0 @@ -# pylint: disable=too-many-lines,too-many-instance-attributes,too-many-locals -# pylint: disable=too-many-statements,too-many-public-methods,too-many-branches -"""ONVIF CLI interactive shell implementation.""" - -import cmd -import json -import os -import re -import shutil -import socket -import ssl -import sys -import textwrap -import threading -import traceback -from datetime import datetime -from typing import Any - -from requests.exceptions import RequestException -from zeep.exceptions import Fault, TransportError - -from onvif.cli.helpers import get_method_documentation, get_operation_type_info -from onvif.cli.helpers.messages import ( - INTERACTIVE_HELP, - INTERACTIVE_SHORTCUTS, - print_interactive_intro, -) -from onvif.cli.utils import ( - colorize, - format_capabilities_as_services, - format_services_list, - get_device_available_services, - get_service_methods, - get_service_required_args, - parse_json_params, -) -from onvif.client import ONVIFClient -from onvif.utils.exceptions import ONVIFOperationException - - -class InteractiveShell(cmd.Cmd): - """Interactive ONVIF CLI shell.""" - - def __init__(self, client: ONVIFClient, args): - super().__init__() - self.client = client - self.args = args - self.current_service = None - self.current_service_name = None - self.stored_data: dict[str, Any] = {} # For storing command results - self.stored_metadata: dict[str, dict[str, Any]] = ( - {} - ) # For storing metadata about stored data (service, method) - self._last_result = None - self._last_method = None - self._last_service_name = None - self._last_operation_timestamp = None - - # Define base commands - self.base_commands = [ - "capabilities", - "caps", - "services", - "help", - "exit", - "store", - "rm", - "show", - "cls", - "clear", - "info", - "debug", - "ls", - "cd", - "shortcuts", - "desc", - "type", - ] - - # For background health check - self._stop_health_check = threading.Event() - self._health_check_thread = threading.Thread( - target=self._periodic_health_check, daemon=True - ) - - # Enable tab completion - # for Windows will use pyreadline3 to install readline module - # pylint: disable=import-outside-toplevel - try: - import readline - - # Set completer to this instance - readline.set_completer(self.complete) # type: ignore[attr-defined, arg-type] - readline.set_completer_delims(" \t\n`!@#$%^&*()=+[{]}\\|;:'\",<>?") # type: ignore[attr-defined] - - # Enable tab completion, making it compatible with both GNU readline and libedit - if ( - hasattr(readline, "__doc__") - and readline.__doc__ - and "libedit" in readline.__doc__ - ): - readline.parse_and_bind("bind ^I rl_complete") # type: ignore[attr-defined] - else: - readline.parse_and_bind("tab: complete") # type: ignore[attr-defined] - except ImportError: - pass # readline not available on some systems - - # Set prompt - self.update_prompt() - - manufacturer = "Unknown" - model = "Unknown" - firmware = "Unknown" - serial = "Unknown" - hardware_id = "Unknown" - onvif_version = "Unknown" - - try: - # Get device information and store it - self.device_data = self.client.devicemgmt().GetDeviceInformation() - manufacturer = getattr(self.device_data, "Manufacturer", "Unknown") - model = getattr(self.device_data, "Model", "Unknown") - firmware = getattr(self.device_data, "FirmwareVersion", "Unknown") - serial = getattr(self.device_data, "SerialNumber", "Unknown") - hardware_id = getattr(self.device_data, "HardwareId", "Unknown") - - # Get latest supported ONVIF version - supported_versions = self.client.devicemgmt().GetCapabilities( - Category="All" - )["Device"]["System"]["SupportedVersions"] - if supported_versions: - latest_version = max( - supported_versions, - key=lambda version: ( - getattr(version, "Major", 0), - getattr(version, "Minor", 0), - ), - ) - - major = getattr(latest_version, "Major", "") - minor = getattr(latest_version, "Minor", "") - - if major != "": - onvif_version = ( - f"{major}.{minor} {colorize('[Latest]', 'green')}" - if minor != "" - else str(major) - ) - except (ONVIFOperationException, KeyError, AttributeError) as e: - if isinstance(e, ONVIFOperationException): - if isinstance(e.original_exception, (RequestException, TransportError)): - self._handle_connection_error() - else: - # For other errors (e.g., GetCapabilities not supported), we can still proceed - # with basic device info if GetDeviceInformation succeeded. - pass - - self.device_info_text = ( - f"\n\n{colorize('[Device Info]', 'cyan')}\n" - f" Manufacturer : {colorize(manufacturer, 'white')}\n" - f" Model : {colorize(model, 'white')}\n" - f" Firmware : {colorize(firmware, 'white')}\n" - f" Serial : {colorize(serial, 'white')}\n" - f" HardwareId : {colorize(hardware_id, 'white')}\n" - f" ONVIF Version : {colorize(onvif_version, 'white')}" - ) - - self.intro = print_interactive_intro(args, self.device_info_text) - - # Start background health check after successful initialization - self._health_check_thread.start() - - def _periodic_health_check(self): - """Periodically checks device connection using TCP or TLS depending on mode.""" - # Get health check interval from args, default to 10 seconds - health_check_interval = getattr(self.args, "health_check_interval", 10) - - # Wait before first check to allow intro to finish - self._stop_health_check.wait(health_check_interval) - - while not self._stop_health_check.is_set(): - sock = None - try: - # For HTTPS, use ssl.create_connection for proper TLS handling - if getattr(self.args, "https", False): - context = ssl.create_default_context() - context.minimum_version = ssl.TLSVersion.TLSv1_2 - context.check_hostname = False - context.verify_mode = ssl.CERT_NONE - - # Use ssl.create_connection instead of wrapping existing socket - sock = context.wrap_socket( - socket.socket(socket.AF_INET, socket.SOCK_STREAM), - server_hostname=self.args.host, - ) - sock.settimeout(5.0) - sock.connect((self.args.host, self.args.port)) - else: - # For HTTP, simple TCP connection check - sock = socket.create_connection( - (self.args.host, self.args.port), timeout=5.0 - ) - - # Connection successful - except ( - socket.timeout, - ConnectionRefusedError, - socket.gaierror, - ssl.SSLError, - ssl.SSLEOFError, - OSError, - ) as e: - # Connection failed, trigger exit - print( - f"\n{colorize('Connection to device lost.', 'red')}", - file=sys.stderr, - ) - print( - f"{colorize('Error:', 'red')} Health check failed: {e}", - file=sys.stderr, - ) - print( - colorize("Exiting ONVIF interactive shell...", "yellow"), - file=sys.stderr, - ) - # Forcibly exit the entire process. This is necessary to interrupt - # the blocking input() call in the main thread. - os._exit(1) - finally: - # Ensure the socket is always closed - if sock: - sock.close() - - # Wait before next check or stop signal - self._stop_health_check.wait(health_check_interval) - - def _handle_connection_error(self): - """Handle connection errors by notifying the user and exiting.""" - print(f"\n{colorize('Connection to device lost.', 'red')}", file=sys.stderr) - # print(f"{colorize('Error:', 'red')} {e}", file=sys.stderr) - if self.args.debug: - traceback.print_exc() - print(colorize("Exiting ONVIF interactive shell...", "yellow"), file=sys.stderr) - print(colorize("Goodbye!", "cyan"), file=sys.stderr) - sys.exit(1) - - def _split_multi_commands(self, s: str) -> list: - """Split a command string by top-level '&&' separators while ignoring - occurrences inside quotes or bracketed structures. - - Returns a list of command strings (trimmed). - """ - parts = [] - buf = [] - i = 0 - length = len(s) - depth = 0 - in_single = False - in_double = False - while i < length: - ch = s[i] - # Toggle quote states - if ch == "'" and not in_double: - in_single = not in_single - buf.append(ch) - i += 1 - continue - if ch == '"' and not in_single: - in_double = not in_double - buf.append(ch) - i += 1 - continue - - # Track bracket depth to avoid splitting inside {...} or [...] - if not in_single and not in_double: - if ch in "{[(": - depth += 1 - elif ch in "}])": - depth = max(0, depth - 1) - - # Detect top-level && - # pylint: disable=too-many-boolean-expressions - if ( - not in_single - and not in_double - and depth == 0 - and ch == "&" - and i + 1 < length - and s[i + 1] == "&" - ): - # finish current buffer - token = "".join(buf).strip() - parts.append(token) - buf = [] - i += 2 - # skip optional spaces after && - while i < length and s[i].isspace(): - i += 1 - continue - - buf.append(ch) - i += 1 - - if buf: - token = "".join(buf).strip() - if token: - parts.append(token) - - return parts - - def _display_grid(self, items): - """Display items in grid format matching TAB completion (vertical layout)""" - if not items: - return - - # Get device services for coloring (only in root mode) - services = ( - get_device_available_services(self.client) - if not self.current_service - else [] - ) - - # Calculate terminal width - try: - term_width = shutil.get_terminal_size().columns - except (KeyError, ValueError, RuntimeError): - term_width = 80 - - # Find the longest item length - longest_length = max(len(item) for item in items) - - # Column width calculation: use same algorithm as cmd.Cmd.columnize() - # Python's columnize uses: maxlen + 2 if maxlen > 0 else 2 - col_width = longest_length + 2 if longest_length > 0 else 2 - - # Calculate number of columns that fit - num_cols = max(1, term_width // col_width) - - # Calculate number of rows needed - num_rows = (len(items) + num_cols - 1) // num_cols - - # Display items in VERTICAL layout (column by column, like TAB completion) - for row_idx in range(num_rows): - row_items = [] - for col_idx in range(num_cols): - # Calculate index in vertical order - item_idx = col_idx * num_rows + row_idx - - if item_idx < len(items): - item = items[item_idx] - - # Apply coloring - if item in services: - colored_item = colorize(item, "cyan") - else: - colored_item = item - - # Calculate padding: align to column width - # Use ljust-style padding for consistency with cmd.Cmd - padding = col_width - len(item) - formatted_item = colored_item + " " * padding - row_items.append(formatted_item) - - if row_items: - # Join and rstrip to remove trailing spaces - line = "".join(row_items).rstrip() - print(line) - - def _resolve_stored_reference(self, reference: str): - """Resolve stored data reference like 'profiles[0].token' or 'profiles.Token'. - - Args: - reference: String reference like 'profiles[0]' or 'profiles.Token' - - Returns: - Resolved value or None if not found - """ - # Parse the reference - e.g., "profiles[0].token" or "services.Namespace" - # Split by dots and brackets - parts = re.split(r"\.|\[|\]", reference) - parts = [p for p in parts if p] # Remove empty strings - - if not parts: - return None - - # Start with the stored variable name - var_name = parts[0] - if var_name not in self.stored_data: - return None - - current = self.stored_data[var_name] - - # Navigate through the rest of the path - for part in parts[1:]: - try: - # Check if it's an integer index - if part.isdigit(): - index = int(part) - if isinstance(current, (list, tuple)): - current = current[index] - else: - # Try to convert to list if it's iterable - try: - current = list(current)[index] - except (TypeError, IndexError): - return None - else: - # It's an attribute name - if hasattr(current, part): - current = getattr(current, part) - elif isinstance(current, dict) and part in current: - current = current[part] - else: - return None - except (IndexError, AttributeError, KeyError, TypeError): - return None - - return current - - def _substitute_stored_references(self, params_str: str) -> str: - """Substitute $variable references in parameter string with stored data. - - Args: - params_str: Parameter string that may contain $variable references - - Returns: - Parameter string with substituted values - """ - # Find all $variable references (e.g., $profiles[0].token) - pattern = ( - r"\$([a-zA-Z_][a-zA-Z0-9_]*(?:\[[0-9]+\])?(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)" - ) - - def replace_reference(match): - reference = match.group(1) - value = self._resolve_stored_reference(reference) - - if value is None: - print(f"{colorize('Warning:', 'yellow')} Cannot resolve ${reference}") - return match.group(0) # Keep original if not found - - # Convert value to string representation suitable for JSON - # Use json.dumps to serialize values correctly for JSON parsing. - # This preserves numbers, booleans, nulls, arrays and objects. - try: - return json.dumps(value) - except (KeyError, ValueError, RuntimeError): - # Fall back to string-quoting for anything not serializable - return json.dumps(str(value)) - - return re.sub(pattern, replace_reference, params_str) - - def update_prompt(self): - """Update command prompt based on current context.""" - if self.current_service_name: - self.prompt = ( - f"{self.args.username}@{self.args.host}:{self.args.port}" - f"/{self.current_service_name} > " - ) - else: - self.prompt = f"{self.args.username}@{self.args.host}:{self.args.port} > " - - def onecmd(self, line): - """Override onecmd to handle service method calls without showing cmd.Cmd - traceback.""" - line = line.strip() - if not line: - return self.emptyline() - - # Support chaining multiple commands with && (only at top-level, - # not inside quotes/brackets). Example: - # media && GetProfiles && store profiles - if "&&" in line: - parts = self._split_multi_commands(line) - stop = None - for part in parts: - part = part.strip() - if not part: - continue - stop = self.onecmd(part) - if stop: - return stop - return None - - # Check if we're in service mode and this might be a method call - if self.current_service and line and not line.startswith("do_"): - # Check if it's a known command first - command, _, line = self.parseline(line) - if command and hasattr(self, f"do_{command}"): - # It's a known command, let parent handle it normally - return super().onecmd(line) - - # It might be a service method call, handle it directly - return self.default(line) - - # For all other cases, use normal cmd.Cmd processing - return super().onecmd(line) - - def default(self, line): - """Handle unknown commands.""" - available_services = get_device_available_services(self.client) - - # Check if it's a service call (with or without arguments) - # Extract first word to check if it's a service name - first_word = line.split()[0] if line.split() else line - - if first_word in available_services: - return self.do_enter_service(line) - - # Check if the whole line (without arguments) is a service - if line in available_services: - return self.do_enter_service(line) - - # Check if it's a method call in service context - if self.current_service: - if self.current_service and " " in line: - parts = line.split(" ", 1) - method_name = parts[0] - params_str = parts[1] if len(parts) > 1 else "" - return self.execute_service_method(method_name, params_str) - - # Method without parameters - return self.execute_service_method(line, "") - - # Try to find suggestions for partial commands - suggestions = self.get_suggestions(line) - if suggestions: - print(f"{colorize('Unknown command:', 'red')} {line}") - print(f"{colorize('Did you mean:', 'yellow')} {', '.join(suggestions)}") - else: - print(f"{colorize('Unknown command:', 'red')} {line}") - print(f"Type {colorize('help', 'white')} for available commands") - - return None - - def get_suggestions(self, partial_cmd: str) -> list[str]: - """Get command suggestions based on partial input.""" - suggestions = [] - - if self.current_service: - # In service mode - suggest methods - methods = get_service_methods(self.current_service) - # Add helper commands in service mode - methods.extend([colorize("type", "yellow"), colorize("desc", "yellow")]) - for method in methods: - if method.lower().startswith(partial_cmd.lower()): - suggestions.append(method) - else: - # In root mode - suggest device-specific services and commands - services = get_device_available_services( - self.client - ) # Use device-specific services - commands = self.base_commands - - for service in services: - if service.lower().startswith(partial_cmd.lower()): - suggestions.append(colorize(service, "cyan")) - - for cmd_base in commands: - if cmd_base.lower().startswith(partial_cmd.lower()): - suggestions.append(cmd_base) - - return suggestions[:5] # Limit to 5 suggestions - - def completenames(self, text: str, *ignored) -> list[str]: - """Tab completion for command names.""" - if self.current_service: - # Complete method names in service mode - methods = get_service_methods(self.current_service) - # Add helper commands in service mode - methods.extend([colorize("type", "yellow"), colorize("desc", "yellow")]) - completions = [ - method for method in methods if method.lower().startswith(text.lower()) - ] - else: - # Complete device-specific service names and basic commands in root mode - services = get_device_available_services( - self.client - ) # Use device-specific services - commands = self.base_commands - all_completions = services + commands - completions = [ - cmd for cmd in all_completions if cmd.lower().startswith(text.lower()) - ] - - return completions - - def cmdloop(self, intro=None): - """Override cmdloop to handle TAB completion manually.""" - if intro is not None: - self.intro = intro - if self.intro: - self.stdout.write(str(self.intro) + "\n") - - stop = None - while not stop: - if self.cmdqueue: - line = self.cmdqueue.pop(0) - else: - try: - line = input(self.prompt) - except EOFError: - line = "EOF" - except KeyboardInterrupt: - print("^C") - line = "" - - line = self.precmd(line) - stop = self.onecmd(line) - stop = self.postcmd(stop, line) - - def columnize(self, list, _displaywidth=80): # pylint: disable=redefined-builtin - """Override columnize to use grid format for TAB completion.""" - if not list: - return - - # Use our grid display for TAB completion with coloring enabled - self._display_grid(list) - - def print_topics(self, header, cmds, _cmdlen, _maxcol): - """Override print_topics to use grid format for TAB completion.""" - if not cmds: - return - - # Print without header if it's empty or whitespace - if header.strip(): - self.stdout.write(f"{header}\n") - - # Use our grid display for TAB completion with coloring enabled - self._display_grid(cmds) - - def do_capabilities(self, _line): - """Show device capabilities in service format.""" - try: - if hasattr(self.client, "capabilities") and self.client.capabilities: - result = self.client.capabilities - else: - result = self.client.devicemgmt().GetCapabilities(Category="All") - - self.stored_data["capabilities"] = result - self.stored_metadata["capabilities"] = { - "service": "devicemgmt", - "method": "GetCapabilities", - } - - # Use special formatting for capabilities - output = format_capabilities_as_services(result) - print(output) - except ONVIFOperationException as e: - if isinstance(e.original_exception, (RequestException, TransportError)): - self._handle_connection_error() - else: - print(f"{colorize('Error:', 'red')} {e}") - - def do_services(self, _line): - """Show available services in service format.""" - try: - if hasattr(self.client, "services") and self.client.services: - result = self.client.services - else: - result = self.client.devicemgmt().GetServices(IncludeCapability=False) - - self.stored_data["services"] = result - self.stored_metadata["services"] = { - "service": "devicemgmt", - "method": "GetServices", - } - - # Use special formatting for services - output = format_services_list(result) - print(output) - except ONVIFOperationException as e: - if isinstance(e.original_exception, (RequestException, TransportError)): - self._handle_connection_error() - else: - print(f"{colorize('Error:', 'red')} {e}") - - def do_enter_service(self, line): - """Enter service mode with optional arguments for services that require them.""" - # Parse service name and arguments - parts = line.split(None, 1) - service_name = parts[0] if parts else "" - args_str = parts[1] if len(parts) > 1 else "" - - available_services = get_device_available_services(self.client) - if service_name not in available_services: - print(f"{colorize('Error:', 'red')} Unknown service '{service_name}'") - colored_services = [colorize(svc, "cyan") for svc in available_services] - print(f"Available services: {', '.join(colored_services)}") - return - - try: - # Check if this service requires arguments - required_args = get_service_required_args(service_name) - - if required_args: - # Service requires arguments - if not args_str: - # No arguments provided, show help - print( - f"{colorize('Error:', 'red')} Service '{service_name}' requires arguments" - ) - print( - f"{colorize('Usage:', 'yellow')} {service_name} " - f"{' '.join([f'{arg}=' for arg in required_args])}" - ) - print( - f"{colorize('Example:', 'yellow')} " - f"{service_name} {required_args[0]}=$subscription" - ) - return - - # Parse arguments - try: - parsed_args = parse_json_params(args_str) - except (OSError, AttributeError, ValueError, TypeError) as e: - print(f"{colorize('Error parsing arguments:', 'red')} {e}") - return - - # Check if all required arguments are provided - missing_args = [arg for arg in required_args if arg not in parsed_args] - if missing_args: - print( - f"{colorize('Error:', 'red')} " - f"Missing required arguments: {', '.join(missing_args)}" - ) - print( - f"{colorize('Usage:', 'yellow')} {service_name} " - f"{' '.join([f'{arg}=' for arg in required_args])}" - ) - return - - # Resolve stored references in arguments - for arg_name in required_args: - arg_value = parsed_args[arg_name] - if isinstance(arg_value, str) and arg_value.startswith("$"): - # Resolve stored reference - reference = arg_value[1:] # Remove $ prefix - resolved_value = self._resolve_stored_reference(reference) - if resolved_value is None: - print( - f"{colorize('Error:', 'red')} " - f"Could not resolve reference '{arg_value}'" - ) - return - parsed_args[arg_name] = resolved_value - - # Call service method with arguments - service_method = getattr(self.client, service_name) - service = service_method(**parsed_args) - else: - # Service doesn't require arguments - service = getattr(self.client, service_name)() - - self.current_service = service - self.current_service_name = service_name - self.update_prompt() - - print( - f"{colorize('Entered service:', 'yellow')} {colorize(service_name, 'cyan')}" - ) - - # Show available methods - methods = get_service_methods(service) - methods_preview = ", ".join(methods[:10]) - if len(methods) > 10: - print( - f"{colorize('Available methods:', 'yellow')} {methods_preview} ... " - f"and {colorize(f'{len(methods) - 10} more.', 'yellow')}" - ) - print( - f"Type {colorize('ls', 'cyan')}/press " - f"<{colorize('TAB', 'yellow')}> to see all." - ) - else: - print(f"{colorize('Available methods:', 'yellow')} {methods_preview}") - print(f"Type {colorize('up', 'cyan')} to exit service mode.") - - except (OSError, RuntimeError, ValueError, AttributeError) as e: - print(f"{colorize('Error entering service:', 'red')} {e}") - if self.args.debug: - traceback.print_exc() - - def do_ls(self, _line): - """List available commands/services like TAB completion.""" - if self.current_service: - # In service mode - show available methods - methods = get_service_methods(self.current_service) - # Add helper commands in service mode - methods.extend([colorize("type", "yellow"), colorize("desc", "yellow")]) - if methods: - # Use the same display format as TAB completion - self._display_grid(methods) - else: - print("No methods available") - else: - # In root mode - show device-specific services and commands - services = get_device_available_services(self.client) - commands = self.base_commands - all_items = services + commands - - if all_items: - # Use the same display format as TAB completion - self._display_grid(all_items) - else: - print("No commands available") - - def do_desc(self, line): - """Describes a method from its WSDL documentation. - - Usage: desc - """ - method_name = line.strip() - - if not self.current_service: - print( - f"{colorize('Error:', 'red')} You must be in a service mode to use 'desc'." - ) - print(f"Enter a service first (e.g., {colorize('devicemgmt', 'yellow')})") - return - - if not method_name: - print("Usage: desc ") - return - - if not hasattr(self.current_service, method_name): - print( - f"{colorize('Error:', 'red')} Method '{method_name}' " - f"not found in service '{self.current_service_name}'." - ) - print(f"Use {colorize('ls', 'yellow')} to see available methods.") - return - - doc_info = get_method_documentation(self.current_service, method_name) - - if doc_info: - print( - f"\n{colorize('Description for', 'yellow')} " - f"{self.current_service_name}.{method_name}():" - ) - doc_parts = doc_info["doc"].split("\n") - for part in doc_parts: - wrapped_doc = textwrap.fill( - part, width=100, initial_indent=" ", subsequent_indent=" " - ) - print(colorize(wrapped_doc, "white")) - - if doc_info["required"]: - print(f"\n{colorize('Required Arguments:', 'green')}") - for arg in doc_info["required"]: - print(f" - {arg}") - - if doc_info["optional"]: - print(f"\n{colorize('Optional Arguments:', 'cyan')}") - for arg in doc_info["optional"]: - print(f" - {arg}") - print() # Add a newline for better spacing - else: - print( - f"No documentation or parameter info found for method '{method_name}'." - ) - - def complete_desc(self, text, _line, _begidx, _endidx): - """Autocomplete method names for desc command.""" - if not self.current_service: - return [] - methods = get_service_methods(self.current_service) - return [m for m in methods if m.lower().startswith(text.lower())] - - def do_type(self, line): - """Show input and output types for a method. - - Usage: type - """ - method_name = line.strip() - - if not self.current_service: - print( - f"{colorize('Error:', 'red')} You must be in a service mode to use 'type'." - ) - print(f"Enter a service first (e.g., {colorize('devicemgmt', 'yellow')})") - return - - if not method_name: - print("Usage: type ") - return - - if not hasattr(self.current_service, method_name): - print( - f"{colorize('Error:', 'red')} Method '{method_name}' " - f"not found in service '{self.current_service_name}'." - ) - print(f"Use {colorize('ls', 'yellow')} to see available methods.") - return - - type_info = get_operation_type_info(self.current_service, method_name) - - if type_info: - # Helper function to display parameters recursively with tree-style indentation - def display_params(params, prefix_lines=None, is_root_level=False): - """Display parameters with tree-style formatting. - - Args: - params: List of parameter dictionaries - prefix_lines: List of strings representing the tree prefix for each line - is_root_level: If True, don't show tree characters at this level - """ - if prefix_lines is None: - prefix_lines = [] - - for idx, param in enumerate(params): - is_last = idx == len(params) - 1 - - # Determine tree characters (only if not root level) - if is_root_level: - tree_branch = "" - tree_continue = "" - else: - if is_last: - tree_branch = "└── " - tree_continue = " " - else: - tree_branch = "├── " - tree_continue = "│ " - - # Determine prefix symbol: + for attributes, - for elements - prefix_symbol = "+" if param.get("is_attribute", False) else "-" - - # Format occurrences based on type - occurs = "" - if param.get("is_attribute", False): - # For attributes: only show "required" if use="required" - # Default for attributes is optional (no label needed) - if param["minOccurs"] == "1": # This means use="required" - occurs = f" - {colorize('required', 'yellow')}" - # If minOccurs == "0", it's optional (default), so no label - else: - # For elements: show unbounded, optional, or required - if param["maxOccurs"] == "unbounded": - occurs = f" - {colorize('unbounded', 'magenta')}" - elif param["minOccurs"] == "0": - occurs = f" - {colorize('optional', 'green')}" - else: - occurs = f" - {colorize('required', 'yellow')}" - - # Format type - type_str = f"[{param['type']}]" if param["type"] else "" - - # Build the current line prefix from all previous levels - current_prefix = "".join(prefix_lines) - - # Add spacing for root level items - if is_root_level: - current_prefix = " " - - # Display parameter name with tree structure, prefix, occurrence, and type - # Only add semicolon separator if there's an occurrence label - separator = ";" if occurs else "" - param_line = ( - f"{current_prefix}{tree_branch}{prefix_symbol}{colorize(param['name'], 'white')}" - f"{occurs}{separator} {colorize(type_str, 'cyan')}" - ) - print(param_line) - - # Display documentation if available - if param.get("documentation"): - doc_lines = param["documentation"].split("\n") - for doc_line in doc_lines: - if doc_line.strip(): - # Add tree continuation for documentation - if is_root_level: - doc_prefix = " " - else: - doc_prefix = current_prefix + tree_continue - wrapped_doc = textwrap.fill( - doc_line.strip(), - width=96, # Slightly less to account for tree chars - initial_indent=doc_prefix + " ", - subsequent_indent=doc_prefix + " ", - ) - print(colorize(wrapped_doc, "reset")) - - # Display children recursively with updated prefix - if param.get("children"): - if is_root_level: - # Start tree structure from children - new_prefix_lines = [" "] - else: - new_prefix_lines = prefix_lines + [tree_continue] - display_params( - param["children"], new_prefix_lines, is_root_level=False - ) - - input_msg = type_info["input"] - # Display Input - if input_msg is not None: - print(f"\n{colorize('Input:', 'cyan')}") - msg_name = f"[{input_msg['name']}]" - print(f"{colorize(msg_name, 'yellow')}") - - if input_msg["parameters"]: - display_params(input_msg["parameters"], is_root_level=True) - else: - print(f" {colorize('(no parameters)', 'reset')}") - else: - print( - f"\n{colorize('Input:', 'cyan')} {colorize('(not defined)', 'reset')}" - ) - - output_msg = type_info["output"] - # Display Output - if output_msg is not None: - print(f"\n{colorize('Output:', 'cyan')}") - msg_name = f"[{output_msg['name']}]" - print(f"{colorize(msg_name, 'yellow')}") - - if output_msg["parameters"]: - display_params(output_msg["parameters"], is_root_level=True) - else: - print(f" {colorize('(no parameters)', 'reset')}") - else: - print( - f"\n{colorize('Output:', 'cyan')} {colorize('(not defined)', 'reset')}" - ) - - print() # Add newline for spacing - else: - print( - f"{colorize('Error:', 'red')} " - f"Could not retrieve type information for '{method_name}'." - ) - - def complete_type(self, text, _line, _begidx, _endidx): - """Autocomplete method names for type command.""" - if not self.current_service: - return [] - methods = get_service_methods(self.current_service) - return [m for m in methods if m.lower().startswith(text.lower())] - - def do_cd(self, line): - """Change to service directory (alias for entering service)""" - if not line: - print("Usage: cd ") - available_services = get_device_available_services(self.client) - colored_services = [colorize(svc, "cyan") for svc in available_services] - print(f"Available services: {', '.join(colored_services)}") - return None - return self.do_enter_service(line) - - def complete_cd(self, text, _line, _begidx, _endidx): - """Autocomplete service names for cd command.""" - services = get_device_available_services(self.client) - return [s for s in services if s.lower().startswith(text.lower())] - - def do_shortcuts(self, _line): - """Show available shortcuts.""" - print(INTERACTIVE_SHORTCUTS) - - def do_caps(self, line): - """Show device capabilities (alias for 'capabilities')""" - return self.do_capabilities(line) - - def do_up(self, _line): - """Exit current service mode (go up one level)""" - if self.current_service: - print( - f"{colorize('Exited service:', 'yellow')} " - f"{colorize(f'{self.current_service_name}', 'cyan')}" - ) - self.current_service = None - self.current_service_name = None - self.update_prompt() - else: - print("Not in service mode") - - def do_exit_service(self, line): - """Exit current service mode (alias for 'up')""" - return self.do_up(line) - - def do_store(self, line): - """Store last result with a name: store """ - if not line: - print("Usage: store ") - return - # Only allow valid Python variable names - if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", line): - print( - f"{colorize('Error:', 'red')} Invalid name '{line}'. " - "Only letters, numbers, and underscores allowed, and must not start with a number." - ) - return - # Must be unique - if line in self.stored_data: - print( - f"{colorize('Error:', 'red')} Name '{line}' already exists. " - "Use a different name or remove it first." - ) - return - if hasattr(self, "_last_result"): - self.stored_data[line] = self._last_result - # Store metadata (service and method info) - metadata = {} - if self.current_service_name: - metadata["service"] = self.current_service_name - if hasattr(self, "_last_method"): - metadata["method"] = self._last_method - self.stored_metadata[line] = metadata - service = metadata.get("service", "unknown") - method = metadata.get("method", "unknown") - print( - f"{colorize('Stored result as:', 'green')} {colorize('$'+line, 'yellow')} - " - f"{colorize(service, 'cyan')}.{colorize(method, 'white')}()" - ) - else: - print(f"{colorize('Error:', 'red')} No result to store") - - def do_rm(self, line): - """Remove stored data: rm """ - if not line: - print("Usage: rm ") - return - if line in self.stored_data: - del self.stored_data[line] - if line in self.stored_metadata: - del self.stored_metadata[line] - print(f"{colorize('Removed:', 'yellow')} {colorize('$'+line, 'cyan')}") - else: - print(f"{colorize('Error:', 'red')} No stored data named '{line}'") - - def complete_rm(self, text, _line, _begidx, _endidx): - """Autocomplete stored variable names for rm command.""" - return [name for name in self.stored_data if name.startswith(text)] - - def do_show(self, line): - """Show stored data: show or show . or show - [index]""" - if not line: - print(colorize("Stored data:", "green")) - for name in self.stored_data: - # Get metadata if available - metadata = self.stored_metadata.get(name, {}) - service = metadata.get("service", "unknown") - method = metadata.get("method", "unknown") - - # Format: name - service.method() - info = ( - f"{colorize('$'+name, 'yellow')} - " - f"{colorize(service, 'cyan')}.{colorize(method, 'white')}()" - ) - print(f" {info}") - return - - # Parse accessor expression (e.g., profiles[0].token or profiles.Token) - result = self._resolve_stored_reference(line) - if result is not None: - print(str(result)) - else: - print(f"{colorize('Error:', 'red')} Cannot resolve '{line}'") - - def complete_show(self, text, line, _begidx, _endidx): - """Autocomplete stored variable names for show command.""" - # Get the part being completed - parts = line.split() - if len(parts) <= 1 or (len(parts) == 2 and not line.endswith(" ")): - # Completing the variable name - return [name for name in self.stored_data if name.startswith(text)] - return [] - - def do_clear(self, _line): - """Clear terminal screen.""" - if sys.platform == "win32": - print("\033[2J\033[3J\033[H", end="") - else: - print("\033[2J\033[H", end="") - - def do_cls(self, _line): - """Clear stored data.""" - self.stored_data.clear() - self.stored_metadata.clear() - print(f"{colorize('Cleared all stored data', 'yellow')}") - - def do_info(self, _line): - """Show connection and device information.""" - # Build connection and CLI options info - options_info = [] - - # Connection options - if hasattr(self.args, "https") and self.args.https: - options_info.append(f" Use HTTPS : {colorize('True', 'green')}") - - if hasattr(self.args, "no_verify_ssl") and self.args.no_verify_ssl: - options_info.append(f" Verify SSL : {colorize('False', 'red')}") - - if hasattr(self.args, "timeout") and self.args.timeout != 10: # 10 is default - options_info.append( - f" Timeout : {colorize(f'{self.args.timeout}s', 'yellow')}" - ) - - # CLI options - if hasattr(self.args, "debug") and self.args.debug: - options_info.append(f" Debug Mode : {colorize('True', 'green')}") - - if hasattr(self.args, "no_patch") and self.args.no_patch: - options_info.append(f" ZeepPatcher : {colorize('Disabled', 'red')}") - - if hasattr(self.args, "wsdl") and self.args.wsdl: - options_info.append( - f" Custom WSDL : {colorize(self.args.wsdl, 'yellow')}" - ) - - if ( - hasattr(self.args, "health_check_interval") - and self.args.health_check_interval != 10 - ): # 10 is default - options_info.append( - f" Health Check : every {colorize(f'{self.args.health_check_interval}s', 'yellow')}" - ) - - # Format options info - options_display = "" - if options_info: - options_display = "\n" + "\n".join(options_info) - - # Display connection and device information using stored data - print( - f"\n{colorize('[ONVIF Terminal Client]', 'yellow')}" - f"\n Connected to : " - f"{colorize(f'{self.args.host}:{self.args.port}', 'yellow')}" - f"\n Auth Method : " - f"{colorize('HTTP Digest' if self.args.digest else 'WS-UsernameToken', 'yellow')}" - f"{options_display}{self.device_info_text}" - ) - print() # Extra newline for spacing - - def execute_service_method(self, method_name, params_str): - """Execute a method on the current service.""" - if not self.current_service: - print(f"{colorize('Error:', 'red')} Not in service mode") - return - - try: - method = getattr(self.current_service, method_name) - except AttributeError: - print( - f"{colorize('Error:', 'red')} Unknown method " - f"'{method_name}' for service '{self.current_service_name}'" - ) - available_methods = get_service_methods(self.current_service) - print( - f"{colorize('Available methods:', 'yellow')} {', '.join(available_methods[:5])}" - ) - if len(available_methods) > 5: - print( - f"Type {colorize('ls', 'cyan')}/press " - f"{colorize('', 'yellow')} to see all available methods" - ) - return - - try: - # Substitute stored data references before parsing - if params_str and "$" in params_str: - params_str = self._substitute_stored_references(params_str) - - params = parse_json_params(params_str) if params_str else {} - result = method(**params) - - self._last_result = result - self._last_method = method_name # Store method name for metadata - self._last_service_name = self.current_service_name # Store service name - self._last_operation_timestamp = datetime.now() - print(str(result)) - - except (ONVIFOperationException, ValueError, TypeError) as e: - # Check if it's a connection error (must be wrapped in ONVIFOperationException) - if isinstance(e, ONVIFOperationException) and isinstance( - e.original_exception, (RequestException, TransportError) - ): - self._handle_connection_error() - else: - # For all other errors (SOAP faults, TypeErrors, etc.), - # just print and continue. - print(f"{colorize('Error:', 'red')} {e}") - if self.args.debug: - # In debug mode, show traceback for unexpected errors, but not for - # common user errors like SOAP faults or missing arguments (TypeError). - is_soap_fault = isinstance( - e, ONVIFOperationException - ) and isinstance(e.original_exception, Fault) - if not is_soap_fault and not isinstance(e, TypeError): - traceback.print_exc() - - def do_debug(self, _line): - """Show debug information.""" - if self.client.xml_plugin: - if self._last_method and self._last_operation_timestamp: - print(f"{colorize('Last Operation:', 'cyan')}") - print(f" operation: {self._last_service_name}.{self._last_method}()") - print( - f" timestamp: {self._last_operation_timestamp.strftime('%Y-%m-%d %H:%M:%S')}\n" - ) - - print(f"{colorize('Last SOAP Request:', 'cyan')}") - print(self.client.xml_plugin.last_sent_xml or "None") - print(f"\n{colorize('Last SOAP Response:', 'cyan')}") - print(self.client.xml_plugin.last_received_xml or "None") - else: - print(f"{colorize('Debug mode not enabled', 'yellow')}") - print( - f"Start CLI with {colorize('--debug', 'white')} flag to enable XML capture" - ) - - def do_exit(self, _line): - """Exit the shell.""" - self._stop_health_check.set() - print(colorize("Goodbye!", "cyan")) - return True - - def emptyline(self): - """Handle empty line.""" - - def do_help(self, arg): - """Show help information.""" - if arg: - super().do_help(arg) - else: - print(INTERACTIVE_HELP) - - def run(self): - """Run the interactive shell.""" - try: - self.cmdloop() - except KeyboardInterrupt: - print(f"\n{colorize('Goodbye!', 'cyan')}") - self._stop_health_check.set() - sys.exit(0) diff --git a/onvif/cli/interactive/__init__.py b/onvif/cli/interactive/__init__.py new file mode 100644 index 0000000..f75800e --- /dev/null +++ b/onvif/cli/interactive/__init__.py @@ -0,0 +1,5 @@ +"""ONVIF Interactive Shell.""" + +from onvif.cli.interactive.shell import InteractiveShell + +__all__ = ["InteractiveShell"] diff --git a/onvif/cli/interactive/context.py b/onvif/cli/interactive/context.py new file mode 100644 index 0000000..0c06def --- /dev/null +++ b/onvif/cli/interactive/context.py @@ -0,0 +1,55 @@ +"""ONVIF Interactive Shell context.""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + +from onvif.client import ONVIFClient + + +@dataclass +class ShellContext: # pylint: disable=too-many-instance-attributes + """Store shared state for the interactive shell.""" + + UNSET = object() + + client: ONVIFClient + args: object + + current_service: object | None = None + current_service_name: str | None = None + stored_data: dict[str, Any] = field( + default_factory=dict + ) # For storing command results + stored_metadata: dict[str, dict[str, Any]] = field( + default_factory=dict + ) # For storing metadata about stored data (service, method + + last_result: Any = field(default=UNSET, repr=False) + last_method: str | None = None + last_service_name: str | None = None + last_operation_timestamp: datetime | None = None + + base_commands: list[str] = field( + default_factory=lambda: [ + "capabilities", + "caps", + "services", + "help", + "exit", + "store", + "rm", + "show", + "cls", + "clear", + "info", + "debug", + "ls", + "cd", + "shortcuts", + "desc", + "type", + ] + ) + + device_info_text: str | None = None diff --git a/onvif/cli/interactive/information.py b/onvif/cli/interactive/information.py new file mode 100644 index 0000000..3508ca8 --- /dev/null +++ b/onvif/cli/interactive/information.py @@ -0,0 +1,175 @@ +"""Information commands implementation.""" + +import cmd +from typing import Callable + +from requests.exceptions import RequestException +from zeep.exceptions import TransportError + +from onvif.cli.helpers.messages import INTERACTIVE_HELP, INTERACTIVE_SHORTCUTS +from onvif.cli.utils import ( + colorize, + format_capabilities_as_services, + format_services_list, +) +from onvif.utils.exceptions import ONVIFOperationException + +from .context import ShellContext + + +class InformationCommands: + """Provide information commands.""" + + context: ShellContext + _handle_connection_error: Callable[..., None] + + def do_help(self, arg): + """Show help information.""" + if arg: + cmd.Cmd.do_help(self, arg) + else: + print(INTERACTIVE_HELP) + + def do_shortcuts(self, _line): + """Show available shortcuts.""" + print(INTERACTIVE_SHORTCUTS) + + def do_info(self, _line): + """Show connection and device information.""" + # Build connection and CLI options info + options_info = [] + + # Connection options + if hasattr(self.context.args, "https") and self.context.args.https: + options_info.append(f" Use HTTPS : {colorize('True', 'green')}") + + if ( + hasattr(self.context.args, "no_verify_ssl") + and self.context.args.no_verify_ssl + ): + options_info.append(f" Verify SSL : {colorize('False', 'red')}") + + if ( + hasattr(self.context.args, "timeout") and self.context.args.timeout != 10 + ): # 10 is default + options_info.append( + f" Timeout : {colorize(f'{self.context.args.timeout}s', 'yellow')}" + ) + + # CLI options + if hasattr(self.context.args, "debug") and self.context.args.debug: + options_info.append(f" Debug Mode : {colorize('True', 'green')}") + + if hasattr(self.context.args, "no_patch") and self.context.args.no_patch: + options_info.append(f" ZeepPatcher : {colorize('Disabled', 'red')}") + + if hasattr(self.context.args, "wsdl") and self.context.args.wsdl: + options_info.append( + f" Custom WSDL : {colorize(self.context.args.wsdl, 'yellow')}" + ) + + if ( + hasattr(self.context.args, "health_check_interval") + and self.context.args.health_check_interval != 10 + ): # 10 is default + options_info.append( + f" Health Check : every {colorize(f'{self.context.args.health_check_interval}s', 'yellow')}" + ) + + # Format options info + options_display = "" + if options_info: + options_display = "\n" + "\n".join(options_info) + + # Display connection and device information using stored data + print( + f"\n{colorize('[ONVIF Terminal Client]', 'yellow')}" + f"\n Connected to : " + f"{colorize(f'{self.context.args.host}:{self.context.args.port}', 'yellow')}" + f"\n Auth Method : " + f"{colorize('HTTP Digest' if self.context.args.digest else 'WS-UsernameToken', 'yellow')}" + f"{options_display}{self.context.device_info_text}" + ) + print() # Extra newline for spacing + + def do_capabilities(self, _line): + """Show device capabilities in service format.""" + try: + if ( + hasattr(self.context.client, "capabilities") + and self.context.client.capabilities + ): + result = self.context.client.capabilities + else: + result = self.context.client.devicemgmt().GetCapabilities( + Category="All" + ) + + self.context.stored_data["capabilities"] = result + self.context.stored_metadata["capabilities"] = { + "service": "devicemgmt", + "method": "GetCapabilities", + } + + # Use special formatting for capabilities + output = format_capabilities_as_services(result) + print(output) + except ONVIFOperationException as e: + if isinstance(e.original_exception, (RequestException, TransportError)): + self._handle_connection_error() + else: + print(f"{colorize('Error:', 'red')} {e}") + + def do_caps(self, line): + """Show device capabilities (alias for 'capabilities')""" + return self.do_capabilities(line) + + def do_services(self, _line): + """Show available ONVIF services in service format.""" + try: + if ( + hasattr(self.context.client, "services") + and self.context.client.services + ): + result = self.context.client.services + else: + result = self.context.client.devicemgmt().GetServices( + IncludeCapability=False + ) + + self.context.stored_data["services"] = result + self.context.stored_metadata["services"] = { + "service": "devicemgmt", + "method": "GetServices", + } + + # Use special formatting for services + output = format_services_list(result) + print(output) + except ONVIFOperationException as e: + if isinstance(e.original_exception, (RequestException, TransportError)): + self._handle_connection_error() + else: + print(f"{colorize('Error:', 'red')} {e}") + + def do_debug(self, _line): + """Show debug information.""" + if self.context.client.xml_plugin: + if self.context.last_method and self.context.last_operation_timestamp: + print(f"{colorize('Last Operation:', 'cyan')}") + print( + f" operation: {self.context.last_service_name}.{self.context.last_method}()" + ) + print( + f" timestamp: {self.context.last_operation_timestamp.strftime('%Y-%m-%d %H:%M:%S')}\n" + ) + + print(f"{colorize('Last SOAP Request:', 'cyan')}") + print(self.context.client.xml_plugin.last_sent_xml or "None") + print(f"\n{colorize('Last SOAP Response:', 'cyan')}") + print(self.context.client.xml_plugin.last_received_xml or "None") + else: + print(f"{colorize('Debug mode not enabled', 'yellow')}") + print( + f"Start CLI with {colorize('--debug', 'white')} flag to enable XML capture" + ) diff --git a/onvif/cli/interactive/navigation.py b/onvif/cli/interactive/navigation.py new file mode 100644 index 0000000..375c5c3 --- /dev/null +++ b/onvif/cli/interactive/navigation.py @@ -0,0 +1,185 @@ +"""Navigation commands implementation.""" + +import traceback +from typing import Callable + +from onvif.cli.utils import ( + colorize, + get_device_available_services, + get_service_methods, + get_service_required_args, + parse_json_params, +) + +from .context import ShellContext + + +class NavigationCommands: + """Provide navigation commands.""" + + context: ShellContext + _display_grid: Callable[..., None] + update_prompt: Callable[[], None] + + def do_ls(self, _line): + """List available commands/services like TAB completion.""" + if self.context.current_service: + # In service mode - show available methods + methods = get_service_methods(self.context.current_service) + # Add helper commands in service mode + methods.extend([colorize("type", "yellow"), colorize("desc", "yellow")]) + if methods: + # Use the same display format as TAB completion + self._display_grid(methods) + else: + print("No methods available") + else: + # In root mode - show device-specific services and commands + services = get_device_available_services(self.context.client) + commands = self.context.base_commands + all_items = services + commands + + if all_items: + # Use the same display format as TAB completion + self._display_grid(all_items) + else: + print("No commands available") + + def do_cd(self, line): + """Change to service directory (alias for entering service)""" + if not line: + print("Usage: cd ") + available_services = get_device_available_services(self.context.client) + colored_services = [colorize(svc, "cyan") for svc in available_services] + print(f"Available services: {', '.join(colored_services)}") + return None + return self.do_enter_service(line) + + def complete_cd(self, text, _line, _begidx, _endidx): + """Autocomplete service names for cd command.""" + services = get_device_available_services(self.context.client) + return [s for s in services if s.lower().startswith(text.lower())] + + def do_up(self, _line): + """Exit current service mode (go up one level)""" + if self.context.current_service: + print( + f"{colorize('Exited service:', 'yellow')} " + f"{colorize(f'{self.context.current_service_name}', 'cyan')}" + ) + self.context.current_service = None + self.context.current_service_name = None + self.update_prompt() + else: + print("Not in service mode") + + # pylint: disable=too-many-locals,too-many-branches,too-many-statements + def do_enter_service(self, line): + """Enter service mode with optional arguments for services that require them.""" + # Parse service name and arguments + parts = line.split(None, 1) + service_name = parts[0] if parts else "" + args_str = parts[1] if len(parts) > 1 else "" + + available_services = get_device_available_services(self.context.client) + if service_name not in available_services: + print(f"{colorize('Error:', 'red')} Unknown service '{service_name}'") + colored_services = [colorize(svc, "cyan") for svc in available_services] + print(f"Available services: {', '.join(colored_services)}") + return + + try: + # Check if this service requires arguments + required_args = get_service_required_args(service_name) + + if required_args: + # Service requires arguments + if not args_str: + # No arguments provided, show help + print( + f"{colorize('Error:', 'red')} Service '{service_name}' requires arguments" + ) + print( + f"{colorize('Usage:', 'yellow')} {service_name} " + f"{' '.join([f'{arg}=' for arg in required_args])}" + ) + print( + f"{colorize('Example:', 'yellow')} " + f"{service_name} {required_args[0]}=$subscription" + ) + return + + # Parse arguments + try: + parsed_args = parse_json_params(args_str) + except (OSError, AttributeError, ValueError, TypeError) as e: + print(f"{colorize('Error parsing arguments:', 'red')} {e}") + return + + # Check if all required arguments are provided + missing_args = [arg for arg in required_args if arg not in parsed_args] + if missing_args: + print( + f"{colorize('Error:', 'red')} " + f"Missing required arguments: {', '.join(missing_args)}" + ) + print( + f"{colorize('Usage:', 'yellow')} {service_name} " + f"{' '.join([f'{arg}=' for arg in required_args])}" + ) + return + + # Resolve stored references in arguments + for arg_name in required_args: + arg_value = parsed_args[arg_name] + if isinstance(arg_value, str) and arg_value.startswith("$"): + # Resolve stored reference + reference = arg_value[1:] # Remove $ prefix + resolved_value = self._resolve_stored_reference(reference) + if resolved_value is None: + print( + f"{colorize('Error:', 'red')} " + f"Could not resolve reference '{arg_value}'" + ) + return + parsed_args[arg_name] = resolved_value + + # Call service method with arguments + service_method = getattr(self.context.client, service_name) + service = service_method(**parsed_args) + else: + # Service doesn't require arguments + service = getattr(self.context.client, service_name)() + + self.context.current_service = service + self.context.current_service_name = service_name + self.update_prompt() + + print( + f"{colorize('Entered service:', 'yellow')} {colorize(service_name, 'cyan')}" + ) + + # Show available methods + methods = get_service_methods(service) + methods_preview = ", ".join(methods[:10]) + if len(methods) > 10: + print( + f"{colorize('Available methods:', 'yellow')} {methods_preview} ... " + f"and {colorize(f'{len(methods) - 10} more.', 'yellow')}" + ) + print( + f"Type {colorize('ls', 'cyan')}/press " + f"<{colorize('TAB', 'yellow')}> to see all." + ) + else: + print(f"{colorize('Available methods:', 'yellow')} {methods_preview}") + print(f"Type {colorize('up', 'cyan')} to exit service mode.") + + except (OSError, RuntimeError, ValueError, AttributeError) as e: + print(f"{colorize('Error entering service:', 'red')} {e}") + if self.context.args.debug: + traceback.print_exc() + + def do_exit_service(self, line): + """Exit current service mode (alias for 'up')""" + return self.do_up(line) diff --git a/onvif/cli/interactive/reference.py b/onvif/cli/interactive/reference.py new file mode 100644 index 0000000..e955863 --- /dev/null +++ b/onvif/cli/interactive/reference.py @@ -0,0 +1,204 @@ +"""Reference commands implementation.""" + +import json +import re + +from onvif.cli.utils import colorize + +from .context import ShellContext + + +class ReferenceCommands: + """Provide stored-reference commands.""" + + context: ShellContext + + def do_store(self, line): + """Store last result with a name: store .""" + if not line: + print("Usage: store ") + return + + if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", line): + print( + f"{colorize('Error:', 'red')} Invalid name '{line}'. " + "Only letters, numbers, and underscores allowed, " + "and must not start with a number." + ) + return + + if line in self.context.stored_data: + print( + f"{colorize('Error:', 'red')} Name '{line}' already exists. " + "Use a different name or remove it first." + ) + return + + UNSET = object() # pylint: disable=invalid-name + + if self.context.last_result is UNSET: + print(f"{colorize('Error:', 'red')} No result to store") + return + + self.context.stored_data[line] = self.context.last_result + + metadata = {} + + if self.context.last_service_name: + metadata["service"] = self.context.last_service_name + + if self.context.last_method: + metadata["method"] = self.context.last_method + + self.context.stored_metadata[line] = metadata + + service = metadata.get("service", "unknown") + method = metadata.get("method", "unknown") + + print( + f"{colorize('Stored result as:', 'green')} " + f"{colorize('$' + line, 'yellow')} - " + f"{colorize(service, 'cyan')}.{colorize(method, 'white')}()" + ) + + def do_show(self, line): + """Show stored data: show or show . or show + [index]""" + if not line: + print(colorize("Stored data:", "green")) + for name in self.context.stored_data: + # Get metadata if available + metadata = self.context.stored_metadata.get(name, {}) + service = metadata.get("service", "unknown") + method = metadata.get("method", "unknown") + + # Format: name - service.method() + info = ( + f"{colorize('$'+name, 'yellow')} - " + f"{colorize(service, 'cyan')}.{colorize(method, 'white')}()" + ) + print(f" {info}") + return + + # Parse accessor expression (e.g., profiles[0].token or profiles.Token) + result = self._resolve_stored_reference(line) + if result is not None: + print(str(result)) + else: + print(f"{colorize('Error:', 'red')} Cannot resolve '{line}'") + + def _substitute_stored_references(self, params_str: str) -> str: + """Substitute $variable references in parameter string with stored data. + + Args: + params_str: Parameter string that may contain $variable references + + Returns: + Parameter string with substituted values + """ + # Find all $variable references (e.g., $profiles[0].token) + pattern = ( + r"\$([a-zA-Z_][a-zA-Z0-9_]*(?:\[[0-9]+\])?(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)" + ) + + def replace_reference(match): + reference = match.group(1) + value = self._resolve_stored_reference(reference) + + if value is None: + print(f"{colorize('Warning:', 'yellow')} Cannot resolve ${reference}") + return match.group(0) # Keep original if not found + + # Convert value to string representation suitable for JSON + # Use json.dumps to serialize values correctly for JSON parsing. + # This preserves numbers, booleans, nulls, arrays and objects. + try: + return json.dumps(value) + except (TypeError, KeyError, ValueError, RuntimeError): + # Fall back to string-quoting for anything not serializable + return json.dumps(str(value)) + + return re.sub(pattern, replace_reference, params_str) + + def _resolve_stored_reference(self, reference: str): + """Resolve stored data reference like 'profiles[0].token' or 'profiles.Token'. + + Args: + reference: String reference like 'profiles[0]' or 'profiles.Token' + + Returns: + Resolved value or None if not found + """ + # Parse the reference - e.g., "profiles[0].token" or "services.Namespace" + # Split by dots and brackets + parts = re.split(r"\.|\[|\]", reference) + parts = [p for p in parts if p] # Remove empty strings + + if not parts: + return None + + # Start with the stored variable name + var_name = parts[0] + if var_name not in self.context.stored_data: + return None + + current = self.context.stored_data[var_name] + + # Navigate through the rest of the path + for part in parts[1:]: + try: + # Check if it's an integer index + if part.isdigit(): + index = int(part) + if isinstance(current, (list, tuple)): + current = current[index] + else: + # Try to convert to list if it's iterable + try: + current = list(current)[index] + except (TypeError, IndexError): + return None + else: + # It's an attribute name + if hasattr(current, part): + current = getattr(current, part) + elif isinstance(current, dict) and part in current: + current = current[part] + else: + return None + except (IndexError, AttributeError, KeyError, TypeError): + return None + + return current + + def complete_show(self, text, line, _begidx, _endidx): + """Autocomplete stored variable names for show command.""" + # Get the part being completed + parts = line.split() + if len(parts) <= 1 or (len(parts) == 2 and not line.endswith(" ")): + # Completing the variable name + return [name for name in self.context.stored_data if name.startswith(text)] + return [] + + def do_rm(self, line): + """Remove stored data: rm """ + if not line: + print("Usage: rm ") + return + if line in self.context.stored_data: + del self.context.stored_data[line] + if line in self.context.stored_metadata: + del self.context.stored_metadata[line] + print(f"{colorize('Removed:', 'yellow')} {colorize('$'+line, 'cyan')}") + else: + print(f"{colorize('Error:', 'red')} No stored data named '{line}'") + + def complete_rm(self, text, _line, _begidx, _endidx): + """Autocomplete stored variable names for rm command.""" + return [name for name in self.context.stored_data if name.startswith(text)] + + def do_cls(self, _line): + """Clear stored data.""" + self.context.stored_data.clear() + self.context.stored_metadata.clear() + print(f"{colorize('Cleared all stored data', 'yellow')}") diff --git a/onvif/cli/interactive/service.py b/onvif/cli/interactive/service.py new file mode 100644 index 0000000..9092de5 --- /dev/null +++ b/onvif/cli/interactive/service.py @@ -0,0 +1,325 @@ +"""Service commands implementation.""" + +import cmd +import textwrap +import traceback +from datetime import datetime +from typing import Callable + +from requests.exceptions import RequestException +from zeep.exceptions import Fault, TransportError + +from onvif.cli.helpers import ( + get_method_documentation, + get_operation_type_info, +) +from onvif.cli.utils import ( + colorize, + get_service_methods, + parse_json_params, +) +from onvif.utils.exceptions import ONVIFOperationException + +from .context import ShellContext + + +class ServiceCommands(cmd.Cmd): + """Provide ONVIF service commands.""" + + context: ShellContext + _substitute_stored_references: Callable[[str], str] + _handle_connection_error: Callable[..., None] + _split_multi_commands: Callable[[str], list] + + def execute_service_method(self, method_name, params_str): + """Execute a method on the current service.""" + if not self.context.current_service: + print(f"{colorize('Error:', 'red')} Not in service mode") + return + + try: + method = getattr(self.context.current_service, method_name) + except AttributeError: + print( + f"{colorize('Error:', 'red')} Unknown method " + f"'{method_name}' for service '{self.context.current_service_name}'" + ) + available_methods = get_service_methods(self.context.current_service) + print( + f"{colorize('Available methods:', 'yellow')} {', '.join(available_methods[:5])}" + ) + if len(available_methods) > 5: + print( + f"Type {colorize('ls', 'cyan')}/press " + f"{colorize('', 'yellow')} to see all available methods" + ) + return + + try: + # Substitute stored data references before parsing + if params_str and "$" in params_str: + params_str = self._substitute_stored_references(params_str) + + params = parse_json_params(params_str) if params_str else {} + result = method(**params) + + self.context.last_result = result + self.context.last_method = method_name # Store method name for metadata + self.context.last_service_name = ( + self.context.current_service_name + ) # Store service name + self.context.last_operation_timestamp = datetime.now() + print(str(result)) + + except (ONVIFOperationException, ValueError, TypeError) as e: + # Check if it's a connection error (must be wrapped in ONVIFOperationException) + if isinstance(e, ONVIFOperationException) and isinstance( + e.original_exception, (RequestException, TransportError) + ): + self._handle_connection_error() + else: + # For all other errors (SOAP faults, TypeErrors, etc.), + # just print and continue. + print(f"{colorize('Error:', 'red')} {e}") + if self.context.args.debug: + # In debug mode, show traceback for unexpected errors, but not for + # common user errors like SOAP faults or missing arguments (TypeError). + is_soap_fault = isinstance( + e, ONVIFOperationException + ) and isinstance(e.original_exception, Fault) + if not is_soap_fault and not isinstance(e, TypeError): + traceback.print_exc() + + def do_desc(self, line): + """Describes a method from its WSDL documentation. + + Usage: desc + """ + method_name = line.strip() + + if not self.context.current_service: + print( + f"{colorize('Error:', 'red')} You must be in a service mode to use 'desc'." + ) + print(f"Enter a service first (e.g., {colorize('devicemgmt', 'yellow')})") + return + + if not method_name: + print("Usage: desc ") + return + + if not hasattr(self.context.current_service, method_name): + print( + f"{colorize('Error:', 'red')} Method '{method_name}' " + f"not found in service '{self.context.current_service_name}'." + ) + print(f"Use {colorize('ls', 'yellow')} to see available methods.") + return + + doc_info = get_method_documentation(self.context.current_service, method_name) + + if doc_info: + print( + f"\n{colorize('Description for', 'yellow')} " + f"{self.context.current_service_name}.{method_name}():" + ) + doc_parts = doc_info["doc"].split("\n") + for part in doc_parts: + wrapped_doc = textwrap.fill( + part, width=100, initial_indent=" ", subsequent_indent=" " + ) + print(colorize(wrapped_doc, "white")) + + if doc_info["required"]: + print(f"\n{colorize('Required Arguments:', 'green')}") + for arg in doc_info["required"]: + print(f" - {arg}") + + if doc_info["optional"]: + print(f"\n{colorize('Optional Arguments:', 'cyan')}") + for arg in doc_info["optional"]: + print(f" - {arg}") + print() # Add a newline for better spacing + else: + print( + f"No documentation or parameter info found for method '{method_name}'." + ) + + def complete_desc(self, text, _line, _begidx, _endidx): + """Autocomplete method names for desc command.""" + if not self.context.current_service: + return [] + methods = get_service_methods(self.context.current_service) + return [m for m in methods if m.lower().startswith(text.lower())] + + # pylint: disable=too-many-branches,too-many-statements + def do_type(self, line): + """Show input and output types for a method. + + Usage: type + """ + method_name = line.strip() + + if not self.context.current_service: + print( + f"{colorize('Error:', 'red')} You must be in a service mode to use 'type'." + ) + print(f"Enter a service first (e.g., {colorize('devicemgmt', 'yellow')})") + return + + if not method_name: + print("Usage: type ") + return + + if not hasattr(self.context.current_service, method_name): + print( + f"{colorize('Error:', 'red')} Method '{method_name}' " + f"not found in service '{self.context.current_service_name}'." + ) + print(f"Use {colorize('ls', 'yellow')} to see available methods.") + return + + type_info = get_operation_type_info(self.context.current_service, method_name) + + if type_info: + # Helper function to display parameters recursively with tree-style indentation + # pylint: disable=too-many-locals,too-many-branches + def display_params(params, prefix_lines=None, is_root_level=False): + """Display parameters with tree-style formatting. + + Args: + params: List of parameter dictionaries + prefix_lines: List of strings representing the tree prefix for each line + is_root_level: If True, don't show tree characters at this level + """ + if prefix_lines is None: + prefix_lines = [] + + for idx, param in enumerate(params): + is_last = idx == len(params) - 1 + + # Determine tree characters (only if not root level) + if is_root_level: + tree_branch = "" + tree_continue = "" + else: + if is_last: + tree_branch = "└── " + tree_continue = " " + else: + tree_branch = "├── " + tree_continue = "│ " + + # Determine prefix symbol: + for attributes, - for elements + prefix_symbol = "+" if param.get("is_attribute", False) else "-" + + # Format occurrences based on type + occurs = "" + if param.get("is_attribute", False): + # For attributes: only show "required" if use="required" + # Default for attributes is optional (no label needed) + if param["minOccurs"] == "1": # This means use="required" + occurs = f" - {colorize('required', 'yellow')}" + # If minOccurs == "0", it's optional (default), so no label + else: + # For elements: show unbounded, optional, or required + if param["maxOccurs"] == "unbounded": + occurs = f" - {colorize('unbounded', 'magenta')}" + elif param["minOccurs"] == "0": + occurs = f" - {colorize('optional', 'green')}" + else: + occurs = f" - {colorize('required', 'yellow')}" + + # Format type + type_str = f"[{param['type']}]" if param["type"] else "" + + # Build the current line prefix from all previous levels + current_prefix = "".join(prefix_lines) + + # Add spacing for root level items + if is_root_level: + current_prefix = " " + + # Display parameter name with tree structure, prefix, occurrence, and type + # Only add semicolon separator if there's an occurrence label + separator = ";" if occurs else "" + param_line = ( + f"{current_prefix}{tree_branch}{prefix_symbol}{colorize(param['name'], 'white')}" + f"{occurs}{separator} {colorize(type_str, 'cyan')}" + ) + print(param_line) + + # Display documentation if available + if param.get("documentation"): + doc_lines = param["documentation"].split("\n") + for doc_line in doc_lines: + if doc_line.strip(): + # Add tree continuation for documentation + if is_root_level: + doc_prefix = " " + else: + doc_prefix = current_prefix + tree_continue + wrapped_doc = textwrap.fill( + doc_line.strip(), + width=96, # Slightly less to account for tree chars + initial_indent=doc_prefix + " ", + subsequent_indent=doc_prefix + " ", + ) + print(colorize(wrapped_doc, "reset")) + + # Display children recursively with updated prefix + if param.get("children"): + if is_root_level: + # Start tree structure from children + new_prefix_lines = [" "] + else: + new_prefix_lines = prefix_lines + [tree_continue] + display_params( + param["children"], new_prefix_lines, is_root_level=False + ) + + input_msg = type_info["input"] + # Display Input + if input_msg is not None: + print(f"\n{colorize('Input:', 'cyan')}") + msg_name = f"[{input_msg['name']}]" + print(f"{colorize(msg_name, 'yellow')}") + + if input_msg["parameters"]: + display_params(input_msg["parameters"], is_root_level=True) + else: + print(f" {colorize('(no parameters)', 'reset')}") + else: + print( + f"\n{colorize('Input:', 'cyan')} {colorize('(not defined)', 'reset')}" + ) + + output_msg = type_info["output"] + # Display Output + if output_msg is not None: + print(f"\n{colorize('Output:', 'cyan')}") + msg_name = f"[{output_msg['name']}]" + print(f"{colorize(msg_name, 'yellow')}") + + if output_msg["parameters"]: + display_params(output_msg["parameters"], is_root_level=True) + else: + print(f" {colorize('(no parameters)', 'reset')}") + else: + print( + f"\n{colorize('Output:', 'cyan')} {colorize('(not defined)', 'reset')}" + ) + + print() # Add newline for spacing + else: + print( + f"{colorize('Error:', 'red')} " + f"Could not retrieve type information for '{method_name}'." + ) + + def complete_type(self, text, _line, _begidx, _endidx): + """Autocomplete method names for type command.""" + if not self.context.current_service: + return [] + methods = get_service_methods(self.context.current_service) + return [m for m in methods if m.lower().startswith(text.lower())] diff --git a/onvif/cli/interactive/shell.py b/onvif/cli/interactive/shell.py new file mode 100644 index 0000000..3896b50 --- /dev/null +++ b/onvif/cli/interactive/shell.py @@ -0,0 +1,246 @@ +"""ONVIF Interactive Shelll implementation.""" + +import cmd +import sys + +from requests.exceptions import RequestException +from zeep.exceptions import TransportError + +from onvif.cli.helpers.messages import print_interactive_intro +from onvif.cli.utils import colorize, get_device_available_services +from onvif.client import ONVIFClient +from onvif.utils.exceptions import ONVIFOperationException + +from .context import ShellContext +from .information import InformationCommands +from .navigation import NavigationCommands +from .reference import ReferenceCommands +from .service import ServiceCommands +from .utils import ShellUtilities + + +class InteractiveShell( + ShellUtilities, + ServiceCommands, + InformationCommands, + ReferenceCommands, + NavigationCommands, + cmd.Cmd, +): + """Interactive ONVIF CLI shell.""" + + def __init__(self, client: ONVIFClient, args): + cmd.Cmd.__init__(self) + + self.context = ShellContext(client=client, args=args) + + self._initialize_health_check() + + # Enable tab completion + # for Windows will use pyreadline3 to install readline module + # pylint: disable=import-outside-toplevel + try: + import readline + + # Set completer to this instance + readline.set_completer(self.complete) # type: ignore[attr-defined, arg-type] + readline.set_completer_delims(" \t\n`!@#$%^&*()=+[{]}\\|;:'\",<>?") # type: ignore[attr-defined] + + # Enable tab completion, making it compatible with both GNU readline and libedit + if ( + hasattr(readline, "__doc__") + and readline.__doc__ + and "libedit" in readline.__doc__ + ): + readline.parse_and_bind("bind ^I rl_complete") # type: ignore[attr-defined] + else: + readline.parse_and_bind("tab: complete") # type: ignore[attr-defined] + except ImportError: + pass # readline not available on some systems + + # Set prompt + self.update_prompt() + + manufacturer = "Unknown" + model = "Unknown" + firmware = "Unknown" + serial = "Unknown" + hardware_id = "Unknown" + onvif_version = "Unknown" + + try: + # Get device information and store it + self.device_data = self.context.client.devicemgmt().GetDeviceInformation() + manufacturer = getattr(self.device_data, "Manufacturer", "Unknown") + model = getattr(self.device_data, "Model", "Unknown") + firmware = getattr(self.device_data, "FirmwareVersion", "Unknown") + serial = getattr(self.device_data, "SerialNumber", "Unknown") + hardware_id = getattr(self.device_data, "HardwareId", "Unknown") + + # Get latest supported ONVIF version + supported_versions = self.context.client.devicemgmt().GetCapabilities( + Category="All" + )["Device"]["System"]["SupportedVersions"] + if supported_versions: + latest_version = max( + supported_versions, + key=lambda version: ( + getattr(version, "Major", 0), + getattr(version, "Minor", 0), + ), + ) + + major = getattr(latest_version, "Major", "") + minor = getattr(latest_version, "Minor", "") + + if major != "": + onvif_version = ( + f"{major}.{minor} {colorize('[Latest]', 'green')}" + if minor != "" + else str(major) + ) + except (ONVIFOperationException, KeyError, AttributeError) as e: + if isinstance(e, ONVIFOperationException): + if isinstance(e.original_exception, (RequestException, TransportError)): + self._handle_connection_error() + else: + # For other errors (e.g., GetCapabilities not supported), we can still proceed + # with basic device info if GetDeviceInformation succeeded. + pass + + self.context.device_info_text = ( + f"\n\n{colorize('[Device Info]', 'cyan')}\n" + f" Manufacturer : {colorize(manufacturer, 'white')}\n" + f" Model : {colorize(model, 'white')}\n" + f" Firmware : {colorize(firmware, 'white')}\n" + f" Serial : {colorize(serial, 'white')}\n" + f" HardwareId : {colorize(hardware_id, 'white')}\n" + f" ONVIF Version : {colorize(onvif_version, 'white')}" + ) + + self.intro = print_interactive_intro(args, self.context.device_info_text) + + # Start background health check after successful initialization + self.health_check_thread.start() + + def cmdloop(self, intro=None): + """Override cmdloop to handle TAB completion manually.""" + if intro is not None: + self.intro = intro + if self.intro: + self.stdout.write(str(self.intro) + "\n") + + stop = None + while not stop: + if self.cmdqueue: + line = self.cmdqueue.pop(0) + else: + try: + line = input(self.prompt) + except EOFError: + line = "EOF" + except KeyboardInterrupt: + print("^C") + line = "" + + line = self.precmd(line) + stop = self.onecmd(line) + stop = self.postcmd(stop, line) + + def onecmd(self, line): + """Override onecmd to handle service method calls without showing cmd.Cmd + traceback.""" + line = line.strip() + if not line: + return self.emptyline() + + # Support chaining multiple commands with && (only at top-level, + # not inside quotes/brackets). Example: + # media && GetProfiles && store profiles + if "&&" in line: + parts = self._split_multi_commands(line) + stop = None + for part in parts: + part = part.strip() + if not part: + continue + stop = self.onecmd(part) + if stop: + return stop + return None + + # Check if we're in service mode and this might be a method call + if self.context.current_service and line and not line.startswith("do_"): + # Check if it's a known command first + command, _, line = self.parseline(line) + if command and hasattr(self, f"do_{command}"): + # It's a known command, let parent handle it normally + return super().onecmd(line) + + # It might be a service method call, handle it directly + return self.default(line) + + # For all other cases, use normal cmd.Cmd processing + return super().onecmd(line) + + def default(self, line): + """Handle unknown commands.""" + available_services = get_device_available_services(self.context.client) + + # Check if it's a service call (with or without arguments) + # Extract first word to check if it's a service name + first_word = line.split()[0] if line.split() else line + + if first_word in available_services: + return self.do_enter_service(line) + + # Check if the whole line (without arguments) is a service + if line in available_services: + return self.do_enter_service(line) + + # Check if it's a method call in service context + if self.context.current_service: + if self.context.current_service and " " in line: + parts = line.split(" ", 1) + method_name = parts[0] + params_str = parts[1] if len(parts) > 1 else "" + return self.execute_service_method(method_name, params_str) + + # Method without parameters + return self.execute_service_method(line, "") + + # Try to find suggestions for partial commands + suggestions = self.get_suggestions(line) + if suggestions: + print(f"{colorize('Unknown command:', 'red')} {line}") + print(f"{colorize('Did you mean:', 'yellow')} {', '.join(suggestions)}") + else: + print(f"{colorize('Unknown command:', 'red')} {line}") + print(f"Type {colorize('help', 'white')} for available commands") + + return None + + def emptyline(self): + """Handle empty line.""" + + def do_clear(self, _line): + """Clear terminal screen.""" + if sys.platform == "win32": + print("\033[2J\033[3J\033[H", end="") + else: + print("\033[2J\033[H", end="") + + def do_exit(self, _line): + """Exit the shell.""" + self.stop_health_check.set() + print(colorize("Goodbye!", "cyan")) + return True + + def run(self): + """Run the interactive shell.""" + try: + self.cmdloop() + except KeyboardInterrupt: + print(f"\n{colorize('Goodbye!', 'cyan')}") + self.stop_health_check.set() + sys.exit(0) diff --git a/onvif/cli/interactive/utils.py b/onvif/cli/interactive/utils.py new file mode 100644 index 0000000..031cb92 --- /dev/null +++ b/onvif/cli/interactive/utils.py @@ -0,0 +1,313 @@ +"""ONVIF Interactive Shell utilities.""" + +import os +import shutil +import socket +import ssl +import sys +import threading +import traceback + +from onvif.cli.utils import ( + colorize, + get_device_available_services, + get_service_methods, +) + +from .context import ShellContext + + +class ShellUtilities: + """Provide interactive shell utilities.""" + + context: ShellContext + prompt: str + stop_health_check: threading.Event + health_check_thread: threading.Thread + + def _initialize_health_check(self): + """Initialize the background health check.""" + self.stop_health_check = threading.Event() + self.health_check_thread = threading.Thread( + target=self._periodic_health_check, daemon=True + ) + + def update_prompt(self): + """Update command prompt based on current context.""" + if self.context.current_service_name: + self.prompt = ( + f"{self.context.args.username}@{self.context.args.host}:{self.context.args.port}" + f"/{self.context.current_service_name} > " + ) + else: + self.prompt = f"{self.context.args.username}@{self.context.args.host}:{self.context.args.port} > " + + # pylint: disable=too-many-locals + def _display_grid(self, items): + """Display items in grid format matching TAB completion (vertical layout)""" + if not items: + return + + # Get device services for coloring (only in root mode) + services = ( + get_device_available_services(self.context.client) + if not self.context.current_service + else [] + ) + + # Calculate terminal width + try: + term_width = shutil.get_terminal_size().columns + except (KeyError, ValueError, RuntimeError): + term_width = 80 + + # Find the longest item length + longest_length = max(len(item) for item in items) + + # Column width calculation: use same algorithm as cmd.Cmd.columnize() + # Python's columnize uses: maxlen + 2 if maxlen > 0 else 2 + col_width = longest_length + 2 if longest_length > 0 else 2 + + # Calculate number of columns that fit + num_cols = max(1, term_width // col_width) + + # Calculate number of rows needed + num_rows = (len(items) + num_cols - 1) // num_cols + + # Display items in VERTICAL layout (column by column, like TAB completion) + for row_idx in range(num_rows): + row_items = [] + for col_idx in range(num_cols): + # Calculate index in vertical order + item_idx = col_idx * num_rows + row_idx + + if item_idx < len(items): + item = items[item_idx] + + # Apply coloring + if item in services: + colored_item = colorize(item, "cyan") + else: + colored_item = item + + # Calculate padding: align to column width + # Use ljust-style padding for consistency with cmd.Cmd + padding = col_width - len(item) + formatted_item = colored_item + " " * padding + row_items.append(formatted_item) + + if row_items: + # Join and rstrip to remove trailing spaces + line = "".join(row_items).rstrip() + print(line) + + def columnize(self, list, _displaywidth=80): # pylint: disable=redefined-builtin + """Override columnize to use grid format for TAB completion.""" + if not list: + return + + # Use our grid display for TAB completion with coloring enabled + self._display_grid(list) + + def print_topics(self, header, cmds, _cmdlen, _maxcol): + """Override print_topics to use grid format for TAB completion.""" + if not cmds: + return + + # Print without header if it's empty or whitespace + if header.strip(): + # pylint: disable=no-member + self.stdout.write(f"{header}\n") + + # Use our grid display for TAB completion with coloring enabled + self._display_grid(cmds) + + def get_suggestions(self, partial_cmd: str) -> list[str]: + """Get command suggestions based on partial input.""" + suggestions = [] + + if self.context.current_service: + # In service mode - suggest methods + methods = get_service_methods(self.context.current_service) + # Add helper commands in service mode + methods.extend([colorize("type", "yellow"), colorize("desc", "yellow")]) + for method in methods: + if method.lower().startswith(partial_cmd.lower()): + suggestions.append(method) + else: + # In root mode - suggest device-specific services and commands + services = get_device_available_services( + self.context.client + ) # Use device-specific services + commands = self.context.base_commands + + for service in services: + if service.lower().startswith(partial_cmd.lower()): + suggestions.append(colorize(service, "cyan")) + + for cmd_base in commands: + if cmd_base.lower().startswith(partial_cmd.lower()): + suggestions.append(cmd_base) + + return suggestions[:5] # Limit to 5 suggestions + + def completenames(self, text: str, *_ignored) -> list[str]: + """Tab completion for command names.""" + if self.context.current_service: + # Complete method names in service mode + methods = get_service_methods(self.context.current_service) + # Add helper commands in service mode + methods.extend([colorize("type", "yellow"), colorize("desc", "yellow")]) + completions = [ + method for method in methods if method.lower().startswith(text.lower()) + ] + else: + # Complete device-specific service names and basic commands in root mode + services = get_device_available_services( + self.context.client + ) # Use device-specific services + commands = self.context.base_commands + all_completions = services + commands + completions = [ + cmd for cmd in all_completions if cmd.lower().startswith(text.lower()) + ] + + return completions + + def _periodic_health_check(self): + """Periodically checks device connection using TCP or TLS depending on mode.""" + # Get health check interval from args, default to 10 seconds + health_check_interval = getattr(self.context.args, "health_check_interval", 10) + + # Wait before first check to allow intro to finish + self.stop_health_check.wait(health_check_interval) + + while not self.stop_health_check.is_set(): + sock = None + try: + # For HTTPS, use ssl.create_connection for proper TLS handling + if getattr(self.context.args, "https", False): + context = ssl.create_default_context() + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + + # Use ssl.create_connection instead of wrapping existing socket + sock = context.wrap_socket( + socket.socket(socket.AF_INET, socket.SOCK_STREAM), + server_hostname=self.context.args.host, + ) + sock.settimeout(5.0) + sock.connect((self.context.args.host, self.context.args.port)) + else: + # For HTTP, simple TCP connection check + sock = socket.create_connection( + (self.context.args.host, self.context.args.port), timeout=5.0 + ) + + # Connection successful + except ( + socket.timeout, + ConnectionRefusedError, + socket.gaierror, + ssl.SSLError, + ssl.SSLEOFError, + OSError, + ) as e: + # Connection failed, trigger exit + print( + f"\n{colorize('Connection to device lost.', 'red')}", + file=sys.stderr, + ) + print( + f"{colorize('Error:', 'red')} Health check failed: {e}", + file=sys.stderr, + ) + print( + colorize("Exiting ONVIF interactive shell...", "yellow"), + file=sys.stderr, + ) + # Forcibly exit the entire process. This is necessary to interrupt + # the blocking input() call in the main thread. + os._exit(1) + finally: + # Ensure the socket is always closed + if sock: + sock.close() + + # Wait before next check or stop signal + self.stop_health_check.wait(health_check_interval) + + def _handle_connection_error(self): + """Handle connection errors by notifying the user and exiting.""" + print(f"\n{colorize('Connection to device lost.', 'red')}", file=sys.stderr) + # print(f"{colorize('Error:', 'red')} {e}", file=sys.stderr) + if self.context.args.debug: + traceback.print_exc() + print(colorize("Exiting ONVIF interactive shell...", "yellow"), file=sys.stderr) + print(colorize("Goodbye!", "cyan"), file=sys.stderr) + sys.exit(1) + + def _split_multi_commands(self, s: str) -> list: + """Split a command string by top-level '&&' separators while ignoring + occurrences inside quotes or bracketed structures. + + Returns a list of command strings (trimmed). + """ + parts = [] + buf = [] + i = 0 + length = len(s) + depth = 0 + in_single = False + in_double = False + while i < length: + ch = s[i] + # Toggle quote states + if ch == "'" and not in_double: + in_single = not in_single + buf.append(ch) + i += 1 + continue + if ch == '"' and not in_single: + in_double = not in_double + buf.append(ch) + i += 1 + continue + + # Track bracket depth to avoid splitting inside {...} or [...] + if not in_single and not in_double: + if ch in "{[(": + depth += 1 + elif ch in "}])": + depth = max(0, depth - 1) + + # Detect top-level && + # pylint: disable=too-many-boolean-expressions + if ( + not in_single + and not in_double + and depth == 0 + and ch == "&" + and i + 1 < length + and s[i + 1] == "&" + ): + # finish current buffer + token = "".join(buf).strip() + parts.append(token) + buf = [] + i += 2 + # skip optional spaces after && + while i < length and s[i].isspace(): + i += 1 + continue + + buf.append(ch) + i += 1 + + if buf: + token = "".join(buf).strip() + if token: + parts.append(token) + + return parts diff --git a/tests/cli/test_cli_interactive.py b/tests/cli/test_cli_interactive.py deleted file mode 100644 index df9a094..0000000 --- a/tests/cli/test_cli_interactive.py +++ /dev/null @@ -1,399 +0,0 @@ -# pylint: disable=protected-access -"""Tests for the interactive ONVIF CLI shell.""" - -from types import SimpleNamespace -from unittest.mock import Mock - -import pytest - -from onvif.cli.interactive import InteractiveShell - - -def make_shell(): - """Create a minimally initialized interactive shell for unit tests.""" - shell = object.__new__(InteractiveShell) - - shell.client = Mock() - shell.args = SimpleNamespace( - username="admin", - host="camera", - port=80, - debug=False, - ) - shell.current_service = None - shell.current_service_name = None - shell.stored_data = {} - shell.stored_metadata = {} - shell._last_result = None - shell._last_method = None - shell._last_service_name = None - shell._last_operation_timestamp = None - shell.base_commands = [ - "capabilities", - "caps", - "services", - "help", - "exit", - "store", - "rm", - "show", - "cls", - "clear", - "info", - "debug", - "ls", - "cd", - "shortcuts", - "desc", - "type", - ] - shell.prompt = "" - shell.device_info_text = "" - shell.intro = "" - shell._stop_health_check = Mock() - return shell - - -class TestSplitMultiCommands: - """Test splitting chained interactive commands.""" - - def test_splits_top_level_commands(self): - """Test that top-level commands are split on double ampersands.""" - shell = make_shell() - - result = shell._split_multi_commands("media && GetProfiles && ls") - - assert result == ["media", "GetProfiles", "ls"] - - def test_preserves_quoted_and_nested_commands(self): - """Test that separators inside quotes and brackets are preserved.""" - shell = make_shell() - - result = shell._split_multi_commands( - 'command {"value": "a && b"} && other [1, "x && y"]' - ) - - assert result == [ - 'command {"value": "a && b"}', - 'other [1, "x && y"]', - ] - - -class TestResolveStoredReference: - """Test resolving values from stored command results.""" - - @pytest.mark.parametrize( - ("reference", "expected"), - [ - ("value", 42), - ("profile.name", "camera"), - ("profiles[0]", "first"), - ("profiles[1].token", "second-token"), - ], - ) - def test_resolves_supported_references(self, reference, expected): - """Test resolving dictionary, attribute, and list references.""" - shell = make_shell() - shell.stored_data = { - "value": 42, - "profile": {"name": "camera"}, - "profiles": [ - "first", - {"token": "second-token"}, - ], - } - - assert shell._resolve_stored_reference(reference) == expected - - def test_returns_none_for_unknown_reference(self): - """Test that an unknown stored reference returns None.""" - shell = make_shell() - - assert shell._resolve_stored_reference("missing.value") is None - - -class TestSubstituteStoredReferences: - """Test substituting stored values into command parameters.""" - - def test_substitutes_stored_values(self): - """Test that stored references are replaced with JSON values.""" - shell = make_shell() - shell.stored_data = {"token": "abc123", "count": 5} - - result = shell._substitute_stored_references( - '{"Token": $token, "Count": $count}' - ) - - assert result == '{"Token": "abc123", "Count": 5}' - - def test_keeps_unresolved_references(self, capsys): - """Test that unresolved references remain unchanged and emit a warning.""" - shell = make_shell() - - result = shell._substitute_stored_references('{"Token": $missing}') - - assert result == '{"Token": $missing}' - - assert "Cannot resolve $missing" in capsys.readouterr().out - - -class TestUpdatePrompt: - """Test interactive shell prompt generation.""" - - def test_builds_root_prompt(self): - """Test that the root prompt contains connection information.""" - shell = make_shell() - - shell.update_prompt() - - assert shell.prompt == "admin@camera:80 > " - - def test_builds_service_prompt(self): - """Test that the service prompt includes the current service name.""" - shell = make_shell() - shell.current_service_name = "media" - - shell.update_prompt() - - assert shell.prompt == "admin@camera:80/media > " - - -class TestGetSuggestions: - """Test command and service suggestions.""" - - def test_suggests_root_commands(self, monkeypatch): - """Test that matching root commands are returned.""" - shell = make_shell() - - monkeypatch.setattr( - "onvif.cli.interactive.get_device_available_services", - lambda _client: ["media", "events"], - ) - - result = shell.get_suggestions("me") - - assert any("media" in suggestion for suggestion in result) - - def test_limits_suggestions(self, monkeypatch): - """Test that suggestions are limited to five results.""" - shell = make_shell() - - monkeypatch.setattr( - "onvif.cli.interactive.get_device_available_services", - lambda _client: [f"service{i}" for i in range(10)], - ) - - result = shell.get_suggestions("service") - - assert len(result) == 5 - - -class TestCompleteNames: - """Test command name completion.""" - - def test_completes_root_commands_and_services(self, monkeypatch): - """Test completion of root-level commands and services.""" - shell = make_shell() - - monkeypatch.setattr( - "onvif.cli.interactive.get_device_available_services", - lambda _client: ["media", "events"], - ) - - result = shell.completenames("me") - - assert "media" in result - - def test_completes_service_methods(self, monkeypatch): - """Test completion of methods while inside a service.""" - shell = make_shell() - shell.current_service = Mock() - - monkeypatch.setattr( - "onvif.cli.interactive.get_service_methods", - lambda _service: ["GetProfiles", "GetStreamUri"], - ) - - result = shell.completenames("getp") - - assert result == ["GetProfiles"] - - -class TestEnterService: - """Test entering an ONVIF service.""" - - def test_enters_service_without_required_arguments(self, monkeypatch): - """Test entering a service that does not require arguments.""" - shell = make_shell() - service = Mock() - - monkeypatch.setattr( - "onvif.cli.interactive.get_device_available_services", - lambda _client: ["media"], - ) - monkeypatch.setattr( - "onvif.cli.interactive.get_service_required_args", - lambda _service: [], - ) - monkeypatch.setattr( - "onvif.cli.interactive.get_service_methods", - lambda _service: ["GetProfiles"], - ) - shell.client.media.return_value = service - - shell.do_enter_service("media") - - assert shell.current_service is service - assert shell.current_service_name == "media" - assert shell.prompt.endswith("/media > ") - - def test_rejects_unknown_service(self, monkeypatch, capsys): - """Test that an unavailable service is rejected.""" - shell = make_shell() - - monkeypatch.setattr( - "onvif.cli.interactive.get_device_available_services", - lambda _client: ["media"], - ) - - shell.do_enter_service("events") - - assert "Unknown service" in capsys.readouterr().out - assert shell.current_service is None - - -class TestExecuteServiceMethod: - """Test executing methods on the active ONVIF service.""" - - def test_rejects_execution_without_service(self, capsys): - """Test that method execution fails outside service mode.""" - shell = make_shell() - - shell.execute_service_method("GetProfiles", "") - - assert "Not in service mode" in capsys.readouterr().out - - def test_executes_method_and_stores_last_result(self, capsys): - """Test executing a service method and recording its result.""" - shell = make_shell() - shell.current_service = Mock() - shell.current_service_name = "media" - shell.current_service.GetProfiles.return_value = {"Profiles": []} - - shell.execute_service_method("GetProfiles", "") - - assert shell._last_result == {"Profiles": []} - assert shell._last_method == "GetProfiles" - assert shell._last_service_name == "media" - assert "{'Profiles': []}" in capsys.readouterr().out - - def test_passes_json_parameters_to_method(self): - """Test that parsed command parameters are passed to the service method.""" - shell = make_shell() - shell.current_service = Mock() - shell.current_service.GetProfile.return_value = "profile" - - shell.execute_service_method( - "GetProfile", - '{"ProfileToken": "token-1"}', - ) - - shell.current_service.GetProfile.assert_called_once_with(ProfileToken="token-1") - - -class TestStoreData: - """Test storing command results for later reuse.""" - - def test_stores_last_result(self): - """Test storing the most recent command result.""" - shell = make_shell() - shell._last_result = {"value": 123} - shell.current_service_name = "media" - shell._last_method = "GetProfiles" - - shell.do_store("profiles") - - assert shell.stored_data["profiles"] == {"value": 123} - assert shell.stored_metadata["profiles"] == { - "service": "media", - "method": "GetProfiles", - } - - def test_rejects_invalid_name(self, capsys): - """Test that invalid stored variable names are rejected.""" - shell = make_shell() - shell._last_result = {"value": 123} - - shell.do_store("123profiles") - - assert "Invalid name" in capsys.readouterr().out - assert not shell.stored_data - - -# pylint: disable=too-few-public-methods -class TestRemoveData: - """Test removing stored command results.""" - - def test_removes_stored_data_and_metadata(self): - """Test that stored data and its metadata are removed together.""" - shell = make_shell() - shell.stored_data["profiles"] = {"value": 1} - shell.stored_metadata["profiles"] = {"service": "media"} - - shell.do_rm("profiles") - - assert "profiles" not in shell.stored_data - assert "profiles" not in shell.stored_metadata - - -class TestServiceMode: - """Test entering and leaving service mode.""" - - def test_exits_service_mode(self): - """Test that leaving service mode resets service state.""" - shell = make_shell() - shell.current_service = Mock() - shell.current_service_name = "media" - - shell.do_up("") - - assert shell.current_service is None - assert shell.current_service_name is None - assert shell.prompt == "admin@camera:80 > " - - def test_reports_when_not_in_service_mode(self, capsys): - """Test the message shown when leaving service mode from root mode.""" - shell = make_shell() - - shell.do_up("") - - assert "Not in service mode" in capsys.readouterr().out - - -# pylint: disable=too-few-public-methods -class TestClearStoredData: - """Test clearing all stored command results.""" - - def test_clears_data_and_metadata(self): - """Test that all stored data and metadata are removed.""" - shell = make_shell() - shell.stored_data["profiles"] = {} - shell.stored_metadata["profiles"] = {} - - shell.do_cls("") - - assert not shell.stored_data - assert not shell.stored_metadata - - -# pylint: disable=too-few-public-methods -class TestExit: - """Test interactive shell shutdown.""" - - def test_stops_health_check_and_returns_true(self, capsys): - """Test that exiting signals the health-check thread and stops the shell.""" - shell = make_shell() - - assert shell.do_exit("") is True - shell._stop_health_check.set.assert_called_once() - assert "Goodbye!" in capsys.readouterr().out From 4adee04fb2e0526f8c1df348d8d4878c650d7038 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:19:42 +0700 Subject: [PATCH 42/43] test(cli): Complete tests for interactive shell modules (#149) --- tests/cli/conftest.py | 10 + tests/cli/test_cli_interactive_information.py | 602 ++++++++++++ tests/cli/test_cli_interactive_navigation.py | 618 ++++++++++++ tests/cli/test_cli_interactive_reference.py | 924 ++++++++++++++++++ tests/cli/test_cli_interactive_service.py | 801 +++++++++++++++ tests/cli/test_cli_interactive_shell.py | 574 +++++++++++ tests/cli/test_cli_interactive_utils.py | 867 ++++++++++++++++ 7 files changed, 4396 insertions(+) create mode 100644 tests/cli/conftest.py create mode 100644 tests/cli/test_cli_interactive_information.py create mode 100644 tests/cli/test_cli_interactive_navigation.py create mode 100644 tests/cli/test_cli_interactive_reference.py create mode 100644 tests/cli/test_cli_interactive_service.py create mode 100644 tests/cli/test_cli_interactive_shell.py create mode 100644 tests/cli/test_cli_interactive_utils.py diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py new file mode 100644 index 0000000..4eb51cc --- /dev/null +++ b/tests/cli/conftest.py @@ -0,0 +1,10 @@ +"""Shared CLI test fixtures and helpers.""" + +import re + +ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + + +def strip_ansi(text): + """Remove ANSI escape sequences from terminal output.""" + return ANSI_ESCAPE.sub("", text) diff --git a/tests/cli/test_cli_interactive_information.py b/tests/cli/test_cli_interactive_information.py new file mode 100644 index 0000000..dad42b3 --- /dev/null +++ b/tests/cli/test_cli_interactive_information.py @@ -0,0 +1,602 @@ +"""Tests for ONVIF interactive shell information commands.""" + +from datetime import datetime +from unittest.mock import MagicMock, patch + +from requests.exceptions import RequestException +from zeep.exceptions import TransportError + +from onvif.cli.interactive.information import InformationCommands +from onvif.utils.exceptions import ONVIFOperationException + + +def create_args(**overrides): + """Create mocked CLI arguments.""" + args = MagicMock() + + args.host = overrides.get("host", "192.168.1.100") + args.port = overrides.get("port", 80) + args.username = overrides.get("username", "admin") + args.digest = overrides.get("digest", False) + args.https = overrides.get("https", False) + args.no_verify_ssl = overrides.get("no_verify_ssl", False) + args.timeout = overrides.get("timeout", 10) + args.debug = overrides.get("debug", False) + args.no_patch = overrides.get("no_patch", False) + args.wsdl = overrides.get("wsdl", None) + args.health_check_interval = overrides.get("health_check_interval", 10) + + return args + + +def create_context(**overrides): + """Create a mocked shell context.""" + context = MagicMock() + + context.args = create_args(**overrides) + context.client = overrides.get("client", MagicMock()) + context.device_info_text = overrides.get( + "device_info_text", + "\n\n[Device Info]\n Manufacturer : Test Manufacturer", + ) + + context.stored_data = {} + context.stored_metadata = {} + + context.last_result = overrides.get("last_result", None) + context.last_method = overrides.get("last_method", None) + context.last_service_name = overrides.get("last_service_name", None) + context.last_operation_timestamp = overrides.get("last_operation_timestamp", None) + + return context + + +def create_commands(**overrides): + """Create InformationCommands with a mocked shell context.""" + commands = InformationCommands() + commands.context = create_context(**overrides) + commands._handle_connection_error = MagicMock() # pylint: disable=protected-access + return commands + + +class TestInformationCommandsHelp: + """Test help and shortcut commands.""" + + def test_do_help_without_argument(self, capsys): + """Verify help displays interactive help when no argument is provided.""" + commands = create_commands() + + with patch( + "onvif.cli.interactive.information.INTERACTIVE_HELP", + "interactive help text", + ): + commands.do_help("") + + captured = capsys.readouterr() + assert captured.out == "interactive help text\n" + + def test_do_help_with_argument(self): + """Verify help delegates to cmd.Cmd for a specific topic.""" + commands = create_commands() + + with patch("onvif.cli.interactive.information.cmd.Cmd.do_help") as do_help: + commands.do_help("capabilities") + + do_help.assert_called_once_with(commands, "capabilities") + + def test_do_shortcuts(self, capsys): + """Verify shortcuts displays the interactive shortcuts.""" + commands = create_commands() + + with patch( + "onvif.cli.interactive.information.INTERACTIVE_SHORTCUTS", + "shortcut text", + ): + commands.do_shortcuts("") + + captured = capsys.readouterr() + assert captured.out == "shortcut text\n" + + +class TestInformationCommandsInfo: + """Test connection and device information output.""" + + def test_do_info_displays_basic_connection_information(self, capsys): + """Verify basic connection information is displayed.""" + commands = create_commands( + host="10.0.0.10", + port=8080, + digest=True, + device_info_text="DEVICE INFO", + ) + + commands.do_info("") + + output = capsys.readouterr().out + + assert "10.0.0.10:8080" in output + assert "HTTP Digest" in output + assert "DEVICE INFO" in output + + def test_do_info_displays_ws_username_token_when_digest_disabled(self, capsys): + """Verify WS-UsernameToken is displayed when digest authentication is disabled.""" + commands = create_commands(digest=False) + + commands.do_info("") + + output = capsys.readouterr().out + + assert "WS-UsernameToken" in output + assert "HTTP Digest" not in output + + def test_do_info_displays_https(self, capsys): + """Verify HTTPS status is displayed when enabled.""" + commands = create_commands(https=True) + + commands.do_info("") + + output = capsys.readouterr().out + + assert "Use HTTPS" in output + assert "True" in output + + def test_do_info_hides_https_when_disabled(self, capsys): + """Verify HTTPS status is omitted when disabled.""" + commands = create_commands(https=False) + + commands.do_info("") + + output = capsys.readouterr().out + + assert "Use HTTPS" not in output + + def test_do_info_displays_ssl_verification_disabled(self, capsys): + """Verify disabled SSL verification is displayed.""" + commands = create_commands(no_verify_ssl=True) + + commands.do_info("") + + output = capsys.readouterr().out + + assert "Verify SSL" in output + assert "False" in output + + def test_do_info_displays_non_default_timeout(self, capsys): + """Verify a non-default timeout is displayed.""" + commands = create_commands(timeout=30) + + commands.do_info("") + + output = capsys.readouterr().out + + assert "Timeout" in output + assert "30s" in output + + def test_do_info_hides_default_timeout(self, capsys): + """Verify the default timeout is omitted.""" + commands = create_commands(timeout=10) + + commands.do_info("") + + output = capsys.readouterr().out + + assert "Timeout" not in output + + def test_do_info_displays_debug_mode(self, capsys): + """Verify debug mode is displayed when enabled.""" + commands = create_commands(debug=True) + + commands.do_info("") + + output = capsys.readouterr().out + + assert "Debug Mode" in output + assert "True" in output + + def test_do_info_displays_no_patch(self, capsys): + """Verify disabled ZeepPatcher status is displayed.""" + commands = create_commands(no_patch=True) + + commands.do_info("") + + output = capsys.readouterr().out + + assert "ZeepPatcher" in output + assert "Disabled" in output + + def test_do_info_displays_custom_wsdl(self, capsys): + """Verify custom WSDL path is displayed.""" + commands = create_commands(wsdl="/custom/wsdl") + + commands.do_info("") + + output = capsys.readouterr().out + + assert "Custom WSDL" in output + assert "/custom/wsdl" in output + + def test_do_info_displays_non_default_health_check_interval(self, capsys): + """Verify a non-default health check interval is displayed.""" + commands = create_commands(health_check_interval=30) + + commands.do_info("") + + output = capsys.readouterr().out + + assert "Health Check" in output + assert "30s" in output + + def test_do_info_hides_default_health_check_interval(self, capsys): + """Verify the default health check interval is omitted.""" + commands = create_commands(health_check_interval=10) + + commands.do_info("") + + output = capsys.readouterr().out + + assert "Health Check" not in output + + def test_do_info_displays_multiple_options(self, capsys): + """Verify multiple enabled options are displayed together.""" + commands = create_commands( + https=True, + no_verify_ssl=True, + timeout=30, + debug=True, + no_patch=True, + wsdl="/custom/wsdl", + health_check_interval=20, + ) + + commands.do_info("") + + output = capsys.readouterr().out + + assert "Use HTTPS" in output + assert "Verify SSL" in output + assert "Timeout" in output + assert "Debug Mode" in output + assert "ZeepPatcher" in output + assert "Custom WSDL" in output + assert "Health Check" in output + + def test_do_info_displays_device_information(self, capsys): + """Verify stored device information is included.""" + commands = create_commands( + device_info_text="Manufacturer: Test Camera\nModel: Test Model" + ) + + commands.do_info("") + + output = capsys.readouterr().out + + assert "Manufacturer: Test Camera" in output + assert "Model: Test Model" in output + + +class TestInformationCommandsCapabilities: + """Test device capabilities commands.""" + + def test_do_capabilities_uses_cached_capabilities(self, capsys): + """Verify cached capabilities are used when available.""" + capabilities = {"Device": {"System": {"DiscoveryResolve": True}}} + + client = MagicMock() + client.capabilities = capabilities + + commands = create_commands(client=client) + + with patch( + "onvif.cli.interactive.information.format_capabilities_as_services", + return_value="formatted capabilities", + ) as formatter: + commands.do_capabilities("") + + client.devicemgmt.assert_not_called() + formatter.assert_called_once_with(capabilities) + + captured = capsys.readouterr() + assert captured.out == "formatted capabilities\n" + + def test_do_capabilities_fetches_capabilities_when_not_cached(self, capsys): + """Verify capabilities are fetched when no cached value exists.""" + capabilities = {"Device": {"System": {}}} + + client = MagicMock() + client.capabilities = None + client.devicemgmt().GetCapabilities.return_value = capabilities + + commands = create_commands(client=client) + + with patch( + "onvif.cli.interactive.information.format_capabilities_as_services", + return_value="formatted capabilities", + ) as formatter: + commands.do_capabilities("") + + client.devicemgmt().GetCapabilities.assert_called_once_with(Category="All") + formatter.assert_called_once_with(capabilities) + + captured = capsys.readouterr() + assert captured.out == "formatted capabilities\n" + + def test_do_capabilities_stores_result(self): + """Verify capabilities are stored in the shell context.""" + capabilities = {"Device": {"System": {}}} + + client = MagicMock() + client.capabilities = capabilities + + commands = create_commands(client=client) + + with patch( + "onvif.cli.interactive.information.format_capabilities_as_services", + return_value="formatted", + ): + commands.do_capabilities("") + + assert commands.context.stored_data["capabilities"] == capabilities + assert commands.context.stored_metadata["capabilities"] == { + "service": "devicemgmt", + "method": "GetCapabilities", + } + + def test_do_capabilities_handles_connection_error(self): + """Verify connection errors trigger the connection error handler.""" + exception = ONVIFOperationException( + "GetCapabilities", + RequestException("connection failed"), + ) + + client = MagicMock() + client.capabilities = None + client.devicemgmt().GetCapabilities.side_effect = exception + + commands = create_commands(client=client) + + commands.do_capabilities("") + + commands._handle_connection_error.assert_called_once_with() # pylint: disable=protected-access + + def test_do_capabilities_prints_non_connection_error(self, capsys): + """Verify non-connection operation errors are printed.""" + exception = ONVIFOperationException( + "GetCapabilities", + ValueError("invalid response"), + ) + + client = MagicMock() + client.capabilities = None + client.devicemgmt().GetCapabilities.side_effect = exception + + commands = create_commands(client=client) + + commands.do_capabilities("") + + captured = capsys.readouterr() + + assert "Error:" in captured.out + assert "invalid response" in captured.out + commands._handle_connection_error.assert_not_called() # pylint: disable=protected-access + + def test_do_caps_delegates_to_capabilities(self): + """Verify caps is an alias for capabilities.""" + commands = create_commands() + + with patch.object(commands, "do_capabilities") as do_capabilities: + commands.do_caps("test") + + do_capabilities.assert_called_once_with("test") + + +class TestInformationCommandsServices: + """Test device services commands.""" + + def test_do_services_uses_cached_services(self, capsys): + """Verify cached services are used when available.""" + services = ["devicemgmt", "media"] + + client = MagicMock() + client.services = services + + commands = create_commands(client=client) + + with patch( + "onvif.cli.interactive.information.format_services_list", + return_value="formatted services", + ) as formatter: + commands.do_services("") + + client.devicemgmt.assert_not_called() + formatter.assert_called_once_with(services) + + captured = capsys.readouterr() + assert captured.out == "formatted services\n" + + def test_do_services_fetches_services_when_not_cached(self, capsys): + """Verify services are fetched when no cached value exists.""" + services = ["devicemgmt", "media"] + + client = MagicMock() + client.services = None + client.devicemgmt().GetServices.return_value = services + + commands = create_commands(client=client) + + with patch( + "onvif.cli.interactive.information.format_services_list", + return_value="formatted services", + ) as formatter: + commands.do_services("") + + client.devicemgmt().GetServices.assert_called_once_with(IncludeCapability=False) + formatter.assert_called_once_with(services) + + captured = capsys.readouterr() + assert captured.out == "formatted services\n" + + def test_do_services_stores_result(self): + """Verify services are stored in the shell context.""" + services = ["devicemgmt", "media"] + + client = MagicMock() + client.services = services + + commands = create_commands(client=client) + + with patch( + "onvif.cli.interactive.information.format_services_list", + return_value="formatted", + ): + commands.do_services("") + + assert commands.context.stored_data["services"] == services + assert commands.context.stored_metadata["services"] == { + "service": "devicemgmt", + "method": "GetServices", + } + + def test_do_services_handles_connection_error(self): + """Verify connection errors trigger the connection error handler.""" + exception = ONVIFOperationException( + "GetServices", + TransportError("connection failed"), + ) + + client = MagicMock() + client.services = None + client.devicemgmt().GetServices.side_effect = exception + + commands = create_commands(client=client) + + commands.do_services("") + + commands._handle_connection_error.assert_called_once_with() # pylint: disable=protected-access + + def test_do_services_prints_non_connection_error(self, capsys): + """Verify non-connection operation errors are printed.""" + exception = ONVIFOperationException( + "GetServices", + ValueError("invalid response"), + ) + + client = MagicMock() + client.services = None + client.devicemgmt().GetServices.side_effect = exception + + commands = create_commands(client=client) + + commands.do_services("") + + captured = capsys.readouterr() + + assert "Error:" in captured.out + assert "invalid response" in captured.out + commands._handle_connection_error.assert_not_called() # pylint: disable=protected-access + + +class TestInformationCommandsDebug: + """Test debug information command.""" + + def test_do_debug_displays_xml_information(self, capsys): + """Verify debug output includes captured SOAP XML.""" + client = MagicMock() + client.xml_plugin = MagicMock() + client.xml_plugin.last_sent_xml = "test" + client.xml_plugin.last_received_xml = "test" + + commands = create_commands(client=client) + + commands.do_debug("") + + output = capsys.readouterr().out + + assert "Last SOAP Request:" in output + assert "test" in output + assert "Last SOAP Response:" in output + assert "test" in output + + def test_do_debug_displays_none_when_xml_is_missing(self, capsys): + """Verify missing XML capture is displayed as None.""" + client = MagicMock() + client.xml_plugin = MagicMock() + client.xml_plugin.last_sent_xml = None + client.xml_plugin.last_received_xml = None + + commands = create_commands(client=client) + + commands.do_debug("") + + output = capsys.readouterr().out + + assert "Last SOAP Request:" in output + assert "Last SOAP Response:" in output + assert "None" in output + + def test_do_debug_displays_last_operation(self, capsys): + """Verify the last operation metadata is displayed.""" + client = MagicMock() + client.xml_plugin = MagicMock() + + timestamp = datetime(2026, 10, 5, 14, 30, 45) + + commands = create_commands( + client=client, + last_method="GetDeviceInformation", + last_service_name="devicemgmt", + last_operation_timestamp=timestamp, + ) + + commands.do_debug("") + + output = capsys.readouterr().out + + assert "Last Operation:" in output + assert "operation: devicemgmt.GetDeviceInformation()" in output + assert "timestamp: 2026-10-05 14:30:45" in output + + def test_do_debug_hides_last_operation_when_no_method(self, capsys): + """Verify operation metadata is omitted when no method was executed.""" + client = MagicMock() + client.xml_plugin = MagicMock() + + commands = create_commands(client=client) + + commands.do_debug("") + + output = capsys.readouterr().out + + assert "Last Operation:" not in output + assert "Last SOAP Request:" in output + + def test_do_debug_hides_last_operation_when_no_timestamp(self, capsys): + """Verify operation metadata is omitted when no timestamp exists.""" + client = MagicMock() + client.xml_plugin = MagicMock() + + commands = create_commands( + client=client, + last_method="GetDeviceInformation", + last_service_name="devicemgmt", + last_operation_timestamp=None, + ) + + commands.do_debug("") + + output = capsys.readouterr().out + + assert "Last Operation:" not in output + + def test_do_debug_handles_disabled_xml_plugin(self, capsys): + """Verify debug command reports when XML capture is unavailable.""" + client = MagicMock() + client.xml_plugin = None + + commands = create_commands(client=client) + + commands.do_debug("") + + output = capsys.readouterr().out + + assert "Debug mode not enabled" in output + assert "--debug" in output diff --git a/tests/cli/test_cli_interactive_navigation.py b/tests/cli/test_cli_interactive_navigation.py new file mode 100644 index 0000000..364ae17 --- /dev/null +++ b/tests/cli/test_cli_interactive_navigation.py @@ -0,0 +1,618 @@ +# pylint: disable=protected-access +"""Tests for ONVIF interactive shell navigation commands.""" + +from unittest.mock import MagicMock, patch + +from onvif.cli.interactive.navigation import NavigationCommands + +from .conftest import strip_ansi + + +def create_args(**overrides): + """Create mocked CLI arguments.""" + args = MagicMock() + args.debug = overrides.get("debug", False) + return args + + +def create_context(**overrides): + """Create a mocked shell context.""" + context = MagicMock() + + context.args = create_args(**overrides) + context.client = overrides.get("client", MagicMock()) + + context.current_service = overrides.get("current_service", None) + context.current_service_name = overrides.get("current_service_name", None) + + context.base_commands = overrides.get( + "base_commands", + [ + "capabilities", + "caps", + "services", + "help", + "exit", + "store", + "rm", + "show", + "cls", + "clear", + "info", + "debug", + "ls", + "cd", + "shortcuts", + "desc", + "type", + ], + ) + + return context + + +def create_commands(**overrides): + """Create NavigationCommands with mocked shell dependencies.""" + commands = NavigationCommands() + commands.context = create_context(**overrides) + commands._display_grid = MagicMock() + commands.update_prompt = MagicMock() + commands._resolve_stored_reference = MagicMock() + return commands + + +class TestNavigationCommandsList: + """Test the ls command.""" + + def test_do_ls_in_root_mode(self): + """Verify ls displays services and base commands in root mode.""" + commands = create_commands() + + services = ["devicemgmt", "media"] + + with patch( + "onvif.cli.interactive.navigation.get_device_available_services", + return_value=services, + ): + commands.do_ls("") + + commands._display_grid.assert_called_once_with( + services + commands.context.base_commands + ) + + def test_do_ls_in_service_mode(self): + """Verify ls displays service methods and helper commands.""" + service = MagicMock() + commands = create_commands(current_service=service) + + methods = ["GetDeviceInformation", "GetCapabilities"] + + with patch( + "onvif.cli.interactive.navigation.get_service_methods", + return_value=methods.copy(), + ): + commands.do_ls("") + + commands._display_grid.assert_called_once() + + displayed = commands._display_grid.call_args.args[0] + + assert displayed[:2] == methods + assert len(displayed) == 4 + assert "type" in displayed[2] + assert "desc" in displayed[3] + + def test_do_ls_displays_helper_commands_when_service_has_no_methods(self): + """Verify ls displays helper commands when a service has no methods.""" + commands = create_commands(current_service=MagicMock()) + + with patch( + "onvif.cli.interactive.navigation.get_service_methods", + return_value=[], + ): + commands.do_ls("") + + commands._display_grid.assert_called_once() + + displayed = commands._display_grid.call_args.args[0] + + assert len(displayed) == 2 + assert "type" in displayed[0] + assert "desc" in displayed[1] + + def test_do_ls_prints_no_commands_when_root_is_empty(self, capsys): + """Verify ls reports when no root commands or services are available.""" + commands = create_commands(base_commands=[]) + + with patch( + "onvif.cli.interactive.navigation.get_device_available_services", + return_value=[], + ): + commands.do_ls("") + + commands._display_grid.assert_not_called() + assert "No commands available" in capsys.readouterr().out + + +class TestNavigationCommandsCd: + """Test the cd command.""" + + def test_do_cd_without_service_name(self, capsys): + """Verify cd displays usage and available services without an argument.""" + commands = create_commands() + + with patch( + "onvif.cli.interactive.navigation.get_device_available_services", + return_value=["devicemgmt", "media"], + ): + result = commands.do_cd("") + + assert result is None + + output = capsys.readouterr().out + assert "Usage: cd " in output + assert "Available services:" in output + assert "devicemgmt" in output + assert "media" in output + + def test_do_cd_delegates_to_enter_service(self): + """Verify cd delegates service entry to do_enter_service.""" + commands = create_commands() + + with patch.object(commands, "do_enter_service") as enter_service: + commands.do_cd("devicemgmt") + + enter_service.assert_called_once_with("devicemgmt") + + def test_complete_cd_returns_matching_services(self): + """Verify cd completion returns matching service names.""" + commands = create_commands() + + with patch( + "onvif.cli.interactive.navigation.get_device_available_services", + return_value=["devicemgmt", "media", "events"], + ): + result = commands.complete_cd("dev", "", 0, 3) + + assert result == ["devicemgmt"] + + def test_complete_cd_is_case_insensitive(self): + """Verify cd completion is case-insensitive.""" + commands = create_commands() + + with patch( + "onvif.cli.interactive.navigation.get_device_available_services", + return_value=["DeviceMgmt", "Media"], + ): + result = commands.complete_cd("device", "", 0, 6) + + assert result == ["DeviceMgmt"] + + def test_complete_cd_returns_empty_for_no_match(self): + """Verify cd completion returns no results when nothing matches.""" + commands = create_commands() + + with patch( + "onvif.cli.interactive.navigation.get_device_available_services", + return_value=["devicemgmt", "media"], + ): + result = commands.complete_cd("xyz", "", 0, 3) + + assert result == [] + + +class TestNavigationCommandsUp: + """Test the up command.""" + + def test_do_up_exits_service_mode(self, capsys): + """Verify up clears the current service and updates the prompt.""" + service = MagicMock() + commands = create_commands( + current_service=service, + current_service_name="devicemgmt", + ) + + commands.do_up("") + + assert commands.context.current_service is None + assert commands.context.current_service_name is None + commands.update_prompt.assert_called_once_with() + + output = capsys.readouterr().out + assert "Exited service:" in output + assert "devicemgmt" in output + + def test_do_up_when_not_in_service_mode(self, capsys): + """Verify up reports when already in root mode.""" + commands = create_commands() + + commands.do_up("") + + commands.update_prompt.assert_not_called() + assert "Not in service mode" in capsys.readouterr().out + + +class TestNavigationCommandsEnterService: + """Test entering ONVIF service mode.""" + + def test_do_enter_service_unknown_service(self, capsys): + """Verify unknown services are rejected.""" + commands = create_commands() + + with patch( + "onvif.cli.interactive.navigation.get_device_available_services", + return_value=["devicemgmt", "media"], + ): + commands.do_enter_service("unknown") + + output = capsys.readouterr().out + + assert "Unknown service 'unknown'" in output + assert "Available services:" in output + assert "devicemgmt" in output + assert "media" in output + + commands.update_prompt.assert_not_called() + + def test_do_enter_service_without_required_arguments(self, capsys): + """Verify missing required service arguments display usage information.""" + commands = create_commands() + + with ( + patch( + "onvif.cli.interactive.navigation.get_device_available_services", + return_value=["events"], + ), + patch( + "onvif.cli.interactive.navigation.get_service_required_args", + return_value=["Reference"], + ), + ): + commands.do_enter_service("events") + + output = capsys.readouterr().out + + assert "Service 'events' requires arguments" in output + assert "Usage:" in output + assert "Reference=" in output + assert "Example:" in output + assert "Reference=$subscription" in output + + commands.update_prompt.assert_not_called() + + def test_do_enter_service_with_required_arguments(self): + """Verify a service requiring arguments is entered successfully.""" + service = MagicMock() + client = MagicMock() + client.events.return_value = service + + commands = create_commands(client=client) + + with ( + patch( + "onvif.cli.interactive.navigation.get_device_available_services", + return_value=["events"], + ), + patch( + "onvif.cli.interactive.navigation.get_service_required_args", + return_value=["Reference"], + ), + patch( + "onvif.cli.interactive.navigation.parse_json_params", + return_value={"Reference": "subscription-token"}, + ), + patch( + "onvif.cli.interactive.navigation.get_service_methods", + return_value=["PullMessages", "Renew"], + ), + ): + commands.do_enter_service('events {"Reference": "subscription-token"}') + + client.events.assert_called_once_with(Reference="subscription-token") + + assert commands.context.current_service is service + assert commands.context.current_service_name == "events" + commands.update_prompt.assert_called_once_with() + + def test_do_enter_service_without_required_arguments_uses_no_args(self): + """Verify a service without required arguments is created without arguments.""" + service = MagicMock() + client = MagicMock() + client.devicemgmt.return_value = service + + commands = create_commands(client=client) + + with ( + patch( + "onvif.cli.interactive.navigation.get_device_available_services", + return_value=["devicemgmt"], + ), + patch( + "onvif.cli.interactive.navigation.get_service_required_args", + return_value=[], + ), + patch( + "onvif.cli.interactive.navigation.get_service_methods", + return_value=["GetDeviceInformation"], + ), + ): + commands.do_enter_service("devicemgmt") + + client.devicemgmt.assert_called_once_with() + + assert commands.context.current_service is service + assert commands.context.current_service_name == "devicemgmt" + commands.update_prompt.assert_called_once_with() + + def test_do_enter_service_missing_required_argument(self, capsys): + """Verify missing required arguments are reported.""" + commands = create_commands() + + with ( + patch( + "onvif.cli.interactive.navigation.get_device_available_services", + return_value=["events"], + ), + patch( + "onvif.cli.interactive.navigation.get_service_required_args", + return_value=["Reference", "Mode"], + ), + patch( + "onvif.cli.interactive.navigation.parse_json_params", + return_value={"Reference": "subscription-token"}, + ), + ): + commands.do_enter_service('events {"Reference": "subscription-token"}') + + output = capsys.readouterr().out + + assert "Missing required arguments: Mode" in output + assert "Usage:" in output + assert "Reference=" in output + assert "Mode=" in output + + commands.update_prompt.assert_not_called() + + def test_do_enter_service_parsing_error(self, capsys): + """Verify invalid service arguments are reported.""" + commands = create_commands() + + with ( + patch( + "onvif.cli.interactive.navigation.get_device_available_services", + return_value=["events"], + ), + patch( + "onvif.cli.interactive.navigation.get_service_required_args", + return_value=["Reference"], + ), + patch( + "onvif.cli.interactive.navigation.parse_json_params", + side_effect=ValueError("invalid JSON"), + ), + ): + commands.do_enter_service("events invalid") + + output = capsys.readouterr().out + + assert "Error parsing arguments:" in output + assert "invalid JSON" in output + commands.update_prompt.assert_not_called() + + def test_do_enter_service_resolves_stored_reference(self): + """Verify stored references are resolved before service creation.""" + service = MagicMock() + client = MagicMock() + client.events.return_value = service + + commands = create_commands(client=client) + commands._resolve_stored_reference.return_value = "resolved-token" + + with ( + patch( + "onvif.cli.interactive.navigation.get_device_available_services", + return_value=["events"], + ), + patch( + "onvif.cli.interactive.navigation.get_service_required_args", + return_value=["Reference"], + ), + patch( + "onvif.cli.interactive.navigation.parse_json_params", + return_value={"Reference": "$subscription"}, + ), + patch( + "onvif.cli.interactive.navigation.get_service_methods", + return_value=["PullMessages"], + ), + ): + commands.do_enter_service('events {"Reference": "$subscription"}') + + commands._resolve_stored_reference.assert_called_once_with("subscription") + client.events.assert_called_once_with(Reference="resolved-token") + + def test_do_enter_service_unresolved_reference(self, capsys): + """Verify unresolved stored references prevent service entry.""" + client = MagicMock() + commands = create_commands(client=client) + commands._resolve_stored_reference.return_value = None + + with ( + patch( + "onvif.cli.interactive.navigation.get_device_available_services", + return_value=["events"], + ), + patch( + "onvif.cli.interactive.navigation.get_service_required_args", + return_value=["Reference"], + ), + patch( + "onvif.cli.interactive.navigation.parse_json_params", + return_value={"Reference": "$missing"}, + ), + ): + commands.do_enter_service('events {"Reference": "$missing"}') + + output = capsys.readouterr().out + + assert "Could not resolve reference '$missing'" in output + client.events.assert_not_called() + commands.update_prompt.assert_not_called() + + def test_do_enter_service_prints_method_preview(self, capsys): + """Verify service methods are displayed after entering a service.""" + service = MagicMock() + client = MagicMock() + client.devicemgmt.return_value = service + + methods = ["Method1", "Method2", "Method3"] + + commands = create_commands(client=client) + + with ( + patch( + "onvif.cli.interactive.navigation.get_device_available_services", + return_value=["devicemgmt"], + ), + patch( + "onvif.cli.interactive.navigation.get_service_required_args", + return_value=[], + ), + patch( + "onvif.cli.interactive.navigation.get_service_methods", + return_value=methods, + ), + ): + commands.do_enter_service("devicemgmt") + + output = strip_ansi(capsys.readouterr().out) + + assert "Entered service:" in output + assert "devicemgmt" in output + assert "Available methods:" in output + assert "Method1" in output + assert "Method2" in output + assert "Method3" in output + assert "Type up to exit service mode." in output + + def test_do_enter_service_prints_more_methods_message(self, capsys): + """Verify more than ten methods display the truncated preview.""" + service = MagicMock() + client = MagicMock() + client.devicemgmt.return_value = service + + methods = [f"Method{i}" for i in range(15)] + + commands = create_commands(client=client) + + with ( + patch( + "onvif.cli.interactive.navigation.get_device_available_services", + return_value=["devicemgmt"], + ), + patch( + "onvif.cli.interactive.navigation.get_service_required_args", + return_value=[], + ), + patch( + "onvif.cli.interactive.navigation.get_service_methods", + return_value=methods, + ), + ): + commands.do_enter_service("devicemgmt") + + output = capsys.readouterr().out + + assert "Method0" in output + assert "Method9" in output + assert "Method10" not in output + assert "5 more." in output + assert "TAB" in output + + def test_do_enter_service_handles_service_creation_error(self, capsys): + """Verify service creation errors are reported.""" + client = MagicMock() + client.devicemgmt.side_effect = RuntimeError("service unavailable") + + commands = create_commands(client=client) + + with ( + patch( + "onvif.cli.interactive.navigation.get_device_available_services", + return_value=["devicemgmt"], + ), + patch( + "onvif.cli.interactive.navigation.get_service_required_args", + return_value=[], + ), + ): + commands.do_enter_service("devicemgmt") + + output = capsys.readouterr().out + + assert "Error entering service:" in output + assert "service unavailable" in output + commands.update_prompt.assert_not_called() + + def test_do_enter_service_prints_traceback_in_debug_mode(self): + """Verify service creation errors print a traceback in debug mode.""" + client = MagicMock() + client.devicemgmt.side_effect = RuntimeError("service unavailable") + + commands = create_commands(client=client, debug=True) + + with ( + patch( + "onvif.cli.interactive.navigation.get_device_available_services", + return_value=["devicemgmt"], + ), + patch( + "onvif.cli.interactive.navigation.get_service_required_args", + return_value=[], + ), + patch( + "onvif.cli.interactive.navigation.traceback.print_exc", + ) as print_exc, + ): + commands.do_enter_service("devicemgmt") + + print_exc.assert_called_once_with() + + def test_do_enter_service_does_not_print_traceback_without_debug( + self, + ): + """Verify service creation errors do not print a traceback without debug.""" + client = MagicMock() + client.devicemgmt.side_effect = RuntimeError("service unavailable") + + commands = create_commands(client=client, debug=False) + + with ( + patch( + "onvif.cli.interactive.navigation.get_device_available_services", + return_value=["devicemgmt"], + ), + patch( + "onvif.cli.interactive.navigation.get_service_required_args", + return_value=[], + ), + patch( + "onvif.cli.interactive.navigation.traceback.print_exc", + ) as print_exc, + ): + commands.do_enter_service("devicemgmt") + + print_exc.assert_not_called() + + +class TestNavigationCommandsExitService: # pylint: disable=too-few-public-methods + """Test exit service alias.""" + + def test_do_exit_service_delegates_to_do_up(self): + """Verify exit_service is an alias for up.""" + commands = create_commands() + + with patch.object(commands, "do_up") as do_up: + commands.do_exit_service("test") + + do_up.assert_called_once_with("test") diff --git a/tests/cli/test_cli_interactive_reference.py b/tests/cli/test_cli_interactive_reference.py new file mode 100644 index 0000000..62b4f68 --- /dev/null +++ b/tests/cli/test_cli_interactive_reference.py @@ -0,0 +1,924 @@ +# pylint: disable=protected-access +"""Tests for interactive shell reference commands.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from onvif.cli.interactive.context import ShellContext +from onvif.cli.interactive.reference import ReferenceCommands + +from .conftest import strip_ansi + + +def create_context(**overrides): + """Create a mocked shell context with sensible defaults.""" + args = SimpleNamespace( + username="admin", + host="192.168.1.100", + port=80, + debug=False, + ) + + context = ShellContext( + client=MagicMock(), + args=args, + ) + + context.current_service_name = overrides.pop("current_service_name", None) + context.stored_data = overrides.pop("stored_data", {}) + context.stored_metadata = overrides.pop("stored_metadata", {}) + context.last_result = overrides.pop("last_result", None) + context.last_method = overrides.pop("last_method", None) + context.last_service_name = overrides.pop("last_service_name", None) + + for key, value in overrides.items(): + setattr(context, key, value) + + return context + + +def create_commands(**overrides): + """Create ReferenceCommands with a mocked shell context.""" + commands = ReferenceCommands() + commands.context = create_context(**overrides) + return commands + + +class TestReferenceCommandsStore: + """Test stored result commands.""" + + def test_do_store_without_name_prints_usage(self, capsys): + """Verify store without a name prints usage information.""" + commands = create_commands() + + commands.do_store("") + + output = capsys.readouterr().out + + assert "Usage: store " in output + assert commands.context.stored_data == {} + + @pytest.mark.parametrize( + "name", + [ + "123name", + "1", + "-name", + "name-with-dash", + "name.with.dot", + "name with space", + "$name", + "name!", + ], + ) + def test_do_store_rejects_invalid_name(self, name, capsys): + """Verify store rejects invalid variable names.""" + commands = create_commands(last_result={"value": 123}) + + commands.do_store(name) + + output = capsys.readouterr().out + + assert "Invalid name" in output + assert name not in commands.context.stored_data + + @pytest.mark.parametrize( + "name", + [ + "result", + "_result", + "Result", + "result1", + "result_1", + "_result_1", + "ABC", + ], + ) + def test_do_store_accepts_valid_name(self, name): + """Verify store accepts valid variable names.""" + commands = create_commands(last_result={"value": 123}) + + commands.do_store(name) + + assert commands.context.stored_data[name] == {"value": 123} + + def test_do_store_rejects_duplicate_name(self, capsys): + """Verify store rejects an existing variable name.""" + commands = create_commands( + stored_data={"result": {"old": True}}, + last_result={"new": True}, + ) + + commands.do_store("result") + + output = capsys.readouterr().out + + assert "already exists" in output + assert commands.context.stored_data["result"] == {"old": True} + + def test_do_store_without_result(self, capsys): + """Verify store rejects an unset last result.""" + commands = create_commands() + + # The implementation currently checks against a newly-created + # sentinel object, so explicitly use the same semantic value only + # through a patched object comparison. + unset = object() + + with patch( + "onvif.cli.interactive.reference.object", + return_value=unset, + ): + commands.context.last_result = unset + commands.do_store("result") + + output = capsys.readouterr().out + + assert "No result to store" in output + assert "result" not in commands.context.stored_data + + def test_do_store_saves_result(self): + """Verify store saves the last result.""" + result = {"Profiles": [{"token": "profile1"}]} + commands = create_commands(last_result=result) + + commands.do_store("profiles") + + assert commands.context.stored_data["profiles"] == result + + def test_do_store_saves_service_metadata(self): + """Verify store saves the last service name as metadata.""" + commands = create_commands( + last_result={"value": 123}, + last_service_name="media", + ) + + commands.do_store("result") + + assert commands.context.stored_metadata["result"] == { + "service": "media", + } + + def test_do_store_saves_method_metadata(self): + """Verify store saves the last method as metadata.""" + commands = create_commands( + last_result={"value": 123}, + last_method="GetProfiles", + ) + + commands.do_store("result") + + assert commands.context.stored_metadata["result"] == { + "method": "GetProfiles", + } + + def test_do_store_saves_complete_metadata(self): + """Verify store saves service and method metadata.""" + commands = create_commands( + last_result={"value": 123}, + last_service_name="media", + last_method="GetProfiles", + ) + + commands.do_store("profiles") + + assert commands.context.stored_metadata["profiles"] == { + "service": "media", + "method": "GetProfiles", + } + + def test_do_store_without_metadata_uses_unknown_in_output(self, capsys): + """Verify store uses unknown metadata when none is available.""" + commands = create_commands(last_result={"value": 123}) + + commands.do_store("result") + + output = strip_ansi(capsys.readouterr().out) + + assert "Stored result as:" in output + assert "$result" in output + assert "unknown.unknown()" in output + + def test_do_store_prints_metadata(self, capsys): + """Verify store displays service and method metadata.""" + commands = create_commands( + last_result={"value": 123}, + last_service_name="media", + last_method="GetProfiles", + ) + + commands.do_store("profiles") + + output = strip_ansi(capsys.readouterr().out) + + assert "Stored result as:" in output + assert "$profiles" in output + assert "media.GetProfiles()" in output + + +class TestReferenceCommandsShow: + """Test show commands.""" + + def test_do_show_without_name_lists_empty_storage(self, capsys): + """Verify show without an argument lists stored data.""" + commands = create_commands() + + commands.do_show("") + + output = capsys.readouterr().out + + assert "Stored data:" in output + + def test_do_show_lists_stored_data(self, capsys): + """Verify show lists all stored variables.""" + commands = create_commands( + stored_data={ + "profiles": [{"token": "profile1"}], + "services": ["media", "device"], + }, + stored_metadata={ + "profiles": { + "service": "media", + "method": "GetProfiles", + }, + "services": { + "service": "devicemgmt", + "method": "GetServices", + }, + }, + ) + + commands.do_show("") + + output = strip_ansi(capsys.readouterr().out) + + assert "$profiles" in output + assert "media.GetProfiles()" in output + assert "$services" in output + assert "devicemgmt.GetServices()" in output + + def test_do_show_uses_unknown_metadata(self, capsys): + """Verify show uses unknown metadata for stored data without metadata.""" + commands = create_commands( + stored_data={"result": {"value": 123}}, + ) + + commands.do_show("") + + output = strip_ansi(capsys.readouterr().out) + + assert "$result" in output + assert "unknown.unknown()" in output + + def test_do_show_resolves_reference(self, capsys): + """Verify show prints a resolved stored reference.""" + commands = create_commands( + stored_data={"profiles": [{"token": "abc123"}]}, + ) + + commands.do_show("profiles[0].token") + + output = capsys.readouterr().out + + assert "abc123" in output + + def test_do_show_prints_unresolved_reference_error(self, capsys): + """Verify show reports an unresolved reference.""" + commands = create_commands( + stored_data={"profiles": [{"token": "abc123"}]}, + ) + + commands.do_show("profiles[99].token") + + output = capsys.readouterr().out + + assert "Cannot resolve" in output + assert "profiles[99].token" in output + + def test_do_show_resolves_attribute_reference(self, capsys): + """Verify show resolves object attributes.""" + profile = SimpleNamespace(token="abc123") + commands = create_commands( + stored_data={"profile": profile}, + ) + + commands.do_show("profile.token") + + output = capsys.readouterr().out + + assert "abc123" in output + + +class TestReferenceCommandsSubstitute: + """Test stored-reference substitution.""" + + def test_substitute_without_references(self): + """Verify strings without references remain unchanged.""" + commands = create_commands() + + params = '{"Name": "Profile"}' + + result = commands._substitute_stored_references(params) + + assert result == params + + def test_substitute_string_reference(self): + """Verify a string reference is converted to a JSON string.""" + commands = create_commands( + stored_data={"token": "abc123"}, + ) + + result = commands._substitute_stored_references('{"Token": $token}') + + assert result == '{"Token": "abc123"}' + + def test_substitute_integer_reference(self): + """Verify an integer reference is serialized correctly.""" + commands = create_commands( + stored_data={"count": 42}, + ) + + result = commands._substitute_stored_references('{"Count": $count}') + + assert result == '{"Count": 42}' + + def test_substitute_boolean_reference(self): + """Verify a boolean reference is serialized correctly.""" + commands = create_commands( + stored_data={"enabled": True}, + ) + + result = commands._substitute_stored_references('{"Enabled": $enabled}') + + assert result == '{"Enabled": true}' + + def test_substitute_null_reference(self): + """Verify a null reference is serialized correctly.""" + commands = create_commands( + stored_data={"value": None}, + ) + + result = commands._substitute_stored_references('{"Value": $value}') + + # None is treated as an unresolved reference by the implementation. + assert result == '{"Value": $value}' + + def test_substitute_list_reference(self): + """Verify a list reference is serialized correctly.""" + commands = create_commands( + stored_data={"items": [1, 2, 3]}, + ) + + result = commands._substitute_stored_references('{"Items": $items}') + + assert result == '{"Items": [1, 2, 3]}' + + def test_substitute_dict_reference(self): + """Verify a dictionary reference is serialized correctly.""" + commands = create_commands( + stored_data={"profile": {"token": "abc123", "enabled": True}}, + ) + + result = commands._substitute_stored_references('{"Profile": $profile}') + + assert result == ('{"Profile": {"token": "abc123", "enabled": true}}') + + def test_substitute_nested_reference(self): + """Verify a nested reference is resolved before serialization.""" + commands = create_commands( + stored_data={ + "profiles": [ + {"token": "abc123"}, + ], + }, + ) + + result = commands._substitute_stored_references('{"Token": $profiles[0].token}') + + assert result == '{"Token": "abc123"}' + + def test_substitute_multiple_references(self): + """Verify multiple references are substituted.""" + commands = create_commands( + stored_data={ + "token": "abc123", + "count": 42, + }, + ) + + result = commands._substitute_stored_references( + '{"Token": $token, "Count": $count}' + ) + + assert result == '{"Token": "abc123", "Count": 42}' + + def test_substitute_unresolved_reference_keeps_original(self, capsys): + """Verify unresolved references remain unchanged.""" + commands = create_commands() + + params = '{"Token": $missing}' + + result = commands._substitute_stored_references(params) + + output = capsys.readouterr().out + + assert result == params + assert "Cannot resolve $missing" in output + + def test_substitute_non_serializable_value_falls_back_to_string(self): + """Verify non-JSON-serializable values are converted to strings.""" + value = object() + commands = create_commands( + stored_data={"value": value}, + ) + + result = commands._substitute_stored_references('{"Value": $value}') + + assert result == f'{{"Value": "{str(value)}"}}' + + def test_substitute_uses_json_dumps(self): + """Verify stored values are serialized using json.dumps.""" + commands = create_commands( + stored_data={"value": {"key": "value"}}, + ) + + with patch( + "onvif.cli.interactive.reference.json.dumps", + wraps=__import__("json").dumps, + ) as dumps: + commands._substitute_stored_references('{"Value": $value}') + + dumps.assert_called_once_with({"key": "value"}) + + def test_substitute_preserves_reference_inside_text(self): + """Verify references can be substituted within larger parameter strings.""" + commands = create_commands( + stored_data={"token": "abc123"}, + ) + + result = commands._substitute_stored_references( + '{"Token": $token, "Other": "value"}' + ) + + assert result == ('{"Token": "abc123", "Other": "value"}') + + +class TestReferenceCommandsResolve: + """Test stored-reference resolution.""" + + def test_resolve_missing_reference(self): + """Verify missing variables resolve to None.""" + commands = create_commands() + + assert commands._resolve_stored_reference("missing") is None + + def test_resolve_empty_reference(self): + """Verify an empty reference resolves to None.""" + commands = create_commands() + + assert commands._resolve_stored_reference("") is None + + def test_resolve_stored_scalar(self): + """Verify a stored scalar value is returned.""" + commands = create_commands( + stored_data={"value": 42}, + ) + + assert commands._resolve_stored_reference("value") == 42 + + def test_resolve_stored_string(self): + """Verify a stored string value is returned.""" + commands = create_commands( + stored_data={"token": "abc123"}, + ) + + assert commands._resolve_stored_reference("token") == "abc123" + + def test_resolve_list_index(self): + """Verify list indexes are resolved.""" + commands = create_commands( + stored_data={ + "profiles": [ + "profile0", + "profile1", + ], + }, + ) + + assert commands._resolve_stored_reference("profiles[0]") == "profile0" + assert commands._resolve_stored_reference("profiles[1]") == "profile1" + + def test_resolve_tuple_index(self): + """Verify tuple indexes are resolved.""" + commands = create_commands( + stored_data={ + "values": ("first", "second"), + }, + ) + + assert commands._resolve_stored_reference("values[1]") == "second" + + def test_resolve_nested_list_and_dict(self): + """Verify nested list and dictionary access.""" + commands = create_commands( + stored_data={ + "profiles": [ + {"token": "abc123"}, + ], + }, + ) + + assert commands._resolve_stored_reference("profiles[0].token") == "abc123" + + def test_resolve_nested_dict(self): + """Verify nested dictionary access.""" + commands = create_commands( + stored_data={ + "profile": { + "credentials": { + "token": "abc123", + }, + }, + }, + ) + + assert ( + commands._resolve_stored_reference("profile.credentials.token") == "abc123" + ) + + def test_resolve_object_attribute(self): + """Verify object attributes are resolved.""" + profile = SimpleNamespace( + token="abc123", + name="Profile 1", + ) + commands = create_commands( + stored_data={"profile": profile}, + ) + + assert commands._resolve_stored_reference("profile.token") == "abc123" + assert commands._resolve_stored_reference("profile.name") == "Profile 1" + + def test_resolve_dictionary_key(self): + """Verify dictionary keys are resolved.""" + commands = create_commands( + stored_data={ + "profile": { + "token": "abc123", + }, + }, + ) + + assert commands._resolve_stored_reference("profile.token") == "abc123" + + def test_resolve_out_of_range_index(self): + """Verify out-of-range list indexes return None.""" + commands = create_commands( + stored_data={"profiles": ["profile0"]}, + ) + + assert commands._resolve_stored_reference("profiles[99]") is None + + def test_resolve_iterable_string_by_index(self): + """Verify string values can be indexed through the generic iterable path.""" + commands = create_commands( + stored_data={"profile": "not-a-list"}, + ) + + assert commands._resolve_stored_reference("profile[0]") == "n" + + def test_resolve_missing_attribute(self): + """Verify missing object attributes return None.""" + profile = SimpleNamespace(token="abc123") + commands = create_commands( + stored_data={"profile": profile}, + ) + + assert commands._resolve_stored_reference("profile.missing") is None + + def test_resolve_missing_dictionary_key(self): + """Verify missing dictionary keys return None.""" + commands = create_commands( + stored_data={"profile": {"token": "abc123"}}, + ) + + assert commands._resolve_stored_reference("profile.missing") is None + + def test_resolve_nested_out_of_range_index(self): + """Verify nested out-of-range indexes return None.""" + commands = create_commands( + stored_data={ + "profiles": [ + {"token": "abc123"}, + ], + }, + ) + + assert commands._resolve_stored_reference("profiles[5].token") is None + + def test_resolve_iterable_non_list_by_index(self): + """Verify index traversal works for other iterable values.""" + commands = create_commands( + stored_data={ + "values": "abc", + }, + ) + + assert commands._resolve_stored_reference("values[1]") == "b" + + def test_resolve_non_iterable_by_index_returns_none(self): + """Verify indexing a non-iterable returns None.""" + commands = create_commands( + stored_data={ + "value": 123, + }, + ) + + assert commands._resolve_stored_reference("value[0]") is None + + +class TestReferenceCommandsCompletion: + """Test reference command autocompletion.""" + + def test_complete_show_returns_matching_names(self): + """Verify show completion returns matching stored names.""" + commands = create_commands( + stored_data={ + "profiles": [], + "profile": {}, + "services": [], + "device": {}, + }, + ) + + result = commands.complete_show( + "prof", + "show prof", + 5, + 9, + ) + + assert result == ["profiles", "profile"] + + def test_complete_show_is_case_sensitive(self): + """Verify show completion preserves case-sensitive behavior.""" + commands = create_commands( + stored_data={ + "Profiles": [], + "profiles": [], + }, + ) + + result = commands.complete_show( + "Prof", + "show Prof", + 5, + 9, + ) + + assert result == ["Profiles"] + + def test_complete_show_without_argument_lists_all_names(self): + """Verify show completion lists all names without an argument.""" + commands = create_commands( + stored_data={ + "profiles": [], + "services": [], + }, + ) + + result = commands.complete_show( + "", + "show ", + 5, + 5, + ) + + assert result == ["profiles", "services"] + + def test_complete_show_with_nested_accessor_returns_no_results(self): + """Verify nested accessor completion returns no results.""" + commands = create_commands( + stored_data={ + "profiles": [], + }, + ) + + result = commands.complete_show( + "profiles[", + "show profiles[", + 5, + 14, + ) + + assert result == [] + + def test_complete_show_after_completed_argument_returns_no_results(self): + """Verify completion after a complete argument returns no results.""" + commands = create_commands( + stored_data={ + "profiles": [], + }, + ) + + result = commands.complete_show( + "", + "show profiles ", + 14, + 14, + ) + + assert result == [] + + def test_complete_rm_returns_matching_names(self): + """Verify rm completion returns matching stored names.""" + commands = create_commands( + stored_data={ + "profiles": [], + "profile": {}, + "services": [], + }, + ) + + result = commands.complete_rm( + "prof", + "rm prof", + 3, + 7, + ) + + assert result == ["profiles", "profile"] + + def test_complete_rm_without_text_returns_all_names(self): + """Verify rm completion returns all names with empty text.""" + commands = create_commands( + stored_data={ + "profiles": [], + "services": [], + }, + ) + + result = commands.complete_rm( + "", + "rm ", + 3, + 3, + ) + + assert result == ["profiles", "services"] + + def test_complete_rm_is_case_sensitive(self): + """Verify rm completion is case-sensitive.""" + commands = create_commands( + stored_data={ + "Profiles": [], + "profiles": [], + }, + ) + + result = commands.complete_rm( + "Prof", + "rm Prof", + 3, + 7, + ) + + assert result == ["Profiles"] + + +class TestReferenceCommandsRemove: + """Test removal of stored references.""" + + def test_do_rm_without_name_prints_usage(self, capsys): + """Verify rm without a name prints usage.""" + commands = create_commands() + + commands.do_rm("") + + output = capsys.readouterr().out + + assert "Usage: rm " in output + + def test_do_rm_removes_stored_data(self, capsys): + """Verify rm removes stored data.""" + commands = create_commands( + stored_data={ + "profiles": [{"token": "abc123"}], + }, + stored_metadata={ + "profiles": { + "service": "media", + "method": "GetProfiles", + }, + }, + ) + + commands.do_rm("profiles") + + output = capsys.readouterr().out + + assert "profiles" not in commands.context.stored_data + assert "profiles" not in commands.context.stored_metadata + assert "Removed:" in output + assert "$profiles" in output + + def test_do_rm_removes_data_without_metadata(self, capsys): + """Verify rm removes data even when metadata does not exist.""" + commands = create_commands( + stored_data={ + "profiles": [{"token": "abc123"}], + }, + ) + + commands.do_rm("profiles") + + output = capsys.readouterr().out + + assert "profiles" not in commands.context.stored_data + assert "Removed:" in output + + def test_do_rm_unknown_name_prints_error(self, capsys): + """Verify rm reports an unknown stored variable.""" + commands = create_commands( + stored_data={ + "profiles": [], + }, + ) + + commands.do_rm("missing") + + output = capsys.readouterr().out + + assert "No stored data named 'missing'" in output + assert commands.context.stored_data == {"profiles": []} + + def test_do_rm_does_not_remove_other_data(self): + """Verify rm only removes the requested variable.""" + commands = create_commands( + stored_data={ + "profiles": [], + "services": [], + }, + stored_metadata={ + "profiles": {"service": "media"}, + "services": {"service": "devicemgmt"}, + }, + ) + + commands.do_rm("profiles") + + assert "profiles" not in commands.context.stored_data + assert "profiles" not in commands.context.stored_metadata + assert "services" in commands.context.stored_data + assert "services" in commands.context.stored_metadata + + +class TestReferenceCommandsClear: + """Test clearing stored references.""" + + def test_do_cls_clears_stored_data(self, capsys): + """Verify cls clears all stored data.""" + commands = create_commands( + stored_data={ + "profiles": [], + "services": [], + }, + stored_metadata={ + "profiles": {"service": "media"}, + "services": {"service": "devicemgmt"}, + }, + ) + + commands.do_cls("") + + output = capsys.readouterr().out + + assert commands.context.stored_data == {} + assert commands.context.stored_metadata == {} + assert "Cleared all stored data" in output + + def test_do_cls_clears_metadata(self): + """Verify cls clears metadata together with stored data.""" + commands = create_commands( + stored_data={"result": 123}, + stored_metadata={"result": {"method": "TestMethod"}}, + ) + + commands.do_cls("") + + assert commands.context.stored_data == {} + assert commands.context.stored_metadata == {} + + def test_do_cls_is_safe_when_storage_is_empty(self, capsys): + """Verify cls works when no stored data exists.""" + commands = create_commands() + + commands.do_cls("") + + output = capsys.readouterr().out + + assert commands.context.stored_data == {} + assert commands.context.stored_metadata == {} + assert "Cleared all stored data" in output diff --git a/tests/cli/test_cli_interactive_service.py b/tests/cli/test_cli_interactive_service.py new file mode 100644 index 0000000..bc8499c --- /dev/null +++ b/tests/cli/test_cli_interactive_service.py @@ -0,0 +1,801 @@ +# pylint: disable=protected-access +"""Tests for ONVIF interactive service commands.""" + +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest +from requests.exceptions import RequestException +from zeep.exceptions import Fault, TransportError + +from onvif.cli.interactive.service import ServiceCommands +from onvif.utils.exceptions import ONVIFOperationException + +from .conftest import strip_ansi + + +def create_args(**overrides): + """Create mocked CLI arguments.""" + args = MagicMock() + + args.host = overrides.get("host", "192.168.1.100") + args.port = overrides.get("port", 80) + args.username = overrides.get("username", "admin") + args.digest = overrides.get("digest", False) + args.https = overrides.get("https", False) + args.no_verify_ssl = overrides.get("no_verify_ssl", False) + args.timeout = overrides.get("timeout", 10) + args.debug = overrides.get("debug", False) + args.no_patch = overrides.get("no_patch", False) + args.wsdl = overrides.get("wsdl", None) + args.health_check_interval = overrides.get("health_check_interval", 10) + + return args + + +def create_context(**overrides): + """Create a mock shell context for service command tests.""" + context = MagicMock() + context.args = create_args(**overrides) + context.current_service = MagicMock() + context.current_service_name = "devicemgmt" + context.last_result = None + context.last_method = None + context.last_service_name = None + context.last_operation_timestamp = None + + for key, value in overrides.items(): + setattr(context, key, value) + + return context + + +def create_commands(**overrides): + """Create ServiceCommands with a mocked shell context.""" + commands = ServiceCommands() + commands.context = create_context(**overrides) + commands._substitute_stored_references = MagicMock() + commands._handle_connection_error = MagicMock() + return commands + + +class TestServiceCommandsExecution: + """Test service method execution.""" + + def test_execute_method_without_service(self, capsys): + """Verify execution fails when not in service mode.""" + commands = create_commands(current_service=None) + + commands.execute_service_method("GetDeviceInformation", "") + + captured = capsys.readouterr() + + assert "Error:" in captured.out + assert "Not in service mode" in captured.out + + def test_execute_unknown_method(self, capsys): + """Verify unknown methods display available methods.""" + commands = create_commands() + + commands.context.current_service = MagicMock(spec=["GetDeviceInformation"]) + commands.context.current_service.GetDeviceInformation = MagicMock() + + with patch( + "onvif.cli.interactive.service.get_service_methods", + return_value=["GetDeviceInformation", "GetCapabilities"], + ): + commands.execute_service_method("UnknownMethod", "") + + captured = capsys.readouterr() + + assert "Unknown method 'UnknownMethod'" in captured.out + assert "devicemgmt" in captured.out + assert "Available methods:" in captured.out + assert "GetDeviceInformation, GetCapabilities" in captured.out + + def test_execute_unknown_method_with_more_than_five_methods(self, capsys): + """Verify long method lists suggest using ls or TAB.""" + commands = create_commands() + + commands.context.current_service = MagicMock(spec=["GetDeviceInformation"]) + + methods = [ + "Method1", + "Method2", + "Method3", + "Method4", + "Method5", + "Method6", + ] + + with patch( + "onvif.cli.interactive.service.get_service_methods", + return_value=methods, + ): + commands.execute_service_method("UnknownMethod", "") + + captured = capsys.readouterr() + + assert "Available methods:" in captured.out + assert "Method1, Method2, Method3, Method4, Method5" in captured.out + assert "Type" in captured.out + assert "ls" in captured.out + + def test_execute_method_without_parameters(self, capsys): + """Verify a service method is called without parameters.""" + method = MagicMock(return_value={"Manufacturer": "Test"}) + commands = create_commands() + commands.context.current_service.GetDeviceInformation = method + + commands.execute_service_method("GetDeviceInformation", "") + + method.assert_called_once_with() + captured = capsys.readouterr() + + assert "{'Manufacturer': 'Test'}" in captured.out + assert commands.context.last_result == {"Manufacturer": "Test"} + assert commands.context.last_method == "GetDeviceInformation" + assert commands.context.last_service_name == "devicemgmt" + assert isinstance(commands.context.last_operation_timestamp, datetime) + + def test_execute_method_with_json_parameters(self, capsys): + """Verify JSON parameters are parsed and passed to the service method.""" + method = MagicMock(return_value="result") + commands = create_commands() + commands.context.current_service.GetCapabilities = method + + with patch( + "onvif.cli.interactive.service.parse_json_params", + return_value={"Category": "All"}, + ) as parse_mock: + commands.execute_service_method( + "GetCapabilities", + '{"Category": "All"}', + ) + + parse_mock.assert_called_once_with('{"Category": "All"}') + method.assert_called_once_with(Category="All") + + captured = capsys.readouterr() + assert "result" in captured.out + + def test_execute_method_substitutes_stored_references(self): + """Verify stored references are substituted before JSON parsing.""" + method = MagicMock(return_value="result") + commands = create_commands() + commands.context.current_service.GetCapabilities = method + commands._substitute_stored_references.return_value = '{"Category": "All"}' + + commands.execute_service_method( + "GetCapabilities", + '{"Category": "$stored"}', + ) + + commands._substitute_stored_references.assert_called_once_with( + '{"Category": "$stored"}' + ) + method.assert_called_once_with(Category="All") + + def test_execute_method_without_dollar_does_not_substitute(self): + """Verify reference substitution is skipped when no dollar sign exists.""" + method = MagicMock(return_value="result") + commands = create_commands() + commands.context.current_service.TestMethod = method + + with patch( + "onvif.cli.interactive.service.parse_json_params", + return_value={"value": "test"}, + ): + commands.execute_service_method( + "TestMethod", + '{"value": "test"}', + ) + + commands._substitute_stored_references.assert_not_called() + + def test_execute_method_updates_last_operation_state(self): + """Verify successful execution stores result metadata.""" + result = {"value": 123} + method = MagicMock(return_value=result) + commands = create_commands() + commands.context.current_service.TestMethod = method + + before = datetime.now() + commands.execute_service_method("TestMethod", "") + after = datetime.now() + + assert commands.context.last_result == result + assert commands.context.last_method == "TestMethod" + assert commands.context.last_service_name == "devicemgmt" + assert before <= commands.context.last_operation_timestamp <= after + + @pytest.mark.parametrize( + "exception", + [ + ValueError("invalid value"), + TypeError("invalid argument"), + ONVIFOperationException("FailedOperation", RuntimeError("Error")), + ], + ) + def test_execute_method_handles_regular_errors(self, exception, capsys): + """Verify regular operation errors are displayed without exiting.""" + method = MagicMock(side_effect=exception) + commands = create_commands() + commands.context.current_service.TestMethod = method + + commands.execute_service_method("TestMethod", "") + + captured = capsys.readouterr() + + assert "Error:" in captured.out + assert str(exception) in captured.out + commands._handle_connection_error.assert_not_called() + + @pytest.mark.parametrize( + "original_exception", + [ + RequestException("request failed"), + TransportError("transport failed"), + ], + ) + def test_execute_method_handles_connection_errors(self, original_exception): + """Verify wrapped connection errors invoke the connection handler.""" + exception = ONVIFOperationException( + "operation failed", + original_exception=original_exception, + ) + method = MagicMock(side_effect=exception) + commands = create_commands() + commands.context.current_service.TestMethod = method + + commands.execute_service_method("TestMethod", "") + + commands._handle_connection_error.assert_called_once_with() + + def test_execute_method_handles_soap_fault_without_traceback(self, capsys): + """Verify SOAP faults are printed without a debug traceback.""" + original_exception = Fault("SOAP operation failed") + exception = ONVIFOperationException( + "operation failed", + original_exception=original_exception, + ) + + method = MagicMock(side_effect=exception) + commands = create_commands(debug=True) + commands.context.current_service.TestMethod = method + + with patch("onvif.cli.interactive.service.traceback.print_exc") as print_exc: + commands.execute_service_method("TestMethod", "") + + print_exc.assert_not_called() + + captured = capsys.readouterr() + assert "Error:" in captured.out + + def test_execute_method_does_not_print_traceback_without_debug( + self, + ): + """Verify regular errors do not print tracebacks when debug is disabled.""" + exception = ONVIFOperationException("unexpected failure", TypeError) + method = MagicMock(side_effect=exception) + commands = create_commands(debug=False) + commands.context.current_service.TestMethod = method + + with patch("onvif.cli.interactive.service.traceback.print_exc") as print_exc: + commands.execute_service_method("TestMethod", "") + + print_exc.assert_not_called() + + def test_type_error_does_not_print_debug_traceback(self): + """Verify TypeError is treated as a common user error.""" + exception = TypeError("missing argument") + method = MagicMock(side_effect=exception) + commands = create_commands(debug=True) + commands.context.current_service.TestMethod = method + + with patch("onvif.cli.interactive.service.traceback.print_exc") as print_exc: + commands.execute_service_method("TestMethod", "") + + print_exc.assert_not_called() + + +class TestServiceCommandsDescription: + """Test the desc command.""" + + def test_desc_without_service(self, capsys): + """Verify desc requires service mode.""" + commands = create_commands(current_service=None) + + commands.do_desc("GetDeviceInformation") + + captured = capsys.readouterr() + + assert "must be in a service mode" in captured.out + assert "Enter a service first" in captured.out + + def test_desc_without_method_name(self, capsys): + """Verify desc displays usage without a method name.""" + commands = create_commands() + + commands.do_desc("") + + captured = capsys.readouterr() + + assert "Usage: desc " in captured.out + + def test_desc_unknown_method(self, capsys): + """Verify desc handles an unknown method.""" + commands = create_commands() + commands.context.current_service = MagicMock(spec=["KnownMethod"]) + + commands.do_desc("UnknownMethod") + + captured = capsys.readouterr() + + assert "Method 'UnknownMethod' not found" in captured.out + assert "Use" in captured.out + assert "ls" in captured.out + + def test_desc_without_documentation(self, capsys): + """Verify desc handles missing documentation.""" + commands = create_commands() + commands.context.current_service = MagicMock(spec=["KnownMethod"]) + + with patch( + "onvif.cli.interactive.service.get_method_documentation", + return_value=None, + ): + commands.do_desc("KnownMethod") + + captured = capsys.readouterr() + + assert ( + "No documentation or parameter info found for method 'KnownMethod'." + in captured.out + ) + + def test_desc_displays_documentation(self, capsys): + """Verify desc displays method documentation.""" + commands = create_commands() + commands.context.current_service = MagicMock(spec=["KnownMethod"]) + + doc_info = { + "doc": "Retrieve device information.\nReturns manufacturer details.", + "required": [], + "optional": [], + } + + with patch( + "onvif.cli.interactive.service.get_method_documentation", + return_value=doc_info, + ): + commands.do_desc("KnownMethod") + + captured = strip_ansi(capsys.readouterr().out) + + assert "Description for devicemgmt.KnownMethod():" in captured + assert "Retrieve device information." in captured + assert "Returns manufacturer details." in captured + + def test_desc_displays_required_and_optional_arguments(self, capsys): + """Verify desc displays required and optional arguments.""" + commands = create_commands() + commands.context.current_service = MagicMock(spec=["KnownMethod"]) + + doc_info = { + "doc": "Test method.", + "required": ["Device", "Token"], + "optional": ["Timeout", "Profile"], + } + + with patch( + "onvif.cli.interactive.service.get_method_documentation", + return_value=doc_info, + ): + commands.do_desc("KnownMethod") + + captured = capsys.readouterr() + + assert "Required Arguments:" in captured.out + assert " - Device" in captured.out + assert " - Token" in captured.out + assert "Optional Arguments:" in captured.out + assert " - Timeout" in captured.out + assert " - Profile" in captured.out + + def test_desc_wraps_long_documentation(self, capsys): + """Verify long documentation is wrapped to the configured width.""" + commands = create_commands() + commands.context.current_service = MagicMock(spec=["KnownMethod"]) + + long_text = "A " * 100 + doc_info = { + "doc": long_text, + "required": [], + "optional": [], + } + + with patch( + "onvif.cli.interactive.service.get_method_documentation", + return_value=doc_info, + ): + commands.do_desc("KnownMethod") + + captured = strip_ansi(capsys.readouterr().out) + + assert "Description for devicemgmt.KnownMethod():" in captured + assert len(captured.splitlines()) > 2 + + def test_complete_desc_without_service(self): + """Verify desc completion returns nothing outside service mode.""" + commands = create_commands(current_service=None) + + assert commands.complete_desc("Get", "", 0, 3) == [] + + def test_complete_desc_matches_methods_case_insensitively(self): + """Verify desc completion matches method names case-insensitively.""" + commands = create_commands() + + with patch( + "onvif.cli.interactive.service.get_service_methods", + return_value=[ + "GetDeviceInformation", + "GetCapabilities", + "SetSystemDateAndTime", + ], + ): + result = commands.complete_desc("get", "", 0, 3) + + assert result == ["GetDeviceInformation", "GetCapabilities"] + + +class TestServiceCommandsType: + """Test the type command.""" + + def test_type_without_service(self, capsys): + """Verify type requires service mode.""" + commands = create_commands(current_service=None) + + commands.do_type("GetDeviceInformation") + + captured = capsys.readouterr() + + assert "must be in a service mode" in captured.out + assert "Enter a service first" in captured.out + + def test_type_without_method_name(self, capsys): + """Verify type displays usage without a method name.""" + commands = create_commands() + + commands.do_type("") + + captured = capsys.readouterr() + + assert "Usage: type " in captured.out + + def test_type_unknown_method(self, capsys): + """Verify type handles an unknown method.""" + commands = create_commands() + commands.context.current_service = MagicMock(spec=["KnownMethod"]) + + commands.do_type("UnknownMethod") + + captured = capsys.readouterr() + + assert "Method 'UnknownMethod' not found" in captured.out + assert "Use" in captured.out + assert "ls" in captured.out + + def test_type_without_type_information(self, capsys): + """Verify type handles unavailable type information.""" + commands = create_commands() + commands.context.current_service = MagicMock(spec=["KnownMethod"]) + + with patch( + "onvif.cli.interactive.service.get_operation_type_info", + return_value=None, + ): + commands.do_type("KnownMethod") + + captured = capsys.readouterr() + + assert "Could not retrieve type information" in captured.out + + def test_type_displays_input_and_output(self, capsys): + """Verify type displays basic input and output information.""" + commands = create_commands() + commands.context.current_service = MagicMock(spec=["TestMethod"]) + + type_info = { + "input": { + "name": "TestMethodRequest", + "parameters": [], + }, + "output": { + "name": "TestMethodResponse", + "parameters": [], + }, + } + + with patch( + "onvif.cli.interactive.service.get_operation_type_info", + return_value=type_info, + ): + commands.do_type("TestMethod") + + captured = capsys.readouterr() + + assert "Input:" in captured.out + assert "[TestMethodRequest]" in captured.out + assert "(no parameters)" in captured.out + assert "Output:" in captured.out + assert "[TestMethodResponse]" in captured.out + + def test_type_displays_missing_input_and_output(self, capsys): + """Verify type displays undefined input and output messages.""" + commands = create_commands() + commands.context.current_service = MagicMock(spec=["TestMethod"]) + + type_info = { + "input": None, + "output": None, + } + + with patch( + "onvif.cli.interactive.service.get_operation_type_info", + return_value=type_info, + ): + commands.do_type("TestMethod") + + captured = capsys.readouterr() + + assert "Input:" in captured.out + assert "(not defined)" in captured.out + assert "Output:" in captured.out + + def test_type_displays_required_optional_and_unbounded_parameters(self, capsys): + """Verify type displays parameter occurrence information.""" + commands = create_commands() + commands.context.current_service = MagicMock(spec=["TestMethod"]) + + type_info = { + "input": { + "name": "TestMethodRequest", + "parameters": [ + { + "name": "RequiredParam", + "type": "string", + "minOccurs": "1", + "maxOccurs": "1", + }, + { + "name": "OptionalParam", + "type": "int", + "minOccurs": "0", + "maxOccurs": "1", + }, + { + "name": "ListParam", + "type": "string", + "minOccurs": "0", + "maxOccurs": "unbounded", + }, + ], + }, + "output": None, + } + + with patch( + "onvif.cli.interactive.service.get_operation_type_info", + return_value=type_info, + ): + commands.do_type("TestMethod") + + captured = strip_ansi(capsys.readouterr().out) + + assert "+RequiredParam" not in captured + assert "-RequiredParam" in captured + assert "[string]" in captured + assert "required" in captured + assert "OptionalParam" in captured + assert "optional" in captured + assert "ListParam" in captured + assert "unbounded" in captured + + def test_type_displays_attributes(self, capsys): + """Verify attributes use the plus prefix and required occurrence.""" + commands = create_commands() + commands.context.current_service = MagicMock(spec=["TestMethod"]) + + type_info = { + "input": { + "name": "TestRequest", + "parameters": [ + { + "name": "OptionalAttribute", + "type": "string", + "minOccurs": "0", + "maxOccurs": "1", + "is_attribute": True, + }, + { + "name": "RequiredAttribute", + "type": "string", + "minOccurs": "1", + "maxOccurs": "1", + "is_attribute": True, + }, + ], + }, + "output": None, + } + + with patch( + "onvif.cli.interactive.service.get_operation_type_info", + return_value=type_info, + ): + commands.do_type("TestMethod") + + captured = strip_ansi(capsys.readouterr().out) + + assert "+OptionalAttribute" in captured + assert "+RequiredAttribute" in captured + assert "required" in captured + + def test_type_displays_nested_parameters(self, capsys): + """Verify nested parameters use tree-style formatting.""" + commands = create_commands() + commands.context.current_service = MagicMock(spec=["TestMethod"]) + + type_info = { + "input": { + "name": "TestRequest", + "parameters": [ + { + "name": "Parent", + "type": "ParentType", + "minOccurs": "1", + "maxOccurs": "1", + "children": [ + { + "name": "ChildOne", + "type": "string", + "minOccurs": "1", + "maxOccurs": "1", + }, + { + "name": "ChildTwo", + "type": "int", + "minOccurs": "0", + "maxOccurs": "1", + }, + ], + } + ], + }, + "output": None, + } + + with patch( + "onvif.cli.interactive.service.get_operation_type_info", + return_value=type_info, + ): + commands.do_type("TestMethod") + + captured = capsys.readouterr() + + assert "Parent" in captured.out + assert "ChildOne" in captured.out + assert "ChildTwo" in captured.out + assert "├──" in captured.out + assert "└──" in captured.out + + def test_type_displays_parameter_documentation(self, capsys): + """Verify parameter documentation is displayed.""" + commands = create_commands() + commands.context.current_service = MagicMock(spec=["TestMethod"]) + + type_info = { + "input": { + "name": "TestRequest", + "parameters": [ + { + "name": "Token", + "type": "string", + "minOccurs": "1", + "maxOccurs": "1", + "documentation": "Identifier used to select the profile.", + } + ], + }, + "output": None, + } + + with patch( + "onvif.cli.interactive.service.get_operation_type_info", + return_value=type_info, + ): + commands.do_type("TestMethod") + + captured = capsys.readouterr() + + assert "Token" in captured.out + assert "Identifier used to select the profile." in captured.out + + def test_type_displays_multiline_documentation(self, capsys): + """Verify multiline parameter documentation is displayed.""" + commands = create_commands() + commands.context.current_service = MagicMock(spec=["TestMethod"]) + + type_info = { + "input": { + "name": "TestRequest", + "parameters": [ + { + "name": "Token", + "type": "string", + "minOccurs": "1", + "maxOccurs": "1", + "documentation": ("First line.\n" "\n" "Second line."), + } + ], + }, + "output": None, + } + + with patch( + "onvif.cli.interactive.service.get_operation_type_info", + return_value=type_info, + ): + commands.do_type("TestMethod") + + captured = capsys.readouterr() + + assert "First line." in captured.out + assert "Second line." in captured.out + + def test_type_handles_empty_output_parameters(self, capsys): + """Verify empty output parameters are displayed correctly.""" + commands = create_commands() + commands.context.current_service = MagicMock(spec=["TestMethod"]) + + type_info = { + "input": None, + "output": { + "name": "TestResponse", + "parameters": [], + }, + } + + with patch( + "onvif.cli.interactive.service.get_operation_type_info", + return_value=type_info, + ): + commands.do_type("TestMethod") + + captured = capsys.readouterr() + + assert "[TestResponse]" in captured.out + assert "(no parameters)" in captured.out + + def test_complete_type_without_service(self): + """Verify type completion returns nothing outside service mode.""" + commands = create_commands(current_service=None) + + assert commands.complete_type("Get", "", 0, 3) == [] + + def test_complete_type_matches_methods_case_insensitively(self): + """Verify type completion matches method names case-insensitively.""" + commands = create_commands() + + with patch( + "onvif.cli.interactive.service.get_service_methods", + return_value=[ + "GetDeviceInformation", + "GetCapabilities", + "SetSystemDateAndTime", + ], + ): + result = commands.complete_type("get", "", 0, 3) + + assert result == ["GetDeviceInformation", "GetCapabilities"] diff --git a/tests/cli/test_cli_interactive_shell.py b/tests/cli/test_cli_interactive_shell.py new file mode 100644 index 0000000..e75fd99 --- /dev/null +++ b/tests/cli/test_cli_interactive_shell.py @@ -0,0 +1,574 @@ +# pylint: disable=protected-access +"""Tests for the ONVIF interactive shell.""" + +import cmd +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from requests.exceptions import RequestException + +from onvif.cli.interactive.shell import InteractiveShell +from onvif.utils.exceptions import ONVIFOperationException + + +def create_args(**overrides): + """Create mocked CLI arguments.""" + args = MagicMock() + + args.host = overrides.get("host", "192.168.1.100") + args.port = overrides.get("port", 80) + args.username = overrides.get("username", "admin") + args.digest = overrides.get("digest", False) + args.https = overrides.get("https", False) + args.no_verify_ssl = overrides.get("no_verify_ssl", False) + args.timeout = overrides.get("timeout", 10) + args.debug = overrides.get("debug", False) + args.no_patch = overrides.get("no_patch", False) + args.wsdl = overrides.get("wsdl", None) + args.health_check_interval = overrides.get("health_check_interval", 10) + + return args + + +def create_client(): + """Create a mock ONVIF client with the responses required by the shell. + + Returns: + A mock ONVIF client configured with basic device information and + supported ONVIF version information. + """ + client = MagicMock() + + device_service = MagicMock() + device_service.GetDeviceInformation.return_value = SimpleNamespace( + Manufacturer="Dahua", + Model="DH-H5D-5F", + FirmwareVersion="2.860.0000000.26.R", + SerialNumber="AM08C90PAG3C79B", + HardwareId="1.00", + ) + device_service.GetCapabilities.return_value = { + "Device": { + "System": { + "SupportedVersions": [ + SimpleNamespace(Major=2, Minor=0), + SimpleNamespace(Major=2, Minor=5), + SimpleNamespace(Major=2, Minor=6), + ] + } + } + } + + client.devicemgmt.return_value = device_service + + return client + + +class TestInteractiveShellInitialization: + """Test initialization and startup behavior of ``InteractiveShell``.""" + + @pytest.fixture + def client(self): + """Provide a mocked ONVIF client for initialization tests.""" + return create_client() + + @pytest.fixture + def args(self): + """Provide default CLI arguments for initialization tests.""" + return create_args() + + @staticmethod + def _create_shell(client, args): + with patch("onvif.cli.interactive.utils.threading.Thread"): + return InteractiveShell(client, args) + + def test_initializes_context(self, client, args): + """Verify that initialization creates the shared shell context.""" + shell = self._create_shell(client, args) + + assert shell.context.client is client + assert shell.context.args is args + + def test_initializes_cmd_state(self, client, args): + """Verify that ``cmd.Cmd`` state is initialized.""" + shell = self._create_shell(client, args) + + assert hasattr(shell, "stdin") + assert hasattr(shell, "stdout") + assert hasattr(shell, "cmdqueue") + assert shell.prompt.endswith("> ") + + def test_fetches_device_information(self, client, args): + """Verify that device information is requested during initialization.""" + self._create_shell(client, args) + + client.devicemgmt.return_value.GetDeviceInformation.assert_called_once_with() + + def test_fetches_supported_onvif_versions(self, client, args): + """Verify that supported ONVIF versions are requested during initialization.""" + self._create_shell(client, args) + + client.devicemgmt.return_value.GetCapabilities.assert_called_once_with( + Category="All" + ) + + def test_builds_device_information_text(self, client, args): + """Verify that device information is formatted into the shell context.""" + shell = self._create_shell(client, args) + + assert shell.context.device_info_text is not None + assert "Dahua" in shell.context.device_info_text + assert "DH-H5D-5F" in shell.context.device_info_text + assert "2.860.0000000.26.R" in shell.context.device_info_text + assert "AM08C90PAG3C79B" in shell.context.device_info_text + assert "1.00" in shell.context.device_info_text + assert "2.6" in shell.context.device_info_text + + def test_selects_latest_supported_onvif_version(self, client, args): + """Verify that the highest supported ONVIF version is displayed.""" + shell = self._create_shell(client, args) + + assert "2.6" in shell.context.device_info_text + assert "2.5" not in shell.context.device_info_text + + def test_prints_intro_with_device_information(self, client, args): + """Verify that the interactive intro receives formatted device information.""" + with patch( + "onvif.cli.interactive.shell.print_interactive_intro", + return_value="Interactive ONVIF shell", + ) as print_intro: + shell = self._create_shell(client, args) + + print_intro.assert_called_once_with( + args, + shell.context.device_info_text, + ) + assert shell.intro == "Interactive ONVIF shell" + + def test_starts_health_check_after_initialization(self, client, args): + """Verify that the background health-check thread starts after initialization.""" + with patch("onvif.cli.interactive.utils.threading.Thread") as thread_cls: + health_thread = thread_cls.return_value + + InteractiveShell(client, args) + + health_thread.start.assert_called_once_with() + + def test_handles_missing_capabilities(self, client, args): + """Verify that initialization continues when capability data is unavailable.""" + client.devicemgmt.return_value.GetCapabilities.side_effect = KeyError( + "SupportedVersions" + ) + + shell = self._create_shell(client, args) + + assert shell.context.device_info_text is not None + assert "Dahua" in shell.context.device_info_text + assert "ONVIF Version" in shell.context.device_info_text + + def test_handles_missing_device_attributes(self, client, args): + """Verify that missing device attributes fall back to ``Unknown``.""" + client.devicemgmt.return_value.GetDeviceInformation.return_value = ( + SimpleNamespace() + ) + + shell = self._create_shell(client, args) + + assert "Unknown" in shell.context.device_info_text + + def test_handles_connection_error(self, client, args): + """Verify that transport-related ONVIF errors invoke connection handling.""" + original_exception = MagicMock() + + original_exception = RequestException("Connection failed") + + client.devicemgmt.return_value.GetDeviceInformation.side_effect = ( + ONVIFOperationException( + "Connection failed", + original_exception=original_exception, + ) + ) + + with ( + patch.object( + InteractiveShell, + "_handle_connection_error", + ) as handle_connection_error, + ): + self._create_shell(client, args) + + handle_connection_error.assert_called_once_with() + + +class TestInteractiveShellPrompt: + """Test prompt management for ``InteractiveShell``.""" + + @pytest.fixture + def shell(self): + """Create a minimally initialized shell for prompt tests.""" + shell = object.__new__(InteractiveShell) + cmd.Cmd.__init__(shell) + shell.context = MagicMock() + shell.context.args = create_args() + shell.context.current_service_name = None + return shell + + def test_updates_root_prompt(self, shell): + """Verify that the prompt identifies the connected device in root mode.""" + shell.update_prompt() + + assert shell.prompt == "admin@192.168.1.100:80 > " + + def test_updates_service_prompt(self, shell): + """Verify that the prompt includes the active service name.""" + shell.context.current_service_name = "devicemgmt" + + shell.update_prompt() + + assert shell.prompt == "admin@192.168.1.100:80/devicemgmt > " + + +class TestInteractiveShellCmdloop: + """Test the customized command-loop implementation.""" + + @pytest.fixture + def shell(self): + """Create a minimally initialized shell for command-loop tests.""" + shell = object.__new__(InteractiveShell) + cmd.Cmd.__init__(shell) + shell.prompt = "test > " + shell.intro = None + return shell + + def test_cmdloop_uses_intro(self, shell): + """Verify that an explicitly supplied intro is written to stdout.""" + shell.cmdqueue = ["exit"] + + with ( + patch.object(shell, "onecmd", return_value=True), + patch.object(shell.stdout, "write") as write, + ): + shell.cmdloop("Test intro") + + assert shell.intro == "Test intro" + write.assert_called_once_with("Test intro\n") + + def test_cmdloop_processes_command_queue(self, shell): + """Verify that queued commands are processed without reading stdin.""" + shell.cmdqueue = ["test"] + + with patch.object( + shell, + "onecmd", + return_value=True, + ) as onecmd: + shell.cmdloop() + + onecmd.assert_called_once_with("test") + + def test_cmdloop_handles_eof(self, shell): + """Verify that EOF is converted into the ``EOF`` command.""" + shell.cmdqueue = [] + + with ( + patch( + "builtins.input", + side_effect=EOFError, + ), + patch.object( + shell, + "onecmd", + return_value=True, + ) as onecmd, + ): + shell.cmdloop() + + onecmd.assert_called_once_with("EOF") + + def test_cmdloop_handles_keyboard_interrupt(self, shell, capsys): + """Verify that Ctrl-C produces an empty command instead of terminating.""" + shell.cmdqueue = [] + + with ( + patch( + "builtins.input", + side_effect=KeyboardInterrupt, + ), + patch.object( + shell, + "onecmd", + return_value=True, + ) as onecmd, + ): + shell.cmdloop() + + onecmd.assert_called_once_with("") + assert "^C" in capsys.readouterr().out + + +class TestInteractiveShellOnecmd: + """Test command dispatch performed by ``InteractiveShell.onecmd``.""" + + @pytest.fixture + def shell(self): + """Create a minimally initialized shell for command-dispatch tests.""" + shell = object.__new__(InteractiveShell) + cmd.Cmd.__init__(shell) + shell.context = MagicMock() + shell.context.current_service = None + return shell + + def test_empty_command_calls_emptyline(self, shell): + """Verify that an empty command is delegated to ``emptyline``.""" + with patch.object(shell, "emptyline", return_value=None) as emptyline: + result = shell.onecmd(" ") + + emptyline.assert_called_once_with() + assert result is None + + def test_splits_chained_commands(self, shell): + """Verify that commands separated by ``&&`` are executed independently.""" + shell._split_multi_commands = MagicMock( + return_value=["first", "second", "third"] + ) + shell.onecmd = MagicMock(side_effect=[None, None, None]) + + # Call the real implementation rather than the mocked recursive method. + # pylint: disable=no-value-for-parameter + real_onecmd = InteractiveShell.onecmd.__get__(shell) + + result = real_onecmd("first && second && third") + + shell._split_multi_commands.assert_called_once_with("first && second && third") + assert result is None + assert shell.onecmd.call_count == 3 + + def test_stops_chained_commands_when_command_requests_exit(self, shell): + """Verify that command chaining stops when a command returns a stop value.""" + shell._split_multi_commands = MagicMock(return_value=["first", "exit", "third"]) + shell.onecmd = MagicMock(side_effect=[None, True]) + + # pylint: disable=no-value-for-parameter + real_onecmd = InteractiveShell.onecmd.__get__(shell) + + result = real_onecmd("first && exit && third") + + assert result is True + assert shell.onecmd.call_count == 2 + + def test_delegates_root_command_to_cmd(self, shell): + """Verify that normal root commands use ``cmd.Cmd`` dispatch.""" + with patch.object( + cmd.Cmd, + "onecmd", + return_value=None, + ) as cmd_onecmd: + result = InteractiveShell.onecmd(shell, "help") + + cmd_onecmd.assert_called_once_with("help") + assert result is None + + def test_dispatches_service_method_in_service_mode(self, shell): + """Verify that unknown commands in service mode use the shell fallback.""" + shell.context.current_service = MagicMock() + + with patch.object( + shell, + "default", + return_value=None, + ) as default: + result = InteractiveShell.onecmd(shell, "GetDeviceInformation") + + default.assert_called_once_with("GetDeviceInformation") + assert result is None + + +class TestInteractiveShellDefault: + """Test fallback handling for unknown commands.""" + + @pytest.fixture + def shell(self): + """Create a minimally initialized shell for default-command tests.""" + shell = object.__new__(InteractiveShell) + cmd.Cmd.__init__(shell) + shell.context = MagicMock() + shell.context.current_service = None + return shell + + def test_enters_known_service(self, shell): + """Verify that a known service name enters service mode.""" + with ( + patch( + "onvif.cli.interactive.shell.get_device_available_services", + return_value=["devicemgmt", "media"], + ), + patch.object( + shell, + "do_enter_service", + return_value=None, + ) as enter_service, + ): + shell.default("devicemgmt") + + enter_service.assert_called_once_with("devicemgmt") + + def test_enters_service_with_arguments(self, shell): + """Verify that a service name followed by arguments enters that service.""" + with ( + patch( + "onvif.cli.interactive.shell.get_device_available_services", + return_value=["subscription"], + ), + patch.object( + shell, + "do_enter_service", + return_value=None, + ) as enter_service, + ): + shell.default("subscription Name=$subscription") + + enter_service.assert_called_once_with("subscription Name=$subscription") + + def test_executes_service_method_without_arguments(self, shell): + """Verify that a service method without parameters is executed.""" + shell.context.current_service = MagicMock() + + with patch.object( + shell, + "execute_service_method", + return_value=None, + ) as execute_method: + shell.default("GetDeviceInformation") + + execute_method.assert_called_once_with("GetDeviceInformation", "") + + def test_executes_service_method_with_arguments(self, shell): + """Verify that a service method with parameters is executed.""" + shell.context.current_service = MagicMock() + + with patch.object( + shell, + "execute_service_method", + return_value=None, + ) as execute_method: + shell.default("GetStreamUri ProfileToken=$profile") + + execute_method.assert_called_once_with( + "GetStreamUri", + "ProfileToken=$profile", + ) + + def test_suggests_similar_commands(self, shell, capsys): + """Verify that similar commands are displayed for unknown input.""" + with ( + patch( + "onvif.cli.interactive.shell.get_device_available_services", + return_value=[], + ), + patch.object( + shell, + "get_suggestions", + return_value=["services", "service"], + ), + ): + shell.default("servce") # codespell:ignore + + output = capsys.readouterr().out + + assert "Unknown command:" in output + assert "servce" in output # codespell:ignore + assert "Did you mean:" in output + assert "services" in output + + def test_reports_unknown_command_without_suggestions(self, shell, capsys): + """Verify that unknown input without suggestions displays help guidance.""" + with ( + patch( + "onvif.cli.interactive.shell.get_device_available_services", + return_value=[], + ), + patch.object( + shell, + "get_suggestions", + return_value=[], + ), + ): + shell.default("foobar") + + output = capsys.readouterr().out + + assert "Unknown command:" in output + assert "foobar" in output + assert "help" in output + + +class TestInteractiveShellTerminalCommands: + """Test terminal and shell lifecycle commands.""" + + @pytest.fixture + def shell(self): + """Create a minimally initialized shell for terminal command tests.""" + shell = object.__new__(InteractiveShell) + cmd.Cmd.__init__(shell) + shell.stop_health_check = MagicMock() + return shell + + def test_emptyline_does_nothing(self, shell): + """Verify that an empty command does not produce output or an action.""" + assert shell.emptyline() is None + + def test_clear_windows_terminal(self, shell): + """Verify that Windows terminal clearing emits the Windows escape sequence.""" + with ( + patch("onvif.cli.interactive.shell.sys.platform", "win32"), + patch("builtins.print") as print_mock, + ): + shell.do_clear("") + + print_mock.assert_called_once_with( + "\033[2J\033[3J\033[H", + end="", + ) + + def test_clear_posix_terminal(self, shell): + """Verify that POSIX terminal clearing emits the POSIX escape sequence.""" + with ( + patch("onvif.cli.interactive.shell.sys.platform", "linux"), + patch("builtins.print") as print_mock, + ): + shell.do_clear("") + + print_mock.assert_called_once_with( + "\033[2J\033[H", + end="", + ) + + def test_exit_stops_health_check(self, shell): + """Verify that exiting the shell stops the background health check.""" + result = shell.do_exit("") + + shell.stop_health_check.set.assert_called_once_with() + assert result is True + + def test_run_starts_command_loop(self, shell): + """Verify that ``run`` delegates execution to ``cmdloop``.""" + with patch.object(shell, "cmdloop") as cmdloop: + shell.run() + + cmdloop.assert_called_once_with() + + def test_run_handles_keyboard_interrupt(self, shell): + """Verify that ``run`` stops the health check and exits on Ctrl-C.""" + with ( + patch.object( + shell, + "cmdloop", + side_effect=KeyboardInterrupt, + ), + patch("onvif.cli.interactive.shell.sys.exit") as exit_mock, + ): + shell.run() + + shell.stop_health_check.set.assert_called_once_with() + exit_mock.assert_called_once_with(0) diff --git a/tests/cli/test_cli_interactive_utils.py b/tests/cli/test_cli_interactive_utils.py new file mode 100644 index 0000000..0575a35 --- /dev/null +++ b/tests/cli/test_cli_interactive_utils.py @@ -0,0 +1,867 @@ +# pylint: disable=protected-access +"""Tests for ONVIF interactive shell utilities.""" + +import socket +import ssl +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from onvif.cli.interactive.utils import ShellUtilities + +from .conftest import strip_ansi + + +def create_args(**overrides): + """Create mocked CLI arguments.""" + args = MagicMock() + + args.host = overrides.get("host", "192.168.1.100") + args.port = overrides.get("port", 80) + args.username = overrides.get("username", "admin") + args.digest = overrides.get("digest", False) + args.https = overrides.get("https", False) + args.no_verify_ssl = overrides.get("no_verify_ssl", False) + args.timeout = overrides.get("timeout", 10) + args.debug = overrides.get("debug", False) + args.no_patch = overrides.get("no_patch", False) + args.wsdl = overrides.get("wsdl", None) + args.health_check_interval = overrides.get("health_check_interval", 10) + + return args + + +def create_context(**overrides): + """Create a mocked shell context for utility tests. + + Args: + **overrides: Optional context values that should override defaults. + + Returns: + A mock shell context configured with common test values. + """ + context = MagicMock() + context.args = create_args() + context.client = MagicMock() + context.current_service = None + context.current_service_name = None + context.base_commands = [ + "capabilities", + "caps", + "services", + "help", + "exit", + "store", + "rm", + "show", + "cls", + "clear", + "info", + "debug", + "ls", + "cd", + "shortcuts", + "desc", + "type", + ] + + for key, value in overrides.items(): + setattr(context, key, value) + + return context + + +def create_utilities(): + """Create a minimally initialized ``ShellUtilities`` instance. + + Returns: + A shell utility instance with a mocked shell context. + """ + utilities = ShellUtilities() + utilities.context = create_context() + utilities.stdout = MagicMock() + return utilities + + +class TestShellUtilitiesInitialization: # pylint: disable=too-few-public-methods + """Test health-check initialization.""" + + def test_initialize_health_check_creates_event(self): + """Verify that health-check initialization creates a stop event.""" + utilities = create_utilities() + + with ( + patch("onvif.cli.interactive.utils.threading.Event") as event_cls, + patch("onvif.cli.interactive.utils.threading.Thread") as thread_cls, + ): + utilities._initialize_health_check() + + event_cls.assert_called_once_with() + assert utilities.stop_health_check is event_cls.return_value + + thread_cls.assert_called_once_with( + target=utilities._periodic_health_check, + daemon=True, + ) + assert utilities.health_check_thread is thread_cls.return_value + + +class TestShellUtilitiesPrompt: + """Test interactive shell prompt generation.""" + + def test_updates_root_prompt(self): + """Verify that the root prompt contains connection information.""" + utilities = create_utilities() + + utilities.update_prompt() + + assert utilities.prompt == "admin@192.168.1.100:80 > " + + def test_updates_service_prompt(self): + """Verify that the service name is included in service mode.""" + utilities = create_utilities() + utilities.context.current_service_name = "devicemgmt" + + utilities.update_prompt() + + assert utilities.prompt == "admin@192.168.1.100:80/devicemgmt > " + + +class TestShellUtilitiesDisplayGrid: + """Test grid formatting and terminal display.""" + + def test_display_grid_returns_without_output_for_empty_items(self): + """Verify that an empty item list produces no output.""" + utilities = create_utilities() + + with patch("builtins.print") as print_mock: + utilities._display_grid([]) + + print_mock.assert_not_called() + + def test_display_grid_uses_terminal_width(self): + """Verify that grid layout uses the detected terminal width.""" + utilities = create_utilities() + + with ( + patch( + "onvif.cli.interactive.utils.get_device_available_services", + return_value=[], + ), + patch( + "onvif.cli.interactive.utils.shutil.get_terminal_size", + return_value=SimpleNamespace(columns=20), + ), + patch("builtins.print") as print_mock, + ): + utilities._display_grid(["one", "two", "three", "four"]) + + assert print_mock.call_count > 0 + + def test_display_grid_falls_back_to_default_width(self): + """Verify that terminal-size failures use an 80-column fallback.""" + utilities = create_utilities() + + with ( + patch( + "onvif.cli.interactive.utils.get_device_available_services", + return_value=[], + ), + patch( + "onvif.cli.interactive.utils.shutil.get_terminal_size", + side_effect=RuntimeError, + ), + patch("builtins.print") as print_mock, + ): + utilities._display_grid(["one", "two"]) + + print_mock.assert_called_once_with("one two") + + @pytest.mark.parametrize( + "exception", + [ + KeyError, + ValueError, + RuntimeError, + ], + ) + def test_display_grid_handles_terminal_size_errors(self, exception): + """Verify that known terminal-size errors use the fallback width.""" + utilities = create_utilities() + + with ( + patch( + "onvif.cli.interactive.utils.get_device_available_services", + return_value=[], + ), + patch( + "onvif.cli.interactive.utils.shutil.get_terminal_size", + side_effect=exception, + ), + patch("builtins.print") as print_mock, + ): + utilities._display_grid(["one", "two"]) + + print_mock.assert_called() + + def test_display_grid_colors_device_services(self): + """Verify that device services are displayed using service coloring.""" + utilities = create_utilities() + + with ( + patch( + "onvif.cli.interactive.utils.get_device_available_services", + return_value=["devicemgmt"], + ), + patch( + "onvif.cli.interactive.utils.colorize", + side_effect=lambda value, _color: f"<{value}>", + ), + patch( + "onvif.cli.interactive.utils.shutil.get_terminal_size", + return_value=SimpleNamespace(columns=80), + ), + patch("builtins.print") as print_mock, + ): + utilities._display_grid(["devicemgmt", "help"]) + + output = print_mock.call_args.args[0] + + assert "" in output + assert "help" in output + + def test_display_grid_does_not_query_services_in_service_mode(self): + """Verify that root-level services are not queried in service mode.""" + utilities = create_utilities() + utilities.context.current_service = MagicMock() + + with ( + patch( + "onvif.cli.interactive.utils.get_device_available_services" + ) as get_services, + patch("builtins.print"), + ): + utilities._display_grid(["GetDeviceInformation"]) + + get_services.assert_not_called() + + def test_columnize_delegates_to_display_grid(self): + """Verify that ``columnize`` delegates to ``_display_grid``.""" + utilities = create_utilities() + + with patch.object(utilities, "_display_grid") as display_grid: + utilities.columnize(["one", "two"], 40) + + display_grid.assert_called_once_with(["one", "two"]) + + def test_columnize_returns_for_empty_list(self): + """Verify that ``columnize`` ignores an empty list.""" + utilities = create_utilities() + + with patch.object(utilities, "_display_grid") as display_grid: + result = utilities.columnize([]) # pylint: disable=assignment-from-none + + display_grid.assert_not_called() + assert result is None + + +class TestShellUtilitiesPrintTopics: + """Test command-topic display.""" + + def test_print_topics_returns_for_empty_commands(self): + """Verify that no output is produced for an empty command list.""" + utilities = create_utilities() + + with patch.object(utilities, "_display_grid") as display_grid: + result = utilities.print_topics( # pylint: disable=assignment-from-none + "Commands", [], 10, 2 + ) + + display_grid.assert_not_called() + assert result is None + + def test_print_topics_writes_nonempty_header(self): + """Verify that a nonempty header is written to stdout.""" + utilities = create_utilities() + + with patch.object(utilities, "_display_grid") as display_grid: + utilities.print_topics("Commands", ["help", "exit"], 10, 2) + + utilities.stdout.write.assert_called_once_with("Commands\n") + display_grid.assert_called_once_with(["help", "exit"]) + + def test_print_topics_ignores_whitespace_header(self): + """Verify that whitespace-only headers are not written.""" + utilities = create_utilities() + + with patch.object(utilities, "_display_grid") as display_grid: + utilities.print_topics(" ", ["help"], 10, 2) + + utilities.stdout.write.assert_not_called() + display_grid.assert_called_once_with(["help"]) + + +class TestShellUtilitiesSuggestions: + """Test command suggestion generation.""" + + def test_get_suggestions_in_root_mode(self): + """Verify that root suggestions include matching services and commands.""" + utilities = create_utilities() + + with ( + patch( + "onvif.cli.interactive.utils.get_device_available_services", + return_value=["devicemgmt", "media", "events"], + ), + patch( + "onvif.cli.interactive.utils.colorize", + side_effect=lambda value, _color: value, + ), + ): + suggestions = utilities.get_suggestions("me") + + assert suggestions == ["media"] + + def test_get_suggestions_includes_root_commands(self): + """Verify that matching root commands are returned.""" + utilities = create_utilities() + + with ( + patch( + "onvif.cli.interactive.utils.get_device_available_services", + return_value=[], + ), + patch( + "onvif.cli.interactive.utils.colorize", + side_effect=lambda value, _color: value, + ), + ): + suggestions = utilities.get_suggestions("hel") # codespell:ignore + + assert suggestions == ["help"] + + def test_get_suggestions_is_case_insensitive(self): + """Verify that suggestion matching ignores case.""" + utilities = create_utilities() + + with patch( + "onvif.cli.interactive.utils.get_device_available_services", + return_value=["Media"], + ): + suggestions = utilities.get_suggestions("med") + + assert [strip_ansi(item) for item in suggestions] == ["Media"] + + def test_get_suggestions_in_service_mode(self): + """Verify that service methods are suggested in service mode.""" + utilities = create_utilities() + utilities.context.current_service = MagicMock() + + with ( + patch( + "onvif.cli.interactive.utils.get_service_methods", + return_value=["GetProfiles", "GetStreamUri", "GetStatus"], + ), + patch( + "onvif.cli.interactive.utils.colorize", + side_effect=lambda value, _color: value, + ), + ): + suggestions = utilities.get_suggestions("getp") + + assert suggestions == ["GetProfiles"] + + def test_get_suggestions_includes_service_helpers(self): + """Verify that ``type`` and ``desc`` are available in service mode.""" + utilities = create_utilities() + utilities.context.current_service = MagicMock() + + with ( + patch( + "onvif.cli.interactive.utils.get_service_methods", + return_value=[], + ), + patch( + "onvif.cli.interactive.utils.colorize", + side_effect=lambda value, _color: value, + ), + ): + suggestions = utilities.get_suggestions("d") + + assert suggestions == ["desc"] + + def test_get_suggestions_limits_results_to_five(self): + """Verify that suggestions are limited to five results.""" + utilities = create_utilities() + + with ( + patch( + "onvif.cli.interactive.utils.get_device_available_services", + return_value=[ + "service1", + "service2", + "service3", + "service4", + "service5", + "service6", + ], + ), + patch( + "onvif.cli.interactive.utils.colorize", + side_effect=lambda value, _color: value, + ), + ): + suggestions = utilities.get_suggestions("service") + + assert len(suggestions) == 5 + + +class TestShellUtilitiesCompletion: + """Test command-name tab completion.""" + + def test_completenames_in_root_mode(self): + """Verify that root completion includes services and base commands.""" + utilities = create_utilities() + + with patch( + "onvif.cli.interactive.utils.get_device_available_services", + return_value=["devicemgmt", "media"], + ): + completions = utilities.completenames("me") + + assert completions == ["media"] + + def test_completenames_includes_root_commands(self): + """Verify that root completion includes matching base commands.""" + utilities = create_utilities() + + with patch( + "onvif.cli.interactive.utils.get_device_available_services", + return_value=[], + ): + completions = utilities.completenames("hel") # codespell:ignore + + assert completions == ["help"] + + def test_completenames_in_service_mode(self): + """Verify that service methods are completed.""" + utilities = create_utilities() + utilities.context.current_service = MagicMock() + + with ( + patch( + "onvif.cli.interactive.utils.get_service_methods", + return_value=["GetProfiles", "GetStreamUri"], + ), + patch( + "onvif.cli.interactive.utils.colorize", + side_effect=lambda value, _color: value, + ), + ): + completions = utilities.completenames("getp") + + assert completions == ["GetProfiles"] + + def test_completenames_includes_service_helpers(self): + """Verify that service helper commands are available for completion.""" + utilities = create_utilities() + utilities.context.current_service = MagicMock() + + with ( + patch( + "onvif.cli.interactive.utils.get_service_methods", + return_value=[], + ), + patch( + "onvif.cli.interactive.utils.colorize", + side_effect=lambda value, _color: value, + ), + ): + completions = utilities.completenames("d") + + assert completions == ["desc"] + + def test_completenames_is_case_insensitive(self): + """Verify that completion matching ignores case.""" + utilities = create_utilities() + + with patch( + "onvif.cli.interactive.utils.get_device_available_services", + return_value=["Media"], + ): + completions = utilities.completenames("med") + + assert completions == ["Media"] + + +class TestShellUtilitiesHealthCheck: + """Test periodic device connection health checks.""" + + def _configure_health_check(self, utilities, **args): + """Configure health-check context and mocked stop event.""" + utilities.context.args = create_args(**args) + utilities.stop_health_check = MagicMock() + + def test_waits_before_first_health_check(self): + """Verify that the first health check waits for the configured interval.""" + utilities = create_utilities() + self._configure_health_check(utilities, health_check_interval=15) + + utilities.stop_health_check.wait.side_effect = [ + None, + None, + ] + utilities.stop_health_check.is_set.side_effect = [ + True, + ] + + utilities._periodic_health_check() + + utilities.stop_health_check.wait.assert_called_once_with(15) + + def test_uses_default_health_check_interval(self): + """Verify that the default health-check interval is ten seconds.""" + utilities = create_utilities() + utilities.context.args = SimpleNamespace( + host="192.168.1.100", + port=80, + https=False, + ) + utilities.stop_health_check = MagicMock() + utilities.stop_health_check.is_set.return_value = True + + utilities._periodic_health_check() + + utilities.stop_health_check.wait.assert_called_once_with(10) + + def test_successful_http_health_check(self): + """Verify that a successful HTTP health check creates and closes a socket.""" + utilities = create_utilities() + self._configure_health_check( + utilities, + https=False, + health_check_interval=10, + ) + + utilities.stop_health_check.is_set.side_effect = [ + False, + True, + ] + + socket_mock = MagicMock() + + with patch( + "onvif.cli.interactive.utils.socket.create_connection", + return_value=socket_mock, + ) as create_connection: + utilities._periodic_health_check() + + create_connection.assert_called_once_with( + ("192.168.1.100", 80), + timeout=5.0, + ) + socket_mock.close.assert_called_once_with() + + def test_successful_https_health_check(self): + """Verify that HTTPS health checks create a TLS socket.""" + utilities = create_utilities() + self._configure_health_check( + utilities, + https=True, + port=443, + health_check_interval=10, + ) + + utilities.stop_health_check.is_set.side_effect = [ + False, + True, + ] + + raw_socket = MagicMock() + tls_socket = MagicMock() + + ssl_context = MagicMock() + ssl_context.wrap_socket.return_value = tls_socket + + with ( + patch( + "onvif.cli.interactive.utils.ssl.create_default_context", + return_value=ssl_context, + ) as create_context, # pylint: disable=redefined-outer-name + patch( + "onvif.cli.interactive.utils.socket.socket", + return_value=raw_socket, + ) as socket_cls, + ): + utilities._periodic_health_check() + + create_context.assert_called_once_with() + socket_cls.assert_called_once_with( + socket.AF_INET, + socket.SOCK_STREAM, + ) + + assert ssl_context.minimum_version == ssl.TLSVersion.TLSv1_2 + assert ssl_context.check_hostname is False + assert ssl_context.verify_mode == ssl.CERT_NONE + + ssl_context.wrap_socket.assert_called_once_with( + raw_socket, + server_hostname="192.168.1.100", + ) + tls_socket.settimeout.assert_called_once_with(5.0) + tls_socket.connect.assert_called_once_with(("192.168.1.100", 443)) + tls_socket.close.assert_called_once_with() + + @pytest.mark.parametrize( + "exception", + [ + socket.timeout("timed out"), + ConnectionRefusedError("connection refused"), + socket.gaierror("name resolution failed"), + ssl.SSLError("TLS failed"), + ssl.SSLEOFError("TLS EOF"), + OSError("socket failure"), + ], + ) + def test_http_health_check_exits_on_connection_failure( + self, + exception, + capsys, + ): + """Verify that connection failures terminate the interactive shell.""" + utilities = create_utilities() + self._configure_health_check( + utilities, + https=False, + health_check_interval=10, + ) + + utilities.stop_health_check.is_set.return_value = False + + with ( + patch( + "onvif.cli.interactive.utils.socket.create_connection", + side_effect=exception, + ), + patch( + "onvif.cli.interactive.utils.os._exit", + side_effect=SystemExit(1), + ) as exit_mock, + ): + with pytest.raises(SystemExit, match="1"): + utilities._periodic_health_check() + + exit_mock.assert_called_once_with(1) + + captured = capsys.readouterr() + + assert "Connection to device lost." in captured.err + assert "Health check failed:" in captured.err + assert "Exiting ONVIF interactive shell..." in captured.err + + def test_health_check_closes_socket_after_success(self): + """Verify that sockets are closed after a successful health check.""" + utilities = create_utilities() + self._configure_health_check(utilities) + + utilities.stop_health_check.is_set.side_effect = [ + False, + True, + ] + + socket_mock = MagicMock() + + with patch( + "onvif.cli.interactive.utils.socket.create_connection", + return_value=socket_mock, + ): + utilities._periodic_health_check() + + socket_mock.close.assert_called_once_with() + + +class TestShellUtilitiesConnectionError: + """Test interactive shell connection-error handling.""" + + def test_handle_connection_error_prints_messages(self, capsys): + """Verify that connection errors produce user-facing messages.""" + utilities = create_utilities() + + with ( + patch( + "onvif.cli.interactive.utils.sys.exit", + side_effect=SystemExit(1), + ) as exit_mock, + ): + with pytest.raises(SystemExit, match="1"): + utilities._handle_connection_error() + + exit_mock.assert_called_once_with(1) + + captured = capsys.readouterr() + + assert "Connection to device lost." in captured.err + assert "Exiting ONVIF interactive shell..." in captured.err + assert "Goodbye!" in captured.err + + def test_handle_connection_error_prints_traceback_in_debug_mode( + self, + capsys, + ): + """Verify that debug mode prints the current exception traceback.""" + utilities = create_utilities() + utilities.context.args.debug = True + + with ( + patch("onvif.cli.interactive.utils.traceback.print_exc") as print_exc, + patch( + "onvif.cli.interactive.utils.sys.exit", + side_effect=SystemExit(1), + ), + ): + with pytest.raises(SystemExit, match="1"): + utilities._handle_connection_error() + + print_exc.assert_called_once_with() + + captured = capsys.readouterr() + + assert "Connection to device lost." in captured.err + + def test_handle_connection_error_does_not_print_traceback_without_debug( + self, + ): + """Verify that traceback output is skipped when debug mode is disabled.""" + utilities = create_utilities() + utilities.context.args.debug = False + + with ( + patch("onvif.cli.interactive.utils.traceback.print_exc") as print_exc, + patch( + "onvif.cli.interactive.utils.sys.exit", + side_effect=SystemExit(1), + ), + ): + with pytest.raises(SystemExit, match="1"): + utilities._handle_connection_error() + + print_exc.assert_not_called() + + +class TestShellUtilitiesSplitCommands: + """Test multi-command string parsing.""" + + @pytest.fixture + def utilities(self): + """Provide a shell utility instance for command parsing.""" + return create_utilities() + + @pytest.mark.parametrize( + ("command", "expected"), + [ + ( + "first && second", + ["first", "second"], + ), + ( + "first && second && third", + ["first", "second", "third"], + ), + ( + " first && second ", + ["first", "second"], + ), + ( + "single command", + ["single command"], + ), + ( + "", + [], + ), + ( + " ", + [], + ), + ], + ) + def test_splits_top_level_commands(self, utilities, command, expected): + """Verify that top-level ``&&`` separators split commands.""" + assert utilities._split_multi_commands(command) == expected + + @pytest.mark.parametrize( + "command", + [ + "first && 'second && third'", + 'first && "second && third"', + 'first && {"value": "a && b"}', + 'first && ["a && b"]', + "first && (a && b)", + ], + ) + def test_ignores_separators_inside_structures(self, utilities, command): + """Verify that ``&&`` inside quotes or brackets is preserved.""" + result = utilities._split_multi_commands(command) + + assert len(result) == 2 + assert result[0] == "first" + assert "&&" in result[1] + + def test_preserves_quotes(self, utilities): + """Verify that quote characters remain in command arguments.""" + command = 'first "value && preserved" && second' + + result = utilities._split_multi_commands(command) + + assert result == [ + 'first "value && preserved"', + "second", + ] + + def test_handles_nested_brackets(self, utilities): + """Verify that nested bracket structures do not split commands.""" + command = 'first {"a": [1, {"b": "x && y"}]} && second' + + result = utilities._split_multi_commands(command) + + assert result == [ + 'first {"a": [1, {"b": "x && y"}]}', + "second", + ] + + def test_handles_unbalanced_closing_brackets(self, utilities): + """Verify that bracket depth never becomes negative.""" + command = "first } && second" + + result = utilities._split_multi_commands(command) + + assert result == [ + "first }", + "second", + ] + + def test_ignores_ampersands_that_are_not_double(self, utilities): + """Verify that single ampersands are not treated as separators.""" + command = "first & second" + + result = utilities._split_multi_commands(command) + + assert result == ["first & second"] + + def test_ignores_trailing_empty_commands(self, utilities): + """Verify that trailing separators do not create empty commands.""" + command = "first &&" + + result = utilities._split_multi_commands(command) + + assert result == ["first"] + + def test_ignores_multiple_spaces_after_separator(self, utilities): + """Verify that spaces after separators are removed.""" + command = "first && second" + + result = utilities._split_multi_commands(command) + + assert result == ["first", "second"] From 80ef9ded89525faac84448aa1e8c2b4b8856cc64 Mon Sep 17 00:00:00 2001 From: Kaburagi <135169292+kaburagisec@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:44:43 +0700 Subject: [PATCH 43/43] =?UTF-8?q?chore:=20Bump=20version=200.4.2=20?= =?UTF-8?q?=E2=86=92=200.4.3=20(#150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/core/onvif_cli.md | 4 ++-- onvif/meta.py | 2 +- pyproject.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/core/onvif_cli.md b/docs/core/onvif_cli.md index 956f37a..b3bac21 100644 --- a/docs/core/onvif_cli.md +++ b/docs/core/onvif_cli.md @@ -55,7 +55,7 @@ This library includes a powerful command-line interface (CLI) for interacting wi [--health-check-interval HEALTH_CHECK_INTERVAL] [--output OUTPUT] [--version] [service] [method] [params ...] - ONVIF Terminal Client — v0.4.2 + ONVIF Terminal Client — v0.4.3 https://github.com/nirsimetri/onvif-python positional arguments: @@ -137,7 +137,7 @@ This library includes a powerful command-line interface (CLI) for interacting wi ??? abstract "Interactive Shell" ```shell - ONVIF Interactive Shell — v0.4.2 + ONVIF Interactive Shell — v0.4.3 https://github.com/nirsimetri/onvif-python Basic Commands: diff --git a/onvif/meta.py b/onvif/meta.py index 425d034..3df0d75 100644 --- a/onvif/meta.py +++ b/onvif/meta.py @@ -2,5 +2,5 @@ from typing import Final -__version__: Final[str] = "0.4.2" +__version__: Final[str] = "0.4.3" __repository__: Final[str] = "https://github.com/nirsimetri/onvif-python" diff --git a/pyproject.toml b/pyproject.toml index aac0f3c..72cadef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "onvif-python" -version = "0.4.2" +version = "0.4.3" description = "A modern Python library for ONVIF-compliant devices" readme = "README.md" requires-python = ">=3.10"