From 7e51689a7681d88a7daf8f2621024b3d89047eb2 Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Wed, 30 Jul 2025 14:09:40 -0400 Subject: [PATCH 1/8] INTEG-2943 - pydantic v2 --- pyproject.toml | 3 +- src/_incydr_cli/cmds/alerts.py | 8 +- src/_incydr_cli/cmds/audit_log.py | 12 +- src/_incydr_cli/cmds/cases.py | 5 +- src/_incydr_cli/cmds/models.py | 14 +- src/_incydr_cli/cmds/sessions.py | 10 +- src/_incydr_sdk/actors/models.py | 51 +-- src/_incydr_sdk/agents/models.py | 23 +- .../alert_rules/models/response.py | 78 ++-- src/_incydr_sdk/alerts/models/alert.py | 100 ++--- src/_incydr_sdk/alerts/models/request.py | 27 +- src/_incydr_sdk/alerts/models/response.py | 4 +- src/_incydr_sdk/audit_log/models.py | 8 +- src/_incydr_sdk/cases/models.py | 92 ++--- src/_incydr_sdk/core/models.py | 50 +-- src/_incydr_sdk/core/settings.py | 72 ++-- src/_incydr_sdk/customer/models.py | 13 +- src/_incydr_sdk/departments/models.py | 6 +- src/_incydr_sdk/devices/models.py | 11 +- src/_incydr_sdk/directory_groups/models.py | 6 +- src/_incydr_sdk/exceptions.py | 6 +- src/_incydr_sdk/file_events/models/event.py | 348 ++++++++++++------ .../file_events/models/response.py | 82 +++-- src/_incydr_sdk/queries/alerts.py | 18 +- src/_incydr_sdk/queries/file_events.py | 30 +- src/_incydr_sdk/risk_profiles/models.py | 15 +- src/_incydr_sdk/sessions/models/models.py | 62 ++-- src/_incydr_sdk/sessions/models/response.py | 42 +-- src/_incydr_sdk/trusted_activities/models.py | 10 +- src/_incydr_sdk/utils.py | 83 +++-- src/_incydr_sdk/watchlists/models/requests.py | 40 +- .../watchlists/models/responses.py | 32 +- tests/queries/test_event_query.py | 4 +- tests/test_actors.py | 32 +- tests/test_agents.py | 12 +- tests/test_alert_rules.py | 20 +- tests/test_cases.py | 6 +- tests/test_core.py | 4 +- tests/test_customer.py | 2 +- tests/test_devices.py | 13 +- tests/test_directory_groups.py | 9 +- tests/test_logging.py | 1 + tests/test_risk_profiles.py | 20 +- tests/test_sessions.py | 16 +- tests/test_trusted_activities.py | 31 +- tests/test_users.py | 34 +- tests/test_utils.py | 56 ++- tests/test_watchlists.py | 156 +++++--- 48 files changed, 1051 insertions(+), 726 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 585147e2..f1c1b777 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,8 @@ dependencies = [ "requests", "requests-toolbelt", "rich", - "pydantic[dotenv]==1.*", + "pydantic[dotenv]>=2.11", + "pydantic-settings", "isodate", "python-dateutil", ] diff --git a/src/_incydr_cli/cmds/alerts.py b/src/_incydr_cli/cmds/alerts.py index 812022c3..350fc0c4 100644 --- a/src/_incydr_cli/cmds/alerts.py +++ b/src/_incydr_cli/cmds/alerts.py @@ -273,13 +273,13 @@ def bulk_update_state( class AlertBulkCSV(CSVModel): alert_id: str = Field(csv_aliases=["id", "alert_id"]) - state: state_type = Field(csv_aliases=["state"]) - note: Optional[str] + state: state_type = Field(None, csv_aliases=["state"]) # type: ignore + note: Optional[str] = None class AlertBulkJSON(Model): alert_id: str = Field(alias="id") - state: state_type - note: Optional[str] + state: state_type = None # type: ignore + note: Optional[str] = None client = Client() if format_ == "csv": diff --git a/src/_incydr_cli/cmds/audit_log.py b/src/_incydr_cli/cmds/audit_log.py index 907ae01e..22374b1d 100644 --- a/src/_incydr_cli/cmds/audit_log.py +++ b/src/_incydr_cli/cmds/audit_log.py @@ -280,9 +280,9 @@ def _get_cursor_store(api_key): class DefaultAuditEvent(Model): - type: str = Field(None, alias="type$") - actor_id: str = Field(None, alias="actorId") - actor_name: str = Field(None, alias="actorName") - actor_agent: str = Field(None, alias="actorAgent") - actor_ip_addresses: str = Field(None, alias="actorIpAddress") - timestamp: str + type: Optional[str] = Field(None, alias="type$") + actor_id: Optional[str] = Field(None, alias="actorId") + actor_name: Optional[str] = Field(None, alias="actorName") + actor_agent: Optional[str] = Field(None, alias="actorAgent") + actor_ip_addresses: Optional[str] = Field(None, alias="actorIpAddress") + timestamp: str = None diff --git a/src/_incydr_cli/cmds/cases.py b/src/_incydr_cli/cmds/cases.py index 4b548ff7..79d24cb8 100644 --- a/src/_incydr_cli/cmds/cases.py +++ b/src/_incydr_cli/cmds/cases.py @@ -5,7 +5,7 @@ import click from pydantic import Field -from pydantic import root_validator +from pydantic import model_validator from requests.exceptions import HTTPError from rich.panel import Panel from rich.progress import track @@ -51,7 +51,8 @@ class FileEventCSV(CSVModel): class FileEventJSON(FileEventV2): event_id: str = Field(alias="eventId") - @root_validator(pre=True) + @model_validator(mode="before") + @classmethod def _event_id_required(cls, values): # noqa # check if input is V2 file event event = values.get("event") diff --git a/src/_incydr_cli/cmds/models.py b/src/_incydr_cli/cmds/models.py index 4a91e547..069ab9b0 100644 --- a/src/_incydr_cli/cmds/models.py +++ b/src/_incydr_cli/cmds/models.py @@ -1,7 +1,7 @@ from typing import Optional from pydantic import Field -from pydantic import root_validator +from pydantic import model_validator from _incydr_sdk.core.models import CSVModel from _incydr_sdk.core.models import Model @@ -12,10 +12,11 @@ class UserCSV(CSVModel): class UserJSON(Model): - username: Optional[str] - userId: Optional[str] + username: Optional[str] = None + userId: Optional[str] = None - @root_validator(pre=True) + @model_validator(mode="before") + @classmethod def _validate(cls, values): # noqa if "username" not in values and "userId" not in values: raise ValueError("A json key of 'username' or 'userId' is required") @@ -31,9 +32,10 @@ class AgentCSV(CSVModel): class AgentJSON(Model): - agent_id: Optional[str] + agent_id: Optional[str] = None - @root_validator(pre=True) + @model_validator(mode="before") + @classmethod def _validate(cls, values): # noqa if "agentGuid" in values: values["agent_id"] = values["agentGuid"] diff --git a/src/_incydr_cli/cmds/sessions.py b/src/_incydr_cli/cmds/sessions.py index 172c8f6b..dab039f3 100644 --- a/src/_incydr_cli/cmds/sessions.py +++ b/src/_incydr_cli/cmds/sessions.py @@ -344,14 +344,14 @@ def bulk_update_state( # Validate input models class SessionCSV(CSVModel): - session_id: str = Field(csv_aliases=["session_id", "sessionId"]) - state: state_type = Field(csv_aliases=["state"]) - note: Optional[str] + session_id: str = Field(None, csv_aliases=["session_id", "sessionId"]) + state: state_type = Field(None, csv_aliases=["state"]) # type: ignore + note: Optional[str] = None class SessionJSON(Model): session_id: str = Field(alias="sessionId") - state: state_type - note: Optional[str] + state: state_type = None # type: ignore + note: Optional[str] = None if format_ == "csv": models = SessionCSV.parse_csv(file) diff --git a/src/_incydr_sdk/actors/models.py b/src/_incydr_sdk/actors/models.py index 55c33bb3..16dfbc8e 100644 --- a/src/_incydr_sdk/actors/models.py +++ b/src/_incydr_sdk/actors/models.py @@ -8,11 +8,11 @@ class QueryActorsRequest(BaseModel): - nameStartsWith: Optional[str] - nameEndsWith: Optional[str] - active: Optional[bool] - pageSize: Optional[int] - page: Optional[int] + nameStartsWith: Optional[str] = None + nameEndsWith: Optional[str] = None + active: Optional[bool] = None + pageSize: Optional[int] = None + page: Optional[int] = None class Actor(ResponseModel): @@ -43,90 +43,93 @@ class Actor(ResponseModel): """ active: Optional[bool] = Field( - None, description="Whether or not the actor is active.", example=True + None, description="Whether or not the actor is active.", examples=[True] ) actor_id: Optional[str] = Field( - None, alias="actorId", description="The actor ID.", example="112233445566" + None, alias="actorId", description="The actor ID.", examples=["112233445566"] ) alternate_names: Optional[List[str]] = Field( None, alias="alternateNames", description="A list of other names the actor may have.", - example=["johnsmith@gmail.com", "john-smith@company.com"], + examples=[["johnsmith@gmail.com", "john-smith@company.com"]], ) country: Optional[str] = Field( - None, description="The actor's country", example="United States" + None, description="The actor's country", examples=["United States"] ) department: Optional[str] = Field( - None, description="The actor's department", example="Product" + None, description="The actor's department", examples=["Product"] ) division: Optional[str] = Field( - None, description="The actor's division", example="Engineering" + None, description="The actor's division", examples=["Engineering"] ) employee_type: Optional[str] = Field( None, alias="employeeType", description="The actor's employment, such as if they're a contractor.", - example="full-time", + examples=["full-time"], ) end_date: Optional[str] = Field( - None, alias="endDate", description="The actor's end date.", example="2024-09-18" + None, + alias="endDate", + description="The actor's end date.", + examples=["2024-09-18"], ) first_name: Optional[str] = Field( None, alias="firstName", description="The first name of the actor.", - example="Smith", + examples=["Smith"], ) in_scope: Optional[bool] = Field( None, alias="inScope", description="The actor's scope state. An actor is considered 'in scope' if their activity is monitored in at least one data source.", - example=True, + examples=[True], ) last_name: Optional[str] = Field( None, alias="lastName", description="The last name of the actor.", - example="John", + examples=["John"], ) locality: Optional[str] = Field( - None, description="The actor's locality (city).", example="Minneapolis" + None, description="The actor's locality (city).", examples=["Minneapolis"] ) manager_actor_id: Optional[str] = Field( None, alias="managerActorId", description="The actor ID of the actor's manager", - example="9988776655", + examples=["9988776655"], ) name: Optional[str] = Field( None, description="The actor's (eg. full username/email) name.", - example="john.smith@gmail.com", + examples=["john.smith@gmail.com"], ) notes: Optional[str] = Field( None, alias="notes", description="Notes about the actor.", - example="A super cool person.", + examples=["A super cool person."], ) parent_actor_id: Optional[str] = Field( None, alias="parentActorId", description="The actor ID of this actor's parent actor. (if the actor has a parent).", - example="442244224422", + examples=["442244224422"], ) region: Optional[str] = Field( - None, description="The actor's region.", example="Minnesota" + None, description="The actor's region.", examples=["Minnesota"] ) start_date: Optional[str] = Field( None, alias="startDate", description="The actor's start date.", - example="2024-09-18", + examples=["2024-09-18"], ) title: Optional[str] = Field( - None, description="The actor's job title", example="Software Engineer" + None, description="The actor's job title", examples=["Software Engineer"] ) diff --git a/src/_incydr_sdk/agents/models.py b/src/_incydr_sdk/agents/models.py index 2ec6b3f7..9bf36df7 100644 --- a/src/_incydr_sdk/agents/models.py +++ b/src/_incydr_sdk/agents/models.py @@ -7,7 +7,7 @@ from pydantic import BaseModel from pydantic import Field -from pydantic import validator +from pydantic import field_validator from _incydr_sdk.core.models import ResponseModel from _incydr_sdk.enums import _Enum @@ -98,16 +98,17 @@ class AgentUpdateRequest(BaseModel): class QueryAgentsRequest(BaseModel): - active: Optional[bool] - agentType: Optional[Union[AgentType, str]] - agentHealthy: Optional[bool] - anyOfAgentHealthIssueTypes: Optional[List[str]] - srtKey: Optional[Union[SortKeys, str]] - srtDir: Optional[str] - pageSize: Optional[int] - page: Optional[int] - - @validator("srtDir") + active: Optional[bool] = None + agentType: Optional[Union[AgentType, str]] = None + agentHealthy: Optional[bool] = None + anyOfAgentHealthIssueTypes: Optional[List[str]] = None + srtKey: Optional[Union[SortKeys, str]] = None + srtDir: Optional[str] = None + pageSize: Optional[int] = None + page: Optional[int] = None + + @field_validator("srtDir") + @classmethod def _validate(cls, value): # noqa value = str(value).upper() assert value in ("ASC", "DESC") diff --git a/src/_incydr_sdk/alert_rules/models/response.py b/src/_incydr_sdk/alert_rules/models/response.py index d594f83c..3fd6fce4 100644 --- a/src/_incydr_sdk/alert_rules/models/response.py +++ b/src/_incydr_sdk/alert_rules/models/response.py @@ -24,13 +24,13 @@ class RuleUser(ResponseModel): user_id_from_authority: str = Field( None, description="User ID from authority.", - example="userIdFromAuthority", + examples=["userIdFromAuthority"], alias="userIdFromAuthority", ) aliases: List[str] = Field( None, description="List of user aliases corresponding to the user ID from the authority.", - example=["userAlias1", "userAlias2"], + examples=[["userAlias1", "userAlias2"]], ) @@ -48,7 +48,7 @@ class RuleUsersList(ResponseModel): id: Optional[str] = Field( None, description="The id of the rule.", - example="669bb117-d07a-4bd9-9f19-f1d85102cf55", + examples=["669bb117-d07a-4bd9-9f19-f1d85102cf55"], ) users: Optional[List[RuleUser]] = Field( None, description="A list of users in the rule's username filter." @@ -64,7 +64,7 @@ class CloudSharingDetails(Model): observe_all: bool = Field( None, description="Indicates whether we are watching the cloud sharing connector or not.", - example=True, + examples=[True], alias="observeAll", ) public_link_share: bool = Field(None, alias="publicLinkShare") @@ -91,13 +91,13 @@ class FileUploadCategory(Model): observe_all: bool = Field( None, description="Indicates whether we are watching all of the destinations in the category.", - example=True, + examples=[True], alias="observeAll", ) destinations: Optional[List[str]] = Field( None, description="A list of specific destinations to watch within the category.", - example=[], + examples=[[]], ) @@ -109,13 +109,13 @@ class RemovableMediaVector(Model): is_enabled: bool = Field( None, description="Indicates whether to watch removable media destinations or not.", - example=True, + examples=[True], alias="isEnabled", ) criteria_order: int = Field( None, description="Order in which this vector was added to the rule.", - example=3, + examples=[3], alias="criteriaOrder", ) @@ -124,12 +124,12 @@ class FileCategoryFilter(Model): categories: Optional[List[str]] = Field( None, description="List of file categories to alert on.", - example=["Archive", "Pdf", "SourceCode"], + examples=[["Archive", "Pdf", "SourceCode"]], ) criteria_order: int = Field( None, description="Order in which this vector was added to the rule.", - example=3, + examples=[3], alias="criteriaOrder", ) @@ -138,12 +138,12 @@ class FileNameFilter(Model): patterns: Optional[List[str]] = Field( None, description="List of file name patterns to alert on.", - example=["*.cs", "*.sh"], + examples=[["*.cs", "*.sh"]], ) criteria_order: int = Field( None, description="Order in which this vector was added to the rule.", - example=3, + examples=[3], alias="criteriaOrder", ) @@ -152,13 +152,13 @@ class FileTypeMismatchFilter(Model): is_enabled: bool = Field( None, description="Indicates whether or not to alert on file type mismatches only.", - example=True, + examples=[True], alias="isEnabled", ) criteria_order: int = Field( None, description="Order in which this vector was added to the rule.", - example=3, + examples=[3], alias="criteriaOrder", ) @@ -185,16 +185,16 @@ class NotificationContact(Model): is_enabled: bool = Field( None, description="Indicates whether the notifications for this contact are enabled.", - example=True, + examples=[True], alias="isEnabled", ) type: Optional[str] = Field( - None, description="Type of notification.", example="EMAIL" + None, description="Type of notification.", examples=["EMAIL"] ) address: Optional[str] = Field( None, description="Address notifications are configured to send to.", - example="myUsername@company.com", + examples=["myUsername@company.com"], ) @@ -202,7 +202,7 @@ class CloudSharingVector(Model): observe_all: Optional[bool] = Field( None, description="Indicates whether to watch all cloud sharing connectors.", - example=False, + examples=[False], alias="observeAll", ) box: Optional[CloudSharingDetails] = Field( @@ -222,7 +222,7 @@ class CloudSharingVector(Model): criteria_order: int = Field( None, description="Order in which this vector was added to the rule.", - example=3, + examples=[3], alias="criteriaOrder", ) @@ -277,7 +277,7 @@ class FileUploadVector(Model): criteria_order: int = Field( None, description="Order in which this vector was added to the rule.", - example=3, + examples=[3], alias="criteriaOrder", ) @@ -286,7 +286,7 @@ class FileVolumeFilter(Model): count_greater_than: int = Field( None, description="File count threshold that must be exceeded to trigger an alert.", - example=125, + examples=[125], alias="countGreaterThan", ) operator: Optional[str] = Field( @@ -296,13 +296,13 @@ class FileVolumeFilter(Model): size_greater_than_in_bytes: int = Field( None, description="File size threshold that must be exceeded to trigger an alert.", - example=1024, + examples=[1024], alias="sizeGreaterThanInBytes", ) criteria_order: int = Field( None, description="Order in which this vector was added to the rule.", - example=3, + examples=[3], alias="criteriaOrder", ) @@ -314,12 +314,12 @@ class UsernameFilter(Model): usernames: Optional[List[str]] = Field( None, description="List of usernames. Will either alert only on these users or on any user not in list.", - example=["myUsername@company.com", "anotherUsername@company.com"], + examples=[["myUsername@company.com", "anotherUsername@company.com"]], ) criteria_order: int = Field( None, description="Order in which this vector was added to the rule.", - example=3, + examples=[3], alias="criteriaOrder", ) @@ -333,7 +333,7 @@ class NotificationSettings(Model): is_enabled: bool = Field( None, description="Indicates whether notifications are enabled.", - example=True, + examples=[True], alias="isEnabled", ) contacts: Optional[List[NotificationContact]] = Field( @@ -425,26 +425,30 @@ class RuleDetails(ResponseModel): name: Optional[str] = Field( None, description="Unique name of the rule.", - example="Suspicious File Mismatch Rule", + examples=["Suspicious File Mismatch Rule"], ) description: Optional[str] = Field( None, description="Description of the rule.", - example="A rule created to trigger alerts on suspicious file mismatch exfiltration", + examples=[ + "A rule created to trigger alerts on suspicious file mismatch exfiltration" + ], ) severity: Optional[str] = Field( - None, description="[Deprecated field] Indicates severity of rule.", example="" + None, + description="[Deprecated field] Indicates severity of rule.", + examples=[""], ) is_enabled: Optional[bool] = Field( None, description="Indicates whether the rule is enabled or not.", - example=True, + examples=[True], alias="isEnabled", ) source: Optional[str] = Field( None, description="[Deprecated field] Indicates source of rule creation.", - example="", + examples=[""], ) notifications: Optional[NotificationSettings] = Field( None, description="Notifications configuration settings for this rule." @@ -461,35 +465,35 @@ class RuleDetails(ResponseModel): id: Optional[str] = Field( None, description="Unique identifier of the rule.", - example="ecec5037-aedc-4cf9-aad3-57dcafe1f204", + examples=["ecec5037-aedc-4cf9-aad3-57dcafe1f204"], ) created_at: Optional[datetime] = Field( None, description="Time when the rule was created.", - example="2022-10-04T17:53:26.4534999Z", + examples=["2022-10-04T17:53:26.4534999Z"], alias="createdAt", ) created_by: Optional[str] = Field( None, description="Individual or service who created the rule.", - example="my-username@company.com", + examples=["my-username@company.com"], alias="createdBy", ) modified_at: Optional[datetime] = Field( None, description="Time when the rule was last modified.", - example="2022-10-04T17:53:26.453532Z", + examples=["2022-10-04T17:53:26.453532Z"], alias="modifiedAt", ) modified_by: Optional[str] = Field( None, description="Individual or service who last modified the rule.", - example="my-username@company.com", + examples=["my-username@company.com"], alias="modifiedBy", ) is_system_rule: Optional[bool] = Field( None, description="Boolean indicator of whether or not the rule is connected to a lens.", - example=True, + examples=[True], alias="isSystemRule", ) diff --git a/src/_incydr_sdk/alerts/models/alert.py b/src/_incydr_sdk/alerts/models/alert.py index e1d7ba5c..36faa6ff 100644 --- a/src/_incydr_sdk/alerts/models/alert.py +++ b/src/_incydr_sdk/alerts/models/alert.py @@ -6,68 +6,72 @@ from typing import Optional import rich.box -from pydantic import constr from pydantic import Field -from pydantic import validator +from pydantic import field_validator +from pydantic import StringConstraints from rich.markdown import Markdown from rich.panel import Panel +from typing_extensions import Annotated from _incydr_sdk.core.models import Model class Observation(Model): id: Optional[str] = Field( - None, description="Id of given observation.", example="uniqueObservationId" + None, description="Id of given observation.", examples=["uniqueObservationId"] ) observed_at: datetime = Field( - ..., + None, alias="observedAt", description="Timestamp when the activity was first observed.", - example="2020-02-19T01:57:45.006683Z", + examples=["2020-02-19T01:57:45.006683Z"], ) last_observed_at: Optional[datetime] = Field( None, alias="lastObservedAt", description="Timestamp when the activity was last observed.", - example="2020-02-19T01:57:45.006683Z", + examples=["2020-02-19T01:57:45.006683Z"], ) type: Optional[str] = Field( None, description="The type of observation data recorded.", - example="FedCloudSharePermissions", + examples=["FedCloudSharePermissions"], ) data: Optional[dict] = Field( None, description="The JSON formatted observation data rolled into one aggregation.", - example='{"type$":"OBSERVED_CLOUD_SHARE_ACTIVITY","id":"exampleId","sources":["OneDrive"],"exposureTypes":["PublicLinkShare"],"firstActivityAt":"2020-02-19T01:50:00.0000000Z","lastActivityAt":"2020-02-19T01:55:00.0000000Z","fileCount":2,"totalFileSize":200,"fileCategories":[{"type$":"OBSERVED_FILE_CATEGORY","category":"Document","fileCount":2,"totalFileSize":53,"isSignificant":false}],"outsideTrustedDomainsEmailsCount":0,"outsideTrustedDomainsTotalDomainCount":0,"outsideTrustedDomainsTotalDomainCountTruncated":false}', + examples=[ + '{"type$":"OBSERVED_CLOUD_SHARE_ACTIVITY","id":"exampleId","sources":["OneDrive"],"exposureTypes":["PublicLinkShare"],"firstActivityAt":"2020-02-19T01:50:00.0000000Z","lastActivityAt":"2020-02-19T01:55:00.0000000Z","fileCount":2,"totalFileSize":200,"fileCategories":[{"type$":"OBSERVED_FILE_CATEGORY","category":"Document","fileCount":2,"totalFileSize":53,"isSignificant":false}],"outsideTrustedDomainsEmailsCount":0,"outsideTrustedDomainsTotalDomainCount":0,"outsideTrustedDomainsTotalDomainCountTruncated":false}' + ], table=lambda data: json.dumps(data, indent=2), ) - @validator("data", pre=True) + @field_validator("data", mode="before") + @classmethod def parse_data_json(cls, value): # noqa return json.loads(value) if value else None class Note(Model): id: Optional[str] = Field( - None, description="Unique id of the note.", example="noteId" + None, description="Unique id of the note.", examples=["noteId"] ) last_modified_at: datetime = Field( - ..., + None, alias="lastModifiedAt", description="Timestamp of when the note was last modified.", - example="2020-02-19T01:57:45.006683Z", + examples=["2020-02-19T01:57:45.006683Z"], ) last_modified_by: Optional[str] = Field( None, alias="lastModifiedBy", description="User who last modified the note.", - example="exampleUsername", + examples=["exampleUsername"], ) message: Optional[str] = Field( None, description="The note itself.", - example="This is a note.", + examples=["This is a note."], table=lambda note: Panel(Markdown(note), width=120, box=rich.box.MINIMAL) if note else note, @@ -79,31 +83,31 @@ class AuditInfo(Model): None, alias="modifiedBy", description="Username of the individual who last modified the rule.", - example="UserWhoMostRecentlyModifiedTheRule", + examples=["UserWhoMostRecentlyModifiedTheRule"], ) modified_at: datetime = Field( - ..., + None, alias="modifiedAt", description="Timestamp of when the rule was last modified.", - example="2020-02-19T01:57:45.006683Z", + examples=["2020-02-19T01:57:45.006683Z"], ) class Watchlist(Model): id: Optional[str] = Field( - None, description="Unique id of this watchlist.", example="guid" + None, description="Unique id of this watchlist.", examples=["guid"] ) name: Optional[str] = Field( - None, description="Name of the watchlist.", example="Development Department" + None, description="Name of the watchlist.", examples=["Development Department"] ) type: Optional[str] = Field( - ..., description="Type of watchlist.", example="DEPARTING_EMPLOYEE" + None, description="Type of watchlist.", examples=["DEPARTING_EMPLOYEE"] ) is_significant: bool = Field( - ..., + None, alias="isSignificant", description="Indicates whether the watchlist was part of the triggering rule's criteria.", - example="true", + examples=["true"], ) @@ -111,12 +115,12 @@ class ObserverRuleMetadata(AuditInfo): name: Optional[str] = Field( None, description="The name of the rule.", - example="My Removable Media Exfiltration Rule", + examples=["My Removable Media Exfiltration Rule"], ) description: Optional[str] = Field( None, description="The description of the rule.", - example="Will generate alerts when files moved to USB.", + examples=["Will generate alerts when files moved to USB."], ) severity: Optional[str] = Field( None, description="The static severity of the rule (deprecated)." @@ -125,19 +129,19 @@ class ObserverRuleMetadata(AuditInfo): None, alias="isSystem", description="Boolean indicating if the rule was created from another Code42 Application.", - example="FALSE", + examples=["FALSE"], ) is_enabled: bool = Field( - ..., + None, alias="isEnabled", description="Boolean indicating if the rule is enabled to trigger alerts.", - example="TRUE", + examples=["TRUE"], ) rule_source: Optional[str] = Field( None, alias="ruleSource", description="The source of the rule. Will be one of [DepartingEmployee, Alerting, HighRiskEmployee]", - example="Alerting", + examples=["Alerting"], ) @@ -165,21 +169,21 @@ class AlertSummary(Model): * **watchlists**: `str` Watchlists the actor is on at the time of the alert (if any). """ - tenant_id: constr(max_length=40) = Field( - ..., + tenant_id: Annotated[str, StringConstraints(max_length=40)] = Field( + None, alias="tenantId", description="The unique identifier representing the tenant.", - example="MyExampleTenant", + examples=["MyExampleTenant"], ) type: Optional[str] = Field(..., description="Rule type that generated the alert.") id: Optional[str] = Field( - None, description="The unique id of the alert.", example="alertId" + None, description="The unique id of the alert.", examples=["alertId"] ) created_at: datetime = Field( - ..., + None, alias="createdAt", description="The timestamp when the alert was created.", - example="2020-02-19T01:57:45.006683Z", + examples=["2020-02-19T01:57:45.006683Z"], ) state: Optional[str] = Field(..., description="The current state of the alert.") state_last_modified_by: Optional[str] = Field(None, alias="stateLastModifiedBy") @@ -189,23 +193,23 @@ class AlertSummary(Model): name: Optional[str] = Field( None, description="The name of the alert. Same as the name of the rule that triggered it.", - example="Removable Media Exfiltration Rule", + examples=["Removable Media Exfiltration Rule"], ) description: Optional[str] = Field( None, description="The description of the alert. Same as the description of the rule that triggered it.", - example="Alert me on all removable media exfiltration.", + examples=["Alert me on all removable media exfiltration."], ) actor: Optional[str] = Field( None, description="The user who triggered the alert.", - example="exampleUser@mycompany.com", + examples=["exampleUser@mycompany.com"], ) actor_id: Optional[str] = Field( None, alias="actorId", description="The authority user id who triggered the alert, if it is available.", - example="authorityUserId", + examples=["authorityUserId"], ) target: Optional[str] = None severity: Optional[str] = Field( @@ -215,18 +219,18 @@ class AlertSummary(Model): None, alias="riskSeverity", description="Indicates event risk severity of the alert.", - example="MODERATE", + examples=["MODERATE"], ) rule_id: Optional[str] = Field( None, alias="ruleId", description="The unique id corresponding to the rule which triggered the alert.", - example="uniqueRuleId", + examples=["uniqueRuleId"], ) watchlists: Optional[List[Watchlist]] = Field( None, description="Watchlists the actor is on at the time of the alert.", - example=[], + examples=[[]], table=lambda watchlists: "\n".join((w.name or w.type) for w in watchlists) if watchlists else watchlists, @@ -237,19 +241,19 @@ class AlertSummary(Model): class ObserverRuleMetadataEssentials(ObserverRuleMetadata): - tenant_id: constr(max_length=40) = Field( - ..., + tenant_id: Annotated[str, StringConstraints(max_length=40)] = Field( + None, alias="tenantId", description="The unique identifier representing the tenant.", - example="MyExampleTenant", + examples=["MyExampleTenant"], ) observer_rule_id: Optional[str] = Field( None, alias="observerRuleId", description="Id of the rule in the observer.", - example="UniqueRuleId", + examples=["UniqueRuleId"], ) - type: Optional[str] = Field(..., description="Rule type of the rule.") + type: Optional[str] = Field(None, description="Rule type of the rule.") class AlertDetails(AlertSummary): @@ -263,5 +267,5 @@ class AlertDetails(AlertSummary): * **note**: `str` Most recent note added to the alert. """ - observations: Optional[List[Observation]] - note: Optional[Note] + observations: Optional[List[Observation]] = None + note: Optional[Note] = None diff --git a/src/_incydr_sdk/alerts/models/request.py b/src/_incydr_sdk/alerts/models/request.py index b736ea4e..ad0b1a93 100644 --- a/src/_incydr_sdk/alerts/models/request.py +++ b/src/_incydr_sdk/alerts/models/request.py @@ -4,33 +4,34 @@ from typing import Optional from pydantic import BaseModel -from pydantic import constr from pydantic import Field +from pydantic import StringConstraints +from typing_extensions import Annotated from _incydr_sdk.enums.alerts import AlertState class UpdateAlertStateRequest(BaseModel): - tenant_id: constr(max_length=40) = Field( + tenant_id: Annotated[str, StringConstraints(max_length=40)] = Field( ..., alias="tenantId", description="The unique identifier representing the tenant.", - example="MyExampleTenant", + examples=["MyExampleTenant"], ) alert_ids: List[str] = Field( ..., alias="alertIds", description="The unique identifiers representing the alerts you want to act upon.", - example=["ExampleAlertId1", "ExampleAlertId2"], + examples=[["ExampleAlertId1", "ExampleAlertId2"]], max_length=100, ) state: AlertState = Field( ..., description="The state to update the given alerts to." ) - note: Optional[constr(max_length=2000)] = Field( + note: Optional[Annotated[str, StringConstraints(max_length=2000)]] = Field( None, description="An optional note to attach to the alert", - example="This is an example note.", + examples=["This is an example note."], ) @@ -39,26 +40,26 @@ class AlertDetailsRequest(BaseModel): ..., alias="alertIds", description="The unique identifiers representing the alerts you want to act upon.", - example=["ExampleAlertId1", "ExampleAlertId2"], + examples=[["ExampleAlertId1", "ExampleAlertId2"]], max_length=100, ) class AddNoteRequest(BaseModel): - tenant_id: constr(max_length=40) = Field( + tenant_id: Annotated[str, StringConstraints(max_length=40)] = Field( ..., alias="tenantId", description="The unique identifier representing the tenant.", - example="MyExampleTenant", + examples=["MyExampleTenant"], ) - alert_id: constr(max_length=40) = Field( + alert_id: Annotated[str, StringConstraints(max_length=40)] = Field( ..., alias="alertId", description="The unique identifier representing the alert you want to act upon.", - example="ExampleAlertId", + examples=["ExampleAlertId"], ) - note: constr(max_length=2000) = Field( + note: Annotated[str, StringConstraints(max_length=2000)] = Field( ..., description="The note to attach to the alert.", - example="This is an example note.", + examples=["This is an example note."], ) diff --git a/src/_incydr_sdk/alerts/models/response.py b/src/_incydr_sdk/alerts/models/response.py index d199ba4a..cc59a77c 100644 --- a/src/_incydr_sdk/alerts/models/response.py +++ b/src/_incydr_sdk/alerts/models/response.py @@ -38,10 +38,10 @@ class AlertQueryPage(ResponseModel): ..., alias="totalCount", description="The number of alerts that match the given query.", - example="3", + examples=["3"], ) problems: Optional[List[QueryProblem]] = Field( None, description="Potential issues that were hit while trying to run the query.", - example=[], + examples=[[]], ) diff --git a/src/_incydr_sdk/audit_log/models.py b/src/_incydr_sdk/audit_log/models.py index 0e907d69..00fe63aa 100644 --- a/src/_incydr_sdk/audit_log/models.py +++ b/src/_incydr_sdk/audit_log/models.py @@ -23,8 +23,8 @@ class UserTypes(str, Enum): class DateRange(BaseModel): - endTime: Optional[float] - startTime: Optional[float] + endTime: Optional[float] = None + startTime: Optional[float] = None class AuditEventsPage(ResponseModel): @@ -46,13 +46,13 @@ class AuditEventsPage(ResponseModel): pagination_range_end_index: int = Field( None, description="The index of the last result returned, in relation to total results found", - example=62, + examples=[62], alias="paginationRangeEndIndex", ) pagination_range_start_index: int = Field( None, description="The index of the first result returned, in relation to total results found", - example=0, + examples=[0], alias="paginationRangeStartIndex", ) diff --git a/src/_incydr_sdk/cases/models.py b/src/_incydr_sdk/cases/models.py index 030fdb62..2a5e3cb1 100644 --- a/src/_incydr_sdk/cases/models.py +++ b/src/_incydr_sdk/cases/models.py @@ -42,33 +42,31 @@ class Case(ResponseModel, validate_assignment=True): """ number: Optional[int] = Field( - allow_mutation=False, description="The identifier of the case." + None, frozen=True, description="The identifier of the case." ) name: Optional[str] - created_at: Optional[datetime] = Field(allow_mutation=False, alias="createdAt") - updated_at: Optional[datetime] = Field(allow_mutation=False, alias="updatedAt") - subject: Optional[str] - subject_username: Optional[str] = Field(alias="subjectUsername") - status: Union[CaseStatus, str] - assignee: Optional[str] + created_at: Optional[datetime] = Field(None, frozen=True, alias="createdAt") + updated_at: Optional[datetime] = Field(None, frozen=True, alias="updatedAt") + subject: Optional[str] = None + subject_username: Optional[str] = Field(None, alias="subjectUsername") + status: Union[CaseStatus, str] = None + assignee: Optional[str] = None assignee_username: Optional[str] = Field( - allow_mutation=False, alias="assigneeUsername" + None, frozen=True, alias="assigneeUsername" ) created_by_user_id: Optional[str] = Field( - allow_mutation=False, alias="createdByUserUid" + None, frozen=True, alias="createdByUserUid" ) created_by_username: Optional[str] = Field( - allow_mutation=False, alias="createdByUsername" + None, frozen=True, alias="createdByUsername" ) last_modified_by_user_id: Optional[str] = Field( - allow_mutation=False, alias="lastModifiedByUserUid" + None, frozen=True, alias="lastModifiedByUserUid" ) last_modified_by_username: Optional[str] = Field( - allow_mutation=False, alias="lastModifiedByUsername" - ) - archival_time: Optional[datetime] = Field( - allow_mutation=False, alias="archivalTime" + None, frozen=True, alias="lastModifiedByUsername" ) + archival_time: Optional[datetime] = Field(None, frozen=True, alias="archivalTime") class CaseDetail(Case): @@ -82,8 +80,10 @@ class CaseDetail(Case): * **findings**: `str | None` Markdown formatted text summarizing the findings for a case. """ - description: Optional[str] - findings: Optional[str] = Field(table=lambda f: f if f is None else Markdown(f)) + description: Optional[str] = None + findings: Optional[str] = Field( + None, table=lambda f: f if f is None else Markdown(f) + ) class CasesPage(ResponseModel): @@ -101,38 +101,40 @@ class CasesPage(ResponseModel): class QueryCasesRequest(Model): - assignee: Optional[str] - createdAt: Optional[str] - isAssigned: Optional[bool] - lastModifiedBy: Optional[str] - name: Optional[str] + assignee: Optional[str] = None + createdAt: Optional[str] = None + isAssigned: Optional[bool] = None + lastModifiedBy: Optional[str] = None + name: Optional[str] = None pgNum: Optional[int] = 1 pgSize: Optional[int] = 100 srtDir: SortDirection = SortDirection.ASC srtKey: SortKeys = SortKeys.NUMBER - status: Optional[CaseStatus] + status: Optional[CaseStatus] = None class CreateCaseRequest(Model): name: str = Field(max_length=50) - assignee: Optional[str] - description: Optional[str] = Field(max_length=250) - findings: Optional[str] = Field(max_length=30_000) - subject: Optional[str] + assignee: Optional[str] = None + description: Optional[str] = Field(None, max_length=250) + findings: Optional[str] = Field(None, max_length=30_000) + subject: Optional[str] = None class UpdateCaseRequest(Model, extra=Extra.ignore): - name: Optional[str] = Field(description="The name of the case.", max_length=50) - assignee: Optional[str] - description: Optional[str] = Field(max_length=250) - findings: Optional[str] = Field(max_length=30_000) - subject: Optional[str] - status: Optional[CaseStatus] + name: Optional[str] = Field( + None, description="The name of the case.", max_length=50 + ) + assignee: Optional[str] = None + description: Optional[str] = Field(None, max_length=250) + findings: Optional[str] = Field(None, max_length=30_000) + subject: Optional[str] = None + status: Optional[CaseStatus] = None class RiskIndicator(BaseModel): - name: str - weight: int + name: str = None + weight: int = None class FileEvent(Model): @@ -140,36 +142,38 @@ class FileEvent(Model): None, alias="eventId", description="The unique identifier for the event.", - example="0_147e9445-2f30-4a91-8b2a-9455332e880a_973435567569502913_986467523038446097_163", + examples=[ + "0_147e9445-2f30-4a91-8b2a-9455332e880a_973435567569502913_986467523038446097_163" + ], ) event_timestamp: Optional[datetime] = Field( None, alias="eventTimestamp", description="Date and time that the Code42 service on the device detected an event; based on the device’s system clock and reported in Coordinated Universal Time (UTC).", - example="2020-12-23T14:24:44.593Z", + examples=["2020-12-23T14:24:44.593Z"], ) exposure: Optional[List[str]] = Field( None, description="Lists indicators that the data may be exposed.", - example=["OutsideTrustedDomains", "IsPublic"], + examples=[["OutsideTrustedDomains", "IsPublic"]], ) file_availability: Optional[Union[FileAvailability, str]] = Field( None, alias="fileAvailability", description="The download availability status of the file associated with the event.", - example="EXACT_FILE_AVAILABLE", + examples=["EXACT_FILE_AVAILABLE"], ) file_name: Optional[str] = Field( None, alias="fileName", description="The name of the file, including the file extension.", - example="example.docx", + examples=["example.docx"], ) file_path: Optional[str] = Field( None, alias="filePath", description="The file location on the user's device; a path forward or backslash should be included at the end of the filepath. Possibly null if the file event occurred on a cloud provider.", - example="/Users/casey/Documents/", + examples=["/Users/casey/Documents/"], ) risk_indicators: Optional[List[Union[RiskIndicator, str]]] = Field( None, @@ -183,13 +187,13 @@ class FileEvent(Model): None, alias="riskScore", description="Sum of the weights for each risk indicator. This score is used to determine the overall risk severity of the event.", - example=12, + examples=[12], ) risk_severity: Optional[str] = Field( None, alias="riskSeverity", description="The general risk assessment of the event, based on the numeric score.", - example="CRITICAL", + examples=["CRITICAL"], ) @@ -209,5 +213,5 @@ class CaseFileEvents(ResponseModel): None, alias="totalCount", description="Total number of events associated with the case.", - example=42, + examples=[42], ) diff --git a/src/_incydr_sdk/core/models.py b/src/_incydr_sdk/core/models.py index f81465ee..04c882b6 100644 --- a/src/_incydr_sdk/core/models.py +++ b/src/_incydr_sdk/core/models.py @@ -9,8 +9,9 @@ import requests from boltons.jsonutils import JSONLIterator from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import model_validator from pydantic import PrivateAttr -from pydantic import root_validator from pydantic import SecretStr from pydantic import ValidationError @@ -27,29 +28,23 @@ def json( include=None, exclude=None, by_alias=True, - skip_defaults=None, exclude_unset=False, exclude_defaults=False, exclude_none=False, - encoder=None, - models_as_dict=True, **dumps_kwargs, ): """ Generate a JSON representation of the model, optionally specifying which fields to include or exclude. - See [Pydantic docs](https://pydantic-docs.helpmanual.io/usage/exporting_models/#modeljson) for full details. + See [Pydantic docs](https://docs.pydantic.dev/latest/concepts/serialization/#modelmodel_dump_json) for full details. """ - return super().json( + return super().model_dump_json( include=include, exclude=exclude, by_alias=by_alias, - skip_defaults=skip_defaults, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, exclude_none=exclude_none, - encoder=encoder, - models_as_dict=models_as_dict, **dumps_kwargs, ) @@ -59,7 +54,6 @@ def dict( include=None, exclude=None, by_alias=True, - skip_defaults=None, exclude_unset=False, exclude_defaults=False, exclude_none=False, @@ -67,13 +61,12 @@ def dict( """ Generate a dict representation of the model, optionally specifying which fields to include or exclude. - See [Pydantic docs](https://pydantic-docs.helpmanual.io/usage/exporting_models/#modeldict) for full details. + See [Pydantic docs](https://docs.pydantic.dev/latest/concepts/serialization/#modelmodel_dump) for full details. """ - return super().dict( + return super().model_dump( include=include, exclude=exclude, by_alias=by_alias, - skip_defaults=skip_defaults, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, exclude_none=exclude_none, @@ -100,18 +93,21 @@ def parse_json_lines(cls, file): f"Unable to parse line {num}. Expecting JSONLines format: https://jsonlines.org" ) - class Config: - allow_population_by_field_name = True - use_enum_values = True - json_encoders = {datetime: lambda dt: dt.isoformat().replace("+00:00", "Z")} - extra = "allow" + # TODO[pydantic]: The following keys were removed: `json_encoders`. + # Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-config for more information. + model_config = ConfigDict( + populate_by_name=True, + use_enum_values=True, + json_encoders={datetime: lambda dt: dt.isoformat().replace("+00:00", "Z")}, + extra="allow", + ) class ResponseModel(Model): @classmethod def parse_response(cls, response: requests.Response): try: - return cls.parse_raw(response.text) + return cls.model_validate_json(response.text) except ValidationError as err: err.response = response raise @@ -134,7 +130,7 @@ def expired(self): ) -class CSVModel(BaseModel, allow_population_by_field_name=True, extra="allow"): +class CSVModel(BaseModel): """ Pydantic model class enables multiple aliases to be assigned to a single field value. If the field is required then at least one of the aliases must be supplied or validation will fail. @@ -156,16 +152,22 @@ class UserCSV(CSVModel): model also allows extra values, "username" will still be accessible on the model at `model.username`. """ - @root_validator(pre=True) + model_config = ConfigDict(extra="allow", validate_by_name=True) + + @model_validator(mode="before") + @classmethod def _alias_validator(cls, values): # noqa - for name, field in cls.__fields__.items(): - aliases = field.field_info.extra.get("csv_aliases", []) + for name, field_info in cls.model_fields.items(): + if field_info.json_schema_extra: + aliases = field_info.json_schema_extra.get("csv_aliases", []) + else: + aliases = [] for alias in aliases: if alias in values and values[alias]: values[name] = values[alias] break else: # no break - if field.required: + if field_info.is_required(): raise ValueError( f"'{name}' required. Valid column aliases: {aliases}" ) diff --git a/src/_incydr_sdk/core/settings.py b/src/_incydr_sdk/core/settings.py index f6c529da..9cbe3d98 100644 --- a/src/_incydr_sdk/core/settings.py +++ b/src/_incydr_sdk/core/settings.py @@ -7,11 +7,12 @@ from textwrap import indent from typing import Union -from pydantic import BaseSettings from pydantic import Field -from pydantic import root_validator +from pydantic import field_validator +from pydantic import model_validator from pydantic import SecretStr -from pydantic import validator +from pydantic_settings import BaseSettings +from pydantic_settings import SettingsConfigDict from requests_toolbelt.utils.dump import dump_response from rich import pretty from rich.console import Console @@ -84,20 +85,27 @@ class IncydrSettings(BaseSettings): and the Python repl. Defaults to True. env_var=`INCYDR_USE_RICH` """ - api_client_id: str = Field(env="incydr_api_client_id") - api_client_secret: SecretStr = Field(env="incydr_api_client_secret") - url: str = Field(env="incydr_url") - page_size: int = Field(default=100, env="incydr_page_size") - max_response_history: int = Field(default=5, env="incydr_max_response_history") - use_rich: bool = Field(default=True, env="incydr_use_rich") - log_stderr: bool = Field(default=True, env="incydr_log_stderr") - log_file: Union[str, Path, IOBase] = Field(default=None, env="incydr_log_file") - log_level: Union[int, str] = Field( - default=logging.WARNING, - env="incydr_log_level", + api_client_id: str + api_client_secret: SecretStr + url: str + page_size: int = Field(default=100) + max_response_history: int = Field(default=5) + use_rich: bool = Field(default=True) + log_stderr: bool = Field(default=True) + log_file: Union[str, Path, IOBase] = Field(default=None) + log_level: Union[int, str] = Field(default=logging.WARNING, validate_default=True) + logger: logging.Logger = Field(default=None) + user_agent_prefix: Union[str] = Field(default="") + + model_config = SettingsConfigDict( + env_prefix="incydr_", + extra="ignore", + env_file=str(Path.home() / ".config" / "incydr" / ".env"), + validate_assignment=True, + custom_logger=False, + validate_by_name=True, + validate_by_alias=True, ) - logger: logging.Logger = None - user_agent_prefix: str = Field(default=None, env="incydr_user_agent_prefix") def __init__(self, **kwargs): # clear any keys from kwargs that are passed as None, which forces lookup of values @@ -108,12 +116,7 @@ def __init__(self, **kwargs): kwargs["_env_file"] = ".env" super().__init__(**kwargs) - class Config: - env_file = str(Path.home() / ".config" / "incydr" / ".env") - validate_assignment = True - custom_logger = False - - @validator("log_level", pre=True, always=True) + @field_validator("log_level", mode="before") def _validate_log_level(cls, value, **kwargs): # noqa try: return int(value) @@ -122,7 +125,8 @@ def _validate_log_level(cls, value, **kwargs): # noqa LogLevel(value) return _log_level_map[value] - @validator("log_file") + @field_validator("log_file", mode="plain") + @classmethod def _validate_log_file(cls, value, **kwargs): # noqa if isinstance(value, (str, Path)): p = Path(value) @@ -136,7 +140,8 @@ def _validate_log_file(cls, value, **kwargs): # noqa raise ValueError(f"{value} is not a valid file path for logging.") return value - @validator("use_rich") + @field_validator("use_rich", mode="before") + @classmethod def _validate_use_rich(cls, value, **kwargs): # noqa if value: pretty.install() @@ -144,7 +149,8 @@ def _validate_use_rich(cls, value, **kwargs): # noqa sys.displayhook = _sys_displayhook return value - @validator("logger") + @field_validator("logger", mode="before") + @classmethod def _validate_logger(cls, value, **kwargs): # noqa if value is None: logger = logging.getLogger("incydr") @@ -156,13 +162,15 @@ def _validate_logger(cls, value, **kwargs): # noqa else: raise ValueError(f"{value} is not a `logging.Logger`.") - @root_validator(skip_on_failure=True) + @model_validator(mode="after") + @classmethod def _configure_logging(cls, values): # noqa - use_rich = values["use_rich"] - log_file = values["log_file"] - log_stderr = values["log_stderr"] - log_level = values["log_level"] - logger = values["logger"] + values_dict = values.dict() + use_rich = values_dict["use_rich"] + log_file = values_dict["log_file"] + log_stderr = values_dict["log_stderr"] + log_level = values_dict["log_level"] + logger = values_dict["logger"] if not hasattr(logger, "_incydr"): warnings.warn( @@ -202,7 +210,7 @@ def _configure_logging(cls, values): # noqa logger.addHandler(std_file_handler) logger.setLevel(log_level) - values["logger"] = logger + cls.logger = logger return values def _log_response_info(self, response): diff --git a/src/_incydr_sdk/customer/models.py b/src/_incydr_sdk/customer/models.py index 95b88b24..87fb9583 100644 --- a/src/_incydr_sdk/customer/models.py +++ b/src/_incydr_sdk/customer/models.py @@ -1,5 +1,6 @@ from typing import Optional +from pydantic import ConfigDict from pydantic import Field from _incydr_sdk.core.models import ResponseModel @@ -16,11 +17,7 @@ class Customer(ResponseModel): * **tenant_id**: `str` The unique identifier for the account within Code42. """ - name: Optional[str] = Field(allow_mutation=False) - registration_key: Optional[str] = Field( - allow_mutation=False, alias="registrationKey" - ) - tenant_id: Optional[str] = Field(allow_mutation=False, alias="tenantId") - - class Config: - validate_assignment = True + name: Optional[str] = Field(frozen=True) + registration_key: Optional[str] = Field(frozen=True, alias="registrationKey") + tenant_id: Optional[str] = Field(frozen=True, alias="tenantId") + model_config = ConfigDict(validate_assignment=True) diff --git a/src/_incydr_sdk/departments/models.py b/src/_incydr_sdk/departments/models.py index 1e21a147..5452a8bc 100644 --- a/src/_incydr_sdk/departments/models.py +++ b/src/_incydr_sdk/departments/models.py @@ -21,12 +21,12 @@ class DepartmentsPage(ResponseModel): total_count: Optional[int] = Field( None, description="The total count of all departments.", - example=10, + examples=[10], alias="totalCount", ) class GetPageRequest(BaseModel): page: int = 1 - page_size: int = None - name: str = None + page_size: Optional[int] = None + name: Optional[str] = None diff --git a/src/_incydr_sdk/devices/models.py b/src/_incydr_sdk/devices/models.py index 6a4f0297..7f5bc786 100644 --- a/src/_incydr_sdk/devices/models.py +++ b/src/_incydr_sdk/devices/models.py @@ -3,6 +3,7 @@ from typing import Optional from pydantic import BaseModel +from pydantic import ConfigDict from pydantic import Field from _incydr_sdk.core.models import ResponseModel @@ -99,7 +100,7 @@ class Device(ResponseModel): description="The last day and time this device was connected to the server.", ) os_name: Optional[str] = Field( - alias="osHostname", + alias="osName", description="Operating system name. Values: Windows*, Mac OS X, Linux, Android, iOS, SunOS, etc", ) os_version: Optional[str] = Field( @@ -156,12 +157,10 @@ class DevicesPage(ResponseModel): class QueryDevicesRequest(BaseModel): - active: Optional[bool] - blocked: Optional[bool] + active: Optional[bool] = None + blocked: Optional[bool] = None page: Optional[int] = 1 pageSize: Optional[int] = 100 sortDirection: SortDirection = SortDirection.ASC sortKey: SortKeys = SortKeys.NAME - - class Config: - use_enum_values = True + model_config = ConfigDict(use_enum_values=True) diff --git a/src/_incydr_sdk/directory_groups/models.py b/src/_incydr_sdk/directory_groups/models.py index 03178f58..c95dc2ee 100644 --- a/src/_incydr_sdk/directory_groups/models.py +++ b/src/_incydr_sdk/directory_groups/models.py @@ -8,9 +8,9 @@ class DirectoryGroup(ResponseModel): group_id: Optional[str] = Field( - None, description="A unique group ID.", example="23", alias="groupId" + None, description="A unique group ID.", examples=["23"], alias="groupId" ) - name: Optional[str] = Field(None, example="Research and development") + name: Optional[str] = Field(None, examples=["Research and development"]) class DirectoryGroupsPage(ResponseModel): @@ -29,6 +29,6 @@ class DirectoryGroupsPage(ResponseModel): total_count: Optional[int] = Field( None, description="The total count of all directory groups.", - example=10, + examples=[10], alias="totalCount", ) diff --git a/src/_incydr_sdk/exceptions.py b/src/_incydr_sdk/exceptions.py index 5114a549..c244072e 100644 --- a/src/_incydr_sdk/exceptions.py +++ b/src/_incydr_sdk/exceptions.py @@ -7,14 +7,14 @@ class IncydrException(Exception): ... -class AuthMissingError(ValidationError, IncydrException): +class AuthMissingError(IncydrException): def __init__(self, validation_error: ValidationError): self.pydantic_error = str(validation_error) - super().__init__(validation_error.raw_errors, validation_error.model) + self.errors = validation_error.errors() @property def error_keys(self): - return [e["loc"][0] for e in self.errors()] + return [e["loc"][0] for e in self.errors] def __str__(self): return ( diff --git a/src/_incydr_sdk/file_events/models/event.py b/src/_incydr_sdk/file_events/models/event.py index 3add337f..cac44dab 100644 --- a/src/_incydr_sdk/file_events/models/event.py +++ b/src/_incydr_sdk/file_events/models/event.py @@ -24,7 +24,7 @@ class ResponseControls(Model): preventative_control: Optional[str] = Field( None, alias="preventativeControl", - example="BLOCKED", + examples=["BLOCKED"], title="The preventative action applied to this event", ) user_justification: Optional[UserJustification] = Field( @@ -53,33 +53,33 @@ class AcquiredFromGit(Model): class AcquiredFromSourceUser(Model): email: Optional[List[str]] = Field( None, - example=["first.last@example.com", "first_last_example_com"], + examples=[["first.last@example.com", "first_last_example_com"]], title="For endpoint events where a file in cloud storage is synced to a device, the email address of the user logged in to the cloud storage provider.", ) class UntrustedValues(Model): account_names: List[str] = Field( - ..., + None, alias="accountNames", title="Account names that do not match an entry in your list of Trusted activity. Values are obtained from the account name metadata for the event. Only applies to event types that are evaluated for trust.", ) domains: List[str] = Field( - ..., + None, title="Domains that do not match an entry in your list of Trusted activity. Values are obtained from the domain section of related metadata for the event. Only applies to event types that are evaluated for trust.", ) git_repository_uris: List[str] = Field( - ..., + None, alias="gitRepositoryUris", title="Git URIs that do not match an entry in your list of Trusted activity. Values are obtained from the Git URI metadata for the event. Only applies to event types that are evaluated for trust.", ) slack_workspaces: List[str] = Field( - ..., + None, alias="slackWorkspaces", title="Slack workspaces that do not match an entry in your list of Trusted activity. Values are obtained from the Slack metadata for the event. Only applies to event types that are evaluated for trust.", ) url_paths: List[str] = Field( - ..., + None, alias="urlPaths", title="URL paths that do not match an entry in your list of Trusted activity. Values are obtained from the URL metadata for the event. Only applies to event types that are evaluated for trust.", ) @@ -87,47 +87,54 @@ class UntrustedValues(Model): class DestinationEmail(Model): recipients: Optional[List[str]] = Field( + None, description="The email addresses of those who received the email. Includes the To, Cc, and Bcc recipients.", - example=["cody@example.com", "theboss@example.com"], + examples=[["cody@example.com", "theboss@example.com"]], ) subject: Optional[str] = Field( + None, description="The subject of the email message.", - example="Important business documents", + examples=["Important business documents"], ) class DestinationUser(Model): email: Optional[List[str]] = Field( + None, description="For endpoint events where a file in cloud storage is synced to a device, the email address of the user logged in to the cloud storage provider. For cloud events, the email addresses of users added as sharing recipients. In some case, OneDrive events may return multiple values, but this is often the same username formatted in different ways.", - example=["first.last@example.com", "first_last_example_com"], + examples=[["first.last@example.com", "first_last_example_com"]], ) class FileClassification(Model): value: Optional[str] = Field( + None, description="The classification value applied to the file.", - example="Classified", + examples=["Classified"], ) vendor: Optional[str] = Field( + None, description="The name of the vendor that classified the file.", - example="MICROSOFT INFORMATION PROTECTION", + examples=["MICROSOFT INFORMATION PROTECTION"], ) class Hash(Model): md5: Optional[str] = Field( + None, description="The MD5 hash of the file contents.", - example="a162591e78eb2c816a28907d3ac020f9", + examples=["a162591e78eb2c816a28907d3ac020f9"], ) md5_error: Optional[str] = Field( - alias="md5Error", description="Reason the MD5 hash is unavailable." + None, alias="md5Error", description="Reason the MD5 hash is unavailable." ) sha256: Optional[str] = Field( + None, description="The SHA-256 hash of the file contents.", - example="ded96d69c63754472efc4aa86fed68d4e17784b38089851cfa84e699e48b4155", + examples=["ded96d69c63754472efc4aa86fed68d4e17784b38089851cfa84e699e48b4155"], ) sha256_error: Optional[str] = Field( - alias="sha256Error", description="Reason the SHA-256 hash is unavailable." + None, alias="sha256Error", description="Reason the SHA-256 hash is unavailable." ) @@ -150,112 +157,133 @@ class Process(Model): executable: Optional[str] = Field( None, description="The name of the process that accessed the file, as reported by the device’s operating system. Depending on your Code42 product plan, this value may be null for some event types.", - example="bash", + examples=["bash"], ) owner: Optional[str] = Field( None, description="The username of the process owner, as reported by the device’s operating system. Depending on your Code42 product plan, this value may be null for some event types.", - example="root", + examples=["root"], ) - extension: Optional[Extension] + extension: Optional[Extension] = None class RemovableMedia(Model): bus_type: Optional[str] = Field( + None, alias="busType", description="For events detected on removable media, indicates the communication system used to transfer data between the host and the removable device.", - example="USB 3.0 Bus", + examples=["USB 3.0 Bus"], ) capacity: Optional[int] = Field( + None, description="For events detected on removable media, the capacity of the removable device in bytes.", - example=15631122432, + examples=[15631122432], ) media_name: Optional[str] = Field( + None, alias="mediaName", description="For events detected on removable media, the media name of the device, as reported by the vendor/device. This is usually very similar to the productName, but can vary based on the type of device. For example, if the device is a hard drive in a USB enclosure, this may be the combination of the drive model and the enclosure model.\nThis value is not provided by all devices, so it may be null in some cases.", - example="Cruzer Blade", + examples=["Cruzer Blade"], ) name: Optional[str] = Field( + None, description="For events detected on removable media, the name of the removable device.", - example="JUMPDRIVE", + examples=["JUMPDRIVE"], ) partition_id: Optional[List[str]] = Field( + None, alias="partitionId", description="For events detected on removable media, a unique identifier assigned to the volume/partition when it was formatted. Windows devices refer to this as the VolumeGuid. On Mac devices, this is the Disk / Partition UUID, which appears when running the Terminal command diskUtil info.", - example=["disk0s2", "disk0s3"], + examples=[["disk0s2", "disk0s3"]], ) serial_number: Optional[str] = Field( + None, alias="serialNumber", description="For events detected on removable media, the serial number of the removable device.", - example="4C531001550407108465", + examples=["4C531001550407108465"], ) vendor: Optional[str] = Field( + None, description="For events detected on removable media, the vendor of the removable device.", - example="SanDisk", + examples=["SanDisk"], ) volume_name: Optional[List[str]] = Field( + None, alias="volumeName", description='For events detected on removable media, the name assigned to the volume when it was formatted, as reported by the device\'s operating system. This is also frequently called the "partition" name.', - example=["MY_FILES"], + examples=[["MY_FILES"]], ) class Report(Model): count: Optional[int] = Field( - description="The total number of rows returned in the report.", example=20 + None, + description="The total number of rows returned in the report.", + examples=[20], ) description: Optional[str] = Field( + None, description="The description of the report.", - example="Top 20 accounts based on annual revenue", + examples=["Top 20 accounts based on annual revenue"], ) headers: Optional[List[str]] = Field( + None, description="The list of column headers that are in the report.", - example=[ - "USERNAME", - "ACCOUNT_NAME", - "TYPE", - "DUE_DATE", - "LAST_UPDATE", - "ADDRESS1_STATE", + examples=[ + [ + "USERNAME", + "ACCOUNT_NAME", + "TYPE", + "DUE_DATE", + "LAST_UPDATE", + "ADDRESS1_STATE", + ] ], ) id: Optional[str] = Field( + None, description="The ID of the report associated with this event.", - example="00OB00000042FHdMAM", + examples=["00OB00000042FHdMAM"], ) name: Optional[str] = Field( + None, description="The display name of the report.", - example="Top Accounts Report", + examples=["Top Accounts Report"], ) type: Optional[Union[ReportType, str]] = Field( + None, description='Indicates if the report is "REPORT_TYPE_AD_HOC" or "REPORT_TYPE_SAVED".', - example="REPORT_TYPE_SAVED", + examples=["REPORT_TYPE_SAVED"], ) class RiskIndicator(Model): name: Optional[str] = Field( + None, description="Name of the risk indicator.", - example="Browser upload", + examples=["Browser upload"], ) id: Optional[str] = Field( None, title="The unique identifier for the risk indicator." ) weight: Optional[int] = Field( + None, description="Configured weight of the risk indicator at the time this event was seen.", - example=5, + examples=[5], ) class SourceEmail(Model): from_: Optional[str] = Field( + None, alias="from", description='The display name of the sender, as it appears in the "From" field in the email. In many cases, this is the same as source.email.sender, but it can be different if the message is sent by a server or other mail agent on behalf of someone else.', - example="ari@example.com", + examples=["ari@example.com"], ) sender: Optional[str] = Field( + None, description="The address of the entity responsible for transmitting the message. In many cases, this is the same as source.email.from, but it can be different if the message is sent by a server or other mail agent on behalf of someone else.", - example="ari@example.com", + examples=["ari@example.com"], ) @@ -263,44 +291,51 @@ class SourceUser(Model): email: Optional[List[str]] = Field( None, description="For endpoint events where a file in cloud storage is synced to a device, the email address of the user logged in to the cloud storage provider.", - example=["first.last@example.com", "first_last_example_com"], + examples=[["first.last@example.com", "first_last_example_com"]], ) class Tab(Model): title: Optional[str] = Field( + None, description="The title of this app or browser tab.", - example="Example Domain", + examples=["Example Domain"], ) title_error: Optional[str] = Field( + None, alias="titleError", description="Reason the title of this app or browser tab is unavailable.", - example="InsufficientPermissions", + examples=["InsufficientPermissions"], ) url: Optional[str] = Field( + None, description="The URL of this browser tab.", - example="https://example.com/", + examples=["https://example.com/"], ) url_error: Optional[str] = Field( + None, alias="urlError", description="Reason the URL of this browser tab is unavailable.", - example="InsufficientPermissions", + examples=["InsufficientPermissions"], ) class User(Model): device_uid: Optional[str] = Field( + None, alias="deviceUid", description="Unique identifier for the device. Null if the file event occurred on a cloud provider.", - example=24681, + examples=[24681], ) email: Optional[str] = Field( + None, description="The Code42 username used to sign in to the Code42 app on the device. Null if the file event occurred on a cloud provider.", - example="cody@example.com", + examples=["cody@example.com"], ) id: Optional[str] = Field( + None, description="Unique identifier for the user of the Code42 app on the device. Null if the file event occurred on a cloud provider.", - example=1138, + examples=[1138], ) @@ -308,7 +343,9 @@ class AcquiredFrom(Model): event_id: Optional[str] = Field( None, alias="eventId", - example="0_147e9445-2f30-4a91-8b2a-9455332e880a_973435567569502913_986467523038446097_163", + examples=[ + "0_147e9445-2f30-4a91-8b2a-9455332e880a_973435567569502913_986467523038446097_163" + ], title="The unique identifier for the event.", ) tabs: Optional[List[Tab]] = Field( @@ -327,49 +364,49 @@ class AcquiredFrom(Model): source_category: Optional[str] = Field( None, alias="sourceCategory", - example="Social Media", + examples=["Social Media"], title="General category of where the file originated. For example: Cloud Storage, Email, Social Media.", ) source_name: Optional[str] = Field( None, alias="sourceName", - example="Mari's MacBook", + examples=["Mari's MacBook"], title="The name reported by the device's operating system. This may be different than the device name in the Code42 console.", ) source_user: Optional[AcquiredFromSourceUser] = Field(None, alias="sourceUser") agent_timestamp: Optional[datetime] = Field( None, alias="agentTimestamp", - example="2020-10-27T15:16:05.369203Z", + examples=["2020-10-27T15:16:05.369203Z"], title="Date and time that the Code42 service on the device detected an event; based on the device’s system clock and reported in Coordinated Universal Time (UTC).", ) user_email: Optional[str] = Field( None, alias="userEmail", - example="cody@example.com", + examples=["cody@example.com"], title="The Code42 username used to sign in to the Code42 app on the device (for endpoint events) or the cloud service username of the person who caused the event (for cloud events).", ) event_action: Optional[str] = Field( None, alias="eventAction", - example="file-downloaded", + examples=["file-downloaded"], title="The type of file event observed. For example: file-modified, application-read, removable-media-created.", ) source_domains: Optional[List[str]] = Field( None, alias="sourceDomains", - example="example.com", + examples=["example.com"], title="The domain section of the URLs reported in file.acquiredFrom.tabs.url.", ) file_name: Optional[str] = Field( None, alias="fileName", - example="example.txt", + examples=["example.txt"], title="The name of the file, including the file extension.", ) md5: Optional[str] = Field( None, - example="6123bbce7f3937667a368bbb9f3d79ce", + examples=["6123bbce7f3937667a368bbb9f3d79ce"], title="The MD5 hash of the file contents.", ) git: Optional[AcquiredFromGit] = None @@ -377,62 +414,76 @@ class AcquiredFrom(Model): class Destination(Model): account_name: Optional[str] = Field( + None, alias="accountName", description="For cloud sync apps installed on user devices, the name of the cloud account where the event was observed. This can help identify if the activity occurred in a business or personal account.", ) account_type: Optional[str] = Field( + None, alias="accountType", description="For cloud sync apps installed on user devices, the type of account where the event was observed. For example, 'BUSINESS' or 'PERSONAL'.", - example="BUSINESS", + examples=["BUSINESS"], ) category: Optional[str] = Field( + None, description="General category of where the file originated. For example: Cloud Storage, Email, Social Media.", - example="Social Media", + examples=["Social Media"], ) domains: Optional[List[str]] = Field( + None, description="The domain section of the URLs reported in destination.tabs.url.", ) email: Optional[DestinationEmail] = Field( - description="Metadata about the destination email." + None, description="Metadata about the destination email." ) ip: Optional[str] = Field( + None, description="The external IP address of the user's device.", - example="127.0.0.1", + examples=["127.0.0.1"], ) name: Optional[str] = Field( + None, description="The name reported by the device's operating system. This may be different than the device name in the Code42 console.", - example="Mari's MacBook", + examples=["Mari's MacBook"], ) operating_system: Optional[str] = Field( + None, alias="operatingSystem", description="The operating system of the destination device.", - example="Windows 10", + examples=["Windows 10"], ) print_job_name: Optional[str] = Field( + None, alias="printJobName", description="For print events, the name of the print job, as reported by the user's device.", - example="printer.exe", + examples=["printer.exe"], ) printer_name: Optional[str] = Field( + None, alias="printerName", description="For print events, the name of the printer the job was sent to.", - example="OfficeJet", + examples=["OfficeJet"], + ) + printed_files_backup_path: Optional[str] = Field( + None, alias="printedFilesBackupPath" ) - printed_files_backup_path: Optional[str] = Field(alias="printedFilesBackupPath") private_ip: Optional[List[str]] = Field( + None, alias="privateIp", description="The IP address of the user's device on your internal network, including Network interfaces, Virtual Network Interface controllers (NICs), and Loopback/non-routable addresses.", - example=["127.0.0.1", "127.0.0.2"], + examples=[["127.0.0.1", "127.0.0.2"]], ) removable_media: Optional[RemovableMedia] = Field( + None, alias="removableMedia", description="Metadata about the removable media destination.", ) tabs: Optional[List[Tab]] = Field( + None, description="Metadata about the browser tab destination.", ) user: Optional[DestinationUser] = Field( - description="Metadata about the destination user." + None, description="Metadata about the destination user." ) remote_hostname: Optional[str] = Field( None, @@ -444,56 +495,64 @@ class Destination(Model): class File(Model): acquired_from: Optional[List[AcquiredFrom]] = Field(None, alias="acquiredFrom") category: Optional[str] = Field( + None, description="A categorization of the file that is inferred from MIME type.", - example="Audio", + examples=["Audio"], ) category_by_bytes: Optional[str] = Field( + None, alias="categoryByBytes", description="A categorization of the file based on its contents.", - example="Image", + examples=["Image"], ) category_by_extension: Optional[str] = Field( + None, alias="categoryByExtension", description="A categorization of the file based on its extension.", - example="Document", + examples=["Document"], ) change_type: Optional[str] = Field( None, alias="changeType", description="The action that caused the event. For example: CREATED, MODIFIED, DELETED.", - example="CREATED", + examples=["CREATED"], ) classifications: Optional[List[FileClassification]] = Field( - description="Data provided by an external file classification vendor." + None, description="Data provided by an external file classification vendor." ) cloud_drive_id: Optional[str] = Field( + None, alias="cloudDriveId", description="Unique identifier reported by the cloud provider for the drive containing the file at the time the event occurred.", - example="RvBpZ48u2m", + examples=["RvBpZ48u2m"], ) created: Optional[datetime] = Field( + None, description="File creation timestamp as reported by the device's operating system in Coordinated Universal Time (UTC); available for Mac and Windows NTFS devices only.", - example="2020-10-27T15:16:05.369203Z", + examples=["2020-10-27T15:16:05.369203Z"], ) directory: Optional[str] = Field( + None, description="The file location on the user's device; a forward or backslash must be included at the end of the filepath. Possibly null if the file event occurred on a cloud provider.", - example="/Users/alix/Documents/", + examples=["/Users/alix/Documents/"], ) original_directory: Optional[str] = Field( None, alias="originalDirectory", - example="/Users/alix/Documents/", + examples=["/Users/alix/Documents/"], title="The original file location on the user’s device or cloud service location; a forward or backslash must be included at the end of the filepath. Possibly null if the file event occurred on a cloud provider.", ) directory_id: Optional[List[str]] = Field( + None, alias="directoryId", description="Unique identifiers of the parent drives that contain the file; searching on directoryId will return events for all of the files contained in the parent drive.", - example=["1234", "56d78"], + examples=[["1234", "56d78"]], ) - hash: Optional[Hash] = Field(description="Hash values of the file.") + hash: Optional[Hash] = Field(None, description="Hash values of the file.") id: Optional[str] = Field( + None, description="Unique identifier reported by the cloud provider for the file associated with the event.", - example="PUL5zWLRrdudiJZ1OCWw", + examples=["PUL5zWLRrdudiJZ1OCWw"], ) mime_type: Optional[str] = Field( None, @@ -501,39 +560,47 @@ class File(Model): title="The MIME type of the file. For endpoint events, if the mimeTypeByBytes differs from mimeTypeByExtension, this indicates the most likely MIME type for the file. For activity observed by a web browser, this is the only MIME type reported.", ) mime_type_by_bytes: Optional[str] = Field( + None, alias="mimeTypeByBytes", description="The MIME type of the file based on its contents.", - example="text/csv", + examples=["text/csv"], ) mime_type_by_extension: Optional[str] = Field( + None, alias="mimeTypeByExtension", description="The MIME type of the file based on its extension.", - example="audio/vorbis", + examples=["audio/vorbis"], ) modified: Optional[datetime] = Field( + None, description="File modification timestamp as reported by the device's operating system. This only indicates changes to file contents. Changes to file permissions, file owner, or other metadata are not reflected in this timestamp. Date is reported in Coordinated Universal Time (UTC).", - example="2020-10-27T15:16:05.369203Z", + examples=["2020-10-27T15:16:05.369203Z"], ) name: Optional[str] = Field( + None, description="The name of the file, including the file extension.", - example="ReadMe.md", + examples=["ReadMe.md"], ) original_name: Optional[str] = Field( None, alias="originalName", - example="ReadMe.md", + examples=["ReadMe.md"], title="The original name of the file, including the file extension.", ) owner: Optional[str] = Field( + None, description="The name of the user who owns the file as reported by the device's file system.", - example="ari.example", + examples=["ari.example"], ) size_in_bytes: Optional[int] = Field( - alias="sizeInBytes", description="Size of the file in bytes.", example=256 + None, + alias="sizeInBytes", + description="Size of the file in bytes.", + examples=[256], ) url: Optional[str] = Field( description="URL reported by the cloud provider at the time the event occurred.", - example="https://example.com", + examples=["https://example.com"], ) archive_id: Optional[str] = Field( None, @@ -554,26 +621,31 @@ class File(Model): class Risk(Model): indicators: Optional[List[RiskIndicator]] = Field( + None, description="List of risk indicators identified for this event. If more than one risk indicator applies to this event, the sum of all indicators determines the total risk score.", ) score: Optional[int] = Field( + None, description="Sum of the weights for each risk indicator. This score is used to determine the overall risk severity of the event.", - example=12, + examples=[12], ) severity: Optional[str] = Field( + None, description="The general risk assessment of the event, based on the numeric score.", - example="CRITICAL", + examples=["CRITICAL"], ) trust_reason: Optional[str] = Field( + None, alias="trustReason", description="The reason the event is trusted. trustReason is only populated if trusted is true for this event.", - example="TRUSTED_DOMAIN_BROWSER_URL", + examples=["TRUSTED_DOMAIN_BROWSER_URL"], ) trusted: Optional[bool] = Field( + None, description="Indicates whether or not the file activity is trusted based on your Data Preferences settings.", - example=True, + examples=[True], ) - untrusted_values: UntrustedValues = Field(..., alias="untrustedValues") + untrusted_values: UntrustedValues = Field(None, alias="untrustedValues") class Source(Model): @@ -588,34 +660,43 @@ class Source(Model): description="For cloud sync apps installed on user devices, the type of account where the event was observed. For example, ‘BUSINESS’ or ‘PERSONAL’.", ) category: Optional[str] = Field( + None, description="General category of where the file originated. For example: Cloud Storage, Email, Social Media.", - example="Social Media", + examples=["Social Media"], ) domain: Optional[str] = Field( + None, description="Fully qualified domain name (FQDN) for the user's device at the time the event is recorded. If the device is unable to resolve the domain name of the host, it reports the IP address of the host.", - example="localhost", + examples=["localhost"], ) domains: Optional[List[str]] = Field( + None, description="The domain section of the URLs reported in source.tabs.url. (Note: Although similar in name, this field has no relation to source.domain, which reports the FQDN or IP address of the user’s device.)", ) - email: Optional[SourceEmail] = Field(description="Metadata about the email source.") + email: Optional[SourceEmail] = Field( + None, description="Metadata about the email source." + ) ip: Optional[str] = Field( + None, description="The external IP address of the user's device.", - example="127.0.0.1", + examples=["127.0.0.1"], ) name: Optional[str] = Field( + None, description="The name reported by the device's operating system. This may be different than the device name in the Code42 console.", - example="Mari's MacBook", + examples=["Mari's MacBook"], ) operating_system: Optional[str] = Field( + None, alias="operatingSystem", description="The operating system of the source device.", - example="Windows 10", + examples=["Windows 10"], ) private_ip: Optional[List[str]] = Field( + None, alias="privateIp", description="The IP address of the user's device on your internal network, including Network interfaces, Virtual Network Interface controllers (NICs), and Loopback/non-routable addresses.", - example=["127.0.0.1", "127.0.0.2"], + examples=[["127.0.0.1", "127.0.0.2"]], ) remote_hostname: Optional[str] = Field( None, @@ -623,11 +704,12 @@ class Source(Model): title="For events where a file transfer tool was used, the source hostname.", ) removable_media: Optional[RemovableMedia] = Field( + None, alias="removableMedia", description="Metadata about the removable media source.", ) tabs: Optional[List[Tab]] = Field( - description="Metadata about the browser tab source." + None, description="Metadata about the browser tab source." ) user: Optional[SourceUser] = Field( None, description="Metadata about the source user." @@ -636,59 +718,74 @@ class Source(Model): class RelatedEvent(Model): agent_timestamp: Optional[datetime] = Field( + None, alias="agentTimestamp", description="Date and time that the Code42 service on the device detected an event; based on the device’s system clock and reported in Coordinated Universal Time (UTC).", - example="2020-10-27T15:16:05.369203Z", + examples=["2020-10-27T15:16:05.369203Z"], ) event_action: Optional[str] = Field( + None, alias="eventAction", description="The type of file event observed. For example: file-modified, application-read, removable-media-created.", - example="file-downloaded", + examples=["file-downloaded"], ) id: Optional[str] = Field( + None, description="The unique identifier for the event.", - example="0_147e9445-2f30-4a91-8b2a-9455332e880a_973435567569502913_986467523038446097_163", + examples=[ + "0_147e9445-2f30-4a91-8b2a-9455332e880a_973435567569502913_986467523038446097_163" + ], ) source_category: Optional[str] = Field( + None, alias="sourceCategory", description="General category of where the file originated. For example: Cloud Storage, Email, Social Media.", - example="Social Media", + examples=["Social Media"], ) source_name: Optional[str] = Field( + None, alias="sourceName", description="The name reported by the device's operating system. This may be different than the device name in the Code42 console.", - example="Mari's MacBook", + examples=["Mari's MacBook"], ) tabs: Optional[List[Tab]] = Field( - description="Metadata about the browser tab source." + None, description="Metadata about the browser tab source." ) user_email: Optional[str] = Field( + None, alias="userEmail", description="The Code42 username used to sign in to the Code42 app on the device (for endpoint events) or the cloud service username of the person who caused the event (for cloud events).", - example="cody@example.com", + examples=["cody@example.com"], ) class Event(Model): action: Optional[str] = Field( + None, description="The type of file event observed. For example: file-modified, application-read, removable-media-created.", - example="file-downloaded", + examples=["file-downloaded"], ) id: Optional[str] = Field( + None, description="The unique identifier for the event.", - example="0_147e9445-2f30-4a91-8b2a-9455332e880a_973435567569502913_986467523038446097_163", + examples=[ + "0_147e9445-2f30-4a91-8b2a-9455332e880a_973435567569502913_986467523038446097_163" + ], ) ingested: Optional[datetime] = Field( + None, description="Date and time the event was initially received by Code42; timestamp is based on the Code42 server system clock and reported in Coordinated Universal Time (UTC).", - example="2020-10-27T15:15:05.369203Z", + examples=["2020-10-27T15:15:05.369203Z"], ) inserted: Optional[datetime] = Field( + None, description="Date and time the event processing is completed by Code42; timestamp is based on the Code42 server system clock and reported in Coordinated Universal Time (UTC). Typically occurs very soon after the event.ingested time.", - example="2020-10-27T15:16:05.369203Z", + examples=["2020-10-27T15:16:05.369203Z"], ) observer: Optional[str] = Field( + None, description="The data source that captured the file event. For example: GoogleDrive, Office365, Salesforce.", - example="Endpoint", + examples=["Endpoint"], ) detector_display_name: Optional[str] = Field( None, @@ -696,17 +793,19 @@ class Event(Model): title="Indicates the name you provided when the cloud data connection was initially configured in the Code42 console.", ) related_events: Optional[List[RelatedEvent]] = Field( + None, alias="relatedEvents", description="List of other events associated with this file. This can help determine the origin of the file.", ) share_type: Optional[List[str]] = Field( + None, alias="shareType", description="Sharing types added by this event.", - example=["SharedViaLink"], + examples=[["SharedViaLink"]], ) vector: Optional[str] = Field( None, - example="GIT_PUSH", + examples=["GIT_PUSH"], title="The method of file movement. For example: UPLOADED, DOWNLOADED, EMAILED.", ) xfc_event_id: Optional[str] = Field( @@ -769,38 +868,49 @@ class FileEventV2(ResponseModel): """ timestamp: Optional[datetime] = Field( + None, alias="@timestamp", description="Date and time that the Code42 service on the device detected an event; based on the device’s system clock and reported in Coordinated Universal Time (UTC).", - example="2020-10-27T15:16:05.369203Z", + examples=["2020-10-27T15:16:05.369203Z"], ) destination: Optional[Destination] = Field( + None, description="Metadata about the destination of the file event.", ) event: Optional[Event] = Field( + None, description="Summary information about the event.", ) file: Optional[File] = Field( + None, description="Metadata about the file for this event.", ) process: Optional[Process] = Field( + None, description="Metadata about the process associated with the event.", ) report: Optional[Report] = Field( + None, description="Metadata for reports from 3rd party sources, such Salesforce downloads.", ) response_controls: Optional[ResponseControls] = Field( + None, alias="responseControls", description="Metadata about preventative actions applied to file activity. Only applies to events for users on a preventative watchlist.", ) risk: Optional[Risk] = Field( + None, description="Risk factor metadata.", ) source: Optional[Source] = Field( + None, description="Metadata about the source of the file event.", ) user: Optional[User] = Field( + None, description="Attributes of the the Code42 username signed in to the Code42 app on the device.", ) git: Optional[Git] = Field( + None, description="Git details for the event.", ) diff --git a/src/_incydr_sdk/file_events/models/response.py b/src/_incydr_sdk/file_events/models/response.py index 137505e9..75e93d18 100644 --- a/src/_incydr_sdk/file_events/models/response.py +++ b/src/_incydr_sdk/file_events/models/response.py @@ -14,34 +14,40 @@ class SearchFilter(ResponseModel): operator: Optional[str] = Field( + None, description="The type of match to perform. Default value is `IS`.", - example="IS_NOT", + examples=["IS_NOT"], + ) + term: Optional[str] = Field( + None, description="The field to match.", examples=["user.email"] ) - term: Optional[str] = Field(description="The field to match.", example="user.email") value: Optional[Union[List[str], str]] = Field( - None, description="The input for the search.", example="ari@example.com" + None, description="The input for the search.", examples=["ari@example.com"] ) class SearchFilterGroup(ResponseModel): filter_clause: Optional[str] = Field( + None, alias="filterClause", description="Grouping clause for filters. Default is `AND`.", - example="AND", + examples=["AND"], ) filters: List[SearchFilter] = Field( - description="One or more SearchFilters to be combined in a query." + None, description="One or more SearchFilters to be combined in a query." ) class SearchFilterGroupV2(ResponseModel): subgroup_clause: Optional[str] = Field( + None, alias="subgroupClause", description="Grouping clause for subgroups.", - example="AND", + examples=["AND"], ) subgroups: Optional[List[Union[SearchFilterGroup, SearchFilterGroupV2]]] = Field( - description="One or more FilterGroups to be combined in a query, or a FilterSubgroupV2" + None, + description="One or more FilterGroups to be combined in a query, or a FilterSubgroupV2", ) @@ -58,15 +64,19 @@ class QueryProblem(ResponseModel): """ bad_filter: Optional[SearchFilter] = Field( + None, alias="badFilter", description="The search filter that caused this problem.", ) description: Optional[str] = Field( + None, description="Additional description of the problem.", - example="Request timed out. Refine your filter criteria and try again.", + examples=["Request timed out. Refine your filter criteria and try again."], ) type: Optional[str] = Field( - description="The type of problem that occured.", example="SEARCH_FAILED" + None, + description="The type of problem that occured.", + examples=["SEARCH_FAILED"], ) @@ -83,20 +93,25 @@ class FileEventsPage(ResponseModel): """ file_events: Optional[List[FileEventV2]] = Field( - alias="fileEvents", description="List of file events in the response." + None, alias="fileEvents", description="List of file events in the response." ) next_pg_token: Optional[str] = Field( + None, alias="nextPgToken", description="Use as the pgToken value in another request to indicate the starting point for additional page results. nextPgToken is null if there are no more results or if pgToken was not supplied.", - example="0_147e9445-2f30-4a91-8b2a-9455332e880a_973435567569502913_986467523038446097_163", + examples=[ + "0_147e9445-2f30-4a91-8b2a-9455332e880a_973435567569502913_986467523038446097_163" + ], ) problems: Optional[List[QueryProblem]] = Field( + None, description="List of problems in the request. A problem with a search request could be an invalid filter value, an operator that can't be used on a term, etc.", ) total_count: Optional[int] = Field( + None, alias="totalCount", description="Total number of file events in the response.", - example=42, + examples=[42], ) @@ -125,66 +140,81 @@ class SavedSearch(ResponseModel): """ api_version: Optional[int] = Field( + None, alias="apiVersion", description="Version of the API used to create the search.", - example=1, + examples=[1], ) columns: Optional[List[str]] = Field( + None, description="List of columns to be displayed in the web app for the search.", ) created_by_uid: Optional[str] = Field( + None, alias="createdByUID", description="User UID of the user who created the saved search.", - example=806150685834341101, + examples=[806150685834341101], ) created_by_username: Optional[str] = Field( + None, alias="createdByUsername", description="Username of the user who created the saved search.", - example="adrian@example.com", + examples=["adrian@example.com"], ) creation_timestamp: Optional[datetime] = Field( + None, alias="creationTimestamp", description="Time at which the saved search was created.", - example="2020-10-27T15:16:05.369203Z", + examples=["2020-10-27T15:16:05.369203Z"], ) group_clause: Optional[str] = Field( + None, alias="groupClause", description="Grouping clause for any specified groups.", - example="OR", + examples=["OR"], ) groups: Optional[List[Union[SearchFilterGroup, SearchFilterGroupV2]]] = Field( - description="One or more FilterGroups to be combined in a query." + None, description="One or more FilterGroups to be combined in a query." ) id: Optional[str] = Field( + None, description="Unique identifier for the saved search.", - example="cde979fa-d551-4be9-b242-39e75b824089", + examples=["cde979fa-d551-4be9-b242-39e75b824089"], ) modified_by_uid: Optional[str] = Field( + None, alias="modifiedByUID", description="User UID of the user who last modified the saved search.", - example=421380797518239242, + examples=[421380797518239242], ) modified_by_username: Optional[str] = Field( + None, alias="modifiedByUsername", description="Username of the user who last modified the saved search.", - example="ari@example.com", + examples=["ari@example.com"], ) modified_timestamp: Optional[datetime] = Field( + None, alias="modifiedTimestamp", description="Time at which the saved search was last modified.", - example="2020-10-27T15:20:26.311894Z", + examples=["2020-10-27T15:20:26.311894Z"], ) name: Optional[str] = Field( + None, description="Name given to the saved search.", - example="Example saved search", + examples=["Example saved search"], ) notes: Optional[str] = Field( + None, description="Optional notes about the search.", - example="This search returns all events.", + examples=["This search returns all events."], ) srt_dir: Optional[SortDirection] = Field( - alias="srtDir", description="Sort direction.", example="asc" + None, alias="srtDir", description="Sort direction.", examples=["asc"] ) srt_key: Optional[str] = Field( - alias="srtKey", description="Search term for sorting.", example="event.id" + None, + alias="srtKey", + description="Search term for sorting.", + examples=["event.id"], ) diff --git a/src/_incydr_sdk/queries/alerts.py b/src/_incydr_sdk/queries/alerts.py index 2c3c0172..103c8bca 100644 --- a/src/_incydr_sdk/queries/alerts.py +++ b/src/_incydr_sdk/queries/alerts.py @@ -6,10 +6,11 @@ from typing import Union from pydantic import BaseModel -from pydantic import conint +from pydantic import ConfigDict from pydantic import Field -from pydantic import root_validator +from pydantic import model_validator from pydantic import StrictBool +from typing_extensions import Annotated from _incydr_sdk.core.models import Model from _incydr_sdk.enums.alerts import AlertSeverity @@ -30,12 +31,11 @@ class Filter(BaseModel): term: AlertTerm operator: Operator - value: Optional[Union[StrictBool, int, str]] + value: Optional[Union[StrictBool, int, str]] = None + model_config = ConfigDict(use_enum_values=True) - class Config: - use_enum_values = True - - @root_validator(pre=True) + @model_validator(mode="before") + @classmethod def _validate_enums(cls, values: dict): # noqa `root_validator` is a classmethod term = values.get("term") operator = values.get("operator") @@ -61,7 +61,7 @@ def _validate_enums(cls, values: dict): # noqa `root_validator` is a classmetho class FilterGroup(BaseModel): filterClause: str = "AND" - filters: Optional[List[Filter]] + filters: Optional[List[Filter]] = None class AlertQuery(Model): @@ -99,7 +99,7 @@ class AlertQuery(Model): group_clause: str = Field("AND", alias="groupClause") groups: Optional[List[FilterGroup]] page_num: int = Field(0, alias="pgNum") - page_size: conint(gt=0, le=500) = Field(100, alias="pgSize") + page_size: Annotated[int, Field(gt=0, le=500)] = Field(100, alias="pgSize") sort_dir: str = Field("DESC", alias="srtDirection") sort_key: AlertTerm = Field("CreatedAt", alias="srtKey") diff --git a/src/_incydr_sdk/queries/file_events.py b/src/_incydr_sdk/queries/file_events.py index 1ae55042..e0deb9a7 100644 --- a/src/_incydr_sdk/queries/file_events.py +++ b/src/_incydr_sdk/queries/file_events.py @@ -9,10 +9,11 @@ from isodate import duration_isoformat from isodate import parse_duration from pydantic import BaseModel -from pydantic import conint +from pydantic import ConfigDict from pydantic import Field -from pydantic import root_validator +from pydantic import model_validator from pydantic import validate_arguments +from typing_extensions import Annotated from _incydr_sdk.core.models import Model from _incydr_sdk.enums.file_events import Category @@ -46,12 +47,11 @@ class Filter(BaseModel): term: str operator: Union[Operator, str] - value: Optional[Union[int, str, List[str]]] + value: Optional[Union[int, str, List[str]]] = None + model_config = ConfigDict(use_enum_values=True) - class Config: - use_enum_values = True - - @root_validator(pre=True) + @model_validator(mode="before") + @classmethod def _validate_enums(cls, values: dict): # noqa `root_validator` is a classmethod term = values.get("term") operator = values.get("operator") @@ -87,7 +87,7 @@ def _validate_enums(cls, values: dict): # noqa `root_validator` is a classmetho class FilterGroup(BaseModel): filterClause: str = "AND" - filters: Optional[List[Filter]] + filters: Optional[List[Filter]] = None class FilterGroupV2(BaseModel): @@ -118,15 +118,17 @@ class EventQuery(Model): group_clause: str = Field("AND", alias="groupClause") groups: Optional[List[FilterGroup]] page_num: int = Field(1, alias="pgNum") - page_size: conint(le=10000) = Field(100, alias="pgSize") + page_size: Annotated[int, Field(le=10000)] = Field(100, alias="pgSize") page_token: Optional[str] = Field("", alias="pgToken") sort_dir: str = Field("asc", alias="srtDir") sort_key: EventSearchTerm = Field("event.id", alias="srtKey") - - class Config: - validate_assignment = True - use_enum_values = True - json_encoders = {datetime: lambda dt: dt.isoformat().replace("+00:00", "Z")} + # TODO[pydantic]: The following keys were removed: `json_encoders`. + # Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-config for more information. + model_config = ConfigDict( + validate_assignment=True, + use_enum_values=True, + json_encoders={datetime: lambda dt: dt.isoformat().replace("+00:00", "Z")}, + ) def __init__( self, diff --git a/src/_incydr_sdk/risk_profiles/models.py b/src/_incydr_sdk/risk_profiles/models.py index e5a7e579..c1b00138 100644 --- a/src/_incydr_sdk/risk_profiles/models.py +++ b/src/_incydr_sdk/risk_profiles/models.py @@ -3,6 +3,7 @@ from typing import List from typing import Optional +from pydantic import ConfigDict from pydantic import Field from rich.markdown import Markdown @@ -119,7 +120,7 @@ class RiskProfilesPage(ResponseModel): total_count: Optional[int] = Field( None, description="The total count of all risk profiles.", - example=10, + examples=[10], alias="totalCount", ) user_risk_profiles: Optional[List[RiskProfile]] = Field( @@ -141,9 +142,7 @@ class QueryRiskProfilesRequest(Model): active: Optional[bool] deleted: Optional[bool] support_user: Optional[bool] - - class Config: - use_enum_values = True + model_config = ConfigDict(use_enum_values=True) class UpdateRiskProfileRequest(Model): @@ -151,7 +150,7 @@ class UpdateRiskProfileRequest(Model): notes: Optional[str] = Field( None, description="Notes to add to the risk profile.", - example="These are my notes", + examples=["These are my notes"], ) startDate: Optional[Date] = None @@ -159,12 +158,14 @@ class UpdateRiskProfileRequest(Model): class AddCloudAliasesRequest(Model): cloudAliases: Optional[List[str]] = None userId: Optional[str] = Field( - None, description="The ID of the actor to add cloud aliases.", example="123" + None, description="The ID of the actor to add cloud aliases.", examples=["123"] ) class DeleteCloudAliasesRequest(Model): cloudAliases: Optional[List[str]] = None userId: Optional[str] = Field( - None, description="The ID of the actor to delete cloud aliases.", example="123" + None, + description="The ID of the actor to delete cloud aliases.", + examples=["123"], ) diff --git a/src/_incydr_sdk/sessions/models/models.py b/src/_incydr_sdk/sessions/models/models.py index c3e18010..b997e677 100644 --- a/src/_incydr_sdk/sessions/models/models.py +++ b/src/_incydr_sdk/sessions/models/models.py @@ -2,6 +2,7 @@ from typing import Optional from pydantic import BaseModel +from pydantic import ConfigDict from pydantic import Field from _incydr_sdk.core.models import Model @@ -12,13 +13,14 @@ class ContentInspectionEvent(Model): - event_id: Optional[str] = Field(alias="eventId") - pii_type: Optional[List[str]] = Field(alias="piiType") - status: Optional[str] + event_id: Optional[str] = Field(None, alias="eventId") + pii_type: Optional[List[str]] = Field(None, alias="piiType") + status: Optional[str] = None class ContentInspectionResult(Model): detected_on_alerts: List[str] = Field( + None, alias="detectedOnAlerts", description="A list of content categories or types found on events which triggered alerts.", ) @@ -32,23 +34,23 @@ class Note(Model): class RiskIndicator(Model): - event_count: Optional[int] = Field(alias="eventCount") - id: Optional[str] - name: Optional[str] - weight: Optional[int] + event_count: Optional[int] = Field(None, alias="eventCount") + id: Optional[str] = None + name: Optional[str] = None + weight: Optional[int] = None class Score(Model): - score: Optional[int] - severity: Optional[int] + score: Optional[int] = None + severity: Optional[int] = None source_timestamp: Optional[int] = Field(alias="sourceTimestamp") class State(Model): - source_timestamp: Optional[int] = Field(alias="sourceTimestamp") - state: SessionStates + source_timestamp: Optional[int] = Field(None, alias="sourceTimestamp") + state: SessionStates = None user_id: Optional[str] = Field( - alias="userId", description="A User ID. (Deprecated)" + None, alias="userId", description="A User ID. (Deprecated)" ) @@ -61,28 +63,26 @@ class Alert(Model): class SessionsCriteriaRequest(BaseModel): - actor_id: Optional[str] - on_or_after: Optional[int] - before: Optional[int] - has_alerts: Optional[str] - risk_indicators: Optional[List[str]] - state: Optional[List[SessionStates]] - severity: Optional[List[int]] - rule_id: Optional[List[str]] - watchlist_id: Optional[List[str]] - content_inspection_status: Optional[ContentInspectionStatuses] - - class Config: - use_enum_values = True + actor_id: Optional[str] = None + on_or_after: Optional[int] = None + before: Optional[int] = None + has_alerts: Optional[str] = None + risk_indicators: Optional[List[str]] = None + state: Optional[List[SessionStates]] = None + severity: Optional[List[int]] = None + rule_id: Optional[List[str]] = None + watchlist_id: Optional[List[str]] = None + content_inspection_status: Optional[ContentInspectionStatuses] = None + model_config = ConfigDict(use_enum_values=True) class SessionsQueryRequest(SessionsCriteriaRequest): - order_by: Optional[SortKeys] - sort_direction: Optional[SortDirection] - page_number: Optional[int] - page_size: Optional[int] + order_by: Optional[SortKeys] = None + sort_direction: Optional[SortDirection] = None + page_number: Optional[int] = None + page_size: Optional[int] = None class SessionsChangeStateRequest(BaseModel): - ids: Optional[List[str]] - newState: Optional[str] + ids: Optional[List[str]] = None + newState: Optional[str] = None diff --git a/src/_incydr_sdk/sessions/models/response.py b/src/_incydr_sdk/sessions/models/response.py index cbc9c1b2..bb573da9 100644 --- a/src/_incydr_sdk/sessions/models/response.py +++ b/src/_incydr_sdk/sessions/models/response.py @@ -41,28 +41,28 @@ class Session(ResponseModel): * **triggered_alerts**: `str` The list of all alerts that were triggered by activity in this session. """ - actor_id: Optional[str] = Field(alias="actorId") - begin_time: Optional[int] = Field(alias="beginTime") + actor_id: Optional[str] = Field(None, alias="actorId") + begin_time: Optional[int] = Field(None, alias="beginTime") content_inspection_results: Optional[ContentInspectionResult] = Field( - alias="contentInspectionResults" + None, alias="contentInspectionResults" ) - context_summary: Optional[str] = Field(alias="contextSummary") - critical_events: Optional[int] = Field(alias="criticalEvents") - end_time: Optional[int] = Field(alias="endTime") - exfiltration_summary: Optional[str] = Field(alias="exfiltrationSummary") - first_observed: Optional[int] = Field(alias="firstObserved") - high_events: Optional[int] = Field(alias="highEvents") - last_updated: Optional[int] = Field(alias="lastUpdated") - low_events: Optional[int] = Field(alias="lowEvents") - moderate_events: Optional[int] = Field(alias="moderateEvents") - no_risk_events: Optional[int] = Field(alias="noRiskEvents") - notes: Optional[List[Note]] - risk_indicators: Optional[List[RiskIndicator]] = Field(alias="riskIndicators") - scores: Optional[List[Score]] - session_id: Optional[str] = Field(alias="sessionId") - states: Optional[List[State]] - tenant_id: Optional[str] = Field(alias="tenantId") - triggered_alerts: Optional[List[Alert]] = Field(alias="triggeredAlerts") + context_summary: Optional[str] = Field(None, alias="contextSummary") + critical_events: Optional[int] = Field(None, alias="criticalEvents") + end_time: Optional[int] = Field(None, alias="endTime") + exfiltration_summary: Optional[str] = Field(None, alias="exfiltrationSummary") + first_observed: Optional[int] = Field(None, alias="firstObserved") + high_events: Optional[int] = Field(None, alias="highEvents") + last_updated: Optional[int] = Field(None, alias="lastUpdated") + low_events: Optional[int] = Field(None, alias="lowEvents") + moderate_events: Optional[int] = Field(None, alias="moderateEvents") + no_risk_events: Optional[int] = Field(None, alias="noRiskEvents") + notes: Optional[List[Note]] = None + risk_indicators: Optional[List[RiskIndicator]] = Field(None, alias="riskIndicators") + scores: Optional[List[Score]] = None + session_id: Optional[str] = Field(None, alias="sessionId") + states: Optional[List[State]] = None + tenant_id: Optional[str] = Field(None, alias="tenantId") + triggered_alerts: Optional[List[Alert]] = Field(None, alias="triggeredAlerts") user_id: Optional[str] = Field(None, alias="userId") @@ -85,4 +85,4 @@ class SessionEvents(ResponseModel): The wrapped file event search response returned when retrieving the events attached to a session. """ - query_result: FileEventsPage = Field(alias="queryResult") + query_result: FileEventsPage = Field(None, alias="queryResult") diff --git a/src/_incydr_sdk/trusted_activities/models.py b/src/_incydr_sdk/trusted_activities/models.py index 27c00c43..9af0ef19 100644 --- a/src/_incydr_sdk/trusted_activities/models.py +++ b/src/_incydr_sdk/trusted_activities/models.py @@ -76,7 +76,7 @@ class TrustedActivity(ResponseModel, validate_assignment=True): None, description="The unique identifier of the trusted activity.", alias="activityId", - allow_mutation=False, + frozen=True, ) is_high_value_source: Optional[bool] = Field( None, @@ -87,7 +87,7 @@ class TrustedActivity(ResponseModel, validate_assignment=True): None, description="A description of the trusted activity." ) principal_type: Optional[Union[PrincipalType, str]] = Field( - None, alias="principalType", allow_mutation=False + None, alias="principalType", frozen=True ) type: Optional[Union[ActivityType, str]] = Field( None, description="The type of the trusted activity.", alias="type" @@ -96,19 +96,19 @@ class TrustedActivity(ResponseModel, validate_assignment=True): None, description="The time at which the trust activity was last created or modified.", alias="updateTime", - allow_mutation=False, + frozen=True, ) updated_by_principal_id: Optional[str] = Field( None, description="The unique identifier of the user who last updated the trust activity.", alias="updatedByPrincipalId", - allow_mutation=False, + frozen=True, ) updated_by_principal_name: Optional[str] = Field( None, description="The username of the user who last updated the trusted activity.", alias="updatedByPrincipalName", - allow_mutation=False, + frozen=True, ) value: Optional[str] = Field(None, description="The value of the trusted activity.") diff --git a/src/_incydr_sdk/utils.py b/src/_incydr_sdk/utils.py index 0b86b30b..58fa0a63 100644 --- a/src/_incydr_sdk/utils.py +++ b/src/_incydr_sdk/utils.py @@ -4,14 +4,16 @@ from itertools import repeat from typing import Any from typing import Generator +from typing import get_args +from typing import get_origin from typing import List from typing import Tuple from typing import Type +from typing import Union import rich.box from pydantic import BaseModel -from pydantic.fields import ModelField -from pydantic.fields import SHAPE_SINGLETON +from pydantic.fields import FieldInfo from rich.console import ConsoleRenderable from rich.console import Group from rich.console import group @@ -21,9 +23,9 @@ def get_field_value_and_info( model: BaseModel, path: List[str] -) -> Tuple[Any, ModelField]: +) -> Tuple[Any, FieldInfo]: """ - Traverse a pydantic model and its sub-models to retrieve both the value and `ModelField` data for a given attribute + Traverse a pydantic model and its sub-models to retrieve both the value and `FieldInfo` data for a given attribute path. For example, given the following model hierarchy: @@ -41,7 +43,7 @@ class Parent(BaseModel): >>> value, field = get_field_value_and_info(model, path=["child", "field_1"]) The `value` var would contain the string "example", and `field` would be the Field object for `Child.field_1`, where - the 'extra_data' would be accessible in `field.field_info.extra`. + the 'extra_data' would be accessible in `field.json_schema_extra`. """ for p in path[:-1]: next_model = getattr(model, p) @@ -49,14 +51,14 @@ class Parent(BaseModel): # class and use that to "construct" an empty version of the model, so child fields will still return valid # field_info, but the value will be `None` for every field on "missing" child models. if next_model is None: - model_type = model.__fields__[p].type_ - model = model_type.construct( - **{field: None for field in model_type.__fields__} + model_type = _get_model_type(type(model).model_fields[p].annotation) + model = model_type.model_construct( + **{field: None for field in model_type.model_fields} ) else: model = next_model value = getattr(model, path[-1]) - field = model.__fields__.get(path[-1]) + field = type(model).model_fields.get(path[-1]) return value, field @@ -72,21 +74,25 @@ def iter_model_formatted( Accepts a list of field names to filter by (if flat=True, `include` list must be flattened dot-notation names). Will automatically attempt to "render" the field values in the following order: - - if `render` arg is a string, it will look in the "extra" section of the pydantic model's Field Info for that + - if `render` arg is a string, it will look in the "json_schema_extra" section of the pydantic model's Field Info for that name (expects a callable to be there) - if value is of a type that the model has a `json_encoder` for, it will use that encoder - otherwise will leave the value unchanged """ - fields = get_fields(model.__class__, include=include, flat=flat) + fields = get_fields(type(model), include=include, flat=flat) for name in fields: path = name.split(".") - value, field = get_field_value_and_info(model, path) - field_renderer = None if not field else field.field_info.extra.get(render) + value, field_info = get_field_value_and_info(model, path) + field_renderer = ( + None + if not field_info.json_schema_extra + else field_info.json_schema_extra.get(render) + ) if render and field_renderer: value = field_renderer(value) yield name, value continue - json_encoder = model.Config.json_encoders.get(type(value)) + json_encoder = model.model_config.get("json_encoders").get(type(value)) if json_encoder: value = json_encoder(value) yield name, value @@ -128,7 +134,7 @@ def list_as_panel( @group() -def model_as_card(model, include=None): +def model_as_card(model: BaseModel, include=None): """ Renders a pydantic model in 'card' format, where field name/value pairs are presented vertically, and when a field is a list of items, it renders it as a separate panel. @@ -171,18 +177,13 @@ class Parent(BaseModel): flatten_fields(Parent) would yield: ['field', 'child.field_1', 'child.field_2'] """ - for name, field in model.__fields__.items(): - # the field.shape tells us if the field contains a single `BaseModel` or something like a `List[BaseModel]` - # we can only traverse singleton models when flattening - try: - is_subclass = issubclass(field.type_, BaseModel) - # TypeError is thrown if field.type_ is a Union type. - # Assumes our endpoints won't return a field that can be one of multiple models - # This would be a pretty odd API design anyway - except TypeError: - is_subclass = False - if field.shape == SHAPE_SINGLETON and is_subclass: - for child_name in flatten_fields(field.type_): + # We want the model type, not an instance thereof + if isinstance(model, BaseModel): + model = type(model) + for name, field in model.model_fields.items(): + model_field_type = _get_model_type(field.annotation) + if _is_singleton(field.annotation) and issubclass(model_field_type, BaseModel): + for child_name in flatten_fields(model_field_type): yield f"{name}.{child_name}" else: yield name @@ -202,7 +203,10 @@ def get_fields( Order is preserved to match `include` order, to allow precise table/csv column ordering based on user input. """ - fields = list(flatten_fields(model)) if flat else model.__fields__ + # We want the model type, not an instance thereof + if isinstance(model, BaseModel): + model = type(model) + fields = list(flatten_fields(model)) if flat else model.model_fields.keys() if not include: yield from fields else: @@ -222,3 +226,26 @@ def get_fields( f"'{i}' is not a valid field path for model: {model.__name__}", list(fields), ) + + +def _is_singleton(type) -> bool: + """Returns `true` if the given type is a single object (for example, Union[int, str]); + returns false if it is a list (e.g. Union[List[int], List[str]])""" + origin = get_origin(type) if get_origin(type) else type + if origin == Union: + return all([_is_singleton(item) for item in get_args(type)]) + if origin in (list, tuple, set): + return False + return True + + +def _get_model_type(type) -> Type[BaseModel]: + """Given a type annotation, gets the type that subclasses BaseModel""" + if issubclass(type, BaseModel): + return type + elif get_origin(type): + return next( + (_get_model_type(item) for item in get_args(type) if _get_model_type(item)), + None, + ) + return None diff --git a/src/_incydr_sdk/watchlists/models/requests.py b/src/_incydr_sdk/watchlists/models/requests.py index 6a4336ee..897dfb96 100644 --- a/src/_incydr_sdk/watchlists/models/requests.py +++ b/src/_incydr_sdk/watchlists/models/requests.py @@ -3,8 +3,10 @@ from typing import Union from pydantic import BaseModel -from pydantic import constr +from pydantic import ConfigDict from pydantic import Field +from pydantic import StringConstraints +from typing_extensions import Annotated from _incydr_sdk.enums.watchlists import WatchlistType @@ -13,7 +15,7 @@ class UpdateExcludedUsersRequest(BaseModel): userIds: Optional[List[str]] = Field( None, description="A list of user IDs to add or remove.", - max_items=100, + max_length=100, ) @@ -21,7 +23,7 @@ class UpdateExcludedActorsRequest(BaseModel): actorIds: Optional[List[str]] = Field( None, description="A list of actor IDs to add or remove.", - max_items=100, + max_length=100, ) @@ -41,10 +43,10 @@ class UpdateIncludedUsersRequest(BaseModel): userIds: Optional[List[str]] = Field( None, description="A list of user IDs to add or remove.", - max_items=100, + max_length=100, ) watchlistId: Optional[str] = Field( - None, description="A unique watchlist ID.", example="123" + None, description="A unique watchlist ID.", examples=["123"] ) @@ -52,50 +54,48 @@ class UpdateIncludedActorsRequest(BaseModel): actorIds: Optional[List[str]] = Field( None, description="A list of actor IDs to add or remove.", - max_items=100, + max_length=100, ) watchlistId: Optional[str] = Field( - None, description="A unique watchlist ID.", example="123" + None, description="A unique watchlist ID.", examples=["123"] ) class CreateWatchlistRequest(BaseModel): - description: Optional[constr(max_length=250)] = Field( + description: Optional[Annotated[str, StringConstraints(max_length=250)]] = Field( None, description="The optional description of a custom watchlist.", - example="List of users that fit a custom use case.", + examples=["List of users that fit a custom use case."], ) - title: Optional[constr(max_length=50)] = Field( + title: Optional[Annotated[str, StringConstraints(max_length=50)]] = Field( None, description="The required title for a custom watchlist.", - example="My Custom List", + examples=["My Custom List"], ) watchlistType: Union[WatchlistType, str] - - class Config: - use_enum_values = True + model_config = ConfigDict(use_enum_values=True) class ListWatchlistsRequest(BaseModel): page: int = 1 pageSize: int = 100 - userId: Optional[str] + userId: Optional[str] = None class ListWatchlistsRequestV2(BaseModel): page: int = 1 pageSize: int = 100 - actorId: Optional[str] + actorId: Optional[str] = None class UpdateWatchlistRequest(BaseModel): - description: Optional[constr(max_length=250)] = Field( + description: Optional[Annotated[str, StringConstraints(max_length=250)]] = Field( None, description="The updated description of a custom watchlist.", - example="List of users that fit a custom use case.", + examples=["List of users that fit a custom use case."], ) - title: Optional[constr(max_length=50)] = Field( + title: Optional[Annotated[str, StringConstraints(max_length=50)]] = Field( None, description="The updated title for a custom watchlist.", - example="My Custom List", + examples=["My Custom List"], ) diff --git a/src/_incydr_sdk/watchlists/models/responses.py b/src/_incydr_sdk/watchlists/models/responses.py index d8ab7abe..48f6b345 100644 --- a/src/_incydr_sdk/watchlists/models/responses.py +++ b/src/_incydr_sdk/watchlists/models/responses.py @@ -37,7 +37,7 @@ class IncludedDepartment(ResponseModel): """ added_time: datetime = Field(None, alias="addedTime") - name: Optional[str] = Field(None, example="Engineering") + name: Optional[str] = Field(None, examples=["Engineering"]) class IncludedDirectoryGroup(ResponseModel): @@ -54,14 +54,14 @@ class IncludedDirectoryGroup(ResponseModel): added_time: datetime = Field(None, alias="addedTime") group_id: Optional[str] = Field( - None, description="A unique group ID.", example="23", alias="groupId" + None, description="A unique group ID.", examples=["23"], alias="groupId" ) is_deleted: Optional[bool] = Field( None, description="Whether the included group was deleted by the directory provider but still referenced by the watchlist", alias="isDeleted", ) - name: Optional[str] = Field(None, example="Research and development") + name: Optional[str] = Field(None, examples=["Research and development"]) class WatchlistUser(ResponseModel): @@ -77,9 +77,9 @@ class WatchlistUser(ResponseModel): added_time: datetime = Field(None, alias="addedTime") user_id: Optional[str] = Field( - None, description="A unique user ID.", example="23", alias="userId" + None, description="A unique user ID.", examples=["23"], alias="userId" ) - username: Optional[str] = Field(None, example="foo@bar.com") + username: Optional[str] = Field(None, examples=["foo@bar.com"]) class WatchlistActor(ResponseModel): @@ -95,9 +95,9 @@ class WatchlistActor(ResponseModel): added_time: datetime = Field(None, alias="addedTime") actor_id: Optional[str] = Field( - None, description="A unique actor ID.", example="23", alias="actorId" + None, description="A unique actor ID.", examples=["23"], alias="actorId" ) - actor_name: Optional[str] = Field(None, example="foo@bar.com", alias="actorname") + actor_name: Optional[str] = Field(None, examples=["foo@bar.com"], alias="actorname") class ExcludedUsersList(ResponseModel): @@ -115,7 +115,7 @@ class ExcludedUsersList(ResponseModel): total_count: Optional[int] = Field( None, description="The total count of all excluded users.", - example=10, + examples=[10], alias="totalCount", ) @@ -137,7 +137,7 @@ class ExcludedActorsList(ResponseModel): total_count: Optional[int] = Field( None, description="The total count of all excluded actors.", - example=10, + examples=[10], alias="totalCount", ) @@ -158,7 +158,7 @@ class IncludedDepartmentsList(ResponseModel): total_count: Optional[int] = Field( None, description="The total count of all included departments.", - example=10, + examples=[10], alias="totalCount", ) @@ -179,7 +179,7 @@ class IncludedDirectoryGroupsList(ResponseModel): total_count: Optional[int] = Field( None, description="The total count of all included directory groups.", - example=10, + examples=[10], alias="totalCount", ) @@ -197,7 +197,7 @@ class IncludedUsersList(ResponseModel): total_count: Optional[int] = Field( None, description="The total count of all included users.", - example=10, + examples=[10], alias="totalCount", ) @@ -217,7 +217,7 @@ class IncludedActorsList(ResponseModel): total_count: Optional[int] = Field( None, description="The total count of all included actors.", - example=10, + examples=[10], alias="totalCount", ) @@ -278,7 +278,7 @@ class WatchlistMembersList(ResponseModel): total_count: Optional[int] = Field( None, description="The total count of all included users.", - example=10, + examples=[10], alias="totalCount", ) watchlist_members: Optional[List[WatchlistUser]] = Field( @@ -302,7 +302,7 @@ class WatchlistMembersListV2(ResponseModel): total_count: Optional[int] = Field( None, description="The total count of all included actors.", - example=10, + examples=[10], alias="totalCount", ) watchlist_members: Optional[List[WatchlistActor]] = Field( @@ -351,7 +351,7 @@ class WatchlistsPage(ResponseModel): total_count: Optional[int] = Field( None, description="The total count of all watchlists.", - example=10, + examples=[10], alias="totalCount", ) watchlists: Optional[List[Watchlist]] = Field( diff --git a/tests/queries/test_event_query.py b/tests/queries/test_event_query.py index 2ae30f73..4320df67 100644 --- a/tests/queries/test_event_query.py +++ b/tests/queries/test_event_query.py @@ -291,7 +291,7 @@ def test_event_query_greater_than_creates_expected_filter_group(input, expected_ def test_event_query_greater_than_when_non_numerical_value_raises_error(): with pytest.raises(ValidationError) as e: EventQuery(start_date=TEST_START_DATE).greater_than("risk.score", "a string") - assert "value is not a valid integer" in str(e.value) + assert "Input should be a valid integer" in str(e.value) @pytest.mark.parametrize("input,expected_value", [(10, 10), ("10", 10.0), (10.0, 10.0)]) @@ -306,7 +306,7 @@ def test_event_query_less_than_creates_expected_filter_group(input, expected_val def test_event_query_less_than_when_non_numerical_value_raises_error(): with pytest.raises(ValidationError) as e: EventQuery(start_date=TEST_START_DATE).less_than("risk.score", "a string") - assert "value is not a valid integer" in str(e.value) + assert "Input should be a valid integer" in str(e.value) def test_event_query_matches_any_sets_query_group_clause_to_or(): diff --git a/tests/test_actors.py b/tests/test_actors.py index 4ac5cb5b..391b7cb2 100644 --- a/tests/test_actors.py +++ b/tests/test_actors.py @@ -236,8 +236,8 @@ def test_get_page_with_default_params_returns_expected_data( client = Client() page = client.actors.v1.get_page() assert isinstance(page, ActorsPage) - assert page.actors[0].json() == json.dumps(PARENT_ACTOR) - assert page.actors[1].json() == json.dumps(CHILD_ACTOR) + assert page.actors[0].json() == json.dumps(PARENT_ACTOR, separators=(",", ":")) + assert page.actors[1].json() == json.dumps(CHILD_ACTOR, separators=(",", ":")) assert len(page.actors) == 2 @@ -267,7 +267,7 @@ def test_get_page_when_custom_query_params_returns_expected_data( page_num=2, ) assert isinstance(page, ActorsPage) - assert page.actors[0].json() == json.dumps(CHILD_ACTOR) + assert page.actors[0].json() == json.dumps(CHILD_ACTOR, separators=(",", ":")) assert len(page.actors) == 1 @@ -286,7 +286,7 @@ def test_get_page_when_prefer_parent_returns_expected_data(httpserver_auth: HTTP client = Client() page = client.actors.v1.get_page(prefer_parent=True) assert isinstance(page, ActorsPage) - assert page.actors[0].json() == json.dumps(PARENT_ACTOR) + assert page.actors[0].json() == json.dumps(PARENT_ACTOR, separators=(",", ":")) assert len(page.actors) == 1 @@ -312,7 +312,7 @@ def test_iter_all_with_default_params_returns_expected_data( for item in iterator: total_count += 1 assert isinstance(item, Actor) - assert item.json() == json.dumps(expected_actors.pop(0)) + assert item.json() == json.dumps(expected_actors.pop(0), separators=(",", ":")) assert total_count == 2 @@ -356,7 +356,7 @@ def test_iter_all_when_custom_params_returns_expected_data( for item in iterator: total_count += 1 assert isinstance(item, Actor) - assert item.json() == json.dumps(expected_actors.pop(0)) + assert item.json() == json.dumps(expected_actors.pop(0), separators=(",", ":")) assert total_count == 2 @@ -386,7 +386,7 @@ def test_iter_all_when_prefer_parent_returns_expected_data(httpserver_auth: HTTP for item in iterator: total_count += 1 assert isinstance(item, Actor) - assert item.json() == json.dumps(expected_actors.pop(0)) + assert item.json() == json.dumps(expected_actors.pop(0), separators=(",", ":")) assert total_count == 2 @@ -395,7 +395,7 @@ def test_get_actor_by_id_returns_expected_data(mock_get_actor_by_id): response = client.actors.v1.get_actor_by_id(CHILD_ACTOR_ID) assert isinstance(response, Actor) assert response.actor_id == CHILD_ACTOR_ID - assert response.json() == json.dumps(CHILD_ACTOR) + assert response.json() == json.dumps(CHILD_ACTOR, separators=(",", ":")) def test_get_actor_by_id_with_prefer_parent_returns_expected_data( @@ -405,7 +405,7 @@ def test_get_actor_by_id_with_prefer_parent_returns_expected_data( response = client.actors.v1.get_actor_by_id(CHILD_ACTOR_ID, prefer_parent=True) assert isinstance(response, Actor) assert response.actor_id == PARENT_ACTOR_ID - assert response.json() == json.dumps(PARENT_ACTOR) + assert response.json() == json.dumps(PARENT_ACTOR, separators=(",", ":")) def test_get_actor_by_name_returns_expected_data(mock_get_actor_by_name): @@ -414,7 +414,7 @@ def test_get_actor_by_name_returns_expected_data(mock_get_actor_by_name): assert isinstance(response, Actor) assert response.actor_id == CHILD_ACTOR_ID assert response.name == CHILD_ACTOR_NAME - assert response.json() == json.dumps(CHILD_ACTOR) + assert response.json() == json.dumps(CHILD_ACTOR, separators=(",", ":")) def test_get_actor_by_name_when_prefer_parent_returns_expected_data( @@ -425,7 +425,7 @@ def test_get_actor_by_name_when_prefer_parent_returns_expected_data( assert isinstance(response, Actor) assert response.actor_id == PARENT_ACTOR_ID assert response.name == PARENT_ACTOR_NAME - assert response.json() == json.dumps(PARENT_ACTOR) + assert response.json() == json.dumps(PARENT_ACTOR, separators=(",", ":")) def test_get_actor_by_name_when_actor_not_found_raises_error( @@ -451,7 +451,7 @@ def test_get_family_by_member_id_returns_expected_data(mock_get_family_by_member assert isinstance(response, ActorFamily) assert isinstance(response.children[0], Actor) assert isinstance(response.parent, Actor) - assert response.json() == json.dumps(ACTOR_FAMILY) + assert response.json() == json.dumps(ACTOR_FAMILY, separators=(",", ":")) def test_get_family_by_member_name_returns_expected_data( @@ -462,7 +462,7 @@ def test_get_family_by_member_name_returns_expected_data( assert isinstance(response, ActorFamily) assert isinstance(response.children[0], Actor) assert isinstance(response.parent, Actor) - assert response.json() == json.dumps(ACTOR_FAMILY) + assert response.json() == json.dumps(ACTOR_FAMILY, separators=(",", ":")) def test_update_updates_actor(mock_update_actor): @@ -471,7 +471,7 @@ def test_update_updates_actor(mock_update_actor): PARENT_ACTOR_ID, notes="example note", start_date="", end_date=None ) assert isinstance(response, Actor) - assert response.json() == json.dumps(UPDATED_ACTOR) + assert response.json() == json.dumps(UPDATED_ACTOR, separators=(",", ":")) def test_update_when_keyword_arg_provided_updates_actor(mock_update_actor): @@ -480,7 +480,7 @@ def test_update_when_keyword_arg_provided_updates_actor(mock_update_actor): actor=PARENT_ACTOR_ID, notes="example note", start_date="", end_date=None ) assert isinstance(response, Actor) - assert response.json() == json.dumps(UPDATED_ACTOR) + assert response.json() == json.dumps(UPDATED_ACTOR, separators=(",", ":")) def test_update_actor_accepts_actor_arg(mock_update_actor): @@ -490,7 +490,7 @@ def test_update_actor_accepts_actor_arg(mock_update_actor): ) response = client.actors.v1.update_actor(test_actor) assert isinstance(response, Actor) - assert response.json() == json.dumps(UPDATED_ACTOR) + assert response.json() == json.dumps(UPDATED_ACTOR, separators=(",", ":")) def test_update_when_parameter_not_provided_does_not_update_parameter( diff --git a/tests/test_agents.py b/tests/test_agents.py index 7aebe47f..f62b3a08 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -105,7 +105,7 @@ def test_get_agent_returns_expected_data(mock_get_agent): agent = client.agents.v1.get_agent("agent-1") assert isinstance(agent, Agent) assert agent.agent_id == "agent-1" - assert agent.json() == json.dumps(TEST_AGENT_1) + assert agent.json() == json.dumps(TEST_AGENT_1, separators=(",", ":")) # test timestamp conversion assert agent.last_connected == datetime.fromisoformat( @@ -125,8 +125,8 @@ def test_get_page_when_default_query_params_returns_expected_data( client = Client() page = client.agents.v1.get_page() assert isinstance(page, AgentsPage) - assert page.agents[0].json() == json.dumps(TEST_AGENT_1) - assert page.agents[1].json() == json.dumps(TEST_AGENT_2) + assert page.agents[0].json() == json.dumps(TEST_AGENT_1, separators=(",", ":")) + assert page.agents[1].json() == json.dumps(TEST_AGENT_2, separators=(",", ":")) assert page.total_count == len(page.agents) == 2 @@ -160,8 +160,8 @@ def test_get_page_when_custom_query_params_returns_expected_data( sort_key=SortKeys.LAST_CONNECTED, ) assert isinstance(page, AgentsPage) - assert page.agents[0].json() == json.dumps(TEST_AGENT_1) - assert page.agents[1].json() == json.dumps(TEST_AGENT_2) + assert page.agents[0].json() == json.dumps(TEST_AGENT_1, separators=(",", ":")) + assert page.agents[1].json() == json.dumps(TEST_AGENT_2, separators=(",", ":")) assert page.total_count == len(page.agents) == 2 @@ -208,7 +208,7 @@ def test_iter_all_when_default_params_returns_expected_data( for item in iterator: total_agents += 1 assert isinstance(item, Agent) - assert item.json() == json.dumps(expected_agents.pop(0)) + assert item.json() == json.dumps(expected_agents.pop(0), separators=(",", ":")) assert total_agents == 3 diff --git a/tests/test_alert_rules.py b/tests/test_alert_rules.py index bc3ffce9..3dcc1f3c 100644 --- a/tests/test_alert_rules.py +++ b/tests/test_alert_rules.py @@ -302,8 +302,8 @@ def test_get_page_when_default_params_returns_expected_data( assert isinstance(response, List) for rule in response: assert isinstance(rule, RuleDetails) - assert response[0].json() == json.dumps(TEST_RULE_1) - assert response[1].json() == json.dumps(TEST_RULE_2) + assert response[0].json() == json.dumps(TEST_RULE_1, separators=(",", ":")) + assert response[1].json() == json.dumps(TEST_RULE_2, separators=(",", ":")) def test_get_page_when_custom_params_makes_expected_call(httpserver_auth: HTTPServer): @@ -321,8 +321,8 @@ def test_get_page_when_custom_params_makes_expected_call(httpserver_auth: HTTPSe assert isinstance(response, List) for rule in response: assert isinstance(rule, RuleDetails) - assert response[0].json() == json.dumps(TEST_RULE_1) - assert response[1].json() == json.dumps(TEST_RULE_2) + assert response[0].json() == json.dumps(TEST_RULE_1, separators=(",", ":")) + assert response[1].json() == json.dumps(TEST_RULE_2, separators=(",", ":")) assert len(response) == 2 @@ -358,7 +358,7 @@ def test_iter_all_returns_expected_data( for item in iterator: total += 1 assert isinstance(item, RuleDetails) - assert item.json() == json.dumps(expected.pop(0)) + assert item.json() == json.dumps(expected.pop(0), separators=(",", ":")) assert total == 3 @@ -373,7 +373,7 @@ def test_get_rule_returns_expected_data(mock_get): assert response.modified_at == datetime.datetime.fromisoformat( TEST_RULE_1["modifiedAt"].replace("Z", "+00:00") ) - assert response.json() == json.dumps(TEST_RULE_1) + assert response.json() == json.dumps(TEST_RULE_1, separators=(",", ":")) def test_enable_rules_when_single_rule_id_returns_expected_data( @@ -439,8 +439,12 @@ def test_get_users_when_default_params_returns_expected_data(mock_get_users): assert response.mode == "INCLUDE" for user in response.users: assert isinstance(user, RuleUser) - assert response.users[0].json() == json.dumps(TEST_RULE_USER_1) - assert response.users[1].json() == json.dumps(TEST_RULE_USER_2) + assert response.users[0].json() == json.dumps( + TEST_RULE_USER_1, separators=(",", ":") + ) + assert response.users[1].json() == json.dumps( + TEST_RULE_USER_2, separators=(",", ":") + ) def test_get_users_when_400_raises_missing_username_criterion_error( diff --git a/tests/test_cases.py b/tests/test_cases.py index e9286dfd..ac41fa3e 100644 --- a/tests/test_cases.py +++ b/tests/test_cases.py @@ -169,7 +169,7 @@ def test_get_single_case(mock_case_get): assert case.created_at == datetime.datetime.fromisoformat( TEST_CASE_2["createdAt"].replace("Z", "+00:00") ) - assert case.json() == json.dumps(TEST_CASE_2) + assert case.json() == json.dumps(TEST_CASE_2, separators=(",", ":")) def test_get_page_when_default_params_returns_expected_data( @@ -200,8 +200,8 @@ def test_get_page_when_default_params_returns_expected_data( client.settings.page_size = 15 page = client.cases.v1.get_page() assert isinstance(page, CasesPage) - assert page.cases[0].json() == json.dumps(slim_1) - assert page.cases[1].json() == json.dumps(slim_2) + assert page.cases[0].json() == json.dumps(slim_1, separators=(",", ":")) + assert page.cases[1].json() == json.dumps(slim_2, separators=(",", ":")) assert page.total_count == len(page.cases) diff --git a/tests/test_core.py b/tests/test_core.py index d145bf54..ac0b0ce7 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -78,7 +78,7 @@ class Test(CSVModel): list(Test.parse_csv(csv_with_no_required_aliases)) assert ( str(err.value) - == "CSV header missing column: 'required_field' required. Valid column aliases: ['required_field', 'requiredField', 'RF']" + == "CSV header missing column: Value error, 'required_field' required. Valid column aliases: ['required_field', 'requiredField', 'RF']" ) @@ -116,7 +116,7 @@ class Test(Model): assert "Error parsing object on line 2: 1 validation error for Test" in str( err.value ) - assert "value is not a valid integer" in str(err.value) + assert "Input should be a valid integer" in str(err.value) def test_user_agent(httpserver_auth: HTTPServer): diff --git a/tests/test_customer.py b/tests/test_customer.py index 7f82cd8d..21844f01 100644 --- a/tests/test_customer.py +++ b/tests/test_customer.py @@ -17,4 +17,4 @@ def test_get_returns_expected_data(httpserver_auth: HTTPServer): client = Client() customer = client.customer.v1.get() assert isinstance(customer, Customer) - assert customer.json() == json.dumps(TEST_CUSTOMER) + assert customer.json() == json.dumps(TEST_CUSTOMER, separators=(",", ":")) diff --git a/tests/test_devices.py b/tests/test_devices.py index 18caee3f..52a34faf 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -133,8 +133,7 @@ def test_get_device_returns_expected_data(mock_get): device = client.devices.v1.get_device("device-1") assert isinstance(device, Device) assert device.device_id == "device-1" - assert device.json() == json.dumps(TEST_DEVICE_1) - + assert json.loads(device.json()) == TEST_DEVICE_1 # test timestamp conversion assert device.last_connected == datetime.fromisoformat( TEST_DEVICE_1["lastConnected"].replace("Z", "+00:00") @@ -154,8 +153,8 @@ def test_get_page_when_default_query_params_returns_expected_data(mock_get_all_d client = Client() page = client.devices.v1.get_page() assert isinstance(page, DevicesPage) - assert page.devices[0].json() == json.dumps(TEST_DEVICE_1) - assert page.devices[1].json() == json.dumps(TEST_DEVICE_2) + assert json.loads(page.devices[0].json()) == TEST_DEVICE_1 + assert json.loads(page.devices[1].json()) == TEST_DEVICE_2 assert page.total_count == len(page.devices) == 2 @@ -186,8 +185,8 @@ def test_get_page_when_custom_query_params_returns_expected_data( sort_key=SortKeys.LAST_CONNECTED, ) assert isinstance(page, DevicesPage) - assert page.devices[0].json() == json.dumps(TEST_DEVICE_1) - assert page.devices[1].json() == json.dumps(TEST_DEVICE_2) + assert json.loads(page.devices[0].json()) == TEST_DEVICE_1 + assert json.loads(page.devices[1].json()) == TEST_DEVICE_2 assert page.total_count == len(page.devices) == 2 @@ -224,7 +223,7 @@ def test_iter_all_when_default_params_returns_expected_data( for item in iterator: total_devices += 1 assert isinstance(item, Device) - assert item.json() == json.dumps(expected_devices.pop(0)) + assert json.loads(item.json()) == expected_devices.pop(0) assert total_devices == 3 diff --git a/tests/test_directory_groups.py b/tests/test_directory_groups.py index 95cb165f..b73feac5 100644 --- a/tests/test_directory_groups.py +++ b/tests/test_directory_groups.py @@ -27,10 +27,11 @@ def test_get_page_when_default_params_returns_expected_data( page = c.directory_groups.v1.get_page() assert isinstance(page, DirectoryGroupsPage) assert page.directory_groups[0].json() == json.dumps( - {"groupId": "group-42", "name": "Sales"} + {"groupId": "group-42", "name": "Sales"}, separators=(",", ":") ) assert page.directory_groups[1].json() == json.dumps( - {"groupId": "group-43", "name": "Research and Development"} + {"groupId": "group-43", "name": "Research and Development"}, + separators=(",", ":"), ) assert page.total_count == len(page.directory_groups) == 2 @@ -55,7 +56,7 @@ def test_get_page_when_custom_params_returns_expected_data( page = c.directory_groups.v1.get_page(page_num=1, page_size=2, name="Sales") assert isinstance(page, DirectoryGroupsPage) assert page.directory_groups[0].json() == json.dumps( - {"groupId": "group-42", "name": "Sales"} + {"groupId": "group-42", "name": "Sales"}, separators=(",", ":") ) assert page.total_count == len(page.directory_groups) == 1 @@ -102,7 +103,7 @@ def test_iter_all_returns_expected_data(httpserver_auth: HTTPServer): for item in iterator: total += 1 assert isinstance(item, DirectoryGroup) - assert item.json() == json.dumps(expected.pop(0)) + assert item.json() == json.dumps(expected.pop(0), separators=(",", ":")) assert total == 3 diff --git a/tests/test_logging.py b/tests/test_logging.py index f9e46f08..d36d1050 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -38,6 +38,7 @@ def test_env_var_sets_logging( ("INFO", "ERROR", logging.ERROR), ("INFO", "WARNING", logging.WARNING), ("WARNING", "INFO", logging.INFO), + ("ERROR", "INFO", logging.INFO), ], ) def test_init_param_overrides_env_var( diff --git a/tests/test_risk_profiles.py b/tests/test_risk_profiles.py index a96ece3d..bb6058e3 100644 --- a/tests/test_risk_profiles.py +++ b/tests/test_risk_profiles.py @@ -128,7 +128,9 @@ def test_get_single_user_risk_profile_when_default_params_returns_expected_data( user_risk_profile = client.risk_profiles.v1.get_risk_profile(TEST_USER_ID) assert isinstance(user_risk_profile, RiskProfile) assert user_risk_profile.user_id == TEST_USER_ID - assert user_risk_profile.json() == json.dumps(TEST_USER_RISK_PROFILE_1) + assert user_risk_profile.json() == json.dumps( + TEST_USER_RISK_PROFILE_1, separators=(",", ":") + ) def test_get_page_when_default_params_returns_expected_data( @@ -148,8 +150,12 @@ def test_get_page_when_default_params_returns_expected_data( client = Client() page = client.risk_profiles.v1.get_page() assert isinstance(page, RiskProfilesPage) - assert page.user_risk_profiles[0].json() == json.dumps(TEST_USER_RISK_PROFILE_1) - assert page.user_risk_profiles[1].json() == json.dumps(TEST_USER_RISK_PROFILE_2) + assert page.user_risk_profiles[0].json() == json.dumps( + TEST_USER_RISK_PROFILE_1, separators=(",", ":") + ) + assert page.user_risk_profiles[1].json() == json.dumps( + TEST_USER_RISK_PROFILE_2, separators=(",", ":") + ) assert page.total_count == len(page.user_risk_profiles) == 2 @@ -186,7 +192,9 @@ def test_iter_all_when_default_params_returns_expected_data( for item in iterator: total_user_risk_profiles += 1 assert isinstance(item, RiskProfile) - assert item.json() == json.dumps(expected_user_risk_profiles.pop(0)) + assert item.json() == json.dumps( + expected_user_risk_profiles.pop(0), separators=(",", ":") + ) assert total_user_risk_profiles == 2 @@ -200,7 +208,9 @@ def test_update_when_default_params_returns_expected_data(mock_update_profile): ) assert isinstance(user_risk_profile, RiskProfile) - assert user_risk_profile.json() == json.dumps(TEST_USER_RISK_PROFILE_2) + assert user_risk_profile.json() == json.dumps( + TEST_USER_RISK_PROFILE_2, separators=(",", ":") + ) # ************************************************ CLI ************************************************ diff --git a/tests/test_sessions.py b/tests/test_sessions.py index ff24e75b..3a39880a 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -109,7 +109,7 @@ def test_get_page_when_default_params_returns_expected_data( client = Client() page = client.sessions.v1.get_page() assert isinstance(page, SessionsPage) - assert page.items[0].json() == json.dumps(TEST_SESSION) + assert page.items[0].json() == json.dumps(TEST_SESSION, separators=(",", ":")) assert len(page.items) == 1 == page.total_count @@ -153,7 +153,7 @@ def test_get_page_when_custom_params_returns_expected_data(httpserver_auth: HTTP content_inspection_status=ContentInspectionStatuses.PENDING, ) assert isinstance(page, SessionsPage) - assert page.items[0].json() == json.dumps(TEST_SESSION) + assert page.items[0].json() == json.dumps(TEST_SESSION, separators=(",", ":")) assert len(page.items) == 1 == page.total_count @@ -197,7 +197,7 @@ def test_get_page_when_given_date_uses_correct_timestamp(httpserver_auth: HTTPSe content_inspection_status=ContentInspectionStatuses.PENDING, ) assert isinstance(page, SessionsPage) - assert page.items[0].json() == json.dumps(TEST_SESSION) + assert page.items[0].json() == json.dumps(TEST_SESSION, separators=(",", ":")) assert len(page.items) == 1 == page.total_count @@ -234,7 +234,9 @@ def test_iter_all_when_default_params_returns_expected_data( for item in iterator: total_count += 1 assert isinstance(item, Session) - assert item.json() == json.dumps(expected_sessions.pop(0)) + assert item.json() == json.dumps( + expected_sessions.pop(0), separators=(",", ":") + ) assert total_count == 1 @@ -299,7 +301,9 @@ def test_iter_all_when_custom_params_returns_expected_data(httpserver_auth: HTTP for item in iterator: total_count += 1 assert isinstance(item, Session) - assert item.json() == json.dumps(expected_sessions.pop(0)) + assert item.json() == json.dumps( + expected_sessions.pop(0), separators=(",", ":") + ) assert total_count == 1 @@ -307,7 +311,7 @@ def test_get_session_details_returns_expected_data(mock_get_session): client = Client() response = client.sessions.v1.get_session_details(TEST_SESSION_ID) assert isinstance(response, Session) - assert response.json() == json.dumps(TEST_SESSION) + assert response.json() == json.dumps(TEST_SESSION, separators=(",", ":")) def test_get_session_events_returns_expected_data( diff --git a/tests/test_trusted_activities.py b/tests/test_trusted_activities.py index e4327719..b5f74989 100644 --- a/tests/test_trusted_activities.py +++ b/tests/test_trusted_activities.py @@ -84,15 +84,21 @@ def test_get_single_trusted_activity_when_default_params_returns_expected_data( trusted_activity = client.trusted_activities.v2.get_trusted_activity("1234") assert isinstance(trusted_activity, TrustedActivity) assert trusted_activity.activity_id == "1234" - assert trusted_activity.json() == json.dumps(TEST_TRUSTED_ACTIVITY_1) + assert trusted_activity.json() == json.dumps( + TEST_TRUSTED_ACTIVITY_1, separators=(",", ":") + ) def test_get_page_when_default_params_returns_expected_data(mock_get_all): client = Client() page = client.trusted_activities.v2.get_page() assert isinstance(page, TrustedActivitiesPage) - assert page.trusted_activities[0].json() == json.dumps(TEST_TRUSTED_ACTIVITY_1) - assert page.trusted_activities[1].json() == json.dumps(TEST_TRUSTED_ACTIVITY_2) + assert page.trusted_activities[0].json() == json.dumps( + TEST_TRUSTED_ACTIVITY_1, separators=(",", ":") + ) + assert page.trusted_activities[1].json() == json.dumps( + TEST_TRUSTED_ACTIVITY_2, separators=(",", ":") + ) assert page.total_count == len(page.trusted_activities) == 2 @@ -130,7 +136,9 @@ def test_iter_all_when_default_params_returns_expected_data( for item in iterator: total_trusted_activities += 1 assert isinstance(item, TrustedActivity) - assert item.json() == json.dumps(expected_trusted_activities.pop(0)) + assert item.json() == json.dumps( + expected_trusted_activities.pop(0), separators=(",", ":") + ) assert total_trusted_activities == 2 @@ -200,7 +208,10 @@ def test_add_domain_when_default_params_returns_expected_data( assert trusted_activity.type == activity_type assert trusted_activity.value == domain assert trusted_activity.description == test_data["description"] - assert trusted_activity.activity_action_groups == activity_action_groups + assert ( + json.loads(trusted_activity.json())["activityActionGroups"] + == activity_action_groups + ) def test_add_domain_when_invalid_trusted_provider_value_raises_error( @@ -336,7 +347,10 @@ def test_add_account_name_when_default_params_returns_expected_data( assert trusted_activity.type == activity_type assert trusted_activity.value == account_name assert trusted_activity.description == test_data["description"] - assert trusted_activity.activity_action_groups == activity_action_groups + assert ( + json.loads(trusted_activity.activity_action_groups[0].json()) + == activity_action_groups[0] + ) def test_add_account_name_when_no_trusted_providers_raises_error( @@ -388,7 +402,10 @@ def test_add_git_repository_when_default_params_returns_expected_data( assert trusted_activity.type == activity_type assert trusted_activity.value == git_uri assert trusted_activity.description == test_data["description"] - assert trusted_activity.activity_action_groups == activity_action_groups + assert ( + json.loads(trusted_activity.activity_action_groups[0].json()) + == activity_action_groups[0] + ) def test_delete_trusted_activity_when_default_params_returns_expected_data( diff --git a/tests/test_users.py b/tests/test_users.py index 5ce73214..700ee5c9 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -254,7 +254,7 @@ def test_get_user_when_user_id_returns_expected_data(mock_get): user = client.users.v1.get_user("user-1") assert isinstance(user, User) assert user.user_id == "user-1" - assert user.json() == json.dumps(TEST_USER_1) + assert user.json() == json.dumps(TEST_USER_1, separators=(",", ":")) # test timestamp conversion assert user.creation_date == datetime.fromisoformat( @@ -298,8 +298,8 @@ def test_get_page_when_default_query_params_returns_expected_data( client = Client() page = client.users.v1.get_page() assert isinstance(page, UsersPage) - assert page.users[0].json() == json.dumps(TEST_USER_1) - assert page.users[1].json() == json.dumps(TEST_USER_2) + assert page.users[0].json() == json.dumps(TEST_USER_1, separators=(",", ":")) + assert page.users[1].json() == json.dumps(TEST_USER_2, separators=(",", ":")) assert page.total_count == len(page.users) == 2 @@ -318,8 +318,8 @@ def test_get_page_when_custom_query_params_returns_expected_data( active=True, blocked=False, page_num=2, page_size=10 ) assert isinstance(page, UsersPage) - assert page.users[0].json() == json.dumps(TEST_USER_1) - assert page.users[1].json() == json.dumps(TEST_USER_2) + assert page.users[0].json() == json.dumps(TEST_USER_1, separators=(",", ":")) + assert page.users[1].json() == json.dumps(TEST_USER_2, separators=(",", ":")) assert page.total_count == len(page.users) == 2 @@ -336,7 +336,7 @@ def test_get_page_when_custom_username_query_param_returns_expected_data( client = Client() page = client.users.v1.get_page(username="username-1") assert isinstance(page, UsersPage) - assert page.users[0].json() == json.dumps(TEST_USER_1) + assert page.users[0].json() == json.dumps(TEST_USER_1, separators=(",", ":")) assert page.total_count == len(page.users) == 1 @@ -366,7 +366,7 @@ def test_iter_all_when_default_params_returns_expected_data( for item in iterator: total_users += 1 assert isinstance(item, User) - assert item.json() == json.dumps(expected_users.pop(0)) + assert item.json() == json.dumps(expected_users.pop(0), separators=(",", ":")) assert total_users == 3 @@ -374,8 +374,8 @@ def test_get_devices_when_default_query_params_returns_expected_data(mock_get_de client = Client() page = client.users.v1.get_devices(user_id="user-1") assert isinstance(page, DevicesPage) - assert page.devices[0].json() == json.dumps(TEST_USER_1_DEVICE_1) - assert page.devices[1].json() == json.dumps(TEST_USER_1_DEVICE_2) + assert json.loads(page.devices[0].json()) == TEST_USER_1_DEVICE_1 + assert json.loads(page.devices[1].json()) == TEST_USER_1_DEVICE_2 assert page.total_count == len(page.devices) == 2 @@ -410,8 +410,8 @@ def test_get_devices_when_custom_query_params_returns_expected_data( sort_key=SortKeys.LAST_CONNECTED, ) assert isinstance(page, DevicesPage) - assert page.devices[0].json() == json.dumps(TEST_USER_1_DEVICE_1) - assert page.devices[1].json() == json.dumps(TEST_USER_1_DEVICE_2) + assert json.loads(page.devices[0].json()) == TEST_USER_1_DEVICE_1 + assert json.loads(page.devices[1].json()) == TEST_USER_1_DEVICE_2 assert page.total_count == len(page.devices) == 2 @@ -421,8 +421,8 @@ def test_get_roles_returns_expected_data(mock_get_user_roles): assert isinstance(roles, list) assert isinstance(roles[0], UserRole) assert isinstance(roles[1], UserRole) - assert roles[0].json() == json.dumps(TEST_USER_ROLE_1) - assert roles[1].json() == json.dumps(TEST_USER_ROLE_2) + assert json.loads(roles[0].json()) == TEST_USER_ROLE_1 + assert json.loads(roles[1].json()) == TEST_USER_ROLE_2 @pytest.mark.parametrize( @@ -445,7 +445,7 @@ def test_update_roles_returns_expected_data( roles=input_roles, ) assert isinstance(response, UpdateRolesResponse) - assert response.json() == json.dumps(TEST_USER_ROLE_UPDATE) + assert response.json() == json.dumps(TEST_USER_ROLE_UPDATE, separators=(",", ":")) def test_get_available_roles_returns_expected_data(mock_list_roles): @@ -454,8 +454,8 @@ def test_get_available_roles_returns_expected_data(mock_list_roles): assert isinstance(roles, list) assert isinstance(roles[0], Role) assert isinstance(roles[1], Role) - assert roles[0].json() == json.dumps(TEST_ROLE_1) - assert roles[1].json() == json.dumps(TEST_ROLE_2) + assert json.loads(roles[0].json()) == TEST_ROLE_1 + assert json.loads(roles[1].json()) == TEST_ROLE_2 @pytest.mark.parametrize("role", ["test-role", "Test Role"]) @@ -465,7 +465,7 @@ def test_get_role_returns_expected_data( client = Client() role = client.users.v1.get_role(role) assert isinstance(role, Role) - assert role.json() == json.dumps(TEST_ROLE_1) + assert json.loads(role.json()) == TEST_ROLE_1 add_roles_input = pytest.mark.parametrize( diff --git a/tests/test_utils.py b/tests/test_utils.py index 44795956..00cdaff8 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,11 +1,15 @@ import datetime +from typing import List from typing import Optional +from typing import Union import pytest from pydantic import BaseModel from pydantic import Field from _incydr_sdk.queries.utils import parse_str_to_dt +from _incydr_sdk.utils import _get_model_type +from _incydr_sdk.utils import _is_singleton from _incydr_sdk.utils import flatten_fields from _incydr_sdk.utils import get_field_value_and_info from _incydr_sdk.utils import get_fields @@ -13,27 +17,28 @@ class GrandChildTestModel(BaseModel): - string_field: Optional[str] = Field(table=lambda x: str(x)) + string_field: Optional[str] = Field(None, table=lambda x: str(x)) class ChildTestModel(BaseModel): - string_field: Optional[str] + string_field: Optional[str] = None int_field: Optional[int] = Field( + None, table=lambda x: str(x + 1) if isinstance(x, int) else x, csv=lambda x: str(x + 2) if isinstance(x, int) else x, ) - grand_child: Optional[GrandChildTestModel] + grand_child: Optional[GrandChildTestModel] = None class Config: json_encoders = {int: lambda i: str(float(i))} class ParentTestModel(BaseModel): - string_field: Optional[str] + string_field: Optional[str] = None int_field: Optional[int] = Field( - table=lambda x: str(x + 1), csv=lambda x: str(x + 2) + None, table=lambda x: str(x + 1), csv=lambda x: str(x + 2) ) - child_model: Optional[ChildTestModel] + child_model: Optional[ChildTestModel] = None class Config: json_encoders = {int: lambda i: str(float(i))} @@ -183,26 +188,26 @@ def test_get_field_value_and_info(): ) value, field = get_field_value_and_info(model, ["int_field"]) assert value == 0 - assert "table" in field.field_info.extra + assert "table" in field.json_schema_extra child_value, child_field = get_field_value_and_info( model, ["child_model", "int_field"] ) assert child_value == 1 - assert "table" in child_field.field_info.extra + assert "table" in child_field.json_schema_extra empty_child_model = ParentTestModel(string_field="test", int_field=0) child_value, child_field = get_field_value_and_info( empty_child_model, ["child_model", "int_field"] ) assert child_value is None - assert "table" in child_field.field_info.extra + assert "table" in child_field.json_schema_extra grandchild_value, grandchild_field = get_field_value_and_info( empty_child_model, ["child_model", "grand_child", "string_field"] ) assert grandchild_value is None - assert "table" in grandchild_field.field_info.extra + assert "table" in grandchild_field.json_schema_extra @pytest.mark.parametrize( @@ -227,3 +232,34 @@ def test_get_field_value_and_info(): ) def test_parse_str_to_dt(ts_str, expected): assert parse_str_to_dt(ts_str) == expected + + +@pytest.mark.parametrize( + "type,expected", + [ + (Union[int, str], True), + (list, False), + (List[int], False), + (Union[List[int], List[str]], False), + (str, True), + (BaseModel, True), + (set, False), + (tuple, False), + ], +) +def test_is_singleton(type, expected): + assert _is_singleton(type) == expected + + +@pytest.mark.parametrize( + "type,expected", + [ + (int, None), + (ParentTestModel, ParentTestModel), + (List[ParentTestModel], ParentTestModel), + (Union[int, ParentTestModel], ParentTestModel), + (Union[ParentTestModel, ChildTestModel], ParentTestModel), + ], +) +def test_get_model_type(type, expected): + assert _get_model_type(type) == expected diff --git a/tests/test_watchlists.py b/tests/test_watchlists.py index edb454b2..492ef4d3 100644 --- a/tests/test_watchlists.py +++ b/tests/test_watchlists.py @@ -355,8 +355,12 @@ def test_get_page_when_default_params_returns_expected_data_v2(mock_get_all_v2): c = Client() page = c.watchlists.v2.get_page() assert isinstance(page, WatchlistsPage) - assert page.watchlists[0].json() == json.dumps(TEST_WATCHLIST_1) - assert page.watchlists[1].json() == json.dumps(TEST_WATCHLIST_2) + assert page.watchlists[0].json() == json.dumps( + TEST_WATCHLIST_1, separators=(",", ":") + ) + assert page.watchlists[1].json() == json.dumps( + TEST_WATCHLIST_2, separators=(",", ":") + ) assert page.total_count == len(page.watchlists) == 2 @@ -374,8 +378,12 @@ def test_get_page_when_custom_params_returns_expected_data_v2( c = Client() page = c.watchlists.v2.get_page(page_num=2, page_size=42, actor_id="user-42") assert isinstance(page, WatchlistsPage) - assert page.watchlists[0].json() == json.dumps(TEST_WATCHLIST_1) - assert page.watchlists[1].json() == json.dumps(TEST_WATCHLIST_2) + assert page.watchlists[0].json() == json.dumps( + TEST_WATCHLIST_1, separators=(",", ":") + ) + assert page.watchlists[1].json() == json.dumps( + TEST_WATCHLIST_2, separators=(",", ":") + ) assert page.total_count == len(page.watchlists) == 2 @@ -408,7 +416,7 @@ def test_iter_all_when_default_params_returns_expected_data_v2( for item in iterator: total += 1 assert isinstance(item, Watchlist) - assert item.json() == json.dumps(expected.pop(0)) + assert item.json() == json.dumps(expected.pop(0), separators=(",", ":")) assert total == 3 @@ -421,7 +429,7 @@ def test_get_returns_expected_data_v2(httpserver_auth: HTTPServer): watchlist = c.watchlists.v2.get(TEST_WATCHLIST_ID) assert isinstance(watchlist, Watchlist) assert watchlist.watchlist_id == TEST_WATCHLIST_ID - assert watchlist.json() == json.dumps(TEST_WATCHLIST_1) + assert watchlist.json() == json.dumps(TEST_WATCHLIST_1, separators=(",", ":")) def test_create_when_required_params_returns_expected_data_v2( @@ -430,7 +438,7 @@ def test_create_when_required_params_returns_expected_data_v2( c = Client() watchlist = c.watchlists.v2.create(WatchlistType.DEPARTING_EMPLOYEE) assert isinstance(watchlist, Watchlist) - assert watchlist.json() == json.dumps(TEST_WATCHLIST_1) + assert watchlist.json() == json.dumps(TEST_WATCHLIST_1, separators=(",", ":")) def test_create_when_all_params_returns_expected_data_v2(mock_create_custom_v2): @@ -439,7 +447,7 @@ def test_create_when_all_params_returns_expected_data_v2(mock_create_custom_v2): "CUSTOM", title="test", description="custom watchlist" ) assert isinstance(watchlist, Watchlist) - assert watchlist.json() == json.dumps(TEST_WATCHLIST_2) + assert watchlist.json() == json.dumps(TEST_WATCHLIST_2, separators=(",", ":")) def test_create_when_custom_and_no_title_raises_error_v2(httpserver_auth: HTTPServer): @@ -472,7 +480,7 @@ def test_update_when_all_params_returns_expected_data_v2(httpserver_auth: HTTPSe TEST_WATCHLIST_ID, title="updated title", description="updated description" ) assert isinstance(response, Watchlist) - assert response.json() == json.dumps(watchlist) + assert response.json() == json.dumps(watchlist, separators=(",", ":")) def test_update_when_one_param_returns_expected_data_v2(httpserver_auth: HTTPServer): @@ -490,7 +498,7 @@ def test_update_when_one_param_returns_expected_data_v2(httpserver_auth: HTTPSer c = Client() response = c.watchlists.v2.update(TEST_WATCHLIST_ID, title="updated title") assert isinstance(response, Watchlist) - assert response.json() == json.dumps(watchlist) + assert response.json() == json.dumps(watchlist, separators=(",", ":")) def test_get_member_returns_expected_data_v2(mock_get_member_v2): @@ -502,7 +510,7 @@ def test_get_member_returns_expected_data_v2(mock_get_member_v2): assert member.added_time == datetime.datetime.fromisoformat( TEST_ACTOR_1["addedTime"].replace("Z", "+00:00") ) - assert member.json() == json.dumps(TEST_ACTOR_1) + assert member.json() == json.dumps(TEST_ACTOR_1, separators=(",", ":")) def test_list_members_returns_expected_data_v2(mock_get_all_members_v2): @@ -510,8 +518,12 @@ def test_list_members_returns_expected_data_v2(mock_get_all_members_v2): members = c.watchlists.v2.list_members(TEST_WATCHLIST_ID) assert isinstance(members, WatchlistMembersListV2) assert isinstance(members.watchlist_members[0], WatchlistActor) - assert members.watchlist_members[0].json() == json.dumps(TEST_ACTOR_1) - assert members.watchlist_members[1].json() == json.dumps(TEST_ACTOR_2) + assert members.watchlist_members[0].json() == json.dumps( + TEST_ACTOR_1, separators=(",", ":") + ) + assert members.watchlist_members[1].json() == json.dumps( + TEST_ACTOR_2, separators=(",", ":") + ) assert members.total_count == len(members.watchlist_members) == 2 @@ -573,15 +585,19 @@ def test_get_included_user_returns_expected_data_v2(mock_get_included_actor): assert user.added_time == datetime.datetime.fromisoformat( TEST_ACTOR_1["addedTime"].replace("Z", "+00:00") ) - assert user.json() == json.dumps(TEST_ACTOR_1) + assert user.json() == json.dumps(TEST_ACTOR_1, separators=(",", ":")) def test_list_included_users_returns_expected_data_v2(mock_get_all_included_actors): c = Client() users = c.watchlists.v2.list_included_actors(TEST_WATCHLIST_ID) assert isinstance(users, IncludedActorsList) - assert users.included_actors[0].json() == json.dumps(TEST_ACTOR_1) - assert users.included_actors[1].json() == json.dumps(TEST_ACTOR_2) + assert users.included_actors[0].json() == json.dumps( + TEST_ACTOR_1, separators=(",", ":") + ) + assert users.included_actors[1].json() == json.dumps( + TEST_ACTOR_2, separators=(",", ":") + ) assert users.total_count == len(users.included_actors) == 2 @@ -623,15 +639,19 @@ def test_get_excluded_user_returns_expected_data_v2(mock_get_excluded_actor): assert user.added_time == datetime.datetime.fromisoformat( TEST_ACTOR_1["addedTime"].replace("Z", "+00:00") ) - assert user.json() == json.dumps(TEST_ACTOR_1) + assert user.json() == json.dumps(TEST_ACTOR_1, separators=(",", ":")) def test_list_excluded_users_returns_expected_data_v2(mock_get_all_excluded_actors): c = Client() users = c.watchlists.v2.list_excluded_actors(TEST_WATCHLIST_ID) assert isinstance(users, ExcludedActorsList) - assert users.excluded_actors[0].json() == json.dumps(TEST_ACTOR_1) - assert users.excluded_actors[1].json() == json.dumps(TEST_ACTOR_2) + assert users.excluded_actors[0].json() == json.dumps( + TEST_ACTOR_1, separators=(",", ":") + ) + assert users.excluded_actors[1].json() == json.dumps( + TEST_ACTOR_2, separators=(",", ":") + ) assert users.total_count == len(users.excluded_actors) == 2 @@ -671,8 +691,12 @@ def test_list_included_directory_groups_returns_expected_data_v2( c = Client() groups = c.watchlists.v2.list_directory_groups(TEST_WATCHLIST_ID) assert isinstance(groups, IncludedDirectoryGroupsList) - assert groups.included_directory_groups[0].json() == json.dumps(TEST_GROUP_1) - assert groups.included_directory_groups[1].json() == json.dumps(TEST_GROUP_2) + assert groups.included_directory_groups[0].json() == json.dumps( + TEST_GROUP_1, separators=(",", ":") + ) + assert groups.included_directory_groups[1].json() == json.dumps( + TEST_GROUP_2, separators=(",", ":") + ) assert groups.total_count == len(groups.included_directory_groups) == 2 @@ -685,7 +709,7 @@ def test_get_directory_group_returns_expected_data_v2(mock_get_directory_group_v assert group.added_time == datetime.datetime.fromisoformat( TEST_GROUP_1["addedTime"].replace("Z", "+00:00") ) - assert group.json() == json.dumps(TEST_GROUP_1) + assert group.json() == json.dumps(TEST_GROUP_1, separators=(",", ":")) @valid_ids_param @@ -720,8 +744,12 @@ def test_list_included_departments_returns_expected_data_v2( c = Client() departments = c.watchlists.v2.list_departments(TEST_WATCHLIST_ID) assert isinstance(departments, IncludedDepartmentsList) - assert departments.included_departments[0].json() == json.dumps(TEST_DEPARTMENT_1) - assert departments.included_departments[1].json() == json.dumps(TEST_DEPARTMENT_2) + assert departments.included_departments[0].json() == json.dumps( + TEST_DEPARTMENT_1, separators=(",", ":") + ) + assert departments.included_departments[1].json() == json.dumps( + TEST_DEPARTMENT_2, separators=(",", ":") + ) assert departments.total_count == len(departments.included_departments) == 2 @@ -733,7 +761,7 @@ def test_get_department_returns_expected_data_v2(mock_get_department_v2): assert department.added_time == datetime.datetime.fromisoformat( TEST_DEPARTMENT_1["addedTime"].replace("Z", "+00:00") ) - assert department.json() == json.dumps(TEST_DEPARTMENT_1) + assert department.json() == json.dumps(TEST_DEPARTMENT_1, separators=(",", ":")) @pytest.mark.parametrize( @@ -778,8 +806,12 @@ def test_get_page_when_default_params_returns_expected_data(mock_get_all): c = Client() page = c.watchlists.v1.get_page() assert isinstance(page, WatchlistsPage) - assert page.watchlists[0].json() == json.dumps(TEST_WATCHLIST_1) - assert page.watchlists[1].json() == json.dumps(TEST_WATCHLIST_2) + assert page.watchlists[0].json() == json.dumps( + TEST_WATCHLIST_1, separators=(",", ":") + ) + assert page.watchlists[1].json() == json.dumps( + TEST_WATCHLIST_2, separators=(",", ":") + ) assert page.total_count == len(page.watchlists) == 2 @@ -795,8 +827,12 @@ def test_get_page_when_custom_params_returns_expected_data(httpserver_auth: HTTP c = Client() page = c.watchlists.v1.get_page(page_num=2, page_size=42, user_id="user-42") assert isinstance(page, WatchlistsPage) - assert page.watchlists[0].json() == json.dumps(TEST_WATCHLIST_1) - assert page.watchlists[1].json() == json.dumps(TEST_WATCHLIST_2) + assert page.watchlists[0].json() == json.dumps( + TEST_WATCHLIST_1, separators=(",", ":") + ) + assert page.watchlists[1].json() == json.dumps( + TEST_WATCHLIST_2, separators=(",", ":") + ) assert page.total_count == len(page.watchlists) == 2 @@ -829,7 +865,7 @@ def test_iter_all_when_default_params_returns_expected_data( for item in iterator: total += 1 assert isinstance(item, Watchlist) - assert item.json() == json.dumps(expected.pop(0)) + assert item.json() == json.dumps(expected.pop(0), separators=(",", ":")) assert total == 3 @@ -842,7 +878,7 @@ def test_get_returns_expected_data(httpserver_auth: HTTPServer): watchlist = c.watchlists.v1.get(TEST_WATCHLIST_ID) assert isinstance(watchlist, Watchlist) assert watchlist.watchlist_id == TEST_WATCHLIST_ID - assert watchlist.json() == json.dumps(TEST_WATCHLIST_1) + assert watchlist.json() == json.dumps(TEST_WATCHLIST_1, separators=(",", ":")) def test_create_when_required_params_returns_expected_data( @@ -851,7 +887,7 @@ def test_create_when_required_params_returns_expected_data( c = Client() watchlist = c.watchlists.v1.create(WatchlistType.DEPARTING_EMPLOYEE) assert isinstance(watchlist, Watchlist) - assert watchlist.json() == json.dumps(TEST_WATCHLIST_1) + assert watchlist.json() == json.dumps(TEST_WATCHLIST_1, separators=(",", ":")) def test_create_when_all_params_returns_expected_data(mock_create_custom): @@ -860,7 +896,7 @@ def test_create_when_all_params_returns_expected_data(mock_create_custom): "CUSTOM", title="test", description="custom watchlist" ) assert isinstance(watchlist, Watchlist) - assert watchlist.json() == json.dumps(TEST_WATCHLIST_2) + assert watchlist.json() == json.dumps(TEST_WATCHLIST_2, separators=(",", ":")) def test_create_when_custom_and_no_title_raises_error(httpserver_auth: HTTPServer): @@ -893,7 +929,7 @@ def test_update_when_all_params_returns_expected_data(httpserver_auth: HTTPServe TEST_WATCHLIST_ID, title="updated title", description="updated description" ) assert isinstance(response, Watchlist) - assert response.json() == json.dumps(watchlist) + assert response.json() == json.dumps(watchlist, separators=(",", ":")) def test_update_when_one_param_returns_expected_data(httpserver_auth: HTTPServer): @@ -911,7 +947,7 @@ def test_update_when_one_param_returns_expected_data(httpserver_auth: HTTPServer c = Client() response = c.watchlists.v1.update(TEST_WATCHLIST_ID, title="updated title") assert isinstance(response, Watchlist) - assert response.json() == json.dumps(watchlist) + assert response.json() == json.dumps(watchlist, separators=(",", ":")) def test_get_member_returns_expected_data(mock_get_member): @@ -923,15 +959,19 @@ def test_get_member_returns_expected_data(mock_get_member): assert member.added_time == datetime.datetime.fromisoformat( TEST_USER_1["addedTime"].replace("Z", "+00:00") ) - assert member.json() == json.dumps(TEST_USER_1) + assert member.json() == json.dumps(TEST_USER_1, separators=(",", ":")) def test_list_members_returns_expected_data(mock_get_all_members): c = Client() members = c.watchlists.v1.list_members(TEST_WATCHLIST_ID) assert isinstance(members, WatchlistMembersList) - assert members.watchlist_members[0].json() == json.dumps(TEST_USER_1) - assert members.watchlist_members[1].json() == json.dumps(TEST_USER_2) + assert members.watchlist_members[0].json() == json.dumps( + TEST_USER_1, separators=(",", ":") + ) + assert members.watchlist_members[1].json() == json.dumps( + TEST_USER_2, separators=(",", ":") + ) assert members.total_count == len(members.watchlist_members) == 2 @@ -993,15 +1033,19 @@ def test_get_included_user_returns_expected_data(mock_get_included_user): assert user.added_time == datetime.datetime.fromisoformat( TEST_USER_1["addedTime"].replace("Z", "+00:00") ) - assert user.json() == json.dumps(TEST_USER_1) + assert user.json() == json.dumps(TEST_USER_1, separators=(",", ":")) def test_list_included_users_returns_expected_data(mock_get_all_included_users): c = Client() users = c.watchlists.v1.list_included_users(TEST_WATCHLIST_ID) assert isinstance(users, IncludedUsersList) - assert users.included_users[0].json() == json.dumps(TEST_USER_1) - assert users.included_users[1].json() == json.dumps(TEST_USER_2) + assert users.included_users[0].json() == json.dumps( + TEST_USER_1, separators=(",", ":") + ) + assert users.included_users[1].json() == json.dumps( + TEST_USER_2, separators=(",", ":") + ) assert users.total_count == len(users.included_users) == 2 @@ -1043,15 +1087,19 @@ def test_get_excluded_user_returns_expected_data(mock_get_excluded_user): assert user.added_time == datetime.datetime.fromisoformat( TEST_USER_1["addedTime"].replace("Z", "+00:00") ) - assert user.json() == json.dumps(TEST_USER_1) + assert user.json() == json.dumps(TEST_USER_1, separators=(",", ":")) def test_list_excluded_users_returns_expected_data(mock_get_all_excluded_users): c = Client() users = c.watchlists.v1.list_excluded_users(TEST_WATCHLIST_ID) assert isinstance(users, ExcludedUsersList) - assert users.excluded_users[0].json() == json.dumps(TEST_USER_1) - assert users.excluded_users[1].json() == json.dumps(TEST_USER_2) + assert users.excluded_users[0].json() == json.dumps( + TEST_USER_1, separators=(",", ":") + ) + assert users.excluded_users[1].json() == json.dumps( + TEST_USER_2, separators=(",", ":") + ) assert users.total_count == len(users.excluded_users) == 2 @@ -1091,8 +1139,12 @@ def test_list_included_directory_groups_returns_expected_data( c = Client() groups = c.watchlists.v1.list_directory_groups(TEST_WATCHLIST_ID) assert isinstance(groups, IncludedDirectoryGroupsList) - assert groups.included_directory_groups[0].json() == json.dumps(TEST_GROUP_1) - assert groups.included_directory_groups[1].json() == json.dumps(TEST_GROUP_2) + assert groups.included_directory_groups[0].json() == json.dumps( + TEST_GROUP_1, separators=(",", ":") + ) + assert groups.included_directory_groups[1].json() == json.dumps( + TEST_GROUP_2, separators=(",", ":") + ) assert groups.total_count == len(groups.included_directory_groups) == 2 @@ -1105,7 +1157,7 @@ def test_get_directory_group_returns_expected_data(mock_get_directory_group): assert group.added_time == datetime.datetime.fromisoformat( TEST_GROUP_1["addedTime"].replace("Z", "+00:00") ) - assert group.json() == json.dumps(TEST_GROUP_1) + assert group.json() == json.dumps(TEST_GROUP_1, separators=(",", ":")) @valid_ids_param @@ -1138,8 +1190,12 @@ def test_list_included_departments_returns_expected_data(mock_get_all_department c = Client() departments = c.watchlists.v1.list_departments(TEST_WATCHLIST_ID) assert isinstance(departments, IncludedDepartmentsList) - assert departments.included_departments[0].json() == json.dumps(TEST_DEPARTMENT_1) - assert departments.included_departments[1].json() == json.dumps(TEST_DEPARTMENT_2) + assert departments.included_departments[0].json() == json.dumps( + TEST_DEPARTMENT_1, separators=(",", ":") + ) + assert departments.included_departments[1].json() == json.dumps( + TEST_DEPARTMENT_2, separators=(",", ":") + ) assert departments.total_count == len(departments.included_departments) == 2 @@ -1151,7 +1207,7 @@ def test_get_department_returns_expected_data(mock_get_department): assert department.added_time == datetime.datetime.fromisoformat( TEST_DEPARTMENT_1["addedTime"].replace("Z", "+00:00") ) - assert department.json() == json.dumps(TEST_DEPARTMENT_1) + assert department.json() == json.dumps(TEST_DEPARTMENT_1, separators=(",", ":")) @pytest.mark.parametrize( From 0f0a23d93874f9f3af4259f85bb0482529619610 Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Wed, 30 Jul 2025 14:14:24 -0400 Subject: [PATCH 2/8] INTEG-2943 - remove excess comments --- src/_incydr_sdk/queries/file_events.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/_incydr_sdk/queries/file_events.py b/src/_incydr_sdk/queries/file_events.py index e0deb9a7..a4e426c3 100644 --- a/src/_incydr_sdk/queries/file_events.py +++ b/src/_incydr_sdk/queries/file_events.py @@ -122,8 +122,6 @@ class EventQuery(Model): page_token: Optional[str] = Field("", alias="pgToken") sort_dir: str = Field("asc", alias="srtDir") sort_key: EventSearchTerm = Field("event.id", alias="srtKey") - # TODO[pydantic]: The following keys were removed: `json_encoders`. - # Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-config for more information. model_config = ConfigDict( validate_assignment=True, use_enum_values=True, From 8edeab70015df667dd919704e61aba9ecbcb5acf Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Wed, 30 Jul 2025 16:46:01 -0400 Subject: [PATCH 3/8] remove unnecessary dotenv annotation --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f1c1b777..b2bcc547 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "requests", "requests-toolbelt", "rich", - "pydantic[dotenv]>=2.11", + "pydantic>=2.11", "pydantic-settings", "isodate", "python-dateutil", From 55bb9b9b7910787f408e0e67e6301ebe95345eea Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Mon, 4 Aug 2025 13:46:48 -0400 Subject: [PATCH 4/8] INTEG-2943: add test for raising on validation failure --- tests/test_file_events.py | 229 +++++++++++++++++++++++++++++++++++++- 1 file changed, 228 insertions(+), 1 deletion(-) diff --git a/tests/test_file_events.py b/tests/test_file_events.py index 9dd1cc4a..7bf1aa4a 100644 --- a/tests/test_file_events.py +++ b/tests/test_file_events.py @@ -6,6 +6,8 @@ import pytest from pytest_httpserver import HTTPServer +from pydantic import ValidationError + from _incydr_cli.cmds.options.output_options import TableFormat from _incydr_cli.cursor import CursorStore @@ -564,6 +566,222 @@ TEST_SAVED_SEARCH_ID = "saved-search-1" +TEST_BAD_EVENT_JSON = """{ + "fileEvents": [ + { + "@timestamp": "2025-08-04T01:01:01.081Z", + "event": { + "id": "example_id", + "inserted": "2025-08-04T01:01:01.816033973Z", + "action": "removable-media-created", + "observer": "Endpoint", + "detectorDisplayName": null, + "shareType": [], + "ingested": "2025-08-04T01:01:01.366Z", + "vector": "REMOVABLE_MEDIA_OUT", + "xfcEventId": "exampleid" + }, + "user": { + "email": "example@example.com", + "id": "12345678", + "deviceUid": "1234565", + "actorHour": "12345678/2025-08-04T09:00:00Z", + "department": "12345", + "groups": [ + { + "id": "12345", + "displayName": "examplegroup" + } + ] + }, + "file": { + "name": "examplename.GIF", + "originalName": "examplename.GIF", + "directory": "D:/exaple/path/on/drive/", + "originalDirectory": "C:/example/path/to/original/", + "category": "Image", + "mimeType": "image/gif", + "mimeTypeByBytes": "image/gif", + "categoryByBytes": null, + "mimeTypeByExtension": "image/gif", + "categoryByExtension": null, + "sizeInBytes": 37757, + "owner":"\Everyone", + "created": "2018-08-14T05:55:09.650Z", + "modified": "2009-02-04T05:49:16Z", + "hash": { + "md5": "da4655be40a207f0ae3bf53c7d255cb9", + "sha256": "2dbc974a038924019344cf44858a863c90f64a3a6c6d2ad24e61d1b019aae9a7", + "md5Error": null, + "sha256Error": null + }, + "id": null, + "url": null, + "directoryId": [], + "cloudDriveId": null, + "classifications": [], + "acquiredFrom": [], + "changeType": "COPIED", + "archiveId": null, + "parentArchiveId": null, + "passwordProtected": null + }, + "report": { + "id": null, + "name": null, + "description": null, + "headers": [], + "count": null, + "type": null + }, + "source": { + "category": "Device", + "name": "example-device", + "user": { + "email": [] + }, + "domain": "example.domain.com", + "ip": "1.2.3.4", + "privateIp": [ + "1.2.3.4" + ], + "operatingSystem": "Windows", + "email": { + "sender": null, + "from": null + }, + "removableMedia": { + "vendor": null, + "name": null, + "serialNumber": null, + "capacity": null, + "busType": null, + "mediaName": null, + "volumeName": [], + "partitionId": [] + }, + "tabs": [], + "accountName": null, + "accountType": null, + "domains": [], + "remoteHostname": null, + "identifiers": null + }, + "destination": { + "category": "Device", + "name": "Removable Media", + "user": { + "email": [], + "emailDomain": [] + }, + "ip": null, + "privateIp": [], + "operatingSystem": null, + "printJobName": null, + "printerName": null, + "printedFilesBackupPath": null, + "removableMedia": { + "vendor": "example vendor", + "name": "example name", + "serialNumber": "exampleserial", + "capacity": 1000204883968, + "busType": "USB", + "mediaName": "example name", + "volumeName": [ + "Kavitha-HDD (D:)" + ], + "partitionId": [ + "exampleid" + ] + }, + "email": { + "recipients": [], + "subject": null + }, + "tabs": [], + "accountName": null, + "accountType": null, + "domains": [], + "remoteHostname": null, + "identifiers": [ + { + "key": "mediaName", + "value": "example name" + }, + { + "key": "serialNumber", + "value": "asdf" + } + ] + }, + "process": { + "executable": "C:/Windows/explorer.exe", + "owner": "exampleowner", + "extension": { + "browser": null, + "version": null, + "loggedInUser": null + } + }, + "risk": { + "score": 3, + "severity": "LOW", + "indicators": [ + { + "name": "Remote", + "id": "Remote", + "weight": 0 + }, + { + "name": "Removable media", + "id": "Removable media", + "weight": 3 + }, + { + "name": "Image", + "id": "Image", + "weight": 0 + } + ], + "activityTier": "Default", + "trusted": false, + "trustReason": null, + "untrustedValues": { + "accountNames": [], + "domains": [], + "gitRepositoryUris": [], + "slackWorkspaces": [], + "urlPaths": [] + } + }, + "git": { + "eventId": null, + "lastCommitHash": null, + "repositoryUri": null, + "repositoryUser": null, + "repositoryEmail": null, + "repositoryEndpointPath": null + }, + "responseControls": { + "preventativeControl": null, + "reason": null, + "userJustification": { + "reason": null, + "text": null + } + }, + "paste": { + "mimeTypes": [], + "totalContentSize": null, + "visibleContentSize": null + } + } + ], + "nextPgToken": "", + "problems": null, + "totalCount": 1 +}""" + @pytest.fixture def mock_get_saved_search(httpserver_auth): @@ -593,7 +811,6 @@ def mock_list_saved_searches(httpserver_auth): "/v2/file-events/saved-searches", method="GET" ).respond_with_json(search_data) - @pytest.mark.parametrize( "query, expected_query", [(TEST_EVENT_QUERY, TEST_DICT_QUERY)], @@ -661,6 +878,16 @@ def test_get_saved_search_returns_expected_data_when_search_has_subgroups( assert isinstance(search, SavedSearch) assert search.json() == TEST_SAVED_SEARCH_3.json() +def test_search_raises_exception_when_bad_event_json(httpserver_auth: HTTPServer): + httpserver_auth.expect_request("/v2/file-events", method="POST").respond_with_data( + TEST_BAD_EVENT_JSON + ) + + client = Client() + query = EventQuery.construct(**TEST_DICT_QUERY) + with pytest.raises(ValidationError): + client.file_events.v2.search(query) + # ************************************************ CLI ************************************************ From 5adc0d934b864e14e4d98fde8541df3cbac509d2 Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Mon, 4 Aug 2025 14:04:09 -0400 Subject: [PATCH 5/8] style --- tests/test_file_events.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_file_events.py b/tests/test_file_events.py index 7bf1aa4a..be58049c 100644 --- a/tests/test_file_events.py +++ b/tests/test_file_events.py @@ -5,9 +5,8 @@ from unittest import mock import pytest -from pytest_httpserver import HTTPServer from pydantic import ValidationError - +from pytest_httpserver import HTTPServer from _incydr_cli.cmds.options.output_options import TableFormat from _incydr_cli.cursor import CursorStore @@ -566,7 +565,7 @@ TEST_SAVED_SEARCH_ID = "saved-search-1" -TEST_BAD_EVENT_JSON = """{ +TEST_BAD_EVENT_JSON = r"""{ "fileEvents": [ { "@timestamp": "2025-08-04T01:01:01.081Z", @@ -811,6 +810,7 @@ def mock_list_saved_searches(httpserver_auth): "/v2/file-events/saved-searches", method="GET" ).respond_with_json(search_data) + @pytest.mark.parametrize( "query, expected_query", [(TEST_EVENT_QUERY, TEST_DICT_QUERY)], @@ -878,6 +878,7 @@ def test_get_saved_search_returns_expected_data_when_search_has_subgroups( assert isinstance(search, SavedSearch) assert search.json() == TEST_SAVED_SEARCH_3.json() + def test_search_raises_exception_when_bad_event_json(httpserver_auth: HTTPServer): httpserver_auth.expect_request("/v2/file-events", method="POST").respond_with_data( TEST_BAD_EVENT_JSON From bee4400b463b24bded3d6cc4d390765119ed9c6f Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Thu, 7 Aug 2025 11:35:45 -0400 Subject: [PATCH 6/8] address review comments --- src/_incydr_sdk/core/models.py | 2 -- src/_incydr_sdk/utils.py | 6 +++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/_incydr_sdk/core/models.py b/src/_incydr_sdk/core/models.py index 04c882b6..5f785fa0 100644 --- a/src/_incydr_sdk/core/models.py +++ b/src/_incydr_sdk/core/models.py @@ -93,8 +93,6 @@ def parse_json_lines(cls, file): f"Unable to parse line {num}. Expecting JSONLines format: https://jsonlines.org" ) - # TODO[pydantic]: The following keys were removed: `json_encoders`. - # Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-config for more information. model_config = ConfigDict( populate_by_name=True, use_enum_values=True, diff --git a/src/_incydr_sdk/utils.py b/src/_incydr_sdk/utils.py index 58fa0a63..093988e3 100644 --- a/src/_incydr_sdk/utils.py +++ b/src/_incydr_sdk/utils.py @@ -182,7 +182,7 @@ class Parent(BaseModel): model = type(model) for name, field in model.model_fields.items(): model_field_type = _get_model_type(field.annotation) - if _is_singleton(field.annotation) and issubclass(model_field_type, BaseModel): + if _is_single(field.annotation) and issubclass(model_field_type, BaseModel): for child_name in flatten_fields(model_field_type): yield f"{name}.{child_name}" else: @@ -228,12 +228,12 @@ def get_fields( ) -def _is_singleton(type) -> bool: +def _is_single(type) -> bool: """Returns `true` if the given type is a single object (for example, Union[int, str]); returns false if it is a list (e.g. Union[List[int], List[str]])""" origin = get_origin(type) if get_origin(type) else type if origin == Union: - return all([_is_singleton(item) for item in get_args(type)]) + return all([_is_single(item) for item in get_args(type)]) if origin in (list, tuple, set): return False return True From a2b310627fe5daa79b6d51adf18020ef6cd324e5 Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Thu, 7 Aug 2025 11:36:44 -0400 Subject: [PATCH 7/8] tests --- tests/test_utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 00cdaff8..7822ecaf 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -9,7 +9,7 @@ from _incydr_sdk.queries.utils import parse_str_to_dt from _incydr_sdk.utils import _get_model_type -from _incydr_sdk.utils import _is_singleton +from _incydr_sdk.utils import _is_single from _incydr_sdk.utils import flatten_fields from _incydr_sdk.utils import get_field_value_and_info from _incydr_sdk.utils import get_fields @@ -247,8 +247,8 @@ def test_parse_str_to_dt(ts_str, expected): (tuple, False), ], ) -def test_is_singleton(type, expected): - assert _is_singleton(type) == expected +def test_is_single(type, expected): + assert _is_single(type) == expected @pytest.mark.parametrize( From 9a225a32840bf00cd1f3794dd590e117c9dce9bf Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Mon, 11 Aug 2025 14:19:26 -0400 Subject: [PATCH 8/8] changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 006498bb..cba9c58f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ how a consumer would use the library or CLI tool (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Updated +- The Incydr SDK and CLI now rely on Pydantic v2, instead of previously when they used v1. This means that the methods available on the models accepted and returned by many SDK methods have changed in some small ways. For most SDK and CLI workflows, no changes will need to be made to accommodate this upgrade. Details of the transition may be found [in Pydantic's documentation](https://docs.pydantic.dev/dev/migration/). + ## 2.6.0 - 2025-07-23 ### Added