From 7ff0d6fddb333dd973a827805c0c0cdcded220c7 Mon Sep 17 00:00:00 2001 From: Max Nanis Date: Mon, 17 Aug 2026 02:54:18 -0700 Subject: Ruff p2 --- generalresearch/models/legacy/api_status.py | 4 +- generalresearch/models/legacy/bucket.py | 112 ++++++++------- generalresearch/models/legacy/offerwall.py | 40 +++--- generalresearch/models/legacy/questions.py | 30 ++-- generalresearch/models/lucid/__init__.py | 1 - generalresearch/models/lucid/question.py | 6 +- generalresearch/models/lucid/survey.py | 18 +-- generalresearch/models/marketplace/summary.py | 21 +-- generalresearch/models/morning/question.py | 2 +- generalresearch/models/morning/task_collection.py | 12 +- generalresearch/models/network/definitions.py | 5 +- generalresearch/models/network/label.py | 15 +- generalresearch/models/network/mtr/command.py | 13 +- generalresearch/models/network/mtr/execute.py | 14 +- generalresearch/models/network/mtr/parser.py | 3 +- generalresearch/models/network/mtr/result.py | 25 ++-- generalresearch/models/network/nmap/command.py | 7 +- generalresearch/models/network/nmap/execute.py | 11 +- generalresearch/models/network/nmap/parser.py | 50 +++---- generalresearch/models/network/nmap/result.py | 110 +++++++-------- generalresearch/models/network/rdns/execute.py | 14 +- generalresearch/models/network/rdns/parser.py | 5 +- generalresearch/models/network/rdns/result.py | 11 +- generalresearch/models/network/tool_run.py | 14 +- generalresearch/models/network/tool_run_command.py | 12 +- generalresearch/models/pollfish/question.py | 12 +- generalresearch/models/precision/definitions.py | 3 +- generalresearch/models/precision/question.py | 22 +-- generalresearch/models/prodege/question.py | 24 ++-- generalresearch/models/prodege/survey.py | 92 ++++++------ generalresearch/models/prodege/task_collection.py | 4 +- generalresearch/models/repdata/question.py | 26 ++-- generalresearch/models/repdata/survey.py | 60 ++++---- generalresearch/models/repdata/task_collection.py | 12 +- generalresearch/models/sago/question.py | 30 ++-- generalresearch/models/sago/survey.py | 74 +++++----- generalresearch/models/sago/task_collection.py | 16 ++- generalresearch/models/spectrum/question.py | 32 ++--- generalresearch/models/spectrum/survey.py | 82 +++++------ generalresearch/models/spectrum/task_collection.py | 14 +- generalresearch/models/thl/__init__.py | 5 +- generalresearch/models/thl/category.py | 16 ++- generalresearch/models/thl/contest/__init__.py | 31 ++--- generalresearch/models/thl/contest/contest.py | 33 ++--- .../models/thl/contest/contest_entry.py | 9 +- generalresearch/models/thl/contest/examples.py | 24 ++-- generalresearch/models/thl/contest/io.py | 7 +- generalresearch/models/thl/contest/leaderboard.py | 20 +-- generalresearch/models/thl/contest/milestone.py | 22 +-- generalresearch/models/thl/contest/raffle.py | 34 ++--- generalresearch/models/thl/contest/utils.py | 8 +- generalresearch/models/thl/demographics.py | 8 +- generalresearch/models/thl/finance.py | 22 +-- generalresearch/models/thl/ipinfo.py | 91 ++++++------ generalresearch/models/thl/leaderboard.py | 6 +- generalresearch/models/thl/ledger.py | 52 +++---- generalresearch/models/thl/ledger_example.py | 12 +- generalresearch/models/thl/locales.py | 12 +- generalresearch/models/thl/maxmind/__init__.py | 0 generalresearch/models/thl/maxmind/definitions.py | 22 --- generalresearch/models/thl/offerwall/__init__.py | 34 ++--- generalresearch/models/thl/offerwall/base.py | 62 ++++----- generalresearch/models/thl/offerwall/behavior.py | 6 +- generalresearch/models/thl/offerwall/bucket.py | 7 +- generalresearch/models/thl/offerwall/cache.py | 18 +-- generalresearch/models/thl/pagination.py | 9 +- generalresearch/models/thl/payout.py | 46 +++--- generalresearch/models/thl/payout_format.py | 2 + generalresearch/models/thl/product.py | 135 ++++++++---------- .../models/thl/profiling/marketplace.py | 18 +-- generalresearch/models/thl/profiling/question.py | 10 +- .../models/thl/profiling/upk_property.py | 15 +- .../models/thl/profiling/upk_question.py | 80 +++++------ .../models/thl/profiling/upk_question_answer.py | 20 +-- generalresearch/models/thl/profiling/user_info.py | 20 +-- .../models/thl/profiling/user_question_answer.py | 24 ++-- generalresearch/models/thl/report_task.py | 8 +- generalresearch/models/thl/session.py | 154 ++++++++++----------- generalresearch/models/thl/soft_pair.py | 9 +- generalresearch/models/thl/stats.py | 4 +- generalresearch/models/thl/survey/__init__.py | 28 ++-- generalresearch/models/thl/survey/buyer.py | 16 ++- generalresearch/models/thl/survey/condition.py | 26 ++-- generalresearch/models/thl/survey/model.py | 68 ++++----- generalresearch/models/thl/survey/penalty.py | 6 +- .../models/thl/survey/task_collection.py | 5 +- .../models/thl/synchronize_global_vars.py | 4 +- generalresearch/models/thl/task_adjustment.py | 7 +- generalresearch/models/thl/task_status.py | 44 +++--- generalresearch/models/thl/user.py | 30 ++-- generalresearch/models/thl/user_iphistory.py | 36 +++-- generalresearch/models/thl/user_profile.py | 22 +-- generalresearch/models/thl/user_quality_event.py | 24 ++-- generalresearch/models/thl/user_streak.py | 17 ++- generalresearch/models/thl/userhealth.py | 2 + .../models/thl/wallet/cashout_method.py | 66 ++++----- generalresearch/models/thl/wallet/payout.py | 31 +++-- 97 files changed, 1306 insertions(+), 1299 deletions(-) delete mode 100644 generalresearch/models/thl/maxmind/__init__.py delete mode 100644 generalresearch/models/thl/maxmind/definitions.py diff --git a/generalresearch/models/legacy/api_status.py b/generalresearch/models/legacy/api_status.py index 8241eca..0422191 100644 --- a/generalresearch/models/legacy/api_status.py +++ b/generalresearch/models/legacy/api_status.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Literal, Optional +from typing import Literal from pydantic import BaseModel, Field @@ -14,7 +14,7 @@ class StatusResponse(BaseModel): status: Literal["success", "error"] = Field( description="The status of the API response.", examples=["success"] ) - msg: Optional[str] = Field( + msg: str | None = Field( description="An optional message, if the status is error.", examples=[""], default=None, diff --git a/generalresearch/models/legacy/bucket.py b/generalresearch/models/legacy/bucket.py index 8ce559b..52cbacf 100644 --- a/generalresearch/models/legacy/bucket.py +++ b/generalresearch/models/legacy/bucket.py @@ -4,7 +4,7 @@ import logging import math from datetime import timedelta from decimal import Decimal -from typing import Any, Dict, List, Literal, Optional, Tuple, Union +from typing import Any, Literal from pydantic import ( BaseModel, @@ -57,9 +57,9 @@ class CategoryAssociation(BaseModel): examples=["People & Society"], ) - adwords_id: Optional[str] = Field(default=None, max_length=8, examples=["14"]) + adwords_id: str | None = Field(default=None, max_length=8, examples=["14"]) - adwords_label: Optional[str] = Field( + adwords_label: str | None = Field( default=None, max_length=255, examples=["People & Society"] ) @@ -151,7 +151,7 @@ class BucketBase(BaseModel): def censor(self): if not hasattr(self, "contents"): return - contents: List[BucketTask] = self.contents + contents: list[BucketTask] = self.contents for content in contents: content.censor() @@ -171,41 +171,41 @@ class Bucket(BaseModel): arbitrary_types_allowed=True, ) - name: Optional[str] = Field(default=None) - description: Optional[str] = Field(default=None) + name: str | None = Field(default=None) + description: str | None = Field(default=None) # pydantic serializes this to seconds - loi_min: Optional[timedelta] = Field(strict=True, default=None) - loi_max: Optional[timedelta] = Field(strict=True, default=None) - loi_mean: Optional[timedelta] = Field(strict=True, default=None) - loi_q1: Optional[timedelta] = Field(strict=True, default=None) - loi_q2: Optional[timedelta] = Field(strict=True, default=None) - loi_q3: Optional[timedelta] = Field(strict=True, default=None) + loi_min: timedelta | None = Field(strict=True, default=None) + loi_max: timedelta | None = Field(strict=True, default=None) + loi_mean: timedelta | None = Field(strict=True, default=None) + loi_q1: timedelta | None = Field(strict=True, default=None) + loi_q2: timedelta | None = Field(strict=True, default=None) + loi_q3: timedelta | None = Field(strict=True, default=None) # Decimal USD. This should not have more than 2 decimal places. # There is no way to make this "strict" and optional, so # we have a separate pre-validator - user_payout_min: Optional[Decimal] = Field(default=None, lt=1000, gt=0) - user_payout_max: Optional[Decimal] = Field(default=None, lt=1000, gt=0) - user_payout_q1: Optional[Decimal] = Field(default=None, lt=1000, gt=0) - user_payout_q2: Optional[Decimal] = Field(default=None, lt=1000, gt=0) - user_payout_q3: Optional[Decimal] = Field(default=None, lt=1000, gt=0) - user_payout_mean: Optional[Decimal] = Field(default=None, lt=1000, gt=0) + user_payout_min: Decimal | None = Field(default=None, lt=1000, gt=0) + user_payout_max: Decimal | None = Field(default=None, lt=1000, gt=0) + user_payout_q1: Decimal | None = Field(default=None, lt=1000, gt=0) + user_payout_q2: Decimal | None = Field(default=None, lt=1000, gt=0) + user_payout_q3: Decimal | None = Field(default=None, lt=1000, gt=0) + user_payout_mean: Decimal | None = Field(default=None, lt=1000, gt=0) - quality_score: Optional[float] = Field(default=None) + quality_score: float | None = Field(default=None) - category: List[CategoryAssociation] = Field(default_factory=list) + category: CategoryAssociation | None = Field(default_factory=list) - contents: Optional[List[BucketTask]] = Field(default=None) + contents: list[BucketTask] | None = Field(default=None) # This could store things like "is_recontact=False" - metadata: Dict[str, Union[str, float, bool, int]] = Field(default_factory=dict) + metadata: dict[str, str | float | bool | int] = Field(default_factory=dict) - eligibility_criteria: Optional[Tuple[SurveyEligibilityCriterion, ...]] = Field( + eligibility_criteria: tuple[SurveyEligibilityCriterion, ...] | None = Field( description="The reasons the user is eligible for tasks in this bucket", default=None, ) - eligibility_explanation: Optional[str] = Field( + eligibility_explanation: str | None = Field( default=None, description="Human-readable text explaining a user's eligibility for tasks in this bucket", examples=[ @@ -300,12 +300,12 @@ class Bucket(BaseModel): @field_validator("category") @classmethod - def check_category(cls, v: List[CategoryAssociation]) -> List[CategoryAssociation]: + def check_category(cls, v: list[CategoryAssociation]) -> list[CategoryAssociation]: assert sum(c.p for c in v) == 1, "sum of category score must be 1" return v @classmethod - def parse_from_offerwall(cls, bucket: Dict): + def parse_from_offerwall(cls, bucket: dict): """ This isn't really consistent across all offerwalls... Handle three cases: Could be {'payout': {'min': 123}}, or {'min_payout': 123} or {'payout': 123} @@ -324,7 +324,7 @@ class Bucket(BaseModel): return cls() @classmethod - def parse_from_offerwall_style1(cls, bucket: Dict): + def parse_from_offerwall_style1(cls, bucket: dict): # {'min_payout': 123} return cls( user_payout_min=cls.usd_cents_to_decimal(bucket["min_payout"]), @@ -360,7 +360,7 @@ class Bucket(BaseModel): ) @classmethod - def parse_from_offerwall_style2(cls, bucket: Dict[str, Any]): + def parse_from_offerwall_style2(cls, bucket: dict[str, Any]): # {'payout': {'min': 123}} loi_min_sec = bucket.get("duration", {}).get("min") loi_max_sec = bucket.get("duration", {}).get("max") @@ -385,7 +385,7 @@ class Bucket(BaseModel): ) @classmethod - def parse_from_offerwall_style3(cls, bucket: Dict[str, Any]): + def parse_from_offerwall_style3(cls, bucket: dict[str, Any]): # {'payout': 123, 'duration': 123} return cls( user_payout_min=cls.usd_cents_to_decimal(bucket["payout"]), @@ -399,13 +399,13 @@ class Bucket(BaseModel): ) @staticmethod - def usd_cents_to_decimal(v: Optional[int]) -> Optional[Decimal]: + def usd_cents_to_decimal(v: int | None) -> Decimal | None: if v is None: return None return Decimal(Decimal(int(v)) / Decimal(100)) @staticmethod - def decimal_to_usd_cents(d: Optional[Decimal]) -> Optional[Decimal]: + def decimal_to_usd_cents(d: Decimal | None) -> Decimal | None: if d is None: return None return round(d * Decimal(100), 2) @@ -421,7 +421,7 @@ class DurationSummary(StatisticalSummary): q1: int = Field(gt=0, le=60 * 90) q2: int = Field(gt=0, le=60 * 90) q3: int = Field(gt=0, le=60 * 90) - mean: Optional[int] = Field(gt=0, le=60 * 90, default=None) + mean: int | None = Field(gt=0, le=60 * 90, default=None) model_config = { "json_schema_extra": { @@ -460,7 +460,7 @@ class PayoutSummaryDecimal(StatisticalSummary): q1: Decimal = Field(gt=0, le=100) q2: Decimal = Field(gt=0, le=100) q3: Decimal = Field(gt=0, le=100) - mean: Optional[Decimal] = Field(gt=0, le=100, default=None) + mean: Decimal | None = Field(gt=0, le=100, default=None) class PayoutSummary(StatisticalSummary): @@ -471,7 +471,7 @@ class PayoutSummary(StatisticalSummary): q1: int = Field(gt=0, le=10000) q2: int = Field(gt=0, le=10000) q3: int = Field(gt=0, le=10000) - mean: Optional[int] = Field(gt=0, le=10000, default=None) + mean: int | None = Field(gt=0, le=10000, default=None) model_config = { "json_schema_extra": { @@ -514,45 +514,43 @@ class SurveyEligibilityCriterion(BaseModel): model_config = ConfigDict(validate_assignment=True) - question_id: Optional[UUIDStr] = Field( - examples=["71a367fb71b243dc89f0012e0ec91749"] - ) - property_code: Optional[PropertyCode] = Field(examples=["c:73629"]) + question_id: UUIDStr | None = Field(examples=["71a367fb71b243dc89f0012e0ec91749"]) + property_code: PropertyCode | None = Field(examples=["c:73629"]) question_text: str = Field( examples=[ "What company administers the retirement plan for your current employer?" ] ) # The answer(s) that were considered qualifying - qualifying_answer: Tuple[str, ...] = Field( + qualifying_answer: tuple[str, ...] = Field( description="User answer(s) that satisfied at least one eligibility rule", examples=["121"], ) - qualifying_answer_label: Optional[Tuple[str, ...]] = Field( + qualifying_answer_label: tuple[str, ...] | None = Field( examples=["Fidelity Investments"] ) - explanation: Optional[str] = Field( + explanation: str | None = Field( default=None, description="Human-readable text explaining how a user's answer to this question affects eligibility", examples=[ "The company that administers your employer's retirement plan is **Fidelity Investments**." ], ) - explanation_fragment: Optional[str] = Field( + explanation_fragment: str | None = Field( default=None, exclude=True, description="For internal use", examples=["who's retirement plan is administered by **Fidelity Investments**"], ) # Rank more "interesting"/rare/salient criterion first. - rank: Optional[NonNegativeInt] = Field( + rank: NonNegativeInt | None = Field( default=None, description="Lower values are shown more prominently in the UI", ) class TopNBucket(BucketBase): - category: List[CategoryAssociation] = Field(default_factory=list) + category: list[CategoryAssociation] = Field(default_factory=list) duration: DurationSummary = Field() payout: PayoutSummary = Field() quality_score: float = Field( @@ -586,8 +584,8 @@ class SingleEntryBucket(BucketBase): class TopNPlusBucket(BucketBase): - category: List[CategoryAssociation] = Field(default_factory=list) - contents: List[BucketTask] = Field() + category: list[CategoryAssociation] = Field(default_factory=list) + contents: list[BucketTask] = Field() duration: DurationSummary = Field() payout: PayoutSummary = Field() quality_score: float = Field() @@ -595,11 +593,11 @@ class TopNPlusBucket(BucketBase): description="This will always be 'USD'", default="USD", examples=["USD"] ) - eligibility_criteria: Tuple[SurveyEligibilityCriterion, ...] = Field( + eligibility_criteria: tuple[SurveyEligibilityCriterion, ...] = Field( description="The reasons the user is eligible for tasks in this bucket", default_factory=tuple, ) - eligibility_explanation: Optional[str] = Field( + eligibility_explanation: str | None = Field( default=None, description="Human-readable text explaining a user's eligibility for tasks in this bucket", examples=[ @@ -643,8 +641,8 @@ class TopNPlusBucket(BucketBase): class TopNPlusRecontactBucket(BucketBase): - category: List[CategoryAssociation] = Field(default_factory=list) - contents: List[BucketTask] = Field() + category: list[CategoryAssociation] = Field(default_factory=list) + contents: list[BucketTask] = Field() duration: DurationSummary = Field() payout: PayoutSummary = Field() quality_score: float = Field() @@ -670,17 +668,17 @@ class TopNPlusRecontactBucket(BucketBase): class SoftPairBucket(BucketBase): - uri: Optional[HttpsUrl] = Field( + uri: HttpsUrl | None = Field( examples=[None], description="The URL to send a respondent into. Must not edit this URL in any way. If the eligibility is " "conditional or ineligible, the uri will be null.", ) - category: List[CategoryAssociation] = Field(default_factory=list) - contents: List[BucketTask] = Field() + category: list[CategoryAssociation] = Field(default_factory=list) + contents: list[BucketTask] = Field() eligibility: Eligibility = Field(examples=["conditional"]) - missing_questions: List[str] = Field( + missing_questions: list[str] = Field( default_factory=list, examples=[["fb20fd4773304500b39c4f6de0012a5a"]] ) loi: int = Field(description="this is the max loi of the contents", examples=[612]) @@ -695,8 +693,8 @@ class SoftPairBucket(BucketBase): class MarketplaceBucket(BucketBase): - category: List[CategoryAssociation] = Field(default_factory=list) - contents: List[BucketTask] = Field() + category: list[CategoryAssociation] = Field(default_factory=list) + contents: list[BucketTask] = Field() duration: DurationSummary = Field() payout: PayoutSummary = Field() source: SourceName = Field( @@ -744,7 +742,7 @@ class OneShotOfferwallBucket(BaseModel): class OneShotSoftPairOfferwallBucket(OneShotOfferwallBucket): eligibility: Eligibility = Field(examples=["conditional"]) - missing_questions: List[str] = Field( + missing_questions: list[str] = Field( default_factory=list, examples=[["fb20fd4773304500b39c4f6de0012a5a"]] ) diff --git a/generalresearch/models/legacy/offerwall.py b/generalresearch/models/legacy/offerwall.py index a8efe9a..0e54387 100644 --- a/generalresearch/models/legacy/offerwall.py +++ b/generalresearch/models/legacy/offerwall.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import Dict, List - from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt from generalresearch.models.custom_types import UUIDStr @@ -70,9 +68,9 @@ class OfferWall(BaseModel): default=0, ) - buckets: List[BucketBase] = Field(default_factory=list) + buckets: list[BucketBase] = Field(default_factory=list) - offerwall_reasons: List[OfferwallReason] = Field( + offerwall_reasons: list[OfferwallReason] = Field( default_factory=list, description=( "Explanations describing why so many or few opportunities are available." @@ -92,7 +90,7 @@ class SingleEntryOfferWall(OfferWall): """ payout_format: PayoutFormatType = PayoutFormatField - buckets: List[SingleEntryBucket] = Field(default_factory=list, max_length=1) + buckets: list[SingleEntryBucket] = Field(default_factory=list, max_length=1) class TopNOfferWall(OfferWall): @@ -102,7 +100,7 @@ class TopNOfferWall(OfferWall): Offerwall code: `45b7228a7` """ - buckets: List[TopNBucket] = Field(default_factory=list) + buckets: list[TopNBucket] = Field(default_factory=list) payout_format: PayoutFormatType = PayoutFormatField @@ -114,7 +112,7 @@ class StarwallOfferWall(OfferWall): Offerwall code: `b59a2d2b` """ - buckets: List[TopNBucket] = Field(default_factory=list) + buckets: list[TopNBucket] = Field(default_factory=list) payout_format: PayoutFormatType = PayoutFormatField @@ -124,7 +122,7 @@ class TopNPlusOfferWall(OfferWall): Offerwall code: `b145b803` """ - buckets: List[TopNPlusBucket] = Field(default_factory=list) + buckets: list[TopNPlusBucket] = Field(default_factory=list) class TopNPlusBlockOfferWall(OfferWall): @@ -134,7 +132,7 @@ class TopNPlusBlockOfferWall(OfferWall): Offerwall code: `d48cce47` """ - buckets: List[TopNPlusBucket] = Field(default_factory=list) + buckets: list[TopNPlusBucket] = Field(default_factory=list) # This incorrectly gets returned only when the user is blocked. It # shouldn't get returned at all @@ -149,7 +147,7 @@ class TopNPlusBlockRecontactOfferWall(OfferWall): Offerwall code: `1e5f0af8` """ - buckets: List[TopNPlusRecontactBucket] = Field(default_factory=list) + buckets: list[TopNPlusRecontactBucket] = Field(default_factory=list) # This incorrectly gets returned only when the user is blocked. It # shouldn't get returned at all @@ -162,7 +160,7 @@ class StarwallPlusOfferWall(OfferWall): Offerwall code: `5481f322` """ - buckets: List[TopNPlusBucket] = Field(default_factory=list) + buckets: list[TopNPlusBucket] = Field(default_factory=list) class StarwallPlusBlockOfferWall(OfferWall): @@ -172,7 +170,7 @@ class StarwallPlusBlockOfferWall(OfferWall): Offerwall code: `7fa1b3f4` """ - buckets: List[TopNPlusBucket] = Field(default_factory=list) + buckets: list[TopNPlusBucket] = Field(default_factory=list) # This incorrectly gets returned only when the user is blocked. It # shouldn't get returned at all @@ -187,7 +185,7 @@ class StarwallPlusBlockRecontactOfferWall(OfferWall): Offerwall code: `630db2a4` """ - buckets: List[TopNPlusRecontactBucket] = Field(default_factory=list) + buckets: list[TopNPlusRecontactBucket] = Field(default_factory=list) # This incorrectly gets returned only when the user is blocked. It # shouldn't get returned at all @@ -203,9 +201,9 @@ class SoftPairOfferwall(OfferWall): Offerwall code: `37d1da64` """ - buckets: List[SoftPairBucket] = Field(default_factory=list) + buckets: list[SoftPairBucket] = Field(default_factory=list) - question_info: Dict[str, "UpkQuestion"] = Field( + question_info: dict[str, "UpkQuestion"] = Field( default_factory=dict, examples=[ # { @@ -228,7 +226,7 @@ class MarketplaceOfferwall(OfferWall): Offerwall code: `5fa23085` """ - buckets: List[MarketplaceBucket] = Field(default_factory=list) + buckets: list[MarketplaceBucket] = Field(default_factory=list) class TimeBucksOfferwall(OfferWall): @@ -242,7 +240,7 @@ class TimeBucksOfferwall(OfferWall): Offerwall code: `1705e4f8` """ - buckets: List[TimeBucksBucket] = Field(default_factory=list) + buckets: list[TimeBucksBucket] = Field(default_factory=list) class TimeBucksBlockOfferwall(OfferWall): @@ -252,7 +250,7 @@ class TimeBucksBlockOfferwall(OfferWall): Offerwall code: `0af0f7ec` """ - buckets: List[TimeBucksBucket] = Field(default_factory=list) + buckets: list[TimeBucksBucket] = Field(default_factory=list) # This incorrectly gets returned only when the user is blocked. It shouldn't get returned at all payout_format: str = Field(exclude=True, default="") @@ -264,7 +262,7 @@ class OneShotOfferwall(OfferWall): Offerwall code: `6f27b1ae` """ - buckets: List[OneShotOfferwallBucket] = Field(default_factory=list) + buckets: list[OneShotOfferwallBucket] = Field(default_factory=list) class OneShotSoftPairOfferwall(SoftPairOfferwall): @@ -274,7 +272,7 @@ class OneShotSoftPairOfferwall(SoftPairOfferwall): Offerwall code: `18347426` """ - buckets: List[OneShotSoftPairOfferwallBucket] = Field(default_factory=list) + buckets: list[OneShotSoftPairOfferwallBucket] = Field(default_factory=list) class WXETOfferwall(OfferWall): @@ -282,7 +280,7 @@ class WXETOfferwall(OfferWall): Offerwall code: `55a4e1a9` """ - buckets: List[WXETOfferwallBucket] = Field(default_factory=list) + buckets: list[WXETOfferwallBucket] = Field(default_factory=list) class SingleEntryOfferWallResponse(OfferWallResponse): diff --git a/generalresearch/models/legacy/questions.py b/generalresearch/models/legacy/questions.py index 1a86121..04891ae 100644 --- a/generalresearch/models/legacy/questions.py +++ b/generalresearch/models/legacy/questions.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any from pydantic import ( BaseModel, @@ -32,11 +32,11 @@ if TYPE_CHECKING: class UpkQuestionResponse(StatusResponse): - questions: List[UpkQuestionOut] = Field() - consent_questions: List[Dict[str, Any]] = Field( + questions: list[UpkQuestionOut] = Field() + consent_questions: list[dict[str, Any]] = Field( description="For internal use", default_factory=list ) - special_questions: List[Dict[str, Any]] = Field( + special_questions: list[dict[str, Any]] = Field( description="For internal use", default_factory=list ) count: NonNegativeInt = Field(description="The number of questions returned") @@ -71,7 +71,7 @@ class UserQuestionAnswerIn(BaseModel): question_id: UUIDStr = Field(examples=["fb20fd4773304500b39c4f6de0012a5a"]) - answer: List[AnswerStr] = Field( + answer: list[AnswerStr] = Field( min_length=1, max_length=10, description="The user's answers to this question. Must pass the " @@ -113,7 +113,7 @@ class UserQuestionAnswerIn(BaseModel): @field_validator("answer", mode="after") @classmethod - def no_duplicate_answer_values(cls, v: List[AnswerStr]) -> List[AnswerStr]: + def no_duplicate_answer_values(cls, v: list[AnswerStr]) -> list[AnswerStr]: if len(v) != len(set(v)): raise ValueError("Don't provide duplicate answers") @@ -121,7 +121,7 @@ class UserQuestionAnswerIn(BaseModel): @field_validator("answer", mode="after") @classmethod - def sort_answer_values(cls, v: List[AnswerStr]) -> List[AnswerStr]: + def sort_answer_values(cls, v: list[AnswerStr]) -> list[AnswerStr]: return sorted(v) # --- Properties --- @@ -162,7 +162,7 @@ class UserQuestionAnswers(BaseModel): # POST /profiling-questions/ that they could use a randomly generated # session_id... I'm not sure, but it's entirely possible this will start # to cause issues in production. - session_id: Optional[UUIDStr] = Field( + session_id: UUIDStr | None = Field( default=None, description="The Session ID corresponds to the Wall.uuid. If profiling" "answers are being submitted directly, this can be None.", @@ -170,7 +170,7 @@ class UserQuestionAnswers(BaseModel): # We don't apply a default_factory here because there is no valid reason # why a GRS submission would come valid without any answers. - answers: Annotated[List[UserQuestionAnswerIn], BeforeValidator(preflight)] = Field( + answers: Annotated[list[UserQuestionAnswerIn], BeforeValidator(preflight)] = Field( min_length=1, max_length=100, description="The list of questions and their answers that are being" @@ -178,8 +178,8 @@ class UserQuestionAnswers(BaseModel): "(if via FSB).", ) - user: Optional[User] = Field(default=None) - wall: Optional[Wall] = Field(default=None) + user: User | None = Field(default=None) + wall: Wall | None = Field(default=None) # --- Validation --- @@ -209,7 +209,7 @@ class UserQuestionAnswers(BaseModel): @field_validator("answers", mode="after") @classmethod - def no_duplicate_questions(cls, v: List[UserQuestionAnswerIn]): + def no_duplicate_questions(cls, v: list[UserQuestionAnswerIn]): answer_qids = [qa.question_id for qa in v] if len(answer_qids) != len(set(answer_qids)): raise ValueError("Don't provide answers to duplicate questions") @@ -220,7 +220,7 @@ class UserQuestionAnswers(BaseModel): def prefetch_user(self, um: "UserManager") -> None: from generalresearch.models.thl.user import User - res: Optional[User] = um.get_user_if_exists( + res: User | None = um.get_user_if_exists( product_id=self.product_id, product_user_id=self.product_user_id ) @@ -228,13 +228,12 @@ class UserQuestionAnswers(BaseModel): raise ValidationError("Invalid user") self.user = res - return None def prefetch_wall(self, wm: "WallManager") -> None: from generalresearch.models import Source from generalresearch.models.thl.session import Wall - res: Optional[Wall] = wm.get_from_uuid_if_exists(wall_uuid=self.session_id) + res: Wall | None = wm.get_from_uuid_if_exists(wall_uuid=self.session_id) if res is None: raise ValueError("Invalid Event for session_id") @@ -252,4 +251,3 @@ class UserQuestionAnswers(BaseModel): raise ValueError("Not a valid GRS event status") self.wall = res - return None diff --git a/generalresearch/models/lucid/__init__.py b/generalresearch/models/lucid/__init__.py index 84a210d..c3365db 100644 --- a/generalresearch/models/lucid/__init__.py +++ b/generalresearch/models/lucid/__init__.py @@ -1,5 +1,4 @@ from pydantic import Field - from typing_extensions import Annotated LucidQuestionIdType = Annotated[ diff --git a/generalresearch/models/lucid/question.py b/generalresearch/models/lucid/question.py index 8542156..01c7859 100644 --- a/generalresearch/models/lucid/question.py +++ b/generalresearch/models/lucid/question.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging from enum import Enum -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional +from typing import TYPE_CHECKING, Any, Literal from pydantic import BaseModel, Field, field_validator, model_validator from typing_extensions import Self @@ -64,7 +64,7 @@ class LucidQuestion(MarketplaceQuestion): question_type: LucidQuestionType = Field( description="The type of question asked", frozen=True ) - options: Optional[List[LucidQuestionOption]] = Field( + options: list[LucidQuestionOption] | None = Field( default=None, min_length=1, frozen=True ) @@ -94,7 +94,7 @@ class LucidQuestion(MarketplaceQuestion): return options @classmethod - def from_db(cls, d: Dict[str, Any]) -> Self: + def from_db(cls, d: dict[str, Any]) -> Self: options = None if d["options"]: options = [ diff --git a/generalresearch/models/lucid/survey.py b/generalresearch/models/lucid/survey.py index 0e01243..4b1bb98 100644 --- a/generalresearch/models/lucid/survey.py +++ b/generalresearch/models/lucid/survey.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional, Self, Set, Tuple +from typing import Any, Self from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt @@ -23,7 +23,7 @@ class LucidCondition(MarketplaceCondition): id: BigAutoInteger = Field() source: Source = Field(default=Source.LUCID) - question_id: Optional[CoercedStr] = Field( + question_id: CoercedStr | None = Field( min_length=1, max_length=16, pattern=r"^[0-9]+$", @@ -41,7 +41,7 @@ class LucidCondition(MarketplaceCondition): return hash(self.id) @classmethod - def from_mysql(cls, x: Dict[str, Any]) -> Self: + def from_mysql(cls, x: dict[str, Any]) -> Self: x["value_type"] = ConditionValueType.LIST x["negate"] = False x["values"] = x.pop("pre_codes").split("|") @@ -58,11 +58,11 @@ class LucidQuota(BaseModel): id: BigAutoInteger = Field() uuid: UUIDStr = Field() upper_limit: NonNegativeInt = Field(examples=[20]) - criteria: List[int] = Field(min_length=1, max_length=25) + criteria: list[int] = Field(min_length=1, max_length=25) modified: AwareDatetimeISO = Field(description="modified or created") # We'll look this up with a special mysql query. If None, it means # that we don't know. - finish_count: Optional[int] = Field(default=None) + finish_count: int | None = Field(default=None) def __hash__(self): return hash(self.id) @@ -71,11 +71,11 @@ class LucidQuota(BaseModel): def is_open(self) -> bool: return self.upper_limit > self.finish_count - def passes(self, criteria_evaluation: Dict[int, Optional[bool]]) -> bool: + def passes(self, criteria_evaluation: dict[int, bool | None]) -> bool: # Passes means we 1) meet all conditions (aka "match") AND 2) the quota is open. return self.is_open and self.matches(criteria_evaluation) - def matches(self, criteria_evaluation: Dict[int, Optional[bool]]) -> bool: + def matches(self, criteria_evaluation: dict[int, bool | None]) -> bool: # Matches means we meet all conditions. # We can "match" a quota that is closed. In that case, we would not be eligible for the survey. return all(criteria_evaluation.get(c) for c in self.criteria) @@ -93,8 +93,8 @@ class LucidQuota(BaseModel): # return True def matches_soft( - self, criteria_evaluation: Dict[int, Optional[bool]] - ) -> Tuple[Optional[bool], Set[int]]: + self, criteria_evaluation: dict[int, bool | None] + ) -> tuple[bool | None, set[int]]: # Passes back "matches" (T/F/none) and a list of unknown criterion hashes hash_evals = {cell: criteria_evaluation.get(cell) for cell in self.criteria} evals = set(hash_evals.values()) diff --git a/generalresearch/models/marketplace/summary.py b/generalresearch/models/marketplace/summary.py index 49cf7e9..f75c530 100644 --- a/generalresearch/models/marketplace/summary.py +++ b/generalresearch/models/marketplace/summary.py @@ -1,7 +1,8 @@ from __future__ import annotations from abc import ABC -from typing import Collection, Dict, List, Literal, Optional +from collections.abc import Collection +from typing import Literal import numpy as np from pydantic import BaseModel, ConfigDict, Field, computed_field @@ -16,7 +17,7 @@ class MarketplaceSummary(BaseModel): inventory: MarketplaceInventorySummary = Field( description="Inventory of the marketplace" ) - user_activity: Optional[str] = Field( + user_activity: str | None = Field( description="User activity of the marketplace", default=None ) @@ -24,32 +25,32 @@ class MarketplaceSummary(BaseModel): class MarketplaceInventorySummary(BaseModel): model_config = ConfigDict(validate_assignment=True) - live_tasks: List[CountStat] = Field( + live_tasks: list[CountStat] = Field( default_factory=list, description="The count of tasks that are currently live", ) - live_gen_pop_tasks: List[CountStat] = Field( + live_gen_pop_tasks: list[CountStat] = Field( default_factory=list, description="The count of gen-pop tasks that are currently live", ) - tasks_created: List[CountStat] = Field( + tasks_created: list[CountStat] = Field( default_factory=list, description="The count of tasks created", ) - required_finishes: List[CountStat] = Field( + required_finishes: list[CountStat] = Field( default_factory=list, description="Number of finishes needed across all live tasks", ) - payout: List[StatisticalSummaryStat] = Field( + payout: list[StatisticalSummaryStat] = Field( default_factory=list, description="The distribution of payouts for all live tasks", ) - expected_duration: List[StatisticalSummaryStat] = Field( + expected_duration: list[StatisticalSummaryStat] = Field( default_factory=list, description="The distribution of expected durations for all live tasks", ) - required_finishes_per_task: List[StatisticalSummaryStat] = Field( + required_finishes_per_task: list[StatisticalSummaryStat] = Field( default_factory=list, description="The distribution of required finishes on all live tasks", ) @@ -59,7 +60,7 @@ FacetKey = Literal["country_iso", "day", "month"] class Stat(BaseModel, ABC): - facet: Dict[FacetKey, str | int | float] = Field( + facet: dict[FacetKey, str | int | float] = Field( examples=[{"country_iso": "us"}], description="The grouping criteria" ) diff --git a/generalresearch/models/morning/question.py b/generalresearch/models/morning/question.py index 6dca8c0..7bc14ac 100644 --- a/generalresearch/models/morning/question.py +++ b/generalresearch/models/morning/question.py @@ -1,6 +1,6 @@ import json from enum import Enum -from typing import Any, Dict, List, Literal, Optional +from typing import Any, Literal from uuid import UUID from pydantic import BaseModel, Field, field_validator, model_validator diff --git a/generalresearch/models/morning/task_collection.py b/generalresearch/models/morning/task_collection.py index 8e1eb4b..b39f01b 100644 --- a/generalresearch/models/morning/task_collection.py +++ b/generalresearch/models/morning/task_collection.py @@ -1,4 +1,4 @@ -from typing import List, Set +from __future__ import annotations import pandas as pd from pandera import Check, Column, DataFrameSchema, Index @@ -11,8 +11,8 @@ from generalresearch.models.thl.survey.task_collection import ( create_empty_df_from_schema, ) -COUNTRY_ISOS: Set[str] = Localelator().get_all_countries() -LANGUAGE_ISOS: Set[str] = Localelator().get_all_languages() +COUNTRY_ISOS: set[str] = Localelator().get_all_countries() +LANGUAGE_ISOS: set[str] = Localelator().get_all_languages() bid_stats_columns = { "system_conversion": Column(float, Check.between(0, 1), nullable=True), @@ -54,8 +54,8 @@ bid_columns = { } quota_columns = { "cpi": Column(float, Check.between(min_value=0, max_value=100)), - "used_question_ids": Column(List[str]), - "all_hashes": Column(List[str]), # set >> list for column support + "used_question_ids": Column(list[str]), + "all_hashes": Column(list[str]), # set >> list for column support } columns = ( @@ -84,7 +84,7 @@ MorningTaskCollectionSchema = DataFrameSchema( class MorningTaskCollection(TaskCollection): - items: List[MorningBid] + items: list[MorningBid] _schema = MorningTaskCollectionSchema def to_rows(self, bid: MorningBid): diff --git a/generalresearch/models/network/definitions.py b/generalresearch/models/network/definitions.py index 4fb44f4..2e1ab91 100644 --- a/generalresearch/models/network/definitions.py +++ b/generalresearch/models/network/definitions.py @@ -1,6 +1,7 @@ +from __future__ import annotations + from enum import StrEnum from ipaddress import ip_address, ip_network -from typing import Optional CGNAT_NET = ip_network("100.64.0.0/10") @@ -36,7 +37,7 @@ class IPKind(StrEnum): UNSPECIFIED = "unspecified" -def get_ip_kind(ip: Optional[str]) -> Optional[IPKind]: +def get_ip_kind(ip: str | None) -> IPKind | None: if not ip: return None diff --git a/generalresearch/models/network/label.py b/generalresearch/models/network/label.py index b9a7659..e4ddd18 100644 --- a/generalresearch/models/network/label.py +++ b/generalresearch/models/network/label.py @@ -2,15 +2,14 @@ from __future__ import annotations import ipaddress from enum import StrEnum -from typing import Optional, List from pydantic import ( BaseModel, + ConfigDict, Field, + IPvAnyNetwork, computed_field, field_validator, - ConfigDict, - IPvAnyNetwork, ) from generalresearch.models.custom_types import ( @@ -69,7 +68,7 @@ class IPLabel(BaseModel): ip: IPvAnyNetwork = Field() labeled_at: AwareDatetimeISO = Field(default_factory=now_utc_factory) - created_at: Optional[AwareDatetimeISO] = Field(default=None) + created_at: AwareDatetimeISO | None = Field(default=None) label_kind: IPLabelKind = Field() source: IPLabelSource = Field() @@ -77,11 +76,11 @@ class IPLabel(BaseModel): confidence: float = Field(default=1.0, ge=0.0, le=1.0) # Optionally, if this is untrusted, which service is providing the proxy/vpn service - provider: Optional[str] = Field( + provider: str | None = Field( default=None, examples=["geonode", "gecko"], max_length=128 ) - metadata: Optional[IPLabelMetadata] = Field(default=None) + metadata: IPLabelMetadata | None = Field(default=None) @field_validator("ip", mode="before") @classmethod @@ -96,7 +95,7 @@ class IPLabel(BaseModel): @field_validator("provider", mode="before") @classmethod - def provider_format(cls, v: Optional[str]) -> Optional[str]: + def provider_format(cls, v: str | None) -> str | None: if v is None: return v return v.lower().strip() @@ -123,4 +122,4 @@ class IPLabelMetadata(BaseModel): model_config = ConfigDict(validate_assignment=True, extra="allow") - services: Optional[List[str]] = Field(min_length=1, examples=[["RDP"]]) + services: list[str] | None = Field(min_length=1, examples=[["RDP"]]) diff --git a/generalresearch/models/network/mtr/command.py b/generalresearch/models/network/mtr/command.py index f8d2d49..fd5a8d0 100644 --- a/generalresearch/models/network/mtr/command.py +++ b/generalresearch/models/network/mtr/command.py @@ -1,5 +1,6 @@ +from __future__ import annotations + import subprocess -from typing import List, Optional from generalresearch.models.network.definitions import IPProtocol from generalresearch.models.network.mtr.parser import parse_mtr_output @@ -17,9 +18,9 @@ PROTOCOLS_W_PORT = {IPProtocol.TCP, IPProtocol.UDP, IPProtocol.SCTP} def build_mtr_command( ip: str, - protocol: Optional[IPProtocol] = None, - port: Optional[int] = None, - report_cycles: int = 10, + protocol: IPProtocol | None = None, + port: int | None = None, + report_cycles: int | None = 10, ) -> str: # https://manpages.ubuntu.com/manpages/focal/man8/mtr.8.html # e.g. "mtr -r -c 2 -b -z -j -T -P 443 74.139.70.149" @@ -67,4 +68,6 @@ def run_mtr(config: MTRRunCommand) -> MTRResult: check=False, ) raw = proc.stdout.strip() - return parse_mtr_output(raw, protocol=config.options.protocol, port=config.options.port) + return parse_mtr_output( + raw, protocol=config.options.protocol, port=config.options.port + ) diff --git a/generalresearch/models/network/mtr/execute.py b/generalresearch/models/network/mtr/execute.py index a6fb82a..d77e814 100644 --- a/generalresearch/models/network/mtr/execute.py +++ b/generalresearch/models/network/mtr/execute.py @@ -1,15 +1,15 @@ +from __future__ import annotations + from datetime import datetime, timezone -from typing import Optional from uuid import uuid4 from generalresearch.models.custom_types import UUIDStr from generalresearch.models.network.definitions import IPProtocol from generalresearch.models.network.mtr.command import ( - run_mtr, get_mtr_version, - build_mtr_command, + run_mtr, ) -from generalresearch.models.network.tool_run import MTRRun, ToolName, ToolClass, Status +from generalresearch.models.network.tool_run import MTRRun, Status, ToolClass, ToolName from generalresearch.models.network.tool_run_command import ( MTRRunCommand, MTRRunCommandOptions, @@ -19,9 +19,9 @@ from generalresearch.models.network.utils import get_source_ip def execute_mtr( ip: str, - scan_group_id: Optional[UUIDStr] = None, - protocol: Optional[IPProtocol] = IPProtocol.ICMP, - port: Optional[int] = None, + scan_group_id: UUIDStr | None = None, + protocol: IPProtocol | None = IPProtocol.ICMP, + port: int | None = None, report_cycles: int = 10, ) -> MTRRun: config = MTRRunCommand( diff --git a/generalresearch/models/network/mtr/parser.py b/generalresearch/models/network/mtr/parser.py index 685eeca..c29439e 100644 --- a/generalresearch/models/network/mtr/parser.py +++ b/generalresearch/models/network/mtr/parser.py @@ -1,5 +1,4 @@ import json -from typing import Dict from generalresearch.models.network.definitions import IPProtocol from generalresearch.models.network.mtr.result import MTRResult @@ -12,7 +11,7 @@ def parse_mtr_output(raw: str, port: int, protocol: IPProtocol) -> MTRResult: return MTRResult.model_validate(data) -def parse_mtr_raw_output(raw: str) -> Dict: +def parse_mtr_raw_output(raw: str) -> dict: data = json.loads(raw)["report"] data.update(data.pop("mtr")) data["hops"] = data.pop("hubs") diff --git a/generalresearch/models/network/mtr/result.py b/generalresearch/models/network/mtr/result.py index 5c775b4..34de845 100644 --- a/generalresearch/models/network/mtr/result.py +++ b/generalresearch/models/network/mtr/result.py @@ -1,19 +1,20 @@ +from __future__ import annotations + import re from functools import cached_property from ipaddress import ip_address -from typing import List, Optional import tldextract from pydantic import ( - Field, - field_validator, BaseModel, ConfigDict, - model_validator, + Field, computed_field, + field_validator, + model_validator, ) -from generalresearch.models.network.definitions import IPProtocol, get_ip_kind, IPKind +from generalresearch.models.network.definitions import IPKind, IPProtocol, get_ip_kind HOST_RE = re.compile(r"^(?P.+?) \((?P[^)]+)\)$") @@ -23,7 +24,7 @@ class MTRHop(BaseModel): hop: int = Field(alias="count") host: str - asn: Optional[int] = Field(default=None, alias="ASN") + asn: int | None = Field(default=None, alias="ASN") loss_pct: float = Field(alias="Loss%") sent: int = Field(alias="Snt") @@ -34,10 +35,10 @@ class MTRHop(BaseModel): worst_ms: float = Field(alias="Wrst") stdev_ms: float = Field(alias="StDev") - hostname: Optional[str] = Field( + hostname: str | None = Field( default=None, examples=["fixed-187-191-8-145.totalplay.net"] ) - ip: Optional[str] = None + ip: str | None = None @field_validator("asn", mode="before") @classmethod @@ -74,7 +75,7 @@ class MTRHop(BaseModel): return self @cached_property - def ip_kind(self) -> Optional[IPKind]: + def ip_kind(self) -> IPKind | None: return get_ip_kind(self.ip) @cached_property @@ -85,7 +86,7 @@ class MTRHop(BaseModel): @computed_field(examples=["totalplay.net"]) @cached_property - def domain(self) -> Optional[str]: + def domain(self) -> str | None: if self.hostname: return tldextract.extract(self.hostname).top_domain_under_public_suffix @@ -120,9 +121,9 @@ class MTRResult(BaseModel): # Protocol used for the traceroute protocol: IPProtocol = Field(default=IPProtocol.ICMP) # The target port number for TCP/SCTP/UDP traces - port: Optional[int] = Field(default=None) + port: int | None = Field(default=None) - hops: List[MTRHop] = Field() + hops: list[MTRHop] = Field() def model_dump_postgres(self): # Writes for the network_mtr table diff --git a/generalresearch/models/network/nmap/command.py b/generalresearch/models/network/nmap/command.py index 47e0a87..6a524a8 100644 --- a/generalresearch/models/network/nmap/command.py +++ b/generalresearch/models/network/nmap/command.py @@ -1,5 +1,6 @@ +from __future__ import annotations + import subprocess -from typing import Optional, List from generalresearch.models.network.nmap.parser import parse_nmap_xml from generalresearch.models.network.nmap.result import NmapResult @@ -11,8 +12,8 @@ def build_nmap_command( no_ping: bool = True, enable_advanced: bool = True, timing: int = 4, - ports: Optional[str] = None, - top_ports: Optional[int] = None, + ports: str | None = None, + top_ports: int | None = None, ) -> str: # e.g. "nmap -Pn -T4 -A --top-ports 1000 -oX - scanme.nmap.org" # https://linux.die.net/man/1/nmap diff --git a/generalresearch/models/network/nmap/execute.py b/generalresearch/models/network/nmap/execute.py index 0334f50..8a73307 100644 --- a/generalresearch/models/network/nmap/execute.py +++ b/generalresearch/models/network/nmap/execute.py @@ -1,9 +1,10 @@ -from typing import Optional +from __future__ import annotations + from uuid import uuid4 from generalresearch.models.custom_types import UUIDStr from generalresearch.models.network.nmap.command import run_nmap -from generalresearch.models.network.tool_run import NmapRun, ToolName, ToolClass, Status +from generalresearch.models.network.tool_run import NmapRun, Status, ToolClass, ToolName from generalresearch.models.network.tool_run_command import ( NmapRunCommand, NmapRunCommandOptions, @@ -12,12 +13,12 @@ from generalresearch.models.network.tool_run_command import ( def execute_nmap( ip: str, - top_ports: Optional[int] = 1000, - ports: Optional[str] = None, + top_ports: int | None = 1000, + ports: str | None = None, no_ping: bool = True, enable_advanced: bool = True, timing: int = 4, - scan_group_id: Optional[UUIDStr] = None, + scan_group_id: UUIDStr | None = None, ): config = NmapRunCommand( options=NmapRunCommandOptions( diff --git a/generalresearch/models/network/nmap/parser.py b/generalresearch/models/network/nmap/parser.py index 5a441bb..49c13c7 100644 --- a/generalresearch/models/network/nmap/parser.py +++ b/generalresearch/models/network/nmap/parser.py @@ -1,26 +1,28 @@ +from __future__ import annotations + import xml.etree.cElementTree as ET from datetime import datetime, timezone -from typing import List, Dict, Any, Tuple, Optional +from typing import Any from generalresearch.models.network.definitions import IPProtocol from generalresearch.models.network.nmap.result import ( NmapHostname, - NmapResult, - NmapPort, - PortState, - PortStateReason, - NmapService, - NmapScript, - NmapPortStats, - NmapScanType, + NmapHostScript, NmapHostState, NmapHostStatusReason, - NmapHostScript, - NmapOSMatch, NmapOSClass, + NmapOSMatch, + NmapPort, + NmapPortStats, + NmapResult, + NmapScanInfo, + NmapScanType, + NmapScript, + NmapService, NmapTrace, NmapTraceHop, - NmapScanInfo, + PortState, + PortStateReason, ) @@ -109,7 +111,7 @@ class NmapXmlParser: return NmapScanInfo.model_validate(data) @classmethod - def _parse_runstats(cls, root: ET.Element) -> Dict: + def _parse_runstats(cls, root: ET.Element) -> dict: runstats = root.find("runstats") if runstats is None: return {} @@ -129,7 +131,7 @@ class NmapXmlParser: } @classmethod - def _parse_nmaprun(cls, nmaprun_el: ET.Element) -> Dict: + def _parse_nmaprun(cls, nmaprun_el: ET.Element) -> dict: nmap_data = dict() nmaprun = dict(nmaprun_el.attrib) nmap_data["command_line"] = nmaprun["args"] @@ -141,7 +143,7 @@ class NmapXmlParser: return nmap_data @classmethod - def _parse_xml_host(cls, host_el: ET.Element) -> Dict: + def _parse_xml_host(cls, host_el: ET.Element) -> dict: """ Receives a XML tag representing a scanned host with its services. @@ -207,15 +209,15 @@ class NmapXmlParser: return data @classmethod - def _parse_os_matches(cls, host_el: ET.Element) -> List[NmapOSMatch] | None: + def _parse_os_matches(cls, host_el: ET.Element) -> list[NmapOSMatch] | None: os_elem = host_el.find("os") if os_elem is None: return None - matches: List[NmapOSMatch] = [] + matches: list[NmapOSMatch] = [] for m in os_elem.findall("osmatch"): - classes: List[NmapOSClass] = [] + classes: list[NmapOSClass] = [] for c in m.findall("osclass"): cpes = [e.text.strip() for e in c.findall("cpe") if e.text] @@ -245,7 +247,7 @@ class NmapXmlParser: return matches or None @classmethod - def _parse_hostnames(cls, hostnames_el: ET.Element) -> List[NmapHostname]: + def _parse_hostnames(cls, hostnames_el: ET.Element) -> list[NmapHostname]: """ Parses the hostnames element. e.g. @@ -269,11 +271,11 @@ class NmapXmlParser: @classmethod def _parse_xml_ports( cls, ports_elem: ET.Element - ) -> Tuple[List[NmapPort], NmapPortStats]: + ) -> tuple[list[NmapPort], NmapPortStats]: """ Parses the list of scanned services from a targeted host. """ - ports: List[NmapPort] = [] + ports: list[NmapPort] = [] stats = NmapPortStats() # handle extraports first @@ -319,7 +321,7 @@ class NmapXmlParser: "output": output, } - elements: Dict[str, Any] = {} + elements: dict[str, Any] = {} # handle value for elem in script_elem.findall(".//elem"): @@ -373,7 +375,7 @@ class NmapXmlParser: return NmapPort.model_validate(port) @classmethod - def _parse_trace(cls, host_elem: ET.Element) -> Optional[NmapTrace]: + def _parse_trace(cls, host_elem: ET.Element) -> NmapTrace | None: trace_elem = host_elem.find("trace") if trace_elem is None: return None @@ -381,7 +383,7 @@ class NmapXmlParser: port_attr = trace_elem.attrib.get("port") proto_attr = trace_elem.attrib.get("proto") - hops: List[NmapTraceHop] = [] + hops: list[NmapTraceHop] = [] for hop_elem in trace_elem.findall("hop"): ttl = hop_elem.attrib.get("ttl") diff --git a/generalresearch/models/network/nmap/result.py b/generalresearch/models/network/nmap/result.py index d75efba..3f9cae6 100644 --- a/generalresearch/models/network/nmap/result.py +++ b/generalresearch/models/network/nmap/result.py @@ -1,10 +1,12 @@ +from __future__ import annotations + import json from datetime import timedelta from enum import StrEnum from functools import cached_property -from typing import Dict, Any, Literal, List, Optional, Tuple, Set +from typing import Any, Literal, Set -from pydantic import computed_field, BaseModel, Field +from pydantic import BaseModel, Field, computed_field from generalresearch.models.custom_types import AwareDatetimeISO, IPvAnyAddressStr from generalresearch.models.network.definitions import IPProtocol @@ -80,18 +82,18 @@ class NmapHostStatusReason(StrEnum): class NmapOSClass(BaseModel): vendor: str = None osfamily: str = None - osgen: Optional[str] = None + osgen: str | None = None accuracy: int = None - cpe: Optional[List[str]] = None + cpe: list[str] | None = None class NmapOSMatch(BaseModel): name: str accuracy: int - classes: List[NmapOSClass] = Field(default_factory=list) + classes: list[NmapOSClass] = Field(default_factory=list) @property - def best_class(self) -> Optional[NmapOSClass]: + def best_class(self) -> NmapOSClass | None: if not self.classes: return None return max(self.classes, key=lambda m: m.accuracy) @@ -108,19 +110,19 @@ class NmapScript(BaseModel): """ id: str - output: Optional[str] = None - elements: Dict[str, Any] = Field(default_factory=dict) + output: str | None = None + elements: dict[str, Any] = Field(default_factory=dict) class NmapService(BaseModel): # - name: Optional[str] = None - product: Optional[str] = None - version: Optional[str] = None - extrainfo: Optional[str] = None - method: Optional[str] = None - conf: Optional[int] = None - cpe: List[str] = Field(default_factory=list) + name: str | None = None + product: str | None = None + version: str | None = None + extrainfo: str | None = None + method: str | None = None + conf: int | None = None + cpe: list[str] = Field(default_factory=list) def model_dump_postgres(self): d = self.model_dump(mode="json") @@ -133,11 +135,11 @@ class NmapPort(BaseModel): protocol: IPProtocol = Field() # Closed ports will not have a NmapPort record state: PortState = Field() - reason: Optional[PortStateReason] = Field(default=None) - reason_ttl: Optional[int] = Field(default=None) + reason: PortStateReason | None = Field(default=None) + reason_ttl: int | None = Field(default=None) - service: Optional[NmapService] = None - scripts: List[NmapScript] = Field(default_factory=list) + service: NmapService | None = None + scripts: list[NmapScript] = Field(default_factory=list) def model_dump_postgres(self, run_id: int): # Writes for the network_portscanport table @@ -160,7 +162,7 @@ class NmapPort(BaseModel): class NmapHostScript(BaseModel): id: str = Field() - output: Optional[str] = Field(default=None) + output: str | None = Field(default=None) class NmapTraceHop(BaseModel): @@ -173,17 +175,17 @@ class NmapTraceHop(BaseModel): ttl: int = Field() - ipaddr: Optional[str] = Field( + ipaddr: str | None = Field( default=None, description="IP address of the responding router or host", ) - rtt_ms: Optional[float] = Field( + rtt_ms: float | None = Field( default=None, description="Round-trip time in milliseconds for the probe reaching this hop.", ) - host: Optional[str] = Field( + host: str | None = Field( default=None, description="Reverse DNS hostname for the hop if Nmap resolved one.", ) @@ -203,29 +205,29 @@ class NmapTrace(BaseModel): """ - port: Optional[int] = Field( + port: int | None = Field( default=None, description="Destination port used for traceroute probes (may be absent depending on scan type).", ) - protocol: Optional[IPProtocol] = Field( + protocol: IPProtocol | None = Field( default=None, description="Transport protocol used for the traceroute probes (tcp, udp, etc.).", ) - hops: List[NmapTraceHop] = Field( + hops: list[NmapTraceHop] = Field( default_factory=list, description="Ordered list of hops observed during the traceroute.", ) @property - def destination(self) -> Optional[NmapTraceHop]: + def destination(self) -> NmapTraceHop | None: return self.hops[-1] if self.hops else None class NmapHostname(BaseModel): # name: str - type: Optional[Literal["PTR", "user"]] = None + type: Literal["PTR", "user"] | None = None class NmapPortStats(BaseModel): @@ -280,11 +282,11 @@ class NmapResult(BaseModel): version: str = Field() xmloutputversion: str = Field() - scan_infos: List[NmapScanInfo] = Field(min_length=1) + scan_infos: list[NmapScanInfo] = Field(min_length=1) # comes from - finished_at: Optional[AwareDatetimeISO] = Field(default=None) - exit_status: Optional[Literal["success", "error"]] = Field(default=None) + finished_at: AwareDatetimeISO | None = Field(default=None) + exit_status: Literal["success", "error"] | None = Field(default=None) ##### # Everything below here is from within the *single* host we've scanned @@ -293,53 +295,53 @@ class NmapResult(BaseModel): # host_state: NmapHostState = Field() host_state_reason: NmapHostStatusReason = Field() - host_state_reason_ttl: Optional[int] = None + host_state_reason_ttl: int | None = None #
target_ip: IPvAnyAddressStr = Field() - hostnames: List[NmapHostname] = Field() + hostnames: list[NmapHostname] = Field() - ports: List[NmapPort] = [] + ports: list[NmapPort] = [] port_stats: NmapPortStats = Field() # - uptime_seconds: Optional[int] = Field(default=None) + uptime_seconds: int | None = Field(default=None) # - distance: Optional[int] = Field(description="approx number of hops", default=None) + distance: int | None = Field(description="approx number of hops", default=None) # - tcp_sequence_index: Optional[int] = None - tcp_sequence_difficulty: Optional[str] = None + tcp_sequence_index: int | None = None + tcp_sequence_difficulty: str | None = None # - ipid_sequence_class: Optional[str] = None + ipid_sequence_class: str | None = None # - tcp_timestamp_class: Optional[str] = None + tcp_timestamp_class: str | None = None # - srtt_us: Optional[int] = Field( + srtt_us: int | None = Field( default=None, description="smoothed RTT estimate (microseconds µs)" ) - rttvar_us: Optional[int] = Field( + rttvar_us: int | None = Field( default=None, description="RTT variance (microseconds µs)" ) - timeout_us: Optional[int] = Field( + timeout_us: int | None = Field( default=None, description="probe timeout (microseconds µs)" ) - os_matches: Optional[List[NmapOSMatch]] = Field(default=None) + os_matches: list[NmapOSMatch] | None = Field(default=None) - host_scripts: List[NmapHostScript] = Field(default_factory=list) + host_scripts: list[NmapHostScript] = Field(default_factory=list) - trace: Optional[NmapTrace] = Field(default=None) + trace: NmapTrace | None = Field(default=None) - raw_xml: Optional[str] = None + raw_xml: str | None = None @computed_field @property - def last_boot(self) -> Optional[AwareDatetimeISO]: + def last_boot(self) -> AwareDatetimeISO | None: if self.uptime_seconds: return self.started_at - timedelta(seconds=self.uptime_seconds) @@ -356,20 +358,20 @@ class NmapResult(BaseModel): ) @property - def latency_ms(self) -> Optional[float]: + def latency_ms(self) -> float | None: return self.srtt_us / 1000 if self.srtt_us is not None else None @property - def best_os_match(self) -> Optional[NmapOSMatch]: + def best_os_match(self) -> NmapOSMatch | None: if not self.os_matches: return None return max(self.os_matches, key=lambda m: m.accuracy) - def filter_ports(self, protocol: IPProtocol, state: PortState) -> List[NmapPort]: + def filter_ports(self, protocol: IPProtocol, state: PortState) -> list[NmapPort]: return [p for p in self.ports if p.protocol == protocol and p.state == state] @property - def tcp_open_ports(self) -> List[int]: + def tcp_open_ports(self) -> list[int]: """ Returns a list of open TCP port numbers. """ @@ -379,7 +381,7 @@ class NmapResult(BaseModel): ] @property - def udp_open_ports(self) -> List[int]: + def udp_open_ports(self) -> list[int]: """ Returns a list of open UDP port numbers. """ @@ -389,7 +391,7 @@ class NmapResult(BaseModel): ] @cached_property - def _port_index(self) -> Dict[Tuple[IPProtocol, int], NmapPort]: + def _port_index(self) -> dict[tuple[IPProtocol, int], NmapPort]: return {(p.protocol, p.port): p for p in self.ports} def get_port_state( diff --git a/generalresearch/models/network/rdns/execute.py b/generalresearch/models/network/rdns/execute.py index 03a5080..cabd13c 100644 --- a/generalresearch/models/network/rdns/execute.py +++ b/generalresearch/models/network/rdns/execute.py @@ -1,18 +1,18 @@ +from __future__ import annotations + from datetime import datetime, timezone -from typing import Optional from uuid import uuid4 from generalresearch.models.custom_types import UUIDStr from generalresearch.models.network.rdns.command import ( - run_rdns, get_dig_version, - build_rdns_command, + run_rdns, ) from generalresearch.models.network.tool_run import ( - ToolName, - ToolClass, - Status, RDNSRun, + Status, + ToolClass, + ToolName, ) from generalresearch.models.network.tool_run_command import ( RDNSRunCommand, @@ -20,7 +20,7 @@ from generalresearch.models.network.tool_run_command import ( ) -def execute_rdns(ip: str, scan_group_id: Optional[UUIDStr] = None): +def execute_rdns(ip: str, scan_group_id: UUIDStr | None = None): started_at = datetime.now(tz=timezone.utc) tool_version = get_dig_version() config = RDNSRunCommand(options=RDNSRunCommandOptions(ip=ip)) diff --git a/generalresearch/models/network/rdns/parser.py b/generalresearch/models/network/rdns/parser.py index 231949e..e1cf023 100644 --- a/generalresearch/models/network/rdns/parser.py +++ b/generalresearch/models/network/rdns/parser.py @@ -1,6 +1,5 @@ import ipaddress import re -from typing import List from generalresearch.models.custom_types import IPvAnyAddressStr from generalresearch.models.network.rdns.result import RDNSResult @@ -8,8 +7,8 @@ from generalresearch.models.network.rdns.result import RDNSResult PTR_RE = re.compile(r"\sPTR\s+([^\s]+)\.") -def parse_rdns_output(ip: IPvAnyAddressStr, raw:str): - hostnames: List[str] = [] +def parse_rdns_output(ip: IPvAnyAddressStr, raw: str): + hostnames: list[str] = [] for line in raw.splitlines(): m = PTR_RE.search(line) diff --git a/generalresearch/models/network/rdns/result.py b/generalresearch/models/network/rdns/result.py index 81b4085..46af643 100644 --- a/generalresearch/models/network/rdns/result.py +++ b/generalresearch/models/network/rdns/result.py @@ -1,9 +1,10 @@ +from __future__ import annotations + import json from functools import cached_property -from typing import Optional, List import tldextract -from pydantic import BaseModel, Field, model_validator, computed_field +from pydantic import BaseModel, Field, computed_field, model_validator from generalresearch.models.custom_types import IPvAnyAddressStr @@ -12,7 +13,7 @@ class RDNSResult(BaseModel): ip: IPvAnyAddressStr = Field() - hostnames: List[str] = Field(default_factory=list) + hostnames: list[str] = Field(default_factory=list) @model_validator(mode="after") def validate_hostname_prop(self): @@ -24,7 +25,7 @@ class RDNSResult(BaseModel): @computed_field(examples=["fixed-187-191-8-145.totalplay.net"]) @cached_property - def primary_hostname(self) -> Optional[str]: + def primary_hostname(self) -> str | None: if self.hostnames: return self.hostnames[0] @@ -35,7 +36,7 @@ class RDNSResult(BaseModel): @computed_field(examples=["totalplay.net"]) @cached_property - def primary_domain(self) -> Optional[str]: + def primary_domain(self) -> str | None: if self.primary_hostname: return tldextract.extract( self.primary_hostname diff --git a/generalresearch/models/network/tool_run.py b/generalresearch/models/network/tool_run.py index 114d4b6..c49ffc0 100644 --- a/generalresearch/models/network/tool_run.py +++ b/generalresearch/models/network/tool_run.py @@ -1,5 +1,7 @@ +from __future__ import annotations + from enum import StrEnum -from typing import Optional, Literal +from typing import Literal from uuid import uuid4 from pydantic import BaseModel, Field, PositiveInt @@ -13,10 +15,10 @@ from generalresearch.models.network.mtr.result import MTRResult from generalresearch.models.network.nmap.result import NmapResult from generalresearch.models.network.rdns.result import RDNSResult from generalresearch.models.network.tool_run_command import ( - ToolRunCommand, + MTRRunCommand, NmapRunCommand, RDNSRunCommand, - MTRRunCommand, + ToolRunCommand, ) @@ -48,7 +50,7 @@ class ToolRun(BaseModel): A run of a networking tool against one host/ip. """ - id: Optional[PositiveInt] = Field(default=None) + id: PositiveInt | None = Field(default=None) ip: IPvAnyAddressStr = Field() scan_group_id: UUIDStr = Field(default_factory=lambda: uuid4().hex) @@ -57,8 +59,8 @@ class ToolRun(BaseModel): tool_version: str = Field() started_at: AwareDatetimeISO = Field() - finished_at: Optional[AwareDatetimeISO] = Field(default=None) - status: Optional[Status] = Field(default=None) + finished_at: AwareDatetimeISO | None = Field(default=None) + status: Status | None = Field(default=None) raw_command: str = Field() diff --git a/generalresearch/models/network/tool_run_command.py b/generalresearch/models/network/tool_run_command.py index 68d2070..6f22d6b 100644 --- a/generalresearch/models/network/tool_run_command.py +++ b/generalresearch/models/network/tool_run_command.py @@ -1,4 +1,6 @@ -from typing import Dict, Optional, Literal +from __future__ import annotations + +from typing import Literal from pydantic import BaseModel, Field @@ -8,13 +10,13 @@ from generalresearch.models.network.definitions import IPProtocol class ToolRunCommand(BaseModel): command: str = Field() - options: Dict[str, Optional[str | int]] = Field(default_factory=dict) + options: dict[str, str | int | None] = Field(default_factory=dict) class NmapRunCommandOptions(BaseModel): ip: IPvAnyAddressStr - top_ports: Optional[int] = Field(default=1000) - ports: Optional[str] = Field(default=None) + top_ports: int | None = Field(default=1000) + ports: str | None = Field(default=None) no_ping: bool = Field(default=True) enable_advanced: bool = Field(default=True) timing: int = Field(default=4) @@ -49,7 +51,7 @@ class RDNSRunCommand(ToolRunCommand): class MTRRunCommandOptions(BaseModel): ip: IPvAnyAddressStr = Field() protocol: IPProtocol = Field(default=IPProtocol.ICMP) - port: Optional[int] = Field(default=None) + port: int | None = Field(default=None) report_cycles: int = Field(default=10) diff --git a/generalresearch/models/pollfish/question.py b/generalresearch/models/pollfish/question.py index a89a793..9508154 100644 --- a/generalresearch/models/pollfish/question.py +++ b/generalresearch/models/pollfish/question.py @@ -1,8 +1,10 @@ +from __future__ import annotations + # https://wss.pollfish.com/mediation/documentation import json import logging from enum import Enum -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Self +from typing import TYPE_CHECKING, Any, Literal, Self from pydantic import BaseModel, Field, model_validator @@ -59,9 +61,9 @@ class PollfishQuestion(MarketplaceQuestion): max_length=1024, min_length=1, description="The text shown to respondents" ) question_type: PollfishQuestionType = Field(frozen=True) - options: Optional[List[PollfishQuestionOption]] = Field(default=None, min_length=1) + options: list[PollfishQuestionOption] | None = Field(default=None, min_length=1) # This comes from the API field "category" - tags: Optional[str] = Field(default=None, frozen=True) + tags: str | None = Field(default=None, frozen=True) source: Literal[Source.POLLFISH] = Source.POLLFISH @property @@ -78,7 +80,7 @@ class PollfishQuestion(MarketplaceQuestion): return self @classmethod - def from_db(cls, d: Dict[str, Any]) -> Self: + def from_db(cls, d: dict[str, Any]) -> Self: options = None if d["options"]: options = [ @@ -97,7 +99,7 @@ class PollfishQuestion(MarketplaceQuestion): category_id=d.get("category_id"), ) - def to_mysql(self) -> Dict[str, Any]: + def to_mysql(self) -> dict[str, Any]: d = self.model_dump(mode="json", by_alias=True) d["options"] = json.dumps(d["options"]) return d diff --git a/generalresearch/models/precision/definitions.py b/generalresearch/models/precision/definitions.py index daa6f64..bd6a9c7 100644 --- a/generalresearch/models/precision/definitions.py +++ b/generalresearch/models/precision/definitions.py @@ -1,7 +1,8 @@ +from __future__ import annotations + # These were sent to us in an excel file. Copied here because we don't use excel files # also available here: https://integrations.precisionsample.com/api.html#API%20Lookup%20Document # https://integrations.precisionsample.com/PS_GetProjects_API_Lookup_values.xlsx - from generalresearch.locales import Localelator locales = Localelator() diff --git a/generalresearch/models/precision/question.py b/generalresearch/models/precision/question.py index 9673f54..d508086 100644 --- a/generalresearch/models/precision/question.py +++ b/generalresearch/models/precision/question.py @@ -1,8 +1,10 @@ +from __future__ import annotations + # https://integrations.precisionsample.com/api.html#Get%20Questions import json import logging from enum import Enum -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Self +from typing import TYPE_CHECKING, Any, Literal from pydantic import BaseModel, Field, field_validator, model_validator @@ -65,7 +67,7 @@ class PrecisionQuestionType(str, Enum): class PrecisionUserQuestionAnswer(MarketplaceUserQuestionAnswer): question_id: PrecisionQuestionID = Field() - question_type: Optional[PrecisionQuestionType] = Field(default=None) + question_type: PrecisionQuestionType | None = Field(default=None) # Was this answer synchronized with precision's user profile API? synced: bool = Field(default=False) @@ -74,16 +76,16 @@ class PrecisionQuestion(MarketplaceQuestion): question_id: PrecisionQuestionID = Field( description="The unique identifier for the qualification" ) - question_name: Optional[str] = Field(default=None, max_length=128) + question_name: str | None = Field(default=None, max_length=128) question_text: str = Field( max_length=1024, min_length=1, description="The text shown to respondents" ) question_type: PrecisionQuestionType = Field(frozen=True) - options: Optional[List[PrecisionQuestionOption]] = Field(default=None, min_length=1) + options: list[PrecisionQuestionOption] | None = Field(default=None, min_length=1) # This comes from the API field ProfileName. idk what the possible values are, looks like: # 'Personal Profile', 'Work Profile', 'Auto Profile', 'Medical Profile', 'Travel & Entertainment'. # I don't know what, if anything, this is used for. - profile: Optional[str] = Field(default=None, frozen=True) + profile: str | None = Field(default=None, frozen=True) source: Literal[Source.PRECISION] = Source.PRECISION @property @@ -91,7 +93,7 @@ class PrecisionQuestion(MarketplaceQuestion): return self.question_id @field_validator("question_text", mode="after") - def remove_nbsp(cls, s: Optional[str]): + def remove_nbsp(cls, s: str | None): return string_utils.remove_nbsp(s) @model_validator(mode="after") @@ -104,7 +106,7 @@ class PrecisionQuestion(MarketplaceQuestion): return self @classmethod - def from_api(cls, d: Dict[str, Any]) -> Optional["PrecisionQuestion"]: + def from_api(cls, d: dict[str, Any]) -> "PrecisionQuestion" | None: """ :param d: Raw response from API """ @@ -115,7 +117,7 @@ class PrecisionQuestion(MarketplaceQuestion): return None @classmethod - def _from_api(cls, d: Dict[str, Any]) -> "PrecisionQuestion": + def _from_api(cls, d: dict[str, Any]) -> "PrecisionQuestion": question_type = PrecisionQuestionType.from_api(d["question_type_name"]) # sometimes an empty option is returned .... ? options = [ @@ -137,7 +139,7 @@ class PrecisionQuestion(MarketplaceQuestion): ) @classmethod - def from_db(cls, d: Dict[str, Any]) -> "PrecisionQuestion": + def from_db(cls, d: dict[str, Any]) -> "PrecisionQuestion": options = None if d["options"]: options = [ @@ -156,7 +158,7 @@ class PrecisionQuestion(MarketplaceQuestion): category_id=d.get("category_id"), ) - def to_mysql(self) -> Dict[str, Any]: + def to_mysql(self) -> dict[str, Any]: d = self.model_dump(mode="json", by_alias=True) d["options"] = json.dumps(d["options"]) return d diff --git a/generalresearch/models/prodege/question.py b/generalresearch/models/prodege/question.py index e3cdf08..f8741c9 100644 --- a/generalresearch/models/prodege/question.py +++ b/generalresearch/models/prodege/question.py @@ -6,7 +6,7 @@ import logging from datetime import datetime, timezone from enum import Enum from functools import cached_property -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Self, Set +from typing import TYPE_CHECKING, Any, Literal from pydantic import BaseModel, ConfigDict, Field, PositiveInt, model_validator @@ -33,13 +33,13 @@ class ProdegeUserQuestionAnswer(BaseModel): # for "anonymous" users, which are represented by a list of question # answers not associated with an actual user. No default b/c we must # explicitly set the field to None. - user_id: Optional[PositiveInt] = Field(lt=MAX_INT32) + user_id: PositiveInt | None = Field(lt=MAX_INT32) question_id: ProdegeQuestionIdType = Field() # This is optional b/c we do not need it when writing these to the # db. When these are fetched from the db for use in yield-management, # we read this field from the prodege_question table. - question_type: Optional[ProdegeQuestionType] = Field(default=None) + question_type: ProdegeQuestionType | None = Field(default=None) # This may be a pipe-separated string if the question_type is multi. regex means any chars except capital letters option_id: str = Field(pattern=r"^[^A-Z]*$") @@ -58,10 +58,10 @@ class ProdegeUserQuestionAnswer(BaseModel): ) @cached_property - def options_ids(self) -> Set[str]: + def options_ids(self) -> set[str]: return set(self.option_id.split("|")) - def to_mysql(self) -> Dict[str, Any]: + def to_mysql(self) -> dict[str, Any]: d = self.model_dump(mode="json", exclude={"question_type"}) d["created"] = self.created.replace(tzinfo=None) return d @@ -122,8 +122,8 @@ class ProdegeQuestion(MarketplaceQuestion): question_text: str = Field(max_length=1024, min_length=1) question_type: ProdegeQuestionType = Field(frozen=True) # This comes from the API category, but is not great (most are "Consumer Lifestyle") - tags: Optional[str] = Field(default=None, frozen=True) - options: Optional[List[ProdegeQuestionOption]] = Field(default=None, min_length=1) + tags: str | None = Field(default=None, frozen=True) + options: list[ProdegeQuestionOption] | None = Field(default=None, min_length=1) source: Literal[Source.PRODEGE] = Source.PRODEGE @property @@ -139,9 +139,7 @@ class ProdegeQuestion(MarketplaceQuestion): return self @classmethod - def from_api( - cls, d: Dict[str, Any], country_iso: str - ) -> Optional["ProdegeQuestion"]: + def from_api(cls, d: dict[str, Any], country_iso: str) -> "ProdegeQuestion" | None: """ :param d: Raw response from API """ @@ -152,7 +150,7 @@ class ProdegeQuestion(MarketplaceQuestion): return None @classmethod - def _from_api(cls, d: Dict[str, Any], country_iso: str) -> "ProdegeQuestion": + def _from_api(cls, d: dict[str, Any], country_iso: str) -> "ProdegeQuestion": # The API has no concept of language at all. Questions for a country # are returned both in english and other languages. Questions do have # a field 'country_specific', and if True, that generally means the @@ -185,7 +183,7 @@ class ProdegeQuestion(MarketplaceQuestion): return cls.model_validate(d) @classmethod - def from_db(cls, d: Dict[str, Any]) -> "ProdegeQuestion": + def from_db(cls, d: dict[str, Any]) -> "ProdegeQuestion": options = None if d["options"]: options = [ @@ -210,7 +208,7 @@ class ProdegeQuestion(MarketplaceQuestion): tags=d.get("tags"), ) - def to_mysql(self) -> Dict[str, Any]: + def to_mysql(self) -> dict[str, Any]: d = self.model_dump(mode="json", by_alias=True) d["options"] = json.dumps(d["options"]) return d diff --git a/generalresearch/models/prodege/survey.py b/generalresearch/models/prodege/survey.py index eaae883..76af35d 100644 --- a/generalresearch/models/prodege/survey.py +++ b/generalresearch/models/prodege/survey.py @@ -7,7 +7,7 @@ from collections import defaultdict from datetime import datetime, timezone from decimal import Decimal from functools import cached_property -from typing import Any, Dict, List, Literal, Optional, Set, Tuple, Type +from typing import Any, Literal, Type from pydantic import ( BaseModel, @@ -52,10 +52,10 @@ class ProdegeCondition(MarketplaceCondition): model_config = ConfigDict(populate_by_name=True) question_id: ProdegeQuestionIdType = Field() - values: List[str] = Field(validation_alias="precodes") + values: list[str] = Field(validation_alias="precodes") @classmethod - def from_api(cls, d: Dict[str, Any]) -> "ProdegeCondition": + def from_api(cls, d: dict[str, Any]) -> "ProdegeCondition": assert d["operator"] in { "OR", "NOT", @@ -97,7 +97,7 @@ class ProdegeQuota(BaseModel): description="The total number of allowed responses that remain from the sample_size", validation_alias="number_of_respondents", ) - condition_hashes: List[str] = Field(min_length=0, default_factory=list) + condition_hashes: list[str] = Field(min_length=0, default_factory=list) # Each quota can have a different calculation type, instead of on the survey calculation_type: TaskCalculationType = Field( description="Indicates whether the targets are counted per Complete or Survey Start", @@ -105,10 +105,10 @@ class ProdegeQuota(BaseModel): ) quota_id: CoercedStr = Field() # If the parent_quota_id is None, then this is a parent. There can be multiple parent quotas. - parent_quota_id: Optional[CoercedStr] = Field() + parent_quota_id: CoercedStr | None = Field() # ISO 3166-1 alpha-2 (two-letter codes, lowercase) - country_iso: Optional[str] = Field( + country_iso: str | None = Field( max_length=2, min_length=2, pattern=r"^[a-z]{2}$", default=None ) @@ -135,7 +135,7 @@ class ProdegeQuota(BaseModel): return "1" @property - def marketplace_genders(self) -> Dict[Gender, Optional[MarketplaceCondition]]: + def marketplace_genders(self) -> dict[Gender, MarketplaceCondition | None]: return { Gender.MALE: ProdegeCondition( question_id="3", @@ -151,7 +151,7 @@ class ProdegeQuota(BaseModel): } @classmethod - def from_api(cls, d: Dict[str, Any]) -> "ProdegeQuota": + def from_api(cls, d: dict[str, Any]) -> "ProdegeQuota": # the API doesn't handle None's correctly? idk if d["parent_quota_id"] == 0: d["parent_quota_id"] = None @@ -166,7 +166,7 @@ class ProdegeQuota(BaseModel): return cls.model_validate(d) def passes( - self, criteria_evaluation: Dict[str, Optional[bool]], country_iso: str + self, criteria_evaluation: dict[str, bool | None], country_iso: str ) -> bool: # Passes means we 1) meet all conditions (aka "match") AND 2) the quota is open. return self.is_open and self.matches( @@ -174,7 +174,7 @@ class ProdegeQuota(BaseModel): ) def matches( - self, criteria_evaluation: Dict[str, Optional[bool]], country_iso: str + self, criteria_evaluation: dict[str, bool | None], country_iso: str ) -> bool: # Match means we meet all conditions. # We can "match" a quota that is closed. In that case, we would @@ -187,7 +187,7 @@ class ProdegeQuota(BaseModel): return self.country_iso is None or self.country_iso == country_iso def passes_verbose( - self, criteria_evaluation: Dict[str, Optional[bool]], country_iso: str + self, criteria_evaluation: dict[str, bool | None], country_iso: str ) -> bool: print(f"quota.is_open: {self.is_open}") print( @@ -202,8 +202,8 @@ class ProdegeQuota(BaseModel): ) def passes_soft( - self, criteria_evaluation: Dict[str, Optional[bool]], country_iso: str - ) -> Tuple[Optional[bool], Set[str]]: + self, criteria_evaluation: dict[str, bool | None], country_iso: str + ) -> tuple[bool | None, set[str]]: # Passes back "passes" (T/F/none) and a list of unknown criterion hashes if self.is_open is False: return False, set() @@ -246,10 +246,10 @@ class ProdegeUserPastParticipation(BaseModel): survey_id: str = Field(min_length=1, max_length=16, pattern=r"^[0-9]+$") started: AwareDatetimeISO = Field() # This is what is returned in the redirect in the url param "status". - ext_status_code_1: Optional[ProdgeRedirectStatus] = Field(default=None) + ext_status_code_1: ProdgeRedirectStatus | None = Field(default=None) @property - def participation_types(self) -> Set[ProdegePastParticipationType]: + def participation_types(self) -> set[ProdegePastParticipationType]: # If the survey is filtering completes, then only a complete # counts. But if the survey is filtering on clicks, then a person # who got a complete ALSO did click. And so, the logic here is that @@ -286,8 +286,8 @@ class ProdegePastParticipation(BaseModel): survey_ids: AlphaNumStrSet = Field(validation_alias="participation_project_ids") filter_type: InclExcl = Field() # API has a mistake. We treat 0 as null - in_past_days: Optional[int] = Field(default=None) - participation_types: List[ProdegePastParticipationType] = Field() + in_past_days: int | None = Field(default=None) + participation_types: list[ProdegePastParticipationType] = Field() """ e.g. Anyone who got a complete in either of these projects in the past 7 days, @@ -299,7 +299,7 @@ class ProdegePastParticipation(BaseModel): """ @classmethod - def from_api(cls, d: Dict[str, Any]) -> "ProdegePastParticipation": + def from_api(cls, d: dict[str, Any]) -> "ProdegePastParticipation": # the API doesn't handle None's correctly? idk if d["in_past_days"] == 0: d["in_past_days"] = None @@ -323,7 +323,7 @@ class ProdegePastParticipation(BaseModel): ) def is_eligible( - self, user_participations: List[ProdegeUserPastParticipation] + self, user_participations: list[ProdegeUserPastParticipation] ) -> bool: if self.filter_type == "include": # User is only eligible if they HAVE participated. Return True as soon as they match anything. @@ -383,12 +383,12 @@ class ProdegeSurvey(MarketplaceTask): # have to store it in the db if we see it. In API res, these are called # "loi" and "actual_ir", but the actual IR is only actually the actual # IR if the "phases" is "actual" :facepalm: - bid_loi: Optional[int] = Field(default=None, le=120 * 60) - bid_ir: Optional[float] = Field(default=None, ge=0, le=1) - actual_loi: Optional[int] = Field(default=None, le=120 * 60) - actual_ir: Optional[float] = Field(default=None, ge=0, le=1) + bid_loi: int | None = Field(default=None, le=120 * 60) + bid_ir: float | None = Field(default=None, ge=0, le=1) + actual_loi: int | None = Field(default=None, le=120 * 60) + actual_ir: float | None = Field(default=None, ge=0, le=1) # Unclear what the difference is bw IR and conversion - conversion_rate: Optional[float] = Field(default=None, ge=0, le=1) + conversion_rate: float | None = Field(default=None, ge=0, le=1) entrance_url: str = Field( description="The link survey respondents should be sent to", @@ -396,12 +396,12 @@ class ProdegeSurvey(MarketplaceTask): ) # This described time-based click rate limiting. - max_clicks_settings: Optional[ProdegeMaxClicksSetting] = Field(default=None) + max_clicks_settings: ProdegeMaxClicksSetting | None = Field(default=None) # This describes the project/surveygroup exclusions - past_participation: Optional[ProdegePastParticipation] = Field(default=None) + past_participation: ProdegePastParticipation | None = Field(default=None) # These describe the panelist exclusions/inclusions - include_psids: Optional[Set[UUIDStr]] = Field(default=None) - exclude_psids: Optional[Set[UUIDStr]] = Field(default=None) + include_psids: set[UUIDStr] | None = Field(default=None) + exclude_psids: set[UUIDStr] | None = Field(default=None) # There are no "qualifications" per se. Instead, everyone has the match a # parent quota (and its children) qualifications: List[str] = @@ -409,23 +409,23 @@ class ProdegeSurvey(MarketplaceTask): # The eligibility is somewhat complex, with parent and children quotas. # Going to keep it flat here. - quotas: List[ProdegeQuota] = Field(default_factory=list) + quotas: list[ProdegeQuota] = Field(default_factory=list) source: Literal[Source.PRODEGE] = Field(default=Source.PRODEGE) - used_question_ids: Set[ProdegeQuestionIdType] = Field(default_factory=set) + used_question_ids: set[ProdegeQuestionIdType] = Field(default_factory=set) # This is a "special" key to store all conditions that are used (as # "condition_hashes") throughout this survey. In the reduced representation # of this task (nearly always, for db i/o, in global_vars) this field will # be null. - conditions: Optional[Dict[str, ProdegeCondition]] = Field(default=None) + conditions: dict[str, ProdegeCondition] = Field(default=None) # These do not come from the API. We set them. - created: Optional[AwareDatetimeISO] = Field( + created: AwareDatetimeISO | None = Field( description="when we created this survey in our system", default=None ) - updated: Optional[AwareDatetimeISO] = Field(default=None) + updated: AwareDatetimeISO | None = Field(default=None) @property def internal_id(self) -> str: @@ -494,7 +494,7 @@ class ProdegeSurvey(MarketplaceTask): return "1" @property - def marketplace_genders(self) -> Dict[Gender, Optional[MarketplaceCondition]]: + def marketplace_genders(self) -> dict[Gender, MarketplaceCondition | None]: return { Gender.MALE: ProdegeCondition( question_id="3", values=["1"], value_type=ConditionValueType.LIST @@ -510,7 +510,7 @@ class ProdegeSurvey(MarketplaceTask): return round(float(v), 2) @classmethod - def from_api(cls, d: Dict[str, Any]) -> Optional["ProdegeSurvey"]: + def from_api(cls, d: dict[str, Any]) -> "ProdegeSurvey" | None: try: return cls._from_api(d) except Exception as e: @@ -518,7 +518,7 @@ class ProdegeSurvey(MarketplaceTask): return None @classmethod - def _from_api(cls, d: Dict[str, Any]) -> "ProdegeSurvey": + def _from_api(cls, d: dict[str, Any]) -> "ProdegeSurvey": # Handle phases. keys in api response are 'loi' and 'actual_ir' if d["phases"]["loi_phase"] == "actual": @@ -575,14 +575,14 @@ class ProdegeSurvey(MarketplaceTask): @computed_field @cached_property - def all_hashes(self) -> Set[str]: + def all_hashes(self) -> set[str]: s = set() for q in self.quotas: s.update(set(q.condition_hashes)) return s @property - def quotas_verbose(self) -> List[List[Dict[str, Any]]]: + def quotas_verbose(self) -> list[list[dict[str, Any]]]: assert self.conditions is not None, "conditions must be set" res = [] for quota_group in self.quotas: @@ -625,7 +625,7 @@ class ProdegeSurvey(MarketplaceTask): return o1 == o2 - def to_mysql(self) -> Dict[str, Any]: + def to_mysql(self) -> dict[str, Any]: d = self.model_dump( mode="json", exclude={ @@ -655,7 +655,7 @@ class ProdegeSurvey(MarketplaceTask): return d @classmethod - def from_db(cls, d: Dict[str, Any]) -> "ProdegeSurvey": + def from_db(cls, d: dict[str, Any]) -> "ProdegeSurvey": d["created"] = d["created"].replace(tzinfo=timezone.utc) d["updated"] = d["updated"].replace(tzinfo=timezone.utc) d["quotas"] = json.loads(d["quotas"]) @@ -675,7 +675,7 @@ class ProdegeSurvey(MarketplaceTask): def passes_quotas( self, - criteria_evaluation: Dict[str, Optional[bool]], + criteria_evaluation: dict[str, bool | None], country_iso: str, verbose: bool = False, ) -> bool: @@ -721,8 +721,8 @@ class ProdegeSurvey(MarketplaceTask): def passes_child_quotas( self, - criteria_evaluation: Dict[str, Optional[bool]], - child_quotas: List[ProdegeQuota], + criteria_evaluation: dict[str, bool | None], + child_quotas: list[ProdegeQuota], country_iso: str, verbose: bool = False, ) -> bool: @@ -753,14 +753,14 @@ class ProdegeSurvey(MarketplaceTask): return passes def determine_eligibility( - self, criteria_evaluation: Dict[str, Optional[bool]], country_iso: str + self, criteria_evaluation: dict[str, bool | None], country_iso: str ) -> bool: return self.is_open and self.passes_quotas( criteria_evaluation, country_iso=country_iso ) def print_eligibility( - self, criteria_evaluation: Dict[str, Optional[bool]], country_iso: str + self, criteria_evaluation: dict[str, bool | None], country_iso: str ) -> None: print(f"is_open: {self.is_open}") print("passes_quotas") @@ -769,5 +769,3 @@ class ProdegeSurvey(MarketplaceTask): criteria_evaluation, country_iso=country_iso, verbose=True ) ) - - return None diff --git a/generalresearch/models/prodege/task_collection.py b/generalresearch/models/prodege/task_collection.py index 0ea09e1..364c5ff 100644 --- a/generalresearch/models/prodege/task_collection.py +++ b/generalresearch/models/prodege/task_collection.py @@ -51,11 +51,11 @@ ProdegeTaskCollectionSchema = DataFrameSchema( class ProdegeTaskCollection(TaskCollection): - items: List[ProdegeSurvey] + items: list[ProdegeSurvey] _schema = ProdegeTaskCollectionSchema @staticmethod - def to_row(s: ProdegeSurvey) -> Dict[str, Any]: + def to_row(s: ProdegeSurvey) -> dict[str, Any]: fields = [ "survey_id", "status", diff --git a/generalresearch/models/repdata/question.py b/generalresearch/models/repdata/question.py index d33a4d8..bd5fded 100644 --- a/generalresearch/models/repdata/question.py +++ b/generalresearch/models/repdata/question.py @@ -4,7 +4,7 @@ import json import logging from enum import Enum from functools import cached_property -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Set +from typing import TYPE_CHECKING, Any, Literal from uuid import UUID from pydantic import ( @@ -35,12 +35,12 @@ class RepDataUserQuestionAnswer(BaseModel): # "anonymous" users, which are represented by a list of question answers # not associated with an actual user. No default b/c we must explicitly # set the field to None. - user_id: Optional[PositiveInt] = Field(lt=MAX_INT32) + user_id: PositiveInt | None = Field(lt=MAX_INT32) question_id: str = Field(min_length=1, max_length=16, pattern=r"^[0-9]+$") # This is optional b/c we do not need it when writing these to the db. When # these are fetched from the db for use in yield-management, we read this # field from the repdata_question table. - question_type: Optional[RepDataQuestionType] = Field(default=None) + question_type: RepDataQuestionType | None = Field(default=None) # This may be a pipe-separated string if the question_type is multi. regex # means any chars except capital letters option_id: str = Field(pattern=r"^[^A-Z]*$") @@ -55,10 +55,10 @@ class RepDataUserQuestionAnswer(BaseModel): ) @cached_property - def options_ids(self) -> Set[str]: + def options_ids(self) -> set[str]: return set(self.option_id.split("|")) - def to_mysql(self) -> Dict[str, Any]: + def to_mysql(self) -> dict[str, Any]: d = self.model_dump(mode="json", exclude={"question_type"}) d["created"] = self.created.replace(tzinfo=None) return d @@ -118,14 +118,14 @@ class RepDataQuestion(MarketplaceQuestion): question_name: str = Field( min_length=1, max_length=64, frozen=True, validation_alias="QualificationName" ) - lucid_id: Optional[str] = Field( + lucid_id: str | None = Field( min_length=1, max_length=16, pattern=r"^[0-9]+$", validation_alias="StandardGlobalID", frozen=True, ) - lucid_name: Optional[str] = Field( + lucid_name: str | None = Field( min_length=1, max_length=64, frozen=True, validation_alias="StandardGlobalName" ) question_text: str = Field( @@ -137,7 +137,7 @@ class RepDataQuestion(MarketplaceQuestion): question_type: RepDataQuestionType = Field( frozen=True, validation_alias="QualificationType" ) - options: Optional[List[RepDataQuestionOption]] = Field(default=None, min_length=1) + options: list[RepDataQuestionOption] | None = Field(default=None, min_length=1) source: Literal[Source.REPDATA] = Source.REPDATA @property @@ -160,8 +160,8 @@ class RepDataQuestion(MarketplaceQuestion): @classmethod def from_api( - cls, d: Dict[str, Any], country_iso: str, language_iso: str - ) -> Optional["RepDataQuestion"]: + cls, d: dict[str, Any], country_iso: str, language_iso: str + ) -> "RepDataQuestion" | None: """ :param d: Raw response from API """ @@ -173,7 +173,7 @@ class RepDataQuestion(MarketplaceQuestion): @classmethod def _from_api( - cls, d: Dict[str, Any], country_iso: str, language_iso: str + cls, d: dict[str, Any], country_iso: str, language_iso: str ) -> "RepDataQuestion": d["QualificationType"] = RepDataQuestionType.from_api(d["QualificationType"]) # zip code/age has a placeholder invalid option for some reason @@ -191,7 +191,7 @@ class RepDataQuestion(MarketplaceQuestion): ) @classmethod - def from_db(cls, d: Dict[str, Any]) -> "RepDataQuestion": + def from_db(cls, d: dict[str, Any]) -> "RepDataQuestion": options = None if d["options"]: options = [ @@ -212,7 +212,7 @@ class RepDataQuestion(MarketplaceQuestion): category_id=d.get("category_id"), ) - def to_mysql(self) -> Dict[str, Any]: + def to_mysql(self) -> dict[str, Any]: d = self.model_dump(mode="json", by_alias=True) d["options"] = json.dumps(d["options"]) return d diff --git a/generalresearch/models/repdata/survey.py b/generalresearch/models/repdata/survey.py index b6acd92..cfc58af 100644 --- a/generalresearch/models/repdata/survey.py +++ b/generalresearch/models/repdata/survey.py @@ -6,7 +6,7 @@ import logging from datetime import datetime, timezone from decimal import Decimal from functools import cached_property -from typing import Any, Dict, List, Literal, Optional, Set, Type +from typing import Any, Literal, Type from uuid import UUID from pydantic import ( @@ -54,13 +54,13 @@ class RepDataCondition(MarketplaceCondition): pattern=r"^[0-9]+$", validation_alias="StandardGlobalQuestionID", ) - values: List[str] = Field(min_length=1, validation_alias="PreCodes") + values: list[str] = Field(min_length=1, validation_alias="PreCodes") value_type: Literal[ConditionValueType.LIST] = Field( default=ConditionValueType.LIST ) @classmethod - def from_api(cls, d: Dict[str, Any]) -> "RepDataCondition": + def from_api(cls, d: dict[str, Any]) -> "RepDataCondition": if d["Condition"] == "Is": d["logical_operator"] = LogicalOperator.OR d["negate"] = False @@ -86,7 +86,7 @@ class RepDataQuota(BaseModel): ) quota_uuid: UUIDStr = Field(validation_alias="QuotaUd") name: str = Field(validation_alias="QuotaName") - desired_count: Optional[int] = Field( + desired_count: int | None = Field( default=None, validation_alias="Quota", description="Desired completes or starts (depending on calculation_type)", @@ -95,15 +95,15 @@ class RepDataQuota(BaseModel): validation_alias="QuotaAchieved", description="Achieved completes or starts (depending on calculation_type)", ) - remaining_count: Optional[int] = Field( + remaining_count: int | None = Field( validation_alias="QuotaRemaining", description="Completes or starts remaining (depending on calculation_type). Should " "be used as the indicator for whether more respondents are needed to a " "specific quota. If QuotaRemaining value = 0, then pause. If None, then the quota" "is completely open (i.e. infinity). Unclear if this is true though (see .is_open)", ) - conditions: List[RepDataCondition] = Field(min_length=1) - condition_hashes: List[str] = Field(min_length=1, default_factory=list) + conditions: list[RepDataCondition] = Field(min_length=1) + condition_hashes: list[str] = Field(min_length=1, default_factory=list) @field_validator("quota_uuid", mode="before") @classmethod @@ -137,7 +137,7 @@ class RepDataQuota(BaseModel): d = self.model_dump(mode="json", exclude={"conditions"}) return RepDataHashedQuota.model_validate(d) - def passes(self, criteria_evaluation: Dict[str, Optional[bool]]) -> Optional[bool]: + def passes(self, criteria_evaluation: dict[str, bool | None]) -> bool | None: # We have to match all conditions within the quota. return self.is_open and all( criteria_evaluation.get(c) for c in self.condition_hashes @@ -162,12 +162,12 @@ class RepDataStream(MarketplaceTask): validation_alias="CalculationType", ) - qualifications: List[RepDataCondition] = Field(min_length=1) - qualification_hashes: List[str] = Field(min_length=1, default_factory=list) - quotas: List[RepDataQuota] = Field(min_length=1) - hashed_quotas: List[RepDataHashedQuota] = Field(min_length=1, default_factory=list) + qualifications: list[RepDataCondition] = Field(min_length=1) + qualification_hashes: list[str] = Field(min_length=1, default_factory=list) + quotas: list[RepDataQuota] = Field(min_length=1) + hashed_quotas: list[RepDataHashedQuota] = Field(min_length=1, default_factory=list) - used_question_ids: Set[str] = Field(default_factory=set) + used_question_ids: set[str] = Field(default_factory=set) # Note: The API returns both Expected and ExpectedStreamCompletes which are the same expected_count: int = Field( @@ -182,7 +182,7 @@ class RepDataStream(MarketplaceTask): ) cpi: Decimal = Field(gt=0, le=100, validation_alias="CPI") - days_in_field: Optional[int] = Field(validation_alias="DaysInField", default=None) + days_in_field: int | None = Field(validation_alias="DaysInField", default=None) # # -------------- # # # Below here: these fields are useless because it is our own data. @@ -275,14 +275,14 @@ class RepDataStream(MarketplaceTask): @computed_field @cached_property - def all_hashes(self) -> Set[str]: + def all_hashes(self) -> set[str]: s = set(self.qualification_hashes.copy()) for q in self.hashed_quotas: s.update(set(q.condition_hashes)) return s @property - def all_conditions(self) -> List[RepDataCondition]: + def all_conditions(self) -> list[RepDataCondition]: cs = self.qualifications.copy() for quota in self.quotas: cs.extend(quota.conditions.copy()) @@ -312,7 +312,7 @@ class RepDataStream(MarketplaceTask): return "42" @property - def marketplace_genders(self) -> Dict[Gender, Optional[MarketplaceCondition]]: + def marketplace_genders(self) -> dict[Gender, MarketplaceCondition | None]: return { Gender.MALE: RepDataCondition( question_id="43", @@ -362,7 +362,7 @@ class RepDataStreamHashed(RepDataStream): @classmethod def from_db( - cls, res: Dict[str, Any], survey: RepDataSurveyHashed + cls, res: dict[str, Any], survey: RepDataSurveyHashed ) -> "RepDataStreamHashed": # We need certain fields copied over here so that a stream can exist # independent of the survey @@ -408,18 +408,18 @@ class RepDataSurvey(BaseModel): validation_alias="PII", description="Indicates whether PII is collected" ) - allowed_devices: List[DeviceType] = Field( + allowed_devices: list[DeviceType] = Field( min_length=1, validation_alias="Device Compatibility" ) - streams: List[RepDataStream] = Field(min_length=1) - hashed_streams: List[RepDataStreamHashed] = Field( + streams: list[RepDataStream] = Field(min_length=1) + hashed_streams: list[RepDataStreamHashed] = Field( min_length=1, default_factory=list ) # These do not come from the API. We set them ourselves - created: Optional[AwareDatetimeISO] = Field(default=None) - last_updated: Optional[AwareDatetimeISO] = Field(default=None) + created: AwareDatetimeISO | None = Field(default=None) + last_updated: AwareDatetimeISO | None = Field(default=None) @field_validator("survey_uuid", "project_uuid", mode="before") @classmethod @@ -434,7 +434,7 @@ class RepDataSurvey(BaseModel): return data @field_validator("allowed_devices", mode="after") - def sort_allowed_devices(cls, values: List[str]): + def sort_allowed_devices(cls, values: list[str]): return sorted(values) @property @@ -452,14 +452,14 @@ class RepDataSurvey(BaseModel): return self.survey_status == RepDataStatus.LIVE @property - def all_hashes(self) -> Set[str]: + def all_hashes(self) -> set[str]: s = set() for stream in self.hashed_streams: s.update(stream.all_hashes) return s @property - def all_conditions(self) -> List[RepDataCondition]: + def all_conditions(self) -> list[RepDataCondition]: cs = list() for stream in self.streams: cs.extend(stream.all_conditions) @@ -472,7 +472,7 @@ class RepDataSurvey(BaseModel): return ",".join(map(str, sorted([d.value for d in self.allowed_devices]))) @classmethod - def from_api(cls, survey_response) -> Optional["RepDataSurvey"]: + def from_api(cls, survey_response) -> "RepDataSurvey" | None: """ :param survey_response: Raw response from API """ @@ -518,7 +518,7 @@ class RepDataSurvey(BaseModel): def is_changed(self, other) -> bool: return not self.is_unchanged(other) - def to_mysql(self) -> Dict[str, Any]: + def to_mysql(self) -> dict[str, Any]: return self.to_hashed_survey().to_mysql() def to_hashed_survey(self) -> "RepDataSurveyHashed": @@ -533,7 +533,7 @@ class RepDataSurveyHashed(RepDataSurvey): streams: None = Field(default=None, exclude=True) @classmethod - def from_db(cls, res: Dict[str, Any]) -> "RepDataSurveyHashed": + def from_db(cls, res: dict[str, Any]) -> "RepDataSurveyHashed": res["allowed_devices"] = [ DeviceType(int(x)) for x in res["allowed_devices"].split(",") ] @@ -542,7 +542,7 @@ class RepDataSurveyHashed(RepDataSurvey): res["last_updated"] = res["last_updated"].replace(tzinfo=timezone.utc) return cls.model_validate(res) - def to_mysql(self) -> Dict[str, Any]: + def to_mysql(self) -> dict[str, Any]: d = self.model_dump(mode="json", by_alias=True, exclude={"hashed_streams"}) d["allowed_devices"] = ",".join( map(str, sorted([d.value for d in self.allowed_devices])) diff --git a/generalresearch/models/repdata/task_collection.py b/generalresearch/models/repdata/task_collection.py index 740670b..b79808d 100644 --- a/generalresearch/models/repdata/task_collection.py +++ b/generalresearch/models/repdata/task_collection.py @@ -1,4 +1,6 @@ -from typing import Any, Dict, List +from __future__ import annotations + +from typing import Any import pandas as pd from pandera import Check, Column, DataFrameSchema, Index @@ -46,8 +48,8 @@ RepDataTaskCollectionSchema = DataFrameSchema( "remaining_count": Column(int, Check.greater_than_or_equal_to(0)), "calculation_type": Column(str, Check.isin(TaskCalculationType)), "cpi": Column(float, Check.between(min_value=0, max_value=100)), - "used_question_ids": Column(List[str]), - "all_hashes": Column(List[str]), # set >> list for column support + "used_question_ids": Column(list[str]), + "all_hashes": Column(list[str]), # set >> list for column support }, checks=[ # # There's only 1 or 2 live surveys, so we can't really assert anything ... @@ -78,10 +80,10 @@ RepDataTaskCollectionSchema = DataFrameSchema( class RepDataTaskCollection(TaskCollection): - items: List[RepDataSurveyHashed] + items: list[RepDataSurveyHashed] _schema = RepDataTaskCollectionSchema - def to_rows(self, s: RepDataSurveyHashed) -> List[Dict[str, Any]]: + def to_rows(self, s: RepDataSurveyHashed) -> list[dict[str, Any]]: survey_fields = [ "survey_id", "survey_uuid", diff --git a/generalresearch/models/sago/question.py b/generalresearch/models/sago/question.py index 62ff363..f1ef8b4 100644 --- a/generalresearch/models/sago/question.py +++ b/generalresearch/models/sago/question.py @@ -1,10 +1,12 @@ +from __future__ import annotations + # https://developer-beta.market-cube.com/api-details#api=definition-api&operation=get-api-v1-definition-qualification # -answers-lanaguge-languageid import json import logging from enum import Enum from functools import cached_property -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Set +from typing import TYPE_CHECKING, Any, Literal from pydantic import ( BaseModel, @@ -39,7 +41,7 @@ class SagoQuestionOption(BaseModel): ) # This is returned by the API but does not seem to be used for anything. # Will keep it any ways. - code: Optional[str] = Field(min_length=1, max_length=16) + code: str | None = Field(min_length=1, max_length=16) text: str = Field( min_length=1, max_length=1024, @@ -94,13 +96,13 @@ class SagoUserQuestionAnswer(BaseModel): # "anonymous" users, which are represented by a list of question answers # not associated with an actual user. No default b/c we must explicitly set # the field to None. - user_id: Optional[PositiveInt] = Field(lt=MAX_INT32) + user_id: PositiveInt | None = Field(lt=MAX_INT32) question_id: str = Field(min_length=1, max_length=16, pattern=r"^[0-9]+$") # This is optional b/c we do not need it when writing these to the db. When # these are fetched from the db for use in yield-management, we read this # field from the question table. - question_type: Optional[SagoQuestionType] = Field(default=None) + question_type: SagoQuestionType | None = Field(default=None) # This may be a pipe-separated string if the question_type is multi. # regex means any chars except capital letters @@ -121,10 +123,10 @@ class SagoUserQuestionAnswer(BaseModel): return self.pre_code @cached_property - def options_ids(self) -> Set[str]: + def options_ids(self) -> set[str]: return set(self.pre_code.split("|")) - def to_mysql(self) -> Dict[str, Any]: + def to_mysql(self) -> dict[str, Any]: d = self.model_dump(mode="json", exclude={"question_type"}) d["created"] = self.created.replace(tzinfo=None) return d @@ -145,10 +147,10 @@ class SagoQuestion(MarketplaceQuestion): max_length=1024, min_length=1, description="The text shown to respondents" ) question_type: SagoQuestionType = Field(frozen=True) - options: Optional[List[SagoQuestionOption]] = Field(default=None, min_length=1) + options: list[SagoQuestionOption] | None = Field(default=None, min_length=1) # This comes from the API field "qualificationCategoryId" - tags: Optional[str] = Field(default=None, frozen=True) + tags: str | None = Field(default=None, frozen=True) source: Literal[Source.SAGO] = Source.SAGO @property @@ -165,13 +167,13 @@ class SagoQuestion(MarketplaceQuestion): return self @field_validator("question_name", "question_text", "tags", mode="after") - def remove_nbsp(cls, s: Optional[str]): + def remove_nbsp(cls, s: str | None): return string_utils.remove_nbsp(s) @classmethod def from_api( - cls, d: Dict[str, Any], country_iso: str, language_iso: str - ) -> Optional["SagoQuestion"]: + cls, d: dict[str, Any], country_iso: str, language_iso: str + ) -> "SagoQuestion" | None: """ :param d: Raw response from API :param country_iso: @@ -186,7 +188,7 @@ class SagoQuestion(MarketplaceQuestion): @classmethod def _from_api( - cls, d: Dict[str, Any], country_iso: str, language_iso: str + cls, d: dict[str, Any], country_iso: str, language_iso: str ) -> "SagoQuestion": sago_category_to_tags = { 1: "Standard", @@ -221,7 +223,7 @@ class SagoQuestion(MarketplaceQuestion): ) @classmethod - def from_db(cls, d: Dict[str, Any]) -> "SagoQuestion": + def from_db(cls, d: dict[str, Any]) -> "SagoQuestion": options = None if d["options"]: options = [ @@ -243,7 +245,7 @@ class SagoQuestion(MarketplaceQuestion): category_id=d.get("category_id"), ) - def to_mysql(self) -> Dict[str, Any]: + def to_mysql(self) -> dict[str, Any]: d = self.model_dump(mode="json", by_alias=True) d["options"] = json.dumps(d["options"]) return d diff --git a/generalresearch/models/sago/survey.py b/generalresearch/models/sago/survey.py index a8188b0..b07c846 100644 --- a/generalresearch/models/sago/survey.py +++ b/generalresearch/models/sago/survey.py @@ -5,7 +5,7 @@ import logging from datetime import timezone from decimal import Decimal from functools import cached_property -from typing import Annotated, Any, Dict, List, Literal, Optional, Set, Tuple, Type +from typing import Annotated, Any, Literal, Type from more_itertools import flatten from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator @@ -38,18 +38,18 @@ locale_helper = Localelator() class SagoCondition(MarketplaceCondition): model_config = ConfigDict(populate_by_name=True, frozen=False, extra="ignore") - question_id: Optional[CoercedStr] = Field( + question_id: CoercedStr | None = Field( min_length=1, max_length=16, pattern=r"^[0-9]+$" ) # There isn't really a hard limit, but their API is inconsistent and # sometimes returns all the options comma-separated instead of as a list. # Try to catch that. - values: List[Annotated[str, Field(max_length=128)]] = Field() + values: list[Annotated[str, Field(max_length=128)]] = Field() _CONVERT_LIST_TO_RANGE = ["59"] @classmethod - def from_api(cls, d: Dict[str, Any]) -> "SagoCondition": + def from_api(cls, d: dict[str, Any]) -> "SagoCondition": d["logical_operator"] = LogicalOperator.OR d["value_type"] = ConditionValueType(d["value_type"]) d["negate"] = False @@ -67,7 +67,7 @@ class SagoQuota(BaseModel): cpi: Decimal = Field(gt=0, le=100, decimal_places=2, max_digits=5) remaining_count: int = Field() - condition_hashes: List[str] = Field(min_length=0, default_factory=list) + condition_hashes: list[str] = Field(min_length=0, default_factory=list) # There is no explicit status. The quota is closed if the count is 0 @@ -80,23 +80,23 @@ class SagoQuota(BaseModel): return self.remaining_count >= min_open_spots @classmethod - def from_api(cls, d: Dict[str, Any]) -> Self: + def from_api(cls, d: dict[str, Any]) -> Self: return cls.model_validate(d) - def passes(self, criteria_evaluation: Dict[str, Optional[bool]]) -> bool: + def passes(self, criteria_evaluation: dict[str, bool | None]) -> bool: # Passes means we 1) meet all conditions (aka "match") AND 2) the # quota is open. return self.is_open and self.matches(criteria_evaluation) - def matches(self, criteria_evaluation: Dict[str, Optional[bool]]) -> bool: + def matches(self, criteria_evaluation: dict[str, bool | None]) -> bool: # Matches means we meet all conditions. # We can "match" a quota that is closed. In that case, we would not be # eligible for the survey. return all(criteria_evaluation.get(c) for c in self.condition_hashes) def matches_optional( - self, criteria_evaluation: Dict[str, Optional[bool]] - ) -> Optional[bool]: + self, criteria_evaluation: dict[str, bool | None] + ) -> bool | None: # We need to know if any conditions are unknown to avoid matching a # full quota. If any fail, then we know we fail regardless of any # being unknown. @@ -108,8 +108,8 @@ class SagoQuota(BaseModel): return True def matches_soft( - self, criteria_evaluation: Dict[str, Optional[bool]] - ) -> Tuple[Optional[bool], Set[str]]: + self, criteria_evaluation: dict[str, bool | None] + ) -> tuple[bool | None, set[str]]: # Passes back "matches" (T/F/none) and a list of unknown criterion hashes hash_evals = { cell: criteria_evaluation.get(cell) for cell in self.condition_hashes @@ -151,35 +151,35 @@ class SagoSurvey(MarketplaceTask): allowed_devices: DeviceTypes = Field(min_length=1) collects_pii: bool = Field(default=False) - survey_exclusions: Optional[AlphaNumStrSet] = Field( + survey_exclusions: AlphaNumStrSet | None = Field( description="list of excluded survey ids", default=None ) - ip_exclusions: Optional[IPLikeStrSet] = Field( + ip_exclusions: IPLikeStrSet | None = Field( description="list of excluded IP addresses", default=None ) # Documentation I think is wrong. These are the keys "LOI" and "IR". it # doesn't say that they are bid or not, but they never seem to change ... - bid_loi: Optional[int] = Field(default=None, le=120 * 60) - bid_ir: Optional[float] = Field(default=None, ge=0, le=1) + bid_loi: int | None = Field(default=None, le=120 * 60) + bid_ir: float | None = Field(default=None, ge=0, le=1) live_link: str = Field() # this comes from the Survey Reservation endpoint remaining_count: int = Field() - qualifications: List[str] = Field(default_factory=list) - quotas: List[SagoQuota] = Field(default_factory=list) + qualifications: list[str] = Field(default_factory=list) + quotas: list[SagoQuota] = Field(default_factory=list) source: Literal[Source.SAGO] = Field(default=Source.SAGO) - used_question_ids: Set[AlphaNumStr] = Field(default_factory=set) + used_question_ids: set[AlphaNumStr] = Field(default_factory=set) # This is a "special" key to store all conditions that are used (as # "condition_hashes") throughout this survey. In the reduced representation # of this task (nearly always, for db i/o, in global_vars) this field will # be null. - conditions: Optional[Dict[str, SagoCondition]] = Field(default=None) + conditions: dict[str, SagoCondition] | None = Field(default=None) # These come from the API modified_api: AwareDatetimeISO = Field( @@ -187,8 +187,8 @@ class SagoSurvey(MarketplaceTask): ) # This does not come from the API. We set it when we update this in the db. - created: Optional[AwareDatetimeISO] = Field(default=None) - updated: Optional[AwareDatetimeISO] = Field(default=None) + created: AwareDatetimeISO | None = Field(default=None) + updated: AwareDatetimeISO | None = Field(default=None) @property def internal_id(self) -> str: @@ -208,7 +208,7 @@ class SagoSurvey(MarketplaceTask): @computed_field @cached_property - def all_hashes(self) -> Set[str]: + def all_hashes(self) -> set[str]: s = set(self.qualifications) for q in self.quotas: s.update(set(q.condition_hashes)) @@ -243,7 +243,7 @@ class SagoSurvey(MarketplaceTask): return "59" @property - def marketplace_genders(self) -> Dict[Gender, Optional[MarketplaceCondition]]: + def marketplace_genders(self) -> dict[Gender, MarketplaceCondition | None]: return { Gender.MALE: SagoCondition( question_id="60", @@ -259,7 +259,7 @@ class SagoSurvey(MarketplaceTask): } @classmethod - def from_api(cls, d: Dict[str, Any]) -> Optional["SagoSurvey"]: + def from_api(cls, d: dict[str, Any]) -> "SagoSurvey" | None: try: return cls._from_api(d) except Exception as e: @@ -267,7 +267,7 @@ class SagoSurvey(MarketplaceTask): return None @classmethod - def _from_api(cls, d: Dict[str, Any]) -> "SagoSurvey": + def _from_api(cls, d: dict[str, Any]) -> "SagoSurvey": return cls.model_validate(d) def __repr__(self) -> str: @@ -294,7 +294,7 @@ class SagoSurvey(MarketplaceTask): exclude={"updated", "conditions", "created"} ) == other.model_dump(exclude={"updated", "conditions", "created"}) - def to_mysql(self) -> Dict[str, Any]: + def to_mysql(self) -> dict[str, Any]: d = self.model_dump( mode="json", exclude={ @@ -313,7 +313,7 @@ class SagoSurvey(MarketplaceTask): return d @classmethod - def from_db(cls, d: Dict[str, Any]): + def from_db(cls, d: dict[str, Any]): d["created"] = d["created"].replace(tzinfo=timezone.utc) d["updated"] = d["updated"].replace(tzinfo=timezone.utc) d["modified_api"] = d["modified_api"].replace(tzinfo=timezone.utc) @@ -323,14 +323,14 @@ class SagoSurvey(MarketplaceTask): return cls.model_validate(d) def passes_qualifications( - self, criteria_evaluation: Dict[str, Optional[bool]] + self, criteria_evaluation: dict[str, bool | None] ) -> bool: # We have to match all quals return all(criteria_evaluation.get(q) for q in self.qualifications) def passes_qualifications_soft( - self, criteria_evaluation: Dict[str, Optional[bool]] - ) -> Tuple[Optional[bool], Set[str]]: + self, criteria_evaluation: dict[str, bool | None] + ) -> tuple[bool | None, set[str]]: # Passes back "passes" (T/F/none) and a list of unknown criterion hashes hash_evals = {q: criteria_evaluation.get(q) for q in self.qualifications} evals = set(hash_evals.values()) @@ -342,7 +342,7 @@ class SagoSurvey(MarketplaceTask): return None, {cell for cell, ev in hash_evals.items() if ev is None} return True, set() - def passes_quotas(self, criteria_evaluation: Dict[str, Optional[bool]]) -> bool: + def passes_quotas(self, criteria_evaluation: dict[str, bool | None]) -> bool: # Many surveys have 0 quotas. Quotas are exclusionary. # They can NOT match a quota where currently_open=0 any_pass = True @@ -354,8 +354,8 @@ class SagoSurvey(MarketplaceTask): return any_pass def passes_quotas_soft( - self, criteria_evaluation: Dict[str, Optional[bool]] - ) -> Tuple[Optional[bool], Set[str]]: + self, criteria_evaluation: dict[str, bool | None] + ) -> tuple[bool | None, set[str]]: # Many surveys have 0 quotas. Quotas are exclusionary. # They can NOT match a quota where currently_open=0 if len(self.quotas) == 0: @@ -394,7 +394,7 @@ class SagoSurvey(MarketplaceTask): return False, set() def determine_eligibility( - self, criteria_evaluation: Dict[str, Optional[bool]] + self, criteria_evaluation: dict[str, bool | None] ) -> bool: return ( self.is_open @@ -403,8 +403,8 @@ class SagoSurvey(MarketplaceTask): ) def determine_eligibility_soft( - self, criteria_evaluation: Dict[str, Optional[bool]] - ) -> Tuple[Optional[bool], Set[str]]: + self, criteria_evaluation: dict[str, bool | None] + ) -> tuple[bool | None, set[str]]: if self.is_open is False: return False, set() pass_quals, h_quals = self.passes_qualifications_soft(criteria_evaluation) diff --git a/generalresearch/models/sago/task_collection.py b/generalresearch/models/sago/task_collection.py index 41d047e..08de55f 100644 --- a/generalresearch/models/sago/task_collection.py +++ b/generalresearch/models/sago/task_collection.py @@ -1,4 +1,6 @@ -from typing import Any, Dict, List, Set +from __future__ import annotations + +from typing import Any import pandas as pd from pandera import Check, Column, DataFrameSchema, Index @@ -11,8 +13,8 @@ from generalresearch.models.thl.survey.task_collection import ( create_empty_df_from_schema, ) -COUNTRY_ISOS: Set[str] = Localelator().get_all_countries() -LANGUAGE_ISOS: Set[str] = Localelator().get_all_languages() +COUNTRY_ISOS: set[str] = Localelator().get_all_countries() +LANGUAGE_ISOS: set[str] = Localelator().get_all_languages() SagoTaskCollectionSchema = DataFrameSchema( columns={ @@ -31,8 +33,8 @@ SagoTaskCollectionSchema = DataFrameSchema( "remaining_count": Column(int), "created": Column(dtype=pd.DatetimeTZDtype(tz="UTC")), "updated": Column(dtype=pd.DatetimeTZDtype(tz="UTC")), - "used_question_ids": Column(List[str]), - "all_hashes": Column(List[str]), # set >> list for column support + "used_question_ids": Column(list[str]), + "all_hashes": Column(list[str]), # set >> list for column support }, checks=[], index=Index( @@ -48,10 +50,10 @@ SagoTaskCollectionSchema = DataFrameSchema( class SagoTaskCollection(TaskCollection): - items: List[SagoSurvey] + items: list[SagoSurvey] _schema = SagoTaskCollectionSchema - def to_row(self, s: SagoSurvey) -> Dict[str, Any]: + def to_row(self, s: SagoSurvey) -> dict[str, Any]: d = s.model_dump( mode="json", exclude={ diff --git a/generalresearch/models/spectrum/question.py b/generalresearch/models/spectrum/question.py index 4f8b5e1..b544c5e 100644 --- a/generalresearch/models/spectrum/question.py +++ b/generalresearch/models/spectrum/question.py @@ -6,7 +6,7 @@ import logging from datetime import datetime, timezone from enum import Enum from functools import cached_property -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Set +from typing import TYPE_CHECKING, Any, Literal from uuid import UUID from pydantic import ( @@ -41,12 +41,12 @@ class SpectrumUserQuestionAnswer(BaseModel): # answers not associated with an actual user. No default b/c we must # explicitly set the field to None. - user_id: Optional[PositiveInt] = Field(lt=MAX_INT32) + user_id: PositiveInt | None = Field(lt=MAX_INT32) question_id: SpectrumQuestionIdType = Field() # This is optional b/c we do not need it when writing these to the # db. When these are fetched from the db for use in yield-management, # we read this field from the spectrum_question table. - question_type: Optional[SpectrumQuestionType] = Field(default=None) + question_type: SpectrumQuestionType | None = Field(default=None) # This may be a pipe-separated string if the question_type is multi. regex # means any chars except capital letters option_id: str = Field(pattern=r"^[^A-Z]*$") @@ -63,10 +63,10 @@ class SpectrumUserQuestionAnswer(BaseModel): ) @cached_property - def options_ids(self) -> Set[str]: + def options_ids(self) -> set[str]: return set(self.option_id.split("|")) - def to_mysql(self) -> Dict[str, Any]: + def to_mysql(self) -> dict[str, Any]: d = self.model_dump(mode="json", exclude={"question_type"}) d["created"] = self.created.replace(tzinfo=None) return d @@ -123,7 +123,7 @@ class SpectrumQuestionType(str, Enum): UNKNOWN = "u" @staticmethod - def get_api_map() -> Dict[int, SpectrumQuestionType]: + def get_api_map() -> dict[int, SpectrumQuestionType]: return { 1: SpectrumQuestionType.SINGLE_SELECT, 2: SpectrumQuestionType.SINGLE_SELECT, @@ -169,8 +169,8 @@ class SpectrumQuestion(MarketplaceQuestion): # looks to be a comma-separated str of "tags" or keywords associated # with a question, but they are freeform and don't pertain to any sort # of structured schema. This will be useful ChatGPT - tags: Optional[str] = Field(default=None, frozen=True) - options: Optional[List[SpectrumQuestionOption]] = Field( + tags: str | None = Field(default=None, frozen=True) + options: list[SpectrumQuestionOption] | None = Field( default=None, min_length=1, frozen=True ) # This comes from the API. Of course there are more than what is documented. @@ -178,7 +178,7 @@ class SpectrumQuestion(MarketplaceQuestion): class_num: SpectrumQuestionClass = Field(frozen=True) # This comes from the API. It is when it was created in Spectrum's DB, # not when we created it - created: Optional[AwareDatetimeISO] = Field(default=None, frozen=True) + created: AwareDatetimeISO | None = Field(default=None, frozen=True) source: Literal[Source.SPECTRUM] = Source.SPECTRUM @@ -207,7 +207,7 @@ class SpectrumQuestion(MarketplaceQuestion): return data @field_validator("question_name", "question_text", "tags", mode="after") - def remove_nbsp(cls, s: Optional[str]): + def remove_nbsp(cls, s: str | None): return string_utils.remove_nbsp(s) @model_validator(mode="before") @@ -236,7 +236,7 @@ class SpectrumQuestion(MarketplaceQuestion): @field_validator("options") @classmethod - def uniquify_options(cls, options: Optional[List[SpectrumQuestionOption]]): + def uniquify_options(cls, options: list[SpectrumQuestionOption] | None): if options: # The API returns questions with identical option IDs multiple # times. They seem to all be typo/corrections to the text, so @@ -251,8 +251,8 @@ class SpectrumQuestion(MarketplaceQuestion): @classmethod def from_api( - cls, d: Dict[str, Any], country_iso: str, language_iso: str - ) -> Optional["SpectrumQuestion"]: + cls, d: dict[str, Any], country_iso: str, language_iso: str + ) -> "SpectrumQuestion" | None: # To not pollute our logs, we know we are skipping any question that # meets the following conditions: if not SpectrumQuestionType.from_api(d["type"]): @@ -268,7 +268,7 @@ class SpectrumQuestion(MarketplaceQuestion): return None @classmethod - def _from_api(cls, d: Dict[str, Any], country_iso: str, language_iso: str) -> Self: + def _from_api(cls, d: dict[str, Any], country_iso: str, language_iso: str) -> Self: options = None if d.get("condition_codes"): # Sometimes they use the key "name" instead of "text" ... ? @@ -301,7 +301,7 @@ class SpectrumQuestion(MarketplaceQuestion): ) @classmethod - def from_db(cls, d: Dict[str, Any]) -> Self: + def from_db(cls, d: dict[str, Any]) -> Self: options = None if d["options"]: options = [ @@ -329,7 +329,7 @@ class SpectrumQuestion(MarketplaceQuestion): created=d["created"], ) - def to_mysql(self) -> Dict[str, Any]: + def to_mysql(self) -> dict[str, Any]: d = self.model_dump(mode="json", by_alias=True) d["options"] = json.dumps(d["options"]) if self.created: diff --git a/generalresearch/models/spectrum/survey.py b/generalresearch/models/spectrum/survey.py index 72093eb..3683745 100644 --- a/generalresearch/models/spectrum/survey.py +++ b/generalresearch/models/spectrum/survey.py @@ -4,7 +4,7 @@ import json import logging from datetime import timezone from decimal import Decimal -from typing import Any, Dict, List, Literal, Optional, Set, Tuple, Type +from typing import Any, Literal, Type from more_itertools import flatten from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator @@ -37,7 +37,7 @@ locale_helper = Localelator() class SpectrumCondition(MarketplaceCondition): model_config = ConfigDict(populate_by_name=True, frozen=False, extra="ignore") - question_id: Optional[CoercedStr] = Field( + question_id: CoercedStr | None = Field( min_length=1, max_length=16, pattern=r"^[0-9]+$", @@ -65,7 +65,7 @@ class SpectrumCondition(MarketplaceCondition): return self @classmethod - def from_api(cls, d: Dict[str, Any]) -> "SpectrumCondition": + def from_api(cls, d: dict[str, Any]) -> "SpectrumCondition": """Ranges can get returns with a key "units" indicating years or months. This is ridiculous, and we don't ask for birthdate, so we can't really get month accuracy. Normalize to years. @@ -98,7 +98,7 @@ class SpectrumQuota(BaseModel): description="Number of completes currently available in the quota. If " "the value is 0, any respondent matching this quota will be rejected." ) - condition_hashes: List[str] = Field(min_length=0, default_factory=list) + condition_hashes: list[str] = Field(min_length=0, default_factory=list) # API also returns remaining & achieved, but these are supplier-scoped. # There is no explicit status. The quota is closed if the count is 0 @@ -114,23 +114,23 @@ class SpectrumQuota(BaseModel): return self.remaining_count >= min_open_spots @classmethod - def from_api(cls, d: Dict) -> Self: + def from_api(cls, d: dict) -> Self: d["remaining_count"] = d["quantities"]["currently_open"] return cls.model_validate(d) - def passes(self, criteria_evaluation: Dict[str, Optional[bool]]) -> bool: + def passes(self, criteria_evaluation: dict[str, bool | None]) -> bool: # Passes means we 1) meet all conditions (aka "match") AND 2) the # quota is open. return self.is_open and self.matches(criteria_evaluation) - def matches(self, criteria_evaluation: Dict[str, Optional[bool]]) -> bool: + def matches(self, criteria_evaluation: dict[str, bool | None]) -> bool: # Matches means we meet all conditions. We can "match" a quota that is # closed. In that case, we would not be eligible for the survey. return all(criteria_evaluation.get(c) for c in self.condition_hashes) def matches_optional( - self, criteria_evaluation: Dict[str, Optional[bool]] - ) -> Optional[bool]: + self, criteria_evaluation: dict[str, bool | None] + ) -> bool | None: # We need to know if any conditions are unknown to avoid matching a # full quota. If any fail, then we know we fail regardless of any # being unknown. @@ -142,8 +142,8 @@ class SpectrumQuota(BaseModel): return True def matches_soft( - self, criteria_evaluation: Dict[str, Optional[bool]] - ) -> Tuple[Optional[bool], Set[str]]: + self, criteria_evaluation: dict[str, bool | None] + ) -> tuple[bool | None, set[str]]: # Passes back "matches" (T/F/none) and a list of unknown criterion hashes hash_evals = { cell: criteria_evaluation.get(cell) for cell in self.condition_hashes @@ -188,7 +188,7 @@ class SpectrumSurvey(MarketplaceTask): # called "survey_grouping" in API. If a respondent has previously taken any # of these surveys, they will be excluded if that survey was taken in # the exclusion_period. - survey_exclusions: Optional[AlphaNumStrSet] = Field( + survey_exclusions: AlphaNumStrSet | None = Field( description="list of excluded survey ids", default=None ) exclusion_period: int = Field(default=30, description="in days") @@ -200,16 +200,16 @@ class SpectrumSurvey(MarketplaceTask): # when we update the db, we must not overwrite this with NULL. # API key: "survey_performance" - bid_loi: Optional[int] = Field(default=None, le=120 * 60) - bid_ir: Optional[float] = Field(default=None, ge=0, le=1) - overall_loi: Optional[int] = Field(default=None, le=120 * 60) - overall_ir: Optional[float] = Field(default=None, ge=0, le=1) - last_block_loi: Optional[int] = Field(default=None, le=120 * 60) - last_block_ir: Optional[float] = Field(default=None, ge=0, le=1) + bid_loi: int | None = Field(default=None, le=120 * 60) + bid_ir: float | None = Field(default=None, ge=0, le=1) + overall_loi: int | None = Field(default=None, le=120 * 60) + overall_ir: float | None = Field(default=None, ge=0, le=1) + last_block_loi: int | None = Field(default=None, le=120 * 60) + last_block_ir: float | None = Field(default=None, ge=0, le=1) # Undocumented. They sent us an email indicating that this is the last time # there was a complete for all suppliers on this survey. - project_last_complete_date: Optional[AwareDatetimeISO] = Field(default=None) + project_last_complete_date: AwareDatetimeISO | None = Field(default=None) # ISO 3166-1 alpha-2 (two-letter codes, lowercase) country_iso: str = Field( @@ -224,21 +224,21 @@ class SpectrumSurvey(MarketplaceTask): # list is an exclude or include list. If incl_excl = 1, the survey has an # include list, and only those in the list are eligible. This list gets # updated everytime someone on the list takes the survey. - include_psids: Optional[UUIDStrSet] = Field(default=None) - exclude_psids: Optional[UUIDStrSet] = Field(default=None) + include_psids: UUIDStrSet | None = Field(default=None) + exclude_psids: UUIDStrSet | None = Field(default=None) - qualifications: List[str] = Field(default_factory=list) - quotas: List[SpectrumQuota] = Field(default_factory=list) + qualifications: list[str] = Field(default_factory=list) + quotas: list[SpectrumQuota] = Field(default_factory=list) source: Literal[Source.SPECTRUM] = Field(default=Source.SPECTRUM) - used_question_ids: Set[AlphaNumStr] = Field(default_factory=set) + used_question_ids: set[AlphaNumStr] = Field(default_factory=set) # This is a "special" key to store all conditions that are used (as # "condition_hashes") throughout this survey. In the reduced # representation of this task (nearly always, for db i/o, in # global_vars) this field will be null. - conditions: Optional[Dict[str, SpectrumCondition]] = Field(default=None) + conditions: dict[str, SpectrumCondition] | None = Field(default=None) # These come from the API created_api: AwareDatetimeISO = Field( @@ -250,7 +250,7 @@ class SpectrumSurvey(MarketplaceTask): ) # This does not come from the API. We set it when we update this in the db. - updated: Optional[AwareDatetimeISO] = Field(default=None) + updated: AwareDatetimeISO | None = Field(default=None) @property def internal_id(self) -> str: @@ -270,7 +270,7 @@ class SpectrumSurvey(MarketplaceTask): @computed_field @property - def all_hashes(self) -> Set[str]: + def all_hashes(self) -> set[str]: s = set(self.qualifications) for q in self.quotas: s.update(set(q.condition_hashes)) @@ -305,7 +305,7 @@ class SpectrumSurvey(MarketplaceTask): return "212" @property - def marketplace_genders(self) -> Dict[Gender, Optional[MarketplaceCondition]]: + def marketplace_genders(self) -> dict[Gender, MarketplaceCondition | None]: return { Gender.MALE: SpectrumCondition( question_id="211", @@ -321,7 +321,7 @@ class SpectrumSurvey(MarketplaceTask): } @classmethod - def from_api(cls, d: Dict[str, Any]) -> Optional["SpectrumSurvey"]: + def from_api(cls, d: dict[str, Any]) -> "SpectrumSurvey" | None: try: return cls._from_api(d) except Exception as e: @@ -329,7 +329,7 @@ class SpectrumSurvey(MarketplaceTask): return None @classmethod - def _from_api(cls, d: Dict[str, Any]) -> Self: + def _from_api(cls, d: dict[str, Any]) -> Self: assert d["click_balancing"] in {0, 1}, "unknown click_balancing value" d["calculation_type"] = ( TaskCalculationType.STARTS @@ -366,7 +366,7 @@ class SpectrumSurvey(MarketplaceTask): exclude={"updated", "conditions"} ) - def to_mysql(self) -> Dict[str, Any]: + def to_mysql(self) -> dict[str, Any]: d = self.model_dump( mode="json", exclude={ @@ -388,7 +388,7 @@ class SpectrumSurvey(MarketplaceTask): return d @classmethod - def from_db(cls, d: Dict[str, Any]) -> Self: + def from_db(cls, d: dict[str, Any]) -> Self: d["created_api"] = d["created_api"].replace(tzinfo=timezone.utc) d["updated"] = d["updated"].replace(tzinfo=timezone.utc) d["modified_api"] = d["modified_api"].replace(tzinfo=timezone.utc) @@ -415,14 +415,14 @@ class SpectrumSurvey(MarketplaceTask): """ def passes_qualifications( - self, criteria_evaluation: Dict[str, Optional[bool]] + self, criteria_evaluation: dict[str, bool | None] ) -> bool: # We have to match all quals return all(criteria_evaluation.get(q) for q in self.qualifications) def passes_qualifications_soft( - self, criteria_evaluation: Dict[str, Optional[bool]] - ) -> Tuple[Optional[bool], Set[str]]: + self, criteria_evaluation: dict[str, bool | None] + ) -> tuple[bool | None, set[str]]: # Passes back "passes" (T/F/none) and a list of unknown criterion hashes hash_evals = {q: criteria_evaluation.get(q) for q in self.qualifications} # We have to match all. So if any are False, we know we don't pass @@ -435,7 +435,7 @@ class SpectrumSurvey(MarketplaceTask): return None, {cell for cell, ev in hash_evals.items() if ev is None} return True, set() - def passes_quotas(self, criteria_evaluation: Dict[str, Optional[bool]]) -> bool: + def passes_quotas(self, criteria_evaluation: dict[str, bool | None]) -> bool: # We have to match at least 1 quota, but they can NOT match a quota # where currently_open=0 any_pass = False @@ -450,8 +450,8 @@ class SpectrumSurvey(MarketplaceTask): return any_pass def passes_quotas_soft( - self, criteria_evaluation: Dict[str, Optional[bool]] - ) -> Tuple[Optional[bool], Set[str]]: + self, criteria_evaluation: dict[str, bool | None] + ) -> tuple[bool | None, set[str]]: # We have to match at least 1 quota, but they can NOT match a quota # where currently_open=0 quota_eval = { @@ -488,7 +488,7 @@ class SpectrumSurvey(MarketplaceTask): return False, set() def determine_eligibility( - self, criteria_evaluation: Dict[str, Optional[bool]] + self, criteria_evaluation: dict[str, bool | None] ) -> bool: return ( self.is_open @@ -497,8 +497,8 @@ class SpectrumSurvey(MarketplaceTask): ) def determine_eligibility_soft( - self, criteria_evaluation: Dict[str, Optional[bool]] - ) -> Tuple[Optional[bool], Set[str]]: + self, criteria_evaluation: dict[str, bool | None] + ) -> tuple[bool | None, set[str]]: if self.is_open is False: return False, set() pass_quals, h_quals = self.passes_qualifications_soft(criteria_evaluation) diff --git a/generalresearch/models/spectrum/task_collection.py b/generalresearch/models/spectrum/task_collection.py index 3c2ee85..4db866b 100644 --- a/generalresearch/models/spectrum/task_collection.py +++ b/generalresearch/models/spectrum/task_collection.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Set +from __future__ import annotations import pandas as pd from pandera import Check, Column, DataFrameSchema, Index @@ -12,8 +12,8 @@ from generalresearch.models.thl.survey.task_collection import ( create_empty_df_from_schema, ) -COUNTRY_ISOS: Set[str] = Localelator().get_all_countries() -LANGUAGE_ISOS: Set[str] = Localelator().get_all_languages() +COUNTRY_ISOS: set[str] = Localelator().get_all_countries() +LANGUAGE_ISOS: set[str] = Localelator().get_all_languages() SpectrumTaskCollectionSchema = DataFrameSchema( columns={ @@ -43,8 +43,8 @@ SpectrumTaskCollectionSchema = DataFrameSchema( "created_api": Column(dtype=pd.DatetimeTZDtype(tz="UTC")), "modified_api": Column(dtype=pd.DatetimeTZDtype(tz="UTC")), "updated": Column(dtype=pd.DatetimeTZDtype(tz="UTC")), - "used_question_ids": Column(List[str]), - "all_hashes": Column(List[str]), # set >> list for column support + "used_question_ids": Column(list[str]), + "all_hashes": Column(list[str]), # set >> list for column support }, checks=[], index=Index( @@ -60,10 +60,10 @@ SpectrumTaskCollectionSchema = DataFrameSchema( class SpectrumTaskCollection(TaskCollection): - items: List[SpectrumSurvey] + items: list[SpectrumSurvey] _schema = SpectrumTaskCollectionSchema - def to_rows(self, s: SpectrumSurvey) -> List[Dict]: + def to_rows(self, s: SpectrumSurvey) -> list[dict]: fields = [ "survey_name", "status", diff --git a/generalresearch/models/thl/__init__.py b/generalresearch/models/thl/__init__.py index 0356842..abc129b 100644 --- a/generalresearch/models/thl/__init__.py +++ b/generalresearch/models/thl/__init__.py @@ -1,5 +1,4 @@ from decimal import Decimal -from typing import Optional from generalresearch.models.thl.finance import ( POPFinancial, @@ -24,11 +23,11 @@ PayoutEvent.model_rebuild() BrokerageProductPayoutEvent.model_rebuild() -def decimal_to_int_cents(usd: Optional[Decimal]) -> Optional[int]: +def decimal_to_int_cents(usd: Decimal | None) -> int | None: return round(usd * 100) if usd is not None else None -def int_cents_to_decimal(value: Optional[int], decimals: int = 2) -> Optional[Decimal]: +def int_cents_to_decimal(value: int | None, decimals: int = 2) -> Decimal | None: if value is None: return None return (Decimal(value) / Decimal(100)).quantize(Decimal(10) ** -decimals) diff --git a/generalresearch/models/thl/category.py b/generalresearch/models/thl/category.py index 4e9e2ff..1ed436a 100644 --- a/generalresearch/models/thl/category.py +++ b/generalresearch/models/thl/category.py @@ -1,4 +1,6 @@ -from typing import Any, Dict, Optional +from __future__ import annotations + +from typing import Any from uuid import uuid4 from pydantic import BaseModel, Field, PositiveInt, model_validator @@ -8,11 +10,11 @@ from generalresearch.models.custom_types import UUIDStr class Category(BaseModel, frozen=True): - id: Optional[PositiveInt] = Field(exclude=True, default=None) + id: PositiveInt | None = Field(exclude=True, default=None) uuid: UUIDStr = Field(examples=[uuid4().hex]) - adwords_vertical_id: Optional[str] = Field(default=None, max_length=8) + adwords_vertical_id: str | None = Field(default=None, max_length=8) label: str = Field(max_length=255, examples=["Hair Loss"]) @@ -23,8 +25,8 @@ class Category(BaseModel, frozen=True): examples=["/Beauty & Fitness/Hair Care/Hair Loss"], ) - parent_id: Optional[PositiveInt] = Field(default=None, exclude=True) - parent_uuid: Optional[UUIDStr] = Field(default=None, examples=[uuid4().hex]) + parent_id: PositiveInt | None = Field(default=None, exclude=True) + parent_uuid: UUIDStr | None = Field(default=None, examples=[uuid4().hex]) @model_validator(mode="after") def check_path(self) -> Self: @@ -45,7 +47,7 @@ class Category(BaseModel, frozen=True): return self.path.split("/", 2)[1] @property - def parent_path(self) -> Optional[str]: + def parent_path(self) -> str | None: # If path is "/Beauty & Fitness/Hair Care/Hair Loss", this returns "/Beauty & Fitness/Hair Care" return self.path.rsplit("/", 1)[0] or None @@ -53,7 +55,7 @@ class Category(BaseModel, frozen=True): def is_root(self) -> bool: return self.parent_path is None - def to_offerwall_api(self) -> Dict[str, Any]: + def to_offerwall_api(self) -> dict[str, Any]: return { "id": self.uuid, "label": self.label, diff --git a/generalresearch/models/thl/contest/__init__.py b/generalresearch/models/thl/contest/__init__.py index f9693eb..363c8c0 100644 --- a/generalresearch/models/thl/contest/__init__.py +++ b/generalresearch/models/thl/contest/__init__.py @@ -1,7 +1,6 @@ from __future__ import annotations from datetime import datetime, timezone -from typing import Any, Dict, Optional from uuid import uuid4 from pydantic import ( @@ -24,12 +23,12 @@ class ContestEntryRule(BaseModel): Only applies if the ContestType is ENTRY! """ - max_entry_amount_per_user: Optional[USDCent | PositiveInt] = Field( + max_entry_amount_per_user: USDCent | PositiveInt | None = Field( description="Maximum total value of entries per user", default=None, ) - max_daily_entries_per_user: Optional[PositiveInt] = Field( + max_daily_entries_per_user: PositiveInt | None = Field( description="Maximum entries per user allowed per day for this contest", default=None, ) @@ -37,9 +36,9 @@ class ContestEntryRule(BaseModel): # TODO: Only allow entries if user meets some criteria: gold-membership # status, ID/phone verified, min_completes etc... Maybe these get put # in a separate model b/c the could apply if the ContestType is not ENTRY - min_completes: Optional[int] = None - min_membership_level: Optional[int] = None - id_verified: Optional[bool] = None + min_completes: int | None = None + min_membership_level: int | None = None + id_verified: bool | None = None class ContestEndCondition(BaseModel): @@ -54,7 +53,7 @@ class ContestEndCondition(BaseModel): description="The contest is over once this amount is reached. (sum of all entry amount)", ) # In a LeaderboardContest, ends_at equals the leaderboard's end period plus 90 minutes - ends_at: Optional[AwareDatetimeISO] = Field( + ends_at: AwareDatetimeISO | None = Field( default=None, description="The contest is over at this time." ) @@ -63,22 +62,22 @@ class ContestPrize(BaseModel): kind: ContestPrizeKind = Field( description=ContestPrizeKind.as_openapi_with_value_descriptions() ) - name: Optional[str] = Field(default=None) - description: Optional[str] = Field(default=None) + name: str | None = Field(default=None) + description: str | None = Field(default=None) estimated_cash_value: USDCent = Field( description="Estimated cash value of prize in USDCents", ) - cash_amount: Optional[USDCent] = Field( + cash_amount: USDCent | None = Field( default=None, description="If the kind=ContestPrizeKind.CASH, this is the amount of the prize", ) - promotion_id: Optional[UUIDStr] = Field( + promotion_id: UUIDStr | None = Field( default=None, description="If the kind=ContestPrizeKind.PROMOTION, this is the promotion ID", ) # only if the contest.contest_type = LEADERBOARD - leaderboard_rank: Optional[PositiveInt] = Field( + leaderboard_rank: PositiveInt | None = Field( default=None, description="The prize is for achieving this rank in the associated " "leaderboard. The highest rank is 1.", @@ -111,11 +110,11 @@ class ContestWinner(BaseModel): description="When this user won this prize", ) - user: Optional[User] = Field(exclude=True, default=None) + user: User | None = Field(exclude=True, default=None) prize: ContestPrize = Field() - awarded_cash_amount: Optional[USDCent] = Field( + awarded_cash_amount: USDCent | None = Field( default=None, description="The actual amount this user receives. For cash prizes, if there was a tie, " "this could be different from the prize amount.", @@ -123,7 +122,7 @@ class ContestWinner(BaseModel): @computed_field() @property - def product_user_id(self) -> Optional[str]: + def product_user_id(self) -> str | None: # TODO: we'll have to pull username or censored emails or something if self.user: return self.user.product_user_id @@ -133,7 +132,7 @@ class ContestWinner(BaseModel): # def censored_product_user_id(self) -> str: # return censor_product_user_id(self.user) - def model_dump_mysql(self, contest_id: int) -> Dict[str, Any]: + def model_dump_mysql(self, contest_id: int) -> dict[str, Any]: data = self.model_dump(mode="json", exclude={"user"}) data["contest_id"] = contest_id diff --git a/generalresearch/models/thl/contest/contest.py b/generalresearch/models/thl/contest/contest.py index af5b136..6889dcb 100644 --- a/generalresearch/models/thl/contest/contest.py +++ b/generalresearch/models/thl/contest/contest.py @@ -3,7 +3,7 @@ from __future__ import annotations import json from abc import ABC, abstractmethod from datetime import datetime, timezone -from typing import Any, Dict, List, Optional, Tuple +from typing import Any from uuid import uuid4 from pydantic import ( @@ -41,7 +41,7 @@ class ContestBase(BaseModel, ABC): name: str = Field( max_length=128, description="Name of contest. Can be displayed to user." ) - description: Optional[str] = Field( + description: str | None = Field( default=None, max_length=2048, description="Description of contest. Can be displayed to user.", @@ -53,18 +53,18 @@ class ContestBase(BaseModel, ABC): end_condition: ContestEndCondition = Field() """Defines the conditions to win one or more prizes once the contest is ended""" - prizes: List[ContestPrize] = Field(default_factory=list, min_length=1) + prizes: list[ContestPrize] = Field(default_factory=list, min_length=1) starts_at: AwareDatetimeISO = Field( description="When the contest starts", default_factory=lambda: datetime.now(tz=timezone.utc), ) - terms_and_conditions: Optional[HttpUrl] = Field(default=None) + terms_and_conditions: HttpUrl | None = Field(default=None) status: ContestStatus = Field(default=ContestStatus.ACTIVE) - country_isos: Optional[CountryISOs] = Field( + country_isos: CountryISOs | None = Field( description="Contest is restricted to these countries. If null, all countries are allowed", default=None, ) @@ -80,7 +80,7 @@ class ContestBase(BaseModel, ABC): class Contest(ContestBase): - id: Optional[int] = Field( + id: int | None = Field( default=None, exclude=True, description="pk in db", @@ -100,17 +100,17 @@ class Contest(ContestBase): "entries being created/modified", ) - ended_at: Optional[AwareDatetimeISO] = Field( + ended_at: AwareDatetimeISO | None = Field( default=None, description="When the contest ended", ) - end_reason: Optional[ContestEndReason] = Field( + end_reason: ContestEndReason | None = Field( default=None, description="The reason the contest ended", ) - all_winners: Optional[List[ContestWinner]] = Field( + all_winners: list[ContestWinner] | None = Field( default=None, exclude=True, description="All prize winners of this contest", @@ -136,7 +136,7 @@ class Contest(ContestBase): # return True # return False - def should_end(self) -> Tuple[bool, Optional[ContestEndReason]]: + def should_end(self) -> tuple[bool, ContestEndReason | None]: if self.status == ContestStatus.ACTIVE: if self.end_condition.ends_at: if datetime.now(tz=timezone.utc) >= self.end_condition.ends_at: @@ -145,12 +145,13 @@ class Contest(ContestBase): return False, None @abstractmethod - def select_winners(self) -> Optional[List[ContestWinner]]: ... + def select_winners(self) -> list[ContestWinner] | None: ... def end_contest(self) -> None: e, reason = self.should_end() if not e: - return None + return + # todo: Acquire a lock here, b/c this next part involves randomness # so we can't have it happen more than once winners = self.select_winners() @@ -169,7 +170,7 @@ class Contest(ContestBase): ) return None - def model_dump_mysql(self, **kwargs) -> Dict[str, Any]: + def model_dump_mysql(self, **kwargs) -> dict[str, Any]: d = self.model_dump(mode="json", **kwargs) d["created_at"] = self.created_at @@ -183,7 +184,7 @@ class Contest(ContestBase): return d @classmethod - def model_validate_mysql(cls, data: Dict[str, Any]) -> Self: + def model_validate_mysql(cls, data: dict[str, Any]) -> Self: data = {k: v for k, v in data.items() if k in cls.model_fields.keys()} if isinstance(data["end_condition"], dict): data["end_condition"] = ContestEndCondition.model_validate( @@ -204,12 +205,12 @@ class ContestUserView(Contest): # TODO: this could show a more detailed ContestWinner model, maybe # including like shipping status or whatever - user_winnings: List[ContestWinner] = Field( + user_winnings: list[ContestWinner] = Field( description="The prizes won in this contest by the requested user", default_factory=list, ) - def is_user_eligible(self, country_iso: str) -> Tuple[bool, str]: + def is_user_eligible(self, country_iso: str) -> tuple[bool, str]: now = datetime.now(tz=timezone.utc) assert country_iso.lower() == country_iso diff --git a/generalresearch/models/thl/contest/contest_entry.py b/generalresearch/models/thl/contest/contest_entry.py index 19586da..cddae14 100644 --- a/generalresearch/models/thl/contest/contest_entry.py +++ b/generalresearch/models/thl/contest/contest_entry.py @@ -1,7 +1,6 @@ from __future__ import annotations from datetime import datetime, timezone -from typing import Any, Dict, Union from uuid import uuid4 from pydantic import ( @@ -20,7 +19,7 @@ from generalresearch.models.thl.user import User class ContestEntryCreate(BaseModel): entry_type: ContestEntryType = Field() # The meaning of this field is dictated by the contest's ContestEntryType - amount: Union[USDCent, int] = Field( + amount: USDCent | int = Field( description="The amount of the entry in integer counts or USD Cents", gt=0, ) @@ -51,7 +50,7 @@ class ContestEntry(BaseModel): entry_type: ContestEntryType = Field() # The meaning of this field is dictated by the contest's ContestEntryType - amount: Union[USDCent, int] = Field( + amount: USDCent | int = Field( description="The amount of the entry in integer counts or USD Cents", gt=0, ) @@ -61,7 +60,7 @@ class ContestEntry(BaseModel): @model_validator(mode="before") @classmethod - def validate_amount_type(cls, data: Dict) -> Dict: + def validate_amount_type(cls, data: dict) -> dict: from generalresearch.models.thl.contest.definitions import ( ContestEntryType, ) @@ -102,7 +101,7 @@ class ContestEntry(BaseModel): return censor_product_user_id(user=self.user) - def model_dump_mysql(self, contest_id: int) -> Dict[str, Any]: + def model_dump_mysql(self, contest_id: int) -> dict[str, Any]: data = self.model_dump(mode="json", exclude={"user"}) data["contest_id"] = contest_id data["created_at"] = self.created_at diff --git a/generalresearch/models/thl/contest/examples.py b/generalresearch/models/thl/contest/examples.py index 4835c14..1810e63 100644 --- a/generalresearch/models/thl/contest/examples.py +++ b/generalresearch/models/thl/contest/examples.py @@ -1,4 +1,6 @@ -from typing import Any, Dict +from __future__ import annotations + +from typing import Any from pydantic import HttpUrl @@ -6,7 +8,7 @@ from generalresearch.config import EXAMPLE_PRODUCT_ID from generalresearch.currency import USDCent -def _example_raffle_create(schema: Dict[str, Any]) -> None: +def _example_raffle_create(schema: dict[str, Any]) -> None: from generalresearch.models.thl.contest import ( ContestEndCondition, ContestEntryRule, @@ -46,7 +48,7 @@ def _example_raffle_create(schema: Dict[str, Any]) -> None: ).model_dump(mode="json") -def _example_raffle(schema: Dict) -> None: +def _example_raffle(schema: dict) -> None: from generalresearch.models.thl.contest import ( ContestEndCondition, ContestEntryRule, @@ -94,7 +96,7 @@ def _example_raffle(schema: Dict) -> None: return None -def _example_raffle_user_view(schema: Dict[str, Any]) -> None: +def _example_raffle_user_view(schema: dict[str, Any]) -> None: from generalresearch.models.thl.contest import ( ContestEndCondition, ContestEntryRule, @@ -145,7 +147,7 @@ def _example_raffle_user_view(schema: Dict[str, Any]) -> None: return None -def _example_milestone_create(schema: Dict[str, Any]) -> None: +def _example_milestone_create(schema: dict[str, Any]) -> None: from generalresearch.models.thl.contest import ( ContestPrize, ) @@ -186,7 +188,7 @@ def _example_milestone_create(schema: Dict[str, Any]) -> None: return None -def _example_milestone(schema: Dict[str, Any]) -> None: +def _example_milestone(schema: dict[str, Any]) -> None: from generalresearch.models.thl.contest import ( ContestPrize, ) @@ -232,7 +234,7 @@ def _example_milestone(schema: Dict[str, Any]) -> None: return None -def _example_milestone_user_view(schema: Dict[str, Any]) -> None: +def _example_milestone_user_view(schema: dict[str, Any]) -> None: from generalresearch.models.thl.contest import ContestPrize from generalresearch.models.thl.contest.definitions import ( ContestPrizeKind, @@ -278,7 +280,7 @@ def _example_milestone_user_view(schema: Dict[str, Any]) -> None: return None -def _example_leaderboard_contest_create(schema: Dict[str, Any]) -> None: +def _example_leaderboard_contest_create(schema: dict[str, Any]) -> None: from generalresearch.models.thl.contest import ( ContestPrize, ) @@ -323,7 +325,7 @@ def _example_leaderboard_contest_create(schema: Dict[str, Any]) -> None: return None -def _example_leaderboard_contest(schema: Dict[str, Any]) -> None: +def _example_leaderboard_contest(schema: dict[str, Any]) -> None: from generalresearch.models.thl.contest import ( ContestPrize, ) @@ -369,7 +371,7 @@ def _example_leaderboard_contest(schema: Dict[str, Any]) -> None: return None -def _example_leaderboard_contest_user_view(schema: Dict[str, Any]) -> None: +def _example_leaderboard_contest_user_view(schema: dict[str, Any]) -> None: from generalresearch.models.thl.contest import ( ContestPrize, ) @@ -412,5 +414,3 @@ def _example_leaderboard_contest_user_view(schema: Dict[str, Any]) -> None: product_id=EXAMPLE_PRODUCT_ID, product_user_id="test-user", ).model_dump(mode="json") - - return None diff --git a/generalresearch/models/thl/contest/io.py b/generalresearch/models/thl/contest/io.py index 8133c1a..e68f76e 100644 --- a/generalresearch/models/thl/contest/io.py +++ b/generalresearch/models/thl/contest/io.py @@ -1,5 +1,6 @@ +from __future__ import annotations + from datetime import datetime, timezone -from typing import Union from uuid import uuid4 from generalresearch.models.thl.contest.definitions import ContestType @@ -29,9 +30,7 @@ user_model_cls = { ContestType.MILESTONE: MilestoneUserView, ContestType.LEADERBOARD: LeaderboardContestUserView, } -ContestCreate = Union[ - RaffleContestCreate, LeaderboardContestCreate, MilestoneContestCreate -] +ContestCreate = RaffleContestCreate | LeaderboardContestCreate | MilestoneContestCreate from generalresearch.models.thl.contest.contest import Contest diff --git a/generalresearch/models/thl/contest/leaderboard.py b/generalresearch/models/thl/contest/leaderboard.py index d0681c4..8167f46 100644 --- a/generalresearch/models/thl/contest/leaderboard.py +++ b/generalresearch/models/thl/contest/leaderboard.py @@ -1,5 +1,7 @@ +from __future__ import annotations + from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Literal, Optional, Tuple +from typing import Any, Literal from pydantic import ( ConfigDict, @@ -96,7 +98,7 @@ class LeaderboardContestCreate(ContestBase): return self @property - def leaderboard_key_parts(self) -> Dict[str, Any]: + def leaderboard_key_parts(self) -> dict[str, Any]: assert self.leaderboard_key.count(":") == 5, "invalid leaderboard_key" parts = self.leaderboard_key.split(":") _, product_id, country_iso, freq_str, date_str, board_code_value = parts @@ -137,8 +139,8 @@ class LeaderboardContest(LeaderboardContestCreate, Contest): default=LeaderboardTieBreakStrategy.SPLIT_PRIZE_POOL ) - _redis_client: Optional[Redis] = PrivateAttr(default=None) - _user_manager: Optional[UserManager] = PrivateAttr(default=None) + _redis_client: Redis | None = PrivateAttr(default=None) + _user_manager: UserManager | None = PrivateAttr(default=None) @model_validator(mode="after") def validate_product_lb_key(self) -> Self: @@ -190,7 +192,7 @@ class LeaderboardContest(LeaderboardContestCreate, Contest): ) return lbm - def should_end(self) -> Tuple[bool, Optional[ContestEndReason]]: + def should_end(self) -> tuple[bool, ContestEndReason | None]: if self.status == ContestStatus.ACTIVE: if self.end_condition.ends_at: if datetime.now(tz=timezone.utc) >= self.end_condition.ends_at: @@ -198,7 +200,7 @@ class LeaderboardContest(LeaderboardContestCreate, Contest): return False, None - def select_winners(self) -> List[ContestWinner]: + def select_winners(self) -> list[ContestWinner]: from generalresearch.models.thl.contest.utils import ( distribute_leaderboard_prizes, ) @@ -233,7 +235,7 @@ class LeaderboardContest(LeaderboardContestCreate, Contest): def country_iso(self) -> str: return self.leaderboard_key.split(":")[2] - def model_dump_mysql(self) -> Dict[str, Any]: + def model_dump_mysql(self) -> dict[str, Any]: d = super().model_dump_mysql( exclude={ "tie_break_strategy", @@ -252,7 +254,7 @@ class LeaderboardContestUserView(LeaderboardContest, ContestUserView): @computed_field(description="The current rank of this user in this contest") @property - def user_rank(self) -> Optional[int]: + def user_rank(self) -> int | None: if not self._redis_client: return None @@ -263,7 +265,7 @@ class LeaderboardContestUserView(LeaderboardContest, ContestUserView): return None - def is_user_eligible(self, country_iso: str) -> Tuple[bool, str]: + def is_user_eligible(self, country_iso: str) -> tuple[bool, str]: passes, msg = super().is_user_eligible(country_iso=country_iso) if not passes: return False, msg diff --git a/generalresearch/models/thl/contest/milestone.py b/generalresearch/models/thl/contest/milestone.py index 8c51a88..f62be8f 100644 --- a/generalresearch/models/thl/contest/milestone.py +++ b/generalresearch/models/thl/contest/milestone.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging from datetime import timedelta -from typing import Any, Dict, Literal, Optional, Tuple +from typing import Any, Literal from pydantic import ( BaseModel, @@ -53,13 +53,13 @@ class MilestoneContestEndCondition(BaseModel): Multiple conditions can be set. The contest is over once ANY conditions are met. """ - max_winners: Optional[PositiveInt] = Field( + max_winners: PositiveInt | None = Field( default=None, description="The contest will end once this many users have won (i.e. reached" "the milestone).", ) - ends_at: Optional[AwareDatetimeISO] = Field( + ends_at: AwareDatetimeISO | None = Field( default=None, description="The Contest is over at the ends_at time." ) @@ -72,17 +72,17 @@ class MilestoneContestConfig(BaseModel): target_amount: PositiveInt = Field( description="Each user 'wins' (receives prizes) once this target amount is reached." ) - entry_trigger: Optional[ContestEntryTrigger] = Field( + entry_trigger: ContestEntryTrigger | None = Field( description="What user action triggers an entry automatically.", default=None, ) # These two fields allow something like: "Get a complete in your first 24 hours!" - valid_for: Optional[timedelta] = Field( + valid_for: timedelta | None = Field( description="The time after valid_for_event for which the contest is open", default=None, ) - valid_for_event: Optional[Literal["signup"]] = Field(default=None) + valid_for_event: Literal["signup"] | None = Field(default=None) class MilestoneContestCreate(ContestBase, MilestoneContestConfig): @@ -127,7 +127,7 @@ class MilestoneContest(MilestoneContestCreate, Contest): default=0, ) - def should_end(self) -> Tuple[bool, Optional[ContestEndReason]]: + def should_end(self) -> tuple[bool, ContestEndReason | None]: res, msg = super().should_end() if res: @@ -145,7 +145,7 @@ class MilestoneContest(MilestoneContestCreate, Contest): # just does nothing return None - def model_dump_mysql(self) -> Dict[str, Any]: + def model_dump_mysql(self) -> dict[str, Any]: d = super().model_dump_mysql( exclude={ "entry_trigger", @@ -163,7 +163,7 @@ class MilestoneContest(MilestoneContestCreate, Contest): return d @classmethod - def model_validate_mysql(cls, data: Dict[str, Any]) -> Self: + def model_validate_mysql(cls, data: dict[str, Any]) -> Self: data.update( MilestoneContestConfig.model_validate(data["milestone_config"]).model_dump() ) @@ -180,7 +180,7 @@ class MilestoneUserView(MilestoneContest, ContestUserView): json_schema_extra=_example_milestone_user_view, ) - valid_until: Optional[AwareDatetimeISO] = Field( + valid_until: AwareDatetimeISO | None = Field( default=None, exclude=True, description="If valid_for is set, this gets populated wrt this user", @@ -201,7 +201,7 @@ class MilestoneUserView(MilestoneContest, ContestUserView): return True return False - def is_user_eligible(self, country_iso: str) -> Tuple[bool, str]: + def is_user_eligible(self, country_iso: str) -> tuple[bool, str]: passes, msg = super().is_user_eligible(country_iso=country_iso) if not passes: return False, msg diff --git a/generalresearch/models/thl/contest/raffle.py b/generalresearch/models/thl/contest/raffle.py index 1592857..4f868a0 100644 --- a/generalresearch/models/thl/contest/raffle.py +++ b/generalresearch/models/thl/contest/raffle.py @@ -4,7 +4,7 @@ import logging import random from collections import defaultdict from datetime import datetime, timezone -from typing import Any, Dict, List, Literal, Optional, Tuple, Union +from typing import Any, Literal from pydantic import ( ConfigDict, @@ -72,9 +72,9 @@ class RaffleContest(RaffleContestCreate, Contest): json_schema_extra=_example_raffle, ) - entries: List[ContestEntry] = Field(default_factory=list, exclude=True) + entries: list[ContestEntry] = Field(default_factory=list, exclude=True) - current_amount: Union[int, USDCent] = Field( + current_amount: int | USDCent = Field( default=0, description="Sum of all entry amounts" ) current_participants: int = Field( @@ -82,7 +82,7 @@ class RaffleContest(RaffleContestCreate, Contest): ) @field_validator("entries", mode="after") - def sort_entries(cls, v: List[ContestEntry]): + def sort_entries(cls, v: list[ContestEntry]): return sorted(v, key=lambda x: x.created_at) @model_validator(mode="after") @@ -116,7 +116,7 @@ class RaffleContest(RaffleContestCreate, Contest): ) return self - def select_winners(self) -> List["ContestWinner"]: + def select_winners(self) -> list["ContestWinner"]: from generalresearch.models.thl.contest import ContestWinner assert self.is_complete(), "contest must be complete to select a winner" @@ -146,7 +146,7 @@ class RaffleContest(RaffleContestCreate, Contest): return winners - def should_end(self) -> Tuple[bool, Optional["ContestEndReason"]]: + def should_end(self) -> tuple[bool, "ContestEndReason" | None]: res, msg = super().should_end() if res: return res, msg @@ -157,7 +157,7 @@ class RaffleContest(RaffleContestCreate, Contest): return False, None @staticmethod - def select_winner(user_amount: Dict[int, int]) -> int: + def select_winner(user_amount: dict[int, int]) -> int: """ user_amount: Dict[user_id, amount], is total entry count for each user, e.g. {1111: 5, 2222: 1, 3333: 2} @@ -186,10 +186,10 @@ class RaffleContest(RaffleContestCreate, Contest): def get_current_participants(self) -> int: return len({entry.user.user_id for entry in self.entries}) - def get_current_amount(self) -> Union[int, USDCent]: + def get_current_amount(self) -> int | USDCent: return sum([x.amount for x in self.entries]) - def get_user_amount(self, product_user_id: str) -> Union[int, USDCent]: + def get_user_amount(self, product_user_id: str) -> int | USDCent: # Sum of this user's amounts return sum( e.amount for e in self.entries if e.user.product_user_id == product_user_id @@ -206,13 +206,13 @@ class RaffleContest(RaffleContestCreate, Contest): return True return False - def model_dump_mysql(self) -> Dict[str, Any]: + def model_dump_mysql(self) -> dict[str, Any]: d = super().model_dump_mysql() d["entry_rule"] = self.entry_rule.model_dump_json() return d @classmethod - def model_validate_mysql(cls, data: Dict) -> Self: + def model_validate_mysql(cls, data: dict) -> Self: data["entry_rule"] = ContestEntryRule.model_validate(data["entry_rule"]) return super().model_validate_mysql(data) @@ -224,10 +224,10 @@ class RaffleUserView(RaffleContest, ContestUserView): json_schema_extra=_example_raffle_user_view, ) - user_amount: Union[int, USDCent] = Field( + user_amount: int | USDCent = Field( description="The total amount this user has entered" ) - user_amount_today: Union[int, USDCent] = Field( + user_amount_today: int | USDCent = Field( description="The total amount this user has entered in the past 24 hours" ) @@ -252,7 +252,7 @@ class RaffleUserView(RaffleContest, ContestUserView): "end condition." ) @property - def projected_win_probability(self) -> Optional[float]: + def projected_win_probability(self) -> float | None: if self.end_condition.target_entry_amount is None: return None @@ -266,7 +266,7 @@ class RaffleUserView(RaffleContest, ContestUserView): # Not sure how to return this in api response, too confusing. Maybe use later. # Left for tests only. @property - def current_prize_count_probability(self) -> Dict[int, float]: + def current_prize_count_probability(self) -> int | float: # M: Population size (total entry amount) M = self.current_amount # n: number of success states (user's entry amount) @@ -278,7 +278,7 @@ class RaffleUserView(RaffleContest, ContestUserView): probs = {k: hypergeom.pmf(k, M, n, N) for k in range(1, N + 1)} return probs - def is_entry_eligible(self, entry: ContestEntry) -> Tuple[bool, str]: + def is_entry_eligible(self, entry: ContestEntry) -> tuple[bool, str]: if self.entry_rule.max_entry_amount_per_user: if ( self.user_amount + entry.amount @@ -292,7 +292,7 @@ class RaffleUserView(RaffleContest, ContestUserView): return False, "Entry would exceed max amount per user per day." return True, "" - def is_user_eligible(self, country_iso: str) -> Tuple[bool, str]: + def is_user_eligible(self, country_iso: str) -> tuple[bool, str]: passes, msg = super().is_user_eligible(country_iso=country_iso) if not passes: return False, msg diff --git a/generalresearch/models/thl/contest/utils.py b/generalresearch/models/thl/contest/utils.py index e5043c2..5ac2bc1 100644 --- a/generalresearch/models/thl/contest/utils.py +++ b/generalresearch/models/thl/contest/utils.py @@ -1,4 +1,6 @@ -from typing import TYPE_CHECKING, Dict, List +from __future__ import annotations + +from typing import TYPE_CHECKING if TYPE_CHECKING: from generalresearch.currency import USDCent @@ -18,8 +20,8 @@ def censor_product_user_id(user: "User") -> str: def distribute_leaderboard_prizes( - prizes: List["USDCent"], leaderboard_rows: List["LeaderboardRow"] -) -> Dict[str, "USDCent"]: + prizes: list["USDCent"], leaderboard_rows: list["LeaderboardRow"] +) -> dict[str, "USDCent"]: """ Distributes leaderboard prizes among tied users. The prizes for the tied places are pooled together and divided diff --git a/generalresearch/models/thl/demographics.py b/generalresearch/models/thl/demographics.py index a688e49..4d4c8c2 100644 --- a/generalresearch/models/thl/demographics.py +++ b/generalresearch/models/thl/demographics.py @@ -4,7 +4,7 @@ import copy from collections import Counter, defaultdict from dataclasses import dataclass from enum import Enum -from typing import TYPE_CHECKING, Any, Dict, List, Literal +from typing import TYPE_CHECKING, Any, Literal import numpy as np @@ -76,7 +76,7 @@ class AgeGroup(Enum): return self.label -def calculate_demographic_metrics(opps: List[MarketplaceTask]) -> List: +def calculate_demographic_metrics(opps: list[MarketplaceTask]) -> list: """ Measurement: marketplace_survey_demographics tags: source (marketplace) @@ -146,8 +146,8 @@ def calculate_demographic_metrics(opps: List[MarketplaceTask]) -> List: def calculate_used_question_metrics( - opps: List[MarketplaceTask], qid_label: Dict[str, str] -) -> List[Dict[str, Any]]: + opps: list[MarketplaceTask], qid_label: dict[str, str] +) -> list[dict[str, Any]]: """ Measurement: marketplace_survey_targeting tags: source (marketplace), "type", country (all and individual) diff --git a/generalresearch/models/thl/finance.py b/generalresearch/models/thl/finance.py index 8d526ac..9ececc3 100644 --- a/generalresearch/models/thl/finance.py +++ b/generalresearch/models/thl/finance.py @@ -1,6 +1,8 @@ +from __future__ import annotations + import random from datetime import timezone -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING from uuid import uuid4 import pandas as pd @@ -51,7 +53,7 @@ class POPFinancial(BaseModel): """ # --- Tracking / Tagging --- - product_id: Optional[UUIDStr] = Field(default=None, examples=[uuid4().hex]) + product_id: UUIDStr | None = Field(default=None, examples=[uuid4().hex]) time: AwareDatetimeISO = Field( description="The starting time block for the respective 'Period' that" @@ -77,7 +79,7 @@ class POPFinancial(BaseModel): examples=[adjustment_example], ) - adjustment_types: List[AdjustmentType] = Field() + adjustment_types: list[AdjustmentType] = Field() expense: int = Field( description="For Product accounts that are setup with Respondent payouts," @@ -101,8 +103,8 @@ class POPFinancial(BaseModel): @staticmethod def list_from_pandas( - input_data: pd.DataFrame, accounts: List["LedgerAccount"] - ) -> List["POPFinancial"]: + input_data: pd.DataFrame, accounts: list["LedgerAccount"] + ) -> list["POPFinancial"]: """ This list can either be for a Product or a Business. The difference is that the list of accounts will either be len()=1 (Product) or @@ -183,8 +185,8 @@ class ProductBalances(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) # --- Tracking / Tagging --- - product_id: Optional[UUIDStr] = Field(default=None, examples=[uuid4().hex]) - last_event: Optional[AwareDatetimeISO] = Field(default=None) + product_id: UUIDStr | None = Field(default=None, examples=[uuid4().hex]) + last_event: AwareDatetimeISO | None = Field(default=None) # --- Numeric --- @@ -539,11 +541,11 @@ class ProductBalances(BaseModel): class BusinessBalances(BaseModel): - product_balances: List[ProductBalances] = Field(default_factory=list) + product_balances: list[ProductBalances] = Field(default_factory=list) # --- Validators --- @field_validator("product_balances") - def required_product_ids(cls, v: List[ProductBalances]): + def required_product_ids(cls, v: list[ProductBalances]): """The BusinessBalances needs to be able to distinguish between all the child Products; in order to do this, we need to assert that they all explicitly are set @@ -863,7 +865,7 @@ class BusinessBalances(BaseModel): from generalresearch.managers.thl.product import ProductManager pm = ProductManager(pg_config=thl_pg_config) - products: List[Product] = pm.get_by_uuids( + products: list[Product] = pm.get_by_uuids( product_uuids=[pb.product_id for pb in product_balances] ) sorted_products_uuids = [ diff --git a/generalresearch/models/thl/ipinfo.py b/generalresearch/models/thl/ipinfo.py index a98689f..5a8e51d 100644 --- a/generalresearch/models/thl/ipinfo.py +++ b/generalresearch/models/thl/ipinfo.py @@ -1,6 +1,8 @@ +from __future__ import annotations + import ipaddress from datetime import datetime, timezone -from typing import Any, Dict, Literal, Optional, Tuple +from typing import Any, Literal import geoip2.models from faker import Faker @@ -27,7 +29,7 @@ fake = Faker() PrefixLength = Literal["/128", "/64", "/32"] -def normalize_ip(ip: IPvAnyAddressStr) -> Tuple[str, PrefixLength]: +def normalize_ip(ip: IPvAnyAddressStr) -> tuple[str, PrefixLength]: """ Normalize an IP address for MySQL storage. @@ -47,51 +49,51 @@ def normalize_ip(ip: IPvAnyAddressStr) -> Tuple[str, PrefixLength]: class IPGeoname(BaseModel): geoname_id: PositiveInt = Field() - continent_code: Optional[str] = Field(default=None, max_length=2) - continent_name: Optional[str] = Field(default=None, max_length=32) + continent_code: str | None = Field(default=None, max_length=2) + continent_name: str | None = Field(default=None, max_length=32) country_iso: CountryISOLike = Field( description="The ISO code of the country associated with the IP address.", examples=[fake.country_code().lower()], ) - country_name: Optional[str] = Field(default=None, max_length=64) + country_name: str | None = Field(default=None, max_length=64) - subdivision_1_iso: Optional[str] = Field( + subdivision_1_iso: str | None = Field( default=None, description="The ISO code of the primary subdivision (e.g., state or province).", max_length=3, ) - subdivision_1_name: Optional[str] = Field( + subdivision_1_name: str | None = Field( default=None, description="The name of the primary subdivision (e.g., state or province).", max_length=255, ) - subdivision_2_iso: Optional[str] = Field( + subdivision_2_iso: str | None = Field( default=None, description="The ISO code of the secondary subdivision (if applicable).", max_length=3, ) - subdivision_2_name: Optional[str] = Field( + subdivision_2_name: str | None = Field( default=None, description="The name of the secondary subdivision (if applicable).", max_length=255, ) - city_name: Optional[str] = Field( + city_name: str | None = Field( default=None, max_length=255, description="The name of the city associated with the IP address.", examples=[fake.city()], ) - metro_code: Optional[int] = Field(default=None) + metro_code: int | None = Field(default=None) - time_zone: Optional[str] = Field( + time_zone: str | None = Field( default=None, max_length=60, description="The time zone associated with the geographical location.", examples=[fake.timezone()], ) - is_in_european_union: Optional[bool] = Field(default=None) + is_in_european_union: bool | None = Field(default=None) updated: AwareDatetimeISO = Field( default_factory=lambda: datetime.now(tz=timezone.utc), @@ -104,20 +106,20 @@ class IPGeoname(BaseModel): "subdivision_2_iso", mode="before", ) - def make_lower(cls, value: Optional[str]) -> Optional[str]: + def make_lower(cls, value: str | None) -> str | None: if value is not None: return value.lower() return value # --- ORM --- - def model_dump_mysql(self) -> Dict[str, Any]: + def model_dump_mysql(self) -> dict[str, Any]: d = self.model_dump(mode="json") d["updated"] = self.updated return d @classmethod - def from_mysql(cls, d: Dict[str, Any]) -> Self: + def from_mysql(cls, d: dict[str, Any]) -> Self: d["updated"] = d["updated"].replace(tzinfo=timezone.utc) return cls.model_validate(d) @@ -167,77 +169,77 @@ class IPGeoname(BaseModel): class IPInformation(BaseModel): ip: IPvAnyAddressStr = Field() # This doesn't get stored in mysql/redis, b/c we only look up by the normalized ip - lookup_prefix: Optional[PrefixLength] = Field(default=None, exclude=True) + lookup_prefix: PrefixLength | None = Field(default=None, exclude=True) - geoname_id: Optional[PositiveInt] = Field(default=None) + geoname_id: PositiveInt | None = Field(default=None) country_iso: CountryISOLike = Field( description="The ISO code of the country associated with the IP address.", examples=[fake.country_code().lower()], ) - registered_country_iso: Optional[CountryISOLike] = Field( + registered_country_iso: CountryISOLike | None = Field( default=None, description="The ISO code of the country where the IP address is " "registered.", examples=[fake.country_code().lower()], ) - is_anonymous: Optional[bool] = Field( + is_anonymous: bool | None = Field( default=None, description="Indicates whether the IP address is associated with an " "anonymous source (e.g., VPN, proxy).", examples=[False], ) - is_anonymous_vpn: Optional[bool] = Field(default=None) - is_hosting_provider: Optional[bool] = Field(default=None) - is_public_proxy: Optional[bool] = Field(default=None) - is_tor_exit_node: Optional[bool] = Field(default=None) - is_residential_proxy: Optional[bool] = Field(default=None) + is_anonymous_vpn: bool | None = Field(default=None) + is_hosting_provider: bool | None = Field(default=None) + is_public_proxy: bool | None = Field(default=None) + is_tor_exit_node: bool | None = Field(default=None) + is_residential_proxy: bool | None = Field(default=None) - autonomous_system_number: Optional[PositiveInt] = Field(default=None) - autonomous_system_organization: Optional[str] = Field(default=None, max_length=255) + autonomous_system_number: PositiveInt | None = Field(default=None) + autonomous_system_organization: str | None = Field(default=None, max_length=255) - domain: Optional[str] = Field(default=None, max_length=255) - isp: Optional[str] = Field( + domain: str | None = Field(default=None, max_length=255) + isp: str | None = Field( default=None, description="The Internet Service Provider associated with the " "IP address.", examples=["Comcast"], ) - mobile_country_code: Optional[str] = Field(default=None, max_length=3) - mobile_network_code: Optional[str] = Field(default=None, max_length=3) + mobile_country_code: str | None = Field(default=None, max_length=3) + mobile_network_code: str | None = Field(default=None, max_length=3) - network: Optional[str] = Field(default=None, max_length=56) - organization: Optional[str] = Field(default=None, max_length=255) + network: str | None = Field(default=None, max_length=56) + organization: str | None = Field(default=None, max_length=255) - static_ip_score: Optional[float] = Field( + static_ip_score: float | None = Field( default=None, description="A score indicating the likelihood that the IP address is static.", ) - user_type: Optional[UserType] = Field( + user_type: UserType | None = Field( default=None, description="The type of user associated with the IP address " "(e.g., 'residential', 'business').", examples=[UserType.SCHOOL], ) - postal_code: Optional[str] = Field( + postal_code: str | None = Field( default=None, description="The postal code associated with the IP address.", examples=[fake.postcode()], ) - latitude: Optional[float] = Field( + latitude: float | None = Field( description="The latitude coordinate of the IP address location.", default=None, examples=[float(fake.latitude())], ) - longitude: Optional[float] = Field( + longitude: float | None = Field( description="The longitude coordinate of the IP address location.", default=None, examples=[float(fake.longitude())], ) - accuracy_radius: Optional[int] = Field( + accuracy_radius: int | None = Field( default=None, description="The approximate radius of accuracy for the latitude " "and longitude, in kilometers.", @@ -248,10 +250,10 @@ class IPInformation(BaseModel): default_factory=lambda: datetime.now(tz=timezone.utc), ) - _geoname: Optional[IPGeoname] = PrivateAttr(default=None) + _geoname: IPGeoname | None = PrivateAttr(default=None) @field_validator("country_iso", "registered_country_iso", mode="before") - def make_lower(cls, value: Optional[str]) -> Optional[str]: + def make_lower(cls, value: str | None) -> str | None: if value is not None: return value.lower() @@ -265,14 +267,13 @@ class IPInformation(BaseModel): return self.is_anonymous is None @property - def geoname(self) -> Optional["IPGeoname"]: + def geoname(self) -> "IPGeoname" | None: return self._geoname or None def normalize_ip(self): normalized_ip, lookup_prefix = normalize_ip(self.ip) self.ip = normalized_ip self.lookup_prefix = lookup_prefix - return None # --- prefetch_* --- def prefetch_geoname( @@ -288,8 +289,6 @@ class IPInformation(BaseModel): self._geoname = ip_gm.get_by_id(geoname_id=self.geoname_id) - return None - # --- ORM --- def model_dump_mysql(self): d = self.model_dump(mode="json", exclude={"geoname"}) @@ -297,7 +296,7 @@ class IPInformation(BaseModel): return d @classmethod - def from_mysql(cls, d: Dict) -> Self: + def from_mysql(cls, d: dict) -> Self: d["updated"] = d["updated"].replace(tzinfo=timezone.utc) return cls.model_validate(d) diff --git a/generalresearch/models/thl/leaderboard.py b/generalresearch/models/thl/leaderboard.py index 7d79091..399a906 100644 --- a/generalresearch/models/thl/leaderboard.py +++ b/generalresearch/models/thl/leaderboard.py @@ -4,7 +4,7 @@ import logging import math from datetime import datetime, timedelta, timezone from enum import Enum -from typing import List, Literal +from typing import Literal from uuid import UUID, uuid3 import pandas as pd @@ -117,7 +117,7 @@ class Leaderboard(BaseModel): description="The total number of rows in the leaderboard.", examples=[2] ) - rows: List[LeaderboardRow] = Field( + rows: list[LeaderboardRow] = Field( default_factory=list, examples=[ [ @@ -342,4 +342,4 @@ class LeaderboardWinner(BaseModel): class LeaderboardWinnerResponse(StatusResponse): - winners: List[LeaderboardWinner] = Field(default_factory=list) + winners: list[LeaderboardWinner] = Field(default_factory=list) diff --git a/generalresearch/models/thl/ledger.py b/generalresearch/models/thl/ledger.py index 9d473c5..3f8b123 100644 --- a/generalresearch/models/thl/ledger.py +++ b/generalresearch/models/thl/ledger.py @@ -1,6 +1,8 @@ +from __future__ import annotations + from datetime import datetime, timezone from enum import Enum -from typing import Annotated, Any, Dict, List, Literal, Optional, Union +from typing import Annotated, Any, Literal, Union from uuid import uuid4 from pydantic import ( @@ -164,9 +166,9 @@ class LedgerAccount(BaseModel, validate_assignment=True, frozen=True): normal_balance: Direction = Field(description=Direction.as_openapi()) - reference_type: Optional[str] = Field(default=None) + reference_type: str | None = Field(default=None) - reference_uuid: Optional[UUIDStr] = Field( + reference_uuid: UUIDStr | None = Field( default=None, description="The associated Product ID or other parent account that" "this Ledger Account is intended to track transactions for." @@ -202,7 +204,7 @@ class LedgerAccount(BaseModel, validate_assignment=True, frozen=True): class LedgerEntry(BaseModel): - id: Optional[int] = Field(default=None) + id: int | None = Field(default=None) direction: Direction account_uuid: UUIDStr @@ -217,7 +219,7 @@ class LedgerEntry(BaseModel): # This really shouldn't be Optional, but it has to be in order to # instantiate this class before the LedgerTransaction exists - transaction_id: Optional[int] = Field(default=None) + transaction_id: int | None = Field(default=None) @classmethod def from_amount(cls, account_uuid: UUIDStr, amount: int): @@ -248,7 +250,7 @@ class LedgerEntry(BaseModel): class LedgerTransaction(BaseModel): model_config = ConfigDict(extra="forbid", validate_assignment=True) - id: Optional[int] = Field(default=None) + id: int | None = Field(default=None) created: AwareDatetimeISO = Field( default_factory=lambda: datetime.now(tz=timezone.utc), @@ -258,11 +260,11 @@ class LedgerTransaction(BaseModel): "TX timestamps will likely be a few milliseconds delayed", ) - ext_description: Optional[str] = Field(default=None, max_length=255) - tag: Optional[str] = Field(default=None, max_length=255) - metadata: Dict[str, str] = Field(default_factory=dict) + ext_description: str | None = Field(default=None, max_length=255) + tag: str | None = Field(default=None, max_length=255) + metadata: dict[str, str] = Field(default_factory=dict) - entries: List[LedgerEntry] = Field( + entries: list[LedgerEntry] = Field( default_factory=list, description="A Transaction (TX) is composed of multiple Entry events.", ) @@ -288,7 +290,7 @@ class LedgerTransaction(BaseModel): @field_validator("entries", mode="after") @classmethod - def check_entries(cls, entries: List[LedgerEntry]) -> List[LedgerEntry]: + def check_entries(cls, entries: list[LedgerEntry]) -> list[LedgerEntry]: """Transactions should enforce double-entry upon creation. Each transaction needs to have at least two entries, which, in aggregate, must affect credit and debit sides in equal amounts. @@ -300,7 +302,7 @@ class LedgerTransaction(BaseModel): ), "ledger entries must balance" return entries - def model_dump_mysql(self, *args, **kwargs) -> Dict[str, Any]: + def model_dump_mysql(self, *args, **kwargs) -> dict[str, Any]: d = self.model_dump(mode="json", *args, **kwargs) if "created" in d: d["created"] = self.created.replace(tzinfo=None) @@ -387,12 +389,12 @@ class UserLedgerTransaction(BaseModel): ) # Needed to generate urls - product_id: Optional[str] = Field(default=None, exclude=True) + product_id: str | None = Field(default=None, exclude=True) # Needed to generate amount_string - payout_format: Optional[PayoutFormatType] = Field(default=None, exclude=True) + payout_format: PayoutFormatType | None = Field(default=None, exclude=True) # The balance in this account immediately after this tx. # It is optional b/c we'll calculate this from the query - balance_after: Optional[int] = Field(default=None) + balance_after: int | None = Field(default=None) def create_url(self, product_id: str): raise NotImplementedError() @@ -400,7 +402,7 @@ class UserLedgerTransaction(BaseModel): @computed_field( description="A link to where the user can get more details about this transaction", ) - def url(self) -> Optional[HttpsUrlStr]: + def url(self) -> HttpsUrlStr | None: if self.product_id is None: return None return self.create_url(product_id=self.product_id) @@ -408,7 +410,7 @@ class UserLedgerTransaction(BaseModel): @computed_field( description="The 'amount' with the payout_format applied.", ) - def amount_string(self) -> Optional[HttpsUrlStr]: + def amount_string(self) -> HttpsUrlStr | None: if self.payout_format is None: return None return format_payout_format( @@ -546,13 +548,13 @@ UserLedgerTransactionType = Annotated[ class UserLedgerTransactionTypeSummary(BaseModel): entry_count: NonNegativeInt = Field(default=0) - min_amount: Optional[int] = Field( + min_amount: int | None = Field( description="positive or negative USDCent", default=None ) - max_amount: Optional[int] = Field( + max_amount: int | None = Field( description="positive or negative USDCent", default=None ) - total_amount: Optional[int] = Field( + total_amount: int | None = Field( description="positive or negative USDCent", default=None ) @@ -578,7 +580,7 @@ class UserLedgerTransactions(Page): A (paginated) collection that holds transaction models that can be shown to a (wallet-managed) user. """ - transactions: List[UserLedgerTransactionType] = Field(default_factory=list) + transactions: list[UserLedgerTransactionType] = Field(default_factory=list) # The summary is w.r.t an optional time-filter. The transactions are # paginated so the counts won't necesarily match. In other words, the # summary is across all transaction in all pages, not this the transactions @@ -589,7 +591,7 @@ class UserLedgerTransactions(Page): def from_txs( cls, user_account: LedgerAccount, - txs: List[LedgerTransaction], + txs: list[LedgerTransaction], product_id: str, payout_format: str, summary: UserLedgerTransactionTypesSummary, @@ -617,11 +619,11 @@ class UserLedgerTransactions(Page): class LedgerAccountStatement(BaseModel): - id: Optional[int] = Field(default=None) + id: int | None = Field(default=None) account_uuid: UUIDStr - filter_str: Optional[str] = Field(default=None) + filter_str: str | None = Field(default=None) effective_at_lower_bound: AwareDatetimeISO effective_at_upper_bound: AwareDatetimeISO starting_balance: int = Field(lt=2**63 - 1, ge=0) ending_balance: int = Field(lt=2**63 - 1, ge=0) - sql_query: Optional[str] = Field(default=None) + sql_query: str | None = Field(default=None) diff --git a/generalresearch/models/thl/ledger_example.py b/generalresearch/models/thl/ledger_example.py index bf120cb..767be85 100644 --- a/generalresearch/models/thl/ledger_example.py +++ b/generalresearch/models/thl/ledger_example.py @@ -1,9 +1,11 @@ +from __future__ import annotations + from datetime import datetime, timezone -from typing import Any, Dict +from typing import Any from uuid import uuid4 -def _example_user_tx_payout(schema: Dict[str, Any]) -> None: +def _example_user_tx_payout(schema: dict[str, Any]) -> None: from generalresearch.models.thl.ledger import ( UserLedgerTransactionUserPayout, ) @@ -18,7 +20,7 @@ def _example_user_tx_payout(schema: Dict[str, Any]) -> None: ).model_dump(mode="json") -def _example_user_tx_bonus(schema: Dict[str, Any]) -> None: +def _example_user_tx_bonus(schema: dict[str, Any]) -> None: from generalresearch.models.thl.ledger import ( UserLedgerTransactionUserBonus, ) @@ -32,7 +34,7 @@ def _example_user_tx_bonus(schema: Dict[str, Any]) -> None: ).model_dump(mode="json") -def _example_user_tx_complete(schema: Dict[str, Any]) -> None: +def _example_user_tx_complete(schema: dict[str, Any]) -> None: from generalresearch.models.thl.ledger import ( UserLedgerTransactionTaskComplete, ) @@ -47,7 +49,7 @@ def _example_user_tx_complete(schema: Dict[str, Any]) -> None: ).model_dump(mode="json") -def _example_user_tx_adjustment(schema: Dict[str, Any]) -> None: +def _example_user_tx_adjustment(schema: dict[str, Any]) -> None: from generalresearch.models.thl.ledger import ( UserLedgerTransactionTaskAdjustment, ) diff --git a/generalresearch/models/thl/locales.py b/generalresearch/models/thl/locales.py index bff774a..85f0c32 100644 --- a/generalresearch/models/thl/locales.py +++ b/generalresearch/models/thl/locales.py @@ -1,4 +1,6 @@ -from typing import Annotated, Set +from __future__ import annotations + +from typing import Annotated from pydantic import AfterValidator @@ -9,8 +11,8 @@ from generalresearch.models.custom_types import ( ) locale_helper = Localelator() -COUNTRY_ISOS: Set[str] = locale_helper.get_all_countries() -LANGUAGE_ISOS: Set[str] = locale_helper.get_all_languages() +COUNTRY_ISOS: set[str] = locale_helper.get_all_countries() +LANGUAGE_ISOS: set[str] = locale_helper.get_all_languages() def is_valid_country_iso(v: str) -> str: @@ -28,5 +30,5 @@ CountryISO = Annotated[str, AfterValidator(is_valid_country_iso)] # 3-char ISO 639-2/B, lowercase LanguageISO = Annotated[str, AfterValidator(is_valid_language_iso)] -CountryISOs = Annotated[Set[CountryISO], to_comma_sep_str, from_comma_sep_str] -LanguageISOs = Annotated[Set[LanguageISO], to_comma_sep_str, from_comma_sep_str] +CountryISOs = Annotated[set[CountryISO], to_comma_sep_str, from_comma_sep_str] +LanguageISOs = Annotated[set[LanguageISO], to_comma_sep_str, from_comma_sep_str] diff --git a/generalresearch/models/thl/maxmind/__init__.py b/generalresearch/models/thl/maxmind/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/generalresearch/models/thl/maxmind/definitions.py b/generalresearch/models/thl/maxmind/definitions.py deleted file mode 100644 index 01431c7..0000000 --- a/generalresearch/models/thl/maxmind/definitions.py +++ /dev/null @@ -1,22 +0,0 @@ -from enum import Enum - -from generalresearch.utils.enum import ReprEnumMeta - - -class UserType(Enum, metaclass=ReprEnumMeta): - # https://support.maxmind.com/hc/en-us/articles/4408430082971-IP-Trait-Risk-Data#h_01FN6V8JMQMWZGWNPPAW77ZPY4 - BUSINESS = "business" - CAFE = "cafe" - CELLULAR = "cellular" - COLLEGE = "college" - CDN = "content_delivery_network" - CPN = "consumer_privacy_network" - GOVERNMENT = "government" - HOSTING = "hosting" - LIBRARY = "library" - MILITARY = "military" - RESIDENTIAL = "residential" - ROUTER = "router" - SCHOOL = "school" - SEARCH_ENGINE = "search_engine_spider" - TRAVELER = "traveler" diff --git a/generalresearch/models/thl/offerwall/__init__.py b/generalresearch/models/thl/offerwall/__init__.py index bf6b7ea..d2d7d36 100644 --- a/generalresearch/models/thl/offerwall/__init__.py +++ b/generalresearch/models/thl/offerwall/__init__.py @@ -4,7 +4,7 @@ import hashlib import json from decimal import Decimal from enum import Enum -from typing import Any, Dict, Literal, Optional, Set +from typing import Any, Literal from pydantic import ( BaseModel, @@ -111,7 +111,7 @@ class OfferWallRequest(BaseModel): offerwall_type: OfferWallType = Field() user: User = Field() - ip: Optional[IPvAnyAddressStr] = Field( + ip: IPvAnyAddressStr | None = Field( default=None, description="Respondent's IP address (IPv4 or IPv6). Either 'ip' must be " "provided, or 'country_iso' must be provided if 'ip' is " @@ -121,38 +121,38 @@ class OfferWallRequest(BaseModel): country_iso: CountryISO = Field( description="Respondent's country code (ISO 3166-1 alpha-2, lowercase)" ) - language_isos: Set[LanguageISO] = Field( + language_isos: set[LanguageISO] = Field( description="Respondent's desired language (ISO 639-2/B, lowercase)", ) - behavior: Optional[OfferWallBehaviorsType] = Field( + behavior: OfferWallBehaviorsType | None = Field( default=None, max_length=12, description="Allows using custom scoring functions. Please " "discuss directly with GRL.", ) - min_payout: Optional[Decimal] = Field( + min_payout: Decimal | None = Field( default=None, description="Decimal representation of the minimum amount of USD that " "any of the tasks will pay", examples=["1.23"], ) - duration: Optional[int] = Field( + duration: int | None = Field( default=60 * 90, description="Maximum length of desired task (in seconds).", gt=0, ) - n_bins: Optional[int] = Field( + n_bins: int | None = Field( default=None, description="Number of bins requested in the offerwall.", le=100, gt=0, ) - min_bin_size: Optional[int] = Field( + min_bin_size: int | None = Field( default=None, description="Minimum number of tasks that need to be in a bucket", gt=0, @@ -169,7 +169,7 @@ class OfferWallRequest(BaseModel): default="payout", description="Cluster tasks by payout or duration" ) - passthrough_kwargs: Dict[str, str] = Field( + passthrough_kwargs: dict[str, str] = Field( default_factory=dict, description="These are pulled from the url params. They are any 'extra' url params " "in the getofferwall request. They'll be available through the task_status " @@ -177,12 +177,12 @@ class OfferWallRequest(BaseModel): ) # Only for soft pair (offerwall_id, max_options, max_questions) - offerwall_id: Optional[str] = Field(default=None) - max_options: Optional[int] = Field( + offerwall_id: str | None = Field(default=None) + max_options: int | None = Field( default=None, description="Max number of options an allowed question can have (allowed to be asked)", ) - max_questions: Optional[int] = Field( + max_questions: int | None = Field( default=None, description="Max number of missing questions on a single bin", ) @@ -199,7 +199,7 @@ class OfferWallRequest(BaseModel): "these may be set in the bpc table globally for a BP.", ) - marketplaces: Optional[Set[Source]] = Field( + marketplaces: set[Source] | None = Field( default=None, description="If set, restrict tasks to those from these marketplaces only.", ) @@ -264,7 +264,7 @@ class OfferWallRequest(BaseModel): json.dumps(self.model_dump(mode="json"), sort_keys=True).encode("utf-8") ).hexdigest()[:7] - def to_grpc_request(self) -> Dict[str, Any]: + def to_grpc_request(self) -> dict[str, Any]: # We need this so thl-core can refresh an offerwall in order to continue # a session d = self.model_dump(mode="json") @@ -301,15 +301,15 @@ class OfferWallRequest(BaseModel): } @property - def product_id(self) -> Optional[str]: + def product_id(self) -> str | None: return self.user.product_id @property - def product_user_id(self) -> Optional[str]: + def product_user_id(self) -> str | None: return self.user.product_user_id @property - def bpuid(self) -> Optional[str]: + def bpuid(self) -> str | None: return self.user.product_user_id @property diff --git a/generalresearch/models/thl/offerwall/base.py b/generalresearch/models/thl/offerwall/base.py index 8ea259d..8e24958 100644 --- a/generalresearch/models/thl/offerwall/base.py +++ b/generalresearch/models/thl/offerwall/base.py @@ -1,8 +1,10 @@ +from __future__ import annotations + import statistics from datetime import timedelta from decimal import Decimal from string import Formatter -from typing import Any, Dict, List, Optional, Set, Tuple +from typing import Any from uuid import uuid4 import numpy as np @@ -218,7 +220,7 @@ class MergeTableFeatures(BaseModel): @model_validator(mode="before") @classmethod - def set_completion_time_log(cls, data: Dict[str, Any]) -> Dict[str, Any]: + def set_completion_time_log(cls, data: dict[str, Any]) -> dict[str, Any]: # This isn't actually in the merge table data["COMPLETION_TIME_LOG"] = np.log(data["COMPLETION_TIME"]) return data @@ -238,7 +240,7 @@ class TaskResult(BaseModel): ) source: Source = Field() country_iso: CountryISO = Field() - buyer_id: Optional[str] = Field(min_length=1, max_length=32, default=None) + buyer_id: str | None = Field(min_length=1, max_length=32, default=None) # todo: GRS is allowed to be 0, but all the others can't. make a validator cpi: Decimal = Field(ge=0, le=100, decimal_places=5, max_digits=7) @@ -246,7 +248,7 @@ class TaskResult(BaseModel): # Only GRS tasks will have this set. All other marketplaces will have # to make a grpc call to generate this. This is a str b/c it is actually # a format string. - entry_link: Optional[str] = Field( + entry_link: str | None = Field( default=None, examples=[ "https://{domain}/session/?39057c8b=c4ed212601494f8c8836e38a55102d10&c184efc0=test&0bb50182={mid}" @@ -327,12 +329,12 @@ class ScoredTaskResult(TaskResult, MergeTableFeatures): # The set of marketplace's question codes (internal id) that are unknown. # This should only be set it SoftPairResultType is conditional - unknown_mp_question_ids: Optional[Set[str]] = Field(default=None) + unknown_mp_question_ids: set[str] | None = Field(default=None) # Question ids (from marketplace_question table) for the questions that # will be asked (that would fulfill the unknown questions specified in # unknown_mp_question_ids) - unknown_question_ids: Optional[Set[UUIDStr]] = Field(default=None) + unknown_question_ids: set[UUIDStr] | None = Field(default=None) # ---- Soft Pair end ---- @@ -343,7 +345,7 @@ class ScoredTaskResult(TaskResult, MergeTableFeatures): return Decimal(v).quantize(Decimal("0.00000")) @property - def unknown_mp_qids(self) -> Optional[Set[str]]: + def unknown_mp_qids(self) -> set[str] | None: # marketplace's curie-formatted question IDs that are unknown return ( {self.source + ":" + q for q in self.unknown_mp_question_ids} @@ -351,14 +353,14 @@ class ScoredTaskResult(TaskResult, MergeTableFeatures): else None ) - def to_row(self) -> Dict[str, Any]: + def to_row(self) -> dict[str, Any]: d = self.model_dump(mode="json") d["id_code"] = self.id_code return d class ScoredTaskResults(BaseModel): - tasks: List[ScoredTaskResult] = Field() + tasks: list[ScoredTaskResult] = Field() @property def availability_count(self) -> NonNegativeInt: @@ -375,7 +377,7 @@ class ScoredTaskResults(BaseModel): df["cpi"] = df["cpi"].astype(float) return df - def take_top(self, n=100) -> List[ScoredTaskResult]: + def take_top(self, n=100) -> list[ScoredTaskResult]: return sorted(self.tasks, key=lambda x: x.score, reverse=True)[:n] @@ -395,7 +397,7 @@ class OfferwallBucket(BaseModel): examples=["5ba2fe5010cc4d078fc3cc0b0cc264c3"], default_factory=lambda: uuid4().hex, ) - uri: Optional[HttpsUrl] = Field( + uri: HttpsUrl | None = Field( examples=[ "https://task.generalresearch.com/api/v1/52d3f63b2709/797df4136c604a6c8599818296aae6d1/?i" "=5ba2fe5010cc4d078fc3cc0b0cc264c3&b=test&66482fb=e7baf5e" @@ -404,13 +406,13 @@ class OfferwallBucket(BaseModel): default=None, ) - tasks: List[ScoredTaskResult] = Field() + tasks: list[ScoredTaskResult] = Field() - category: List[CategoryAssociation] = Field(default_factory=list) + category: list[CategoryAssociation] = Field(default_factory=list) # Used only in marketplace offerwall - source: Optional[Source] = Field(default=None) - source_name: Optional[str] = Field(default=None) + source: Source | None = Field(default=None) + source_name: str | None = Field(default=None) # Normally these are calculated. However, in some offerwalls we duplicate # buckets, so they're not "true" calculated values. @@ -420,7 +422,7 @@ class OfferwallBucket(BaseModel): description="Custom: Min payout across all tasks", default=None, ) - custom_q1_duration: Optional[float] = Field( + custom_q1_duration: float | None = Field( description="Custom: Q1 loi across all tasks", default=None, gt=0, @@ -429,11 +431,11 @@ class OfferwallBucket(BaseModel): quality_score: float = Field(default=0) - eligibility_criteria: Optional[Tuple[SurveyEligibilityCriterion, ...]] = Field( + eligibility_criteria: tuple[SurveyEligibilityCriterion, ...] | None = Field( description="The reasons the user is eligible for tasks in this bucket", default=None, ) - eligibility_explanation: Optional[str] = Field( + eligibility_explanation: str | None = Field( default=None, description="Human-readable text explaining a user's eligibility for tasks in this bucket", examples=[ @@ -442,7 +444,7 @@ class OfferwallBucket(BaseModel): ) @property - def missing_questions(self) -> Set[UUIDStr]: + def missing_questions(self) -> set[UUIDStr]: # Used only in softpair. # The question id is the question's uuid (in the marketplace_question table / UpkQuestion.id) # It is just the set union of task.softpair.question_ids for all tasks in this bucket. @@ -461,12 +463,12 @@ class OfferwallBucket(BaseModel): return float(np.sqrt((score**2).sum())) @property - def payout(self) -> Optional[Decimal]: + def payout(self) -> Decimal | None: # The payout is the Min payout across all tasks return min([x.payout for x in self.tasks], default=None) @property - def loi(self) -> Optional[float]: + def loi(self) -> float | None: # The loi is the Max LOI across all tasks return max([x.loi for x in self.tasks], default=None) @@ -487,7 +489,7 @@ class OfferwallBucket(BaseModel): return self.duration_summary.max @property - def sns(self) -> List[str]: + def sns(self) -> list[str]: return [t.id_code for t in self.tasks] @property @@ -541,7 +543,7 @@ class OfferwallBucket(BaseModel): ) @property - def eligibility(self) -> Optional[SoftPairResultType]: + def eligibility(self) -> SoftPairResultType | None: # We're assuming there is never a conditional or ineligible survey # after a unconditional. There can be unconditional surveys # after conditional surveys, in which case the bucket is still @@ -558,7 +560,7 @@ class OfferwallBucket(BaseModel): raise ValueError(f"Unexpected pair_type {pair_type}") @property - def eligibility_str(self) -> Optional[Eligibility]: + def eligibility_str(self) -> Eligibility | None: return ( { SoftPairResultType.UNCONDITIONAL: "unconditional", @@ -586,7 +588,7 @@ class OfferwallBucket(BaseModel): ) def generate_bucket_entry_url( - self, user: User, request_id: Optional[str] = None + self, user: User, request_id: str | None = None ) -> None: product_id = user.product_id product_user_id = user.product_user_id @@ -601,8 +603,6 @@ class OfferwallBucket(BaseModel): base_enter_url, self.id, product_user_id, request_id=request_id ) - return None - # def __repr__(self): # exclude = { # "PRESCREEN_CONVERSION_ALPHA", @@ -646,7 +646,7 @@ class OfferwallBase(BaseModel): default_factory=lambda: uuid4().hex, ) offerwall_type: OfferWallType = Field() - buckets: List[OfferwallBucket] = Field() + buckets: list[OfferwallBucket] = Field() # Note: this != the sum(len(tasks) in buckets) b/c we filter out a lot availability_count: int = Field(default=0, description="Number of available tasks") @@ -660,7 +660,7 @@ class OfferwallBase(BaseModel): examples=[7], default=0, ) - offerwall_reasons: List[OfferwallReason] = Field( + offerwall_reasons: list[OfferwallReason] = Field( default_factory=list, description=( "Explanations describing why so many or few opportunities are available." @@ -670,7 +670,7 @@ class OfferwallBase(BaseModel): # Contains the full info about any questions in any bucket's # missing_questions. - questions: List[UpkQuestion] = Field(default_factory=list) + questions: list[UpkQuestion] = Field(default_factory=list) @property def offerwall_type_class(self) -> OfferWallTypeClass: @@ -683,5 +683,3 @@ class OfferwallBase(BaseModel): def generate_bucket_entry_urls(self, user: User, request_id: str) -> None: for bucket in self.buckets: bucket.generate_bucket_entry_url(user=user, request_id=request_id) - - return None diff --git a/generalresearch/models/thl/offerwall/behavior.py b/generalresearch/models/thl/offerwall/behavior.py index 8e6c089..555870d 100644 --- a/generalresearch/models/thl/offerwall/behavior.py +++ b/generalresearch/models/thl/offerwall/behavior.py @@ -1,4 +1,6 @@ -from typing import Any, Dict, Literal +from __future__ import annotations + +from typing import Any, Literal from pydantic import BaseModel, Field @@ -6,7 +8,7 @@ from pydantic import BaseModel, Field class OfferWallBehavior(BaseModel): id: str = Field() name: str = Field() - kwargs: Dict[str, Any] = Field(default_factory=dict) + kwargs: dict[str, Any] = Field(default_factory=dict) OFFERWALL_BEHAVIOR_PRESETS = [ diff --git a/generalresearch/models/thl/offerwall/bucket.py b/generalresearch/models/thl/offerwall/bucket.py index 1e08e32..bb18ebe 100644 --- a/generalresearch/models/thl/offerwall/bucket.py +++ b/generalresearch/models/thl/offerwall/bucket.py @@ -1,4 +1,5 @@ -from typing import Optional +from __future__ import annotations + from urllib.parse import urlencode @@ -6,8 +7,8 @@ def generate_offerwall_entry_url( base_url: str, obj_id: str, bp_user_id: str, - request_id: Optional[str] = None, - nudge_id: Optional[str] = None, + request_id: str | None = None, + nudge_id: str | None = None, ) -> str: # For an offerwall entry link, we need the clicked bucket_id and the # request hash (so we know which GetOfferwall cache to get diff --git a/generalresearch/models/thl/offerwall/cache.py b/generalresearch/models/thl/offerwall/cache.py index b75a803..c36568e 100644 --- a/generalresearch/models/thl/offerwall/cache.py +++ b/generalresearch/models/thl/offerwall/cache.py @@ -1,5 +1,7 @@ +from __future__ import annotations + from datetime import datetime, timezone -from typing import Any, Dict, List, Optional +from typing import Any from pydantic import BaseModel, Field @@ -23,21 +25,21 @@ class GetOfferWallCache(BaseModel): request: OfferWallRequest = Field() request_id: str = Field() offerwall: OfferwallBase = Field() - all_sids: List[str] = Field() + all_sids: list[str] = Field() timestamp: AwareDatetimeISO = Field( default_factory=lambda: datetime.now(timezone.utc) ) - latest_ip_info: Dict[str, Any] = Field( + latest_ip_info: dict[str, Any] = Field( description="So we can easily check if user's IP info has changed" ) - profiling_task: Optional[TaskResult] = Field( + profiling_task: TaskResult | None = Field( description="Profiling task", default=None ) is_avg_offerwall: bool = Field() # These only get set once a bucket is clicked. - clicked_timestamp: Optional[AwareDatetimeISO] = Field(default=None) - clicked_bucket: Optional[UUIDStr] = Field(default=None) + clicked_timestamp: AwareDatetimeISO | None = Field(default=None) + clicked_bucket: UUIDStr | None = Field(default=None) class SessionInfoCache(BaseModel): @@ -47,13 +49,13 @@ class SessionInfoCache(BaseModel): # This starts out as just the tasks within the clicked bucket, but # will get pruned as tasks are attempted - tasks: List[ScoredTaskResult] = Field() + tasks: list[ScoredTaskResult] = Field() started: AwareDatetimeISO = Field( default_factory=lambda: datetime.now(tz=timezone.utc) ) # The count of attempts per marketplace - mp_retry_count: Dict[Source, int] = Field(default_factory=dict) + mp_retry_count: dict[Source, int] = Field(default_factory=dict) hard_retry_count: int = Field(default=0) diff --git a/generalresearch/models/thl/pagination.py b/generalresearch/models/thl/pagination.py index 1b31078..e8311f0 100644 --- a/generalresearch/models/thl/pagination.py +++ b/generalresearch/models/thl/pagination.py @@ -1,5 +1,6 @@ +from __future__ import annotations + from math import ceil -from typing import Optional from pydantic import BaseModel, Field, computed_field @@ -8,12 +9,10 @@ class Page(BaseModel): # Based on fastapi_pagination.Page page: int = Field(default=1, ge=1, description="Page number") size: int = Field(default=50, ge=1, le=100, description="Page size") - total: Optional[int] = Field( - default=None, ge=0, description="Total number of results" - ) + total: int | None = Field(default=None, ge=0, description="Total number of results") @computed_field(description="Total number of pages") - def pages(self) -> Optional[int]: + def pages(self) -> int | None: if self.size == 0: return 0 elif self.total is not None: diff --git a/generalresearch/models/thl/payout.py b/generalresearch/models/thl/payout.py index 8ab01ec..1a9d534 100644 --- a/generalresearch/models/thl/payout.py +++ b/generalresearch/models/thl/payout.py @@ -1,6 +1,8 @@ +from __future__ import annotations + import json from datetime import datetime, timezone -from typing import Collection, Dict, List, Optional +from typing import Collection from uuid import uuid4 from pydantic import ( @@ -70,25 +72,25 @@ class PayoutEvent(BaseModel): examples=[531], ) - status: Optional[PayoutStatus] = Field( + status: PayoutStatus | None = Field( default=PayoutStatus.PENDING, description=PayoutStatus.as_openapi(), examples=[PayoutStatus.COMPLETE], ) # Used for holding an external, payout-type-specific identifier - ext_ref_id: Optional[str] = Field(default=None) + ext_ref_id: str | None = Field(default=None) payout_type: PayoutType = Field( description=PayoutType.as_openapi(), examples=[PayoutType.ACH] ) - request_data: Dict = Field( + request_data: dict = Field( default_factory=dict, description="Stores payout-type-specific information that is used to " "request this payout from the external provider.", ) - order_data: Optional[Dict | CashMailOrderData] = Field( + order_data: dict | CashMailOrderData | None = Field( default=None, description="Stores payout-type-specific order information that is " "returned from the external payout provider.", @@ -97,8 +99,8 @@ class PayoutEvent(BaseModel): def update( self, status: PayoutStatus, - ext_ref_id: Optional[str] = None, - order_data: Optional[Dict] = None, + ext_ref_id: str | None = None, + order_data: dict | None = None, ) -> None: self.check_status_change_allowed(status) @@ -108,15 +110,13 @@ class PayoutEvent(BaseModel): self.ext_ref_id = ext_ref_id self.order_data = order_data - return None - def check_status_change_allowed(self, status: PayoutStatus) -> None: # We may not be changing the status when this method gets called. It's # possible to be called when we're updating other attributes so # allow immediate bypass if it isn't actually different. if self.status == status: - return None + return if self.status in { PayoutStatus.REJECTED, @@ -144,7 +144,7 @@ class PayoutEvent(BaseModel): # --- ORM --- - def model_dump_mysql(self, *args, **kwargs) -> Dict: + def model_dump_mysql(self, *args, **kwargs) -> dict: d = self.model_dump(mode="json", *args, **kwargs) if "created" in d: @@ -171,13 +171,13 @@ class UserPayoutEvent(PayoutEvent): # Requires joining on: # - accounting_cashoutmethod # - ledger_account - account_reference_type: Optional[str] = Field(default=None) - account_reference_uuid: Optional[UUIDStr] = Field(default=None) + account_reference_type: str | None = Field(default=None) + account_reference_uuid: UUIDStr | None = Field(default=None) # By default, this will just be the cashout_method.name. This also is # populated from the db and so does not need to be set (there is no # `description` field in event_payout) - description: Optional[str] = Field(default=None) + description: str | None = Field(default=None) @field_validator("payout_type", mode="before") @classmethod @@ -226,14 +226,14 @@ class BrokerageProductPayoutEvent(PayoutEvent): def from_payout_event( cls, pe: PayoutEvent, - account_product_mapping: Optional[Dict[UUIDStr, UUIDStr]] = None, - redis_config: Optional[RedisConfig] = None, + account_product_mapping: dict[UUIDStr, UUIDStr] | None = None, + redis_config: RedisConfig | None = None, ) -> Self: # TODO!: prevent re-assignment, rework this... if account_product_mapping is None: rc = redis_config.create_redis_client() - account_product_mapping: Dict = rc.hgetall(name="pem:account_to_product") + account_product_mapping: dict = rc.hgetall(name="pem:account_to_product") assert isinstance(account_product_mapping, dict) assert pe.uuid in account_product_mapping.keys() @@ -246,14 +246,14 @@ class BrokerageProductPayoutEvent(PayoutEvent): cls, payout_events: Collection[PayoutEvent], order_by=OrderBy, - account_product_mapping: Optional[Dict[UUIDStr, UUIDStr]] = None, - redis_config: Optional[RedisConfig] = None, - ) -> List[Self]: + account_product_mapping: dict[UUIDStr, UUIDStr] | None = None, + redis_config: RedisConfig | None = None, + ) -> list[Self]: # TODO!: prevent re-assignment, rework this... if account_product_mapping is None: rc = redis_config.create_redis_client() - account_product_mapping: Dict = rc.hgetall(name="pem:account_to_product") + account_product_mapping: dict = rc.hgetall(name="pem:account_to_product") assert isinstance(account_product_mapping, dict) res = [] @@ -278,7 +278,7 @@ class BrokerageProductPayoutEvent(PayoutEvent): class BusinessPayoutEvent(BaseModel): """A single ACH or Wire event to a Business Bank Account""" - bp_payouts: List[BrokerageProductPayoutEvent] = Field( + bp_payouts: list[BrokerageProductPayoutEvent] = Field( description="Here is the list of Brokerage Product Payouts that" "this Business Payout includes.", min_length=1, @@ -326,7 +326,7 @@ class BusinessPayoutEvent(BaseModel): @computed_field( title="External Reference ID", description="ACH Transaction ID", - return_type=Optional[str], + return_type=str | None, ) @property def ext_ref_id(self): diff --git a/generalresearch/models/thl/payout_format.py b/generalresearch/models/thl/payout_format.py index 4989bdc..9f22ace 100644 --- a/generalresearch/models/thl/payout_format.py +++ b/generalresearch/models/thl/payout_format.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import decimal import re diff --git a/generalresearch/models/thl/product.py b/generalresearch/models/thl/product.py index eb542e1..47e50d0 100644 --- a/generalresearch/models/thl/product.py +++ b/generalresearch/models/thl/product.py @@ -13,11 +13,7 @@ from typing import ( TYPE_CHECKING, Any, Callable, - Dict, - List, Literal, - Optional, - Set, ) from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit from uuid import uuid4 @@ -138,7 +134,7 @@ class ProfilingConfig(BaseModel): the GRS system but won't present them any questions.""", ) - n_questions: Optional[PositiveInt] = Field( + n_questions: PositiveInt | None = Field( default=None, description="Use to hard code the number of questions to ask. None means use default algorithm.", ) @@ -187,7 +183,7 @@ class UserHealthConfig(BaseModel): # Users in these countries are "blocked". Blocked in quotes because # the user doesn't actually get blocked, they just are treated like they # are blocked. - banned_countries: List[CountryISOLike] = Field(default_factory=list) + banned_countries: list[CountryISOLike] = Field(default_factory=list) # Decide if a user can be blocked for IP-related triggers such as sharing # IPs and location history. This should eventually be deprecated and @@ -196,13 +192,13 @@ class UserHealthConfig(BaseModel): # These are only checked by ym-user-predict, which I'm not sure even # works properly. To be deprecated ... don't even use them. - userprofit_cutoff: Optional[Decimal] = Field(default=None, exclude=True) - recon_cutoff: Optional[float] = Field(default=None, exclude=True) - droprate_cutoff: Optional[float] = Field(default=None, exclude=True) - conversion_cutoff: Optional[float] = Field(default=None, exclude=True) + userprofit_cutoff: Decimal | None = Field(default=None, exclude=True) + recon_cutoff: float | None = Field(default=None, exclude=True) + droprate_cutoff: float | None = Field(default=None, exclude=True) + conversion_cutoff: float | None = Field(default=None, exclude=True) @field_validator("banned_countries", mode="after") - def sort_values(cls, values: List[str]): + def sort_values(cls, values: list[str]): return sorted(values) @@ -246,15 +242,15 @@ class OfferWallRequestYieldmanParams(BaseModel): class OfferWallCategoryRequest(BaseModel): # Only include these categories - adwords_category: Optional[List[str]] = Field(default=None, examples=[["45", "65"]]) - category: Optional[List[str]] = Field( + adwords_category: list[str] | None = Field(default=None, examples=[["45", "65"]]) + category: list[str] | None = Field( default=None, examples=[["98c137e4e90a4d92ac6c00e523eb1b50"]] ) # Exclude these categories - exclude_adwords_category: Optional[List[str]] = Field( + exclude_adwords_category: list[str] | None = Field( default=None, examples=[["1558"]] ) - exclude_category: Optional[List[str]] = Field( + exclude_category: list[str] | None = Field( default=None, examples=[ [ @@ -291,7 +287,7 @@ class SourcesConfig(BaseModel): model_config = ConfigDict(frozen=True) - user_defined: List[SourceConfig] = Field(default_factory=list) + user_defined: list[SourceConfig] = Field(default_factory=list) @model_validator(mode="after") def validate_user_defined(self): @@ -300,11 +296,11 @@ class SourcesConfig(BaseModel): return self @cached_property - def default_sources(self) -> List[SourceConfig]: + def default_sources(self) -> list[SourceConfig]: return [SourceConfig.model_validate({"name": s}) for s in Source] @cached_property - def sources(self) -> List[SourceConfig]: + def sources(self) -> list[SourceConfig]: # If a BP has no user_defined SourceConfigs, we use the default. Any # defined in user_defined will replace the default for that # SourceConfig.name @@ -320,13 +316,13 @@ class PayoutConfig(BaseModel): """Store configuration related to payouts, payout transformation, and user payout formatting.""" - payout_format: Optional[PayoutFormatType] = Field( + payout_format: PayoutFormatType | None = Field( default=None, description=payout_format_description, examples=payout_format_examples, ) - payout_transformation: Optional[PayoutTransformation] = Field( + payout_transformation: PayoutTransformation | None = Field( default=None, description="How the BP's payout is converted to the User's payout", ) @@ -395,7 +391,7 @@ class UserCreateConfig(BaseModel): description="The smallest allowed value for the hourly user create limit.", ) - max_hourly_create_limit: Optional[NonNegativeInt] = Field( + max_hourly_create_limit: NonNegativeInt | None = Field( default=None, description="The largest allowed value for the hourly user create " "limit. If None, the hourly create limit is unconstrained.", @@ -420,11 +416,11 @@ class UserWalletConfig(BaseModel): # This field could go in supported_payout_types ---v amt: bool = Field(default=False, description="Uses Amazon Mechanical Turk") - supported_payout_types: Set["PayoutType"] = Field( + supported_payout_types: set["PayoutType"] = Field( default={PayoutType.CASH_IN_MAIL, PayoutType.TANGO, PayoutType.PAYPAL} ) - min_cashout: Optional[Decimal] = Field( + min_cashout: Decimal | None = Field( default=None, gt=0, description="Minimum cashout amount. If enabled is True and no min_cashout is " @@ -434,8 +430,8 @@ class UserWalletConfig(BaseModel): @field_serializer("supported_payout_types", when_used="json") def serialize_supported_payout_types_in_order( - self, supported_payout_types: Set["PayoutType"] - ) -> Set["PayoutType"]: + self, supported_payout_types: set["PayoutType"] + ) -> set["PayoutType"]: return set(sorted(supported_payout_types)) @field_validator("min_cashout", mode="after") @@ -470,14 +466,14 @@ class PayoutTransformationPercentArgs(BaseModel): examples=[0.5], ) - min_payout: Optional[Decimal] = Field( + min_payout: Decimal | None = Field( default=None, description="The minimum amount paid for a complete. Note: This does not " "check that the actual payout was at least this amount.", examples=[Decimal("0.50")], ) - max_payout: Optional[Decimal] = Field( + max_payout: Decimal | None = Field( default=None, description="The maximum amount paid for a complete", examples=[Decimal("5.00")], @@ -524,7 +520,7 @@ class PayoutTransformation(BaseModel): description="The name of the transformation function to use." ) - kwargs: Optional[PayoutTransformationPercentArgs] = Field( + kwargs: PayoutTransformationPercentArgs | None = Field( description="The kwargs to pass to the transformation function.", examples=[{"pct": 0.50, "max_payout": "5.00"}], default=None, @@ -549,8 +545,8 @@ class PayoutTransformation(BaseModel): self, payout: Decimal, pct: Decimal = 1, - min_payout: Decimal = 0, - max_payout: Optional[Decimal] = None, + min_payout: Decimal | None = 0, + max_payout: Decimal | None = None, ) -> Decimal: """Payout transformation for user displayed values""" if min_payout is None: @@ -567,7 +563,7 @@ class PayoutTransformation(BaseModel): return payout def payout_transformation_amt( - self, payout: Decimal, user_wallet_balance: Optional[Decimal] = None + self, payout: Decimal, user_wallet_balance: Decimal | None = None ) -> Decimal: """Payout transformation for user displayed values""" # If user_wallet_balance isn't passed, we are re-calculating this @@ -600,7 +596,7 @@ class SourceConfig(BaseModel): name: Source = Field() active: bool = Field(default=True) - banned_countries: List[CountryISOLike] = Field(default_factory=list) + banned_countries: list[CountryISOLike] = Field(default_factory=list) allow_mobile_ip: bool = Field(default=True) allow_pii_only_buyers: bool = Field( @@ -651,7 +647,7 @@ class SupplyConfig(BaseModel): model_config = ConfigDict(frozen=False, validate_assignment=True) - policies: List[SupplyPolicy] = Field(default_factory=list) + policies: list[SupplyPolicy] = Field(default_factory=list) @property def configs(self): @@ -702,13 +698,13 @@ class SupplyConfig(BaseModel): return [c for c in self.policies if c.scope == Scope.PRODUCT] @property - def global_scoped_policies_dict(self) -> Dict[Source, SupplyPolicy]: + def global_scoped_policies_dict(self) -> dict[Source, SupplyPolicy]: return {c.name: c for c in self.policies if c.scope == Scope.GLOBAL} @property def team_scoped_policies_dict( self, - ) -> Dict[str, Dict[Source, SupplyPolicy]]: + ) -> dict[str, dict[Source, SupplyPolicy]]: # str in top-level dict is the team_id d = defaultdict(dict) for c in self.team_scoped_policies: @@ -719,7 +715,7 @@ class SupplyConfig(BaseModel): @property def product_scoped_policies_dict( self, - ) -> Dict[str, Dict[Source, SupplyPolicy]]: + ) -> dict[str, dict[Source, SupplyPolicy]]: # str in top-level dict is the product_id d = defaultdict(dict) for c in self.product_scoped_policies: @@ -729,7 +725,7 @@ class SupplyConfig(BaseModel): def get_policies_for( self, product_id: str, team_id: str - ) -> Dict[Source, SupplyPolicy]: + ) -> dict[Source, SupplyPolicy]: """ Is there a config scoped to this product? If not, Is there a config scoped to this team? If not, @@ -768,20 +764,20 @@ class SupplyPolicy(SourceConfig): internally in grpc logic. """ - address: List[str] = Field(description="address for the grpc GetOpps call") + address: list[str] = Field(description="address for the grpc GetOpps call") allow_vpn: bool = Field(default=False) distribute_harmonizer_active: bool = Field(default=True) - supplier_id: Optional[str] = Field( + supplier_id: str | None = Field( default=None, description="For some inventory Sources, we may partition traffic using " "different supplier accounts instead", ) - team_ids: Optional[List[UUIDStr]] = Field(default=None) - product_ids: Optional[List[UUIDStr]] = Field(default=None) + team_ids: list[UUIDStr] | None = Field(default=None) + product_ids: list[UUIDStr] | None = Field(default=None) integration_mode: IntegrationMode = Field(default=IntegrationMode.PLATFORM) @@ -847,7 +843,7 @@ class Product(BaseModel, validate_assignment=True): examples=["1108d053e4fa47c5b0dbdcd03a7981e7"], ) - id_int: SkipJsonSchema[Optional[PositiveInt]] = Field(default=None) + id_int: SkipJsonSchema[PositiveInt | None] = Field(default=None) name: str = Field( min_length=3, @@ -868,14 +864,14 @@ class Product(BaseModel, validate_assignment=True): "be made to the Product.", ) - created: Optional[AwareDatetimeISO] = Field( + created: AwareDatetimeISO | None = Field( # TODO: make this non-nullable default=None, description="When the Product was created, this does necessarily mean " "it started to retrieve traffic at that time.", ) - team_id: Optional[UUIDStr] = Field( + team_id: UUIDStr | None = Field( # TODO: make this non-nullable default=None, examples=["b96c1209cf4a4baaa27d38082421a039"], @@ -884,7 +880,7 @@ class Product(BaseModel, validate_assignment=True): "Product", ) - business_id: Optional[UUIDStr] = Field( + business_id: UUIDStr | None = Field( default=None, examples=[uuid4().hex], description="The legal business entity or individual that is " @@ -892,7 +888,7 @@ class Product(BaseModel, validate_assignment=True): "Payments for this Product's activity.", ) - tags: Set["SupplierTag"] = Field( + tags: set["SupplierTag"] = Field( default_factory=set, description="Tags which are used to annotate supplier traffic", ) @@ -945,24 +941,24 @@ class Product(BaseModel, validate_assignment=True): # Initialization is deferred until unless it's called # (see .prebuild_***()) - balance: Optional["ProductBalances"] = Field( + balance: "ProductBalances" | None = Field( default=None, description="Product Balance" ) - payouts_total_str: Optional[str] = Field(default=None) - payouts_total: Optional[USDCent] = Field(default=None) - payouts: Optional[List["BrokerageProductPayoutEvent"]] = Field( + payouts_total_str: str | None = Field(default=None) + payouts_total: USDCent | None = Field(default=None) + payouts: list["BrokerageProductPayoutEvent"] | None = Field( default=None, description="Product Payouts. These are the ACH or Wire payments that were sent to the" "Business on behalf of this specific Product", ) - pop_financial: Optional[List["POPFinancial"]] = Field(default=None) - bp_account: Optional[LedgerAccount] = Field(default=None) + pop_financial: list["POPFinancial"] | None = Field(default=None) + bp_account: LedgerAccount | None = Field(default=None) # --- Validators --- @field_validator("harmonizer_domain", mode="before") - def harmonizer_domain_https(cls, s: Optional[str]): + def harmonizer_domain_https(cls, s: str | None): # in the db, this has no scheme. accept both with a default of https:// if s is not None and not (s.startswith("https://") or s.startswith("http://")): s = f"https://{s}" @@ -1022,7 +1018,7 @@ class Product(BaseModel, validate_assignment=True): return self.sources_config.sources @property - def sources_dict(self) -> Dict[Source, SourceConfig]: + def sources_dict(self) -> dict[Source, SourceConfig]: # This stores the same info as sources but with the keys as a Source return {x.name: x for x in self.sources} @@ -1054,12 +1050,10 @@ class Product(BaseModel, validate_assignment=True): return f"product-{self.uuid}" # --- Prefetch --- - def prefetch_bp_account(self, thl_lm: "ThlLedgerManager"): + def prefetch_bp_account(self, thl_lm: "ThlLedgerManager") -> None: account = thl_lm.get_account_or_create_bp_wallet(product=self) self.bp_account = account - return None - # --- Prebuild --- def prebuild_balance( @@ -1067,7 +1061,7 @@ class Product(BaseModel, validate_assignment=True): thl_lm: "ThlLedgerManager", ds: "GRLDatasets", client: Client, - pop_ledger: Optional["PopLedgerMerge"] = None, + pop_ledger: "PopLedgerMerge" | None = None, ) -> None: """ This returns the Product's Balances that are calculated across @@ -1129,7 +1123,6 @@ class Product(BaseModel, validate_assignment=True): "If the df is empty, we can also assume that there should be no " "transactions in the ledger." ) - return None df = df.set_index("time_idx") from generalresearch.models.thl.finance import ProductBalances @@ -1143,14 +1136,13 @@ class Product(BaseModel, validate_assignment=True): assert bal == balance.balance, "Sql and Parquet Balance inconsistent" self.balance = balance - return None def prebuild_pop_financial( self, thl_lm: "ThlLedgerManager", ds: "GRLDatasets", client: Client, - pop_ledger: Optional["PopLedgerMerge"] = None, + pop_ledger: "PopLedgerMerge" | None = None, ) -> None: """This is very similar to the Product POP Financial endpoint; however, it returns more than one item for a single time interval. This is @@ -1186,13 +1178,13 @@ class Product(BaseModel, validate_assignment=True): ) if ddf is None: self.pop_financial = [] - return None + return df = client.compute(collections=ddf, sync=True) if df.empty: self.pop_financial = [] - return None + return df = df.groupby( [pd.Grouper(key="time_idx", freq=rr.interval), "account_id"] @@ -1204,7 +1196,7 @@ class Product(BaseModel, validate_assignment=True): input_data=df, accounts=[self.bp_account] ) - return None + return def prebuild_payouts( self, @@ -1228,8 +1220,6 @@ class Product(BaseModel, validate_assignment=True): self.payouts_total = USDCent(sum([po.amount for po in self.payouts])) self.payouts_total_str = self.payouts_total.to_usd_str() - return None - # def prebuild_pop(self): # account = LM.get_account(qualified_name=f"{LM.currency.value}:bp_wallet:{product.id}") # @@ -1326,7 +1316,7 @@ class Product(BaseModel, validate_assignment=True): client: Client, bp_pem: "BrokerageProductPayoutEventManager", redis_config: RedisConfig, - pop_ledger: Optional[PopLedgerMerge] = None, + pop_ledger: PopLedgerMerge | None = None, ) -> None: LOG.debug(f"Product.set_cache({self.uuid=})") @@ -1352,8 +1342,6 @@ class Product(BaseModel, validate_assignment=True): rc = redis_config.create_redis_client() rc.set(name=self.cache_key, value=self.model_dump_json(), ex=ex_secs) - return None - def determine_bp_payment(self, thl_net: Decimal) -> Decimal: """ How much should we pay the BP? @@ -1369,7 +1357,6 @@ class Product(BaseModel, validate_assignment=True): return (thl_net * self.commission_pct).quantize(Decimal("0.01")) def get_payout_transformation_func(self) -> Callable: - """ """ if self.payout_config.payout_transformation is None: return lambda x: x else: @@ -1378,8 +1365,8 @@ class Product(BaseModel, validate_assignment=True): ) def calculate_user_payment( - self, bp_payout: Decimal, user_wallet_balance: Optional[Decimal] = None - ) -> Optional[Decimal]: + self, bp_payout: Decimal, user_wallet_balance: Decimal | None = None + ) -> Decimal | None: """ :param bp_payout: This is the amount we paid to the brokerage product :return: The amount that should be paid to the user @@ -1403,7 +1390,7 @@ class Product(BaseModel, validate_assignment=True): url = urlunsplit(url_split) return url - def format_payout_format(self, payout: Decimal) -> Optional[str]: + def format_payout_format(self, payout: Decimal) -> str | None: assert isinstance(payout, Decimal), "payout should be a Decimal" if self.payout_config.payout_format is None: return None @@ -1412,7 +1399,7 @@ class Product(BaseModel, validate_assignment=True): # --- ORM --- - def model_dump_mysql(self, *args, **kwargs) -> Dict[str, Any]: + def model_dump_mysql(self, *args, **kwargs) -> dict[str, Any]: d = self.model_dump(mode="json", *args, **kwargs) if "created" in d: diff --git a/generalresearch/models/thl/profiling/marketplace.py b/generalresearch/models/thl/profiling/marketplace.py index 2dd7028..027aa4c 100644 --- a/generalresearch/models/thl/profiling/marketplace.py +++ b/generalresearch/models/thl/profiling/marketplace.py @@ -1,7 +1,9 @@ +from __future__ import annotations + from abc import ABC, abstractmethod from datetime import datetime, timezone from functools import cached_property -from typing import Any, Dict, Optional, Set, Tuple +from typing import Any from pydantic import BaseModel, ConfigDict, Field, PositiveInt, computed_field @@ -37,7 +39,7 @@ class MarketplaceQuestion(BaseModel, ABC): # Refers to a Category that we annotate. The info is stored in different # dbs, so it may not be possible to retrieve the Category from the id, # so we just store the id here. - category_id: Optional[UUIDStr] = Field(default=None) + category_id: UUIDStr | None = Field(default=None) # # This doesn't work # @property @@ -58,7 +60,7 @@ class MarketplaceQuestion(BaseModel, ABC): return f"{self.source.value}:{self.internal_id}" @property - def _key(self) -> Tuple[str, CountryISOLike, LanguageISOLike]: + def _key(self) -> tuple[str, CountryISOLike, LanguageISOLike]: """This uniquely identifies a question in a locale. There is a unique index on this in the db. e.g. (question_id, country_iso, language_iso) """ @@ -68,7 +70,7 @@ class MarketplaceQuestion(BaseModel, ABC): def to_upk_question(self): ... @computed_field - def num_options(self) -> Optional[int]: + def num_options(self) -> int | None: return len(self.options) if self.options is not None else None def __hash__(self): @@ -96,7 +98,7 @@ class MarketplaceUserQuestionAnswer(BaseModel): # "anonymous" users, which are represented by a list of question answers # not associated with an actual user. No default b/c we must explicitly # set the field to None. - user_id: Optional[PositiveInt] = Field(lt=MAX_INT32) + user_id: PositiveInt | None = Field(lt=MAX_INT32) question_id: str = Field() @@ -104,7 +106,7 @@ class MarketplaceUserQuestionAnswer(BaseModel): # these are fetched from the db for use in yield-management, we read this # field from the marketplace's question table. # This should be overloaded in each implementation !!! - question_type: Optional[str] = Field(default=None) + question_type: str | None = Field(default=None) # This may be a pipe-separated string if the question_type is multi. Regex # means any chars except capital letters @@ -116,14 +118,14 @@ class MarketplaceUserQuestionAnswer(BaseModel): language_iso: LanguageISO = Field(frozen=True) @cached_property - def options_ids(self) -> Set[str]: + def options_ids(self) -> set[str]: return set(self.option_id.split("|")) @property def pre_code(self) -> str: return self.option_id - def to_mysql(self) -> Dict[str, Any]: + def to_mysql(self) -> dict[str, Any]: d = self.model_dump(mode="json", exclude={"question_type"}) d["created"] = self.created.replace(tzinfo=None) return d diff --git a/generalresearch/models/thl/profiling/question.py b/generalresearch/models/thl/profiling/question.py index 72115fe..3e2984a 100644 --- a/generalresearch/models/thl/profiling/question.py +++ b/generalresearch/models/thl/profiling/question.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Dict, Optional, Tuple +from typing import Any from pydantic import ( BaseModel, @@ -21,20 +21,20 @@ from generalresearch.models.thl.profiling.upk_question import UpkQuestion class Question(BaseModel): model_config = ConfigDict(validate_assignment=True) - id: Optional[UUIDStr] = Field(default=None, alias="question_id") + id: UUIDStr | None = Field(default=None, alias="question_id") # ISO 3166-1 alpha-2 (two-letter codes, lowercase) country_iso: CountryISOLike = Field() # 3-char ISO 639-2/B, lowercase language_iso: LanguageISOLike = Field() - property_code: Optional[str] = Field( + property_code: str | None = Field( default=None, description="What marketplace question this question links to", pattern=r"^[a-z]{1,2}\:.*", ) data: UpkQuestion = Field() is_live: bool = Field() - custom: Dict[str, Any] = Field(default_factory=dict) + custom: dict[str, Any] = Field(default_factory=dict) last_updated: AwareDatetimeISO = Field() @computed_field @@ -42,5 +42,5 @@ class Question(BaseModel): def md5sum(self) -> str: return self.data.md5sum - def validate_question_answer(self, answer: Tuple[str, ...]) -> Tuple[bool, str]: + def validate_question_answer(self, answer: tuple[str, ...]) -> tuple[bool, str]: return self.data.validate_question_answer(answer=answer) diff --git a/generalresearch/models/thl/profiling/upk_property.py b/generalresearch/models/thl/profiling/upk_property.py index 9eede95..9e78692 100644 --- a/generalresearch/models/thl/profiling/upk_property.py +++ b/generalresearch/models/thl/profiling/upk_property.py @@ -1,6 +1,7 @@ +from __future__ import annotations + from enum import Enum from functools import cached_property -from typing import Dict, List, Optional from uuid import uuid4 from pydantic import BaseModel, ConfigDict, Field, TypeAdapter @@ -34,7 +35,7 @@ class Cardinality(str, Enum, metaclass=ReprEnumMeta): class UpkItem(BaseModel): id: UUIDStr = Field(examples=["497b1fedec464151b063cd5367643ffa"]) label: str = Field(max_length=255, examples=["high_school_completion"]) - description: Optional[str] = Field( + description: str | None = Field( max_length=1024, examples=["Completed high school"], default=None ) @@ -78,17 +79,17 @@ class UpkProperty(BaseModel): "the Nudge API.", ) - allowed_items: Optional[List[UpkItem]] = Field(default=None) + allowed_items: list[UpkItem] | None = Field(default=None) - categories: List[Category] = Field(default_factory=list) + categories: list[Category] = Field(default_factory=list) @cached_property - def allowed_items_by_label(self) -> Dict[str, UpkItem]: + def allowed_items_by_label(self) -> dict[str, UpkItem]: return {i.label: i for i in self.allowed_items} @cached_property - def allowed_items_by_id(self) -> Dict[UUIDStr, UpkItem]: + def allowed_items_by_id(self) -> dict[UUIDStr, UpkItem]: return {i.id: i for i in self.allowed_items} -ProfilingInfo = TypeAdapter(List[UpkProperty]) +ProfilingInfo = TypeAdapter(list[UpkProperty]) diff --git a/generalresearch/models/thl/profiling/upk_question.py b/generalresearch/models/thl/profiling/upk_question.py index 5c908b0..307bc33 100644 --- a/generalresearch/models/thl/profiling/upk_question.py +++ b/generalresearch/models/thl/profiling/upk_question.py @@ -5,7 +5,7 @@ import json import re from enum import Enum from functools import cached_property -from typing import Any, Dict, List, Literal, Optional, Set, Tuple, Union +from typing import Any, List, Literal, Union from pydantic import ( BaseModel, @@ -24,21 +24,21 @@ from generalresearch.models.thl.category import Category class UPKImportance(BaseModel): - task_count: Optional[int] = Field( + task_count: int | None = Field( ge=0, default=None, examples=[47], description="The number of live Tasks that use this UPK Question", ) - task_score: Optional[float] = Field( + task_score: float | None = Field( ge=0, default=None, examples=[0.11175522477414712], description="GRL's internal ranked score for the UPK Question", ) - marketplace_task_count: Optional[Dict[Source, NonNegativeInt]] = Field( + marketplace_task_count: dict[Source, NonNegativeInt] | None = Field( default=None, examples=[{Source.DYNATA: 23, Source.SPECTRUM: 24}], description="The number of live Tasks that use this UPK Question per marketplace", @@ -80,14 +80,14 @@ class UpkQuestionChoice(BaseModel): order: NonNegativeInt = Field() # Allows you to group answer choices together (used for display or extra logic) - group: Optional[int] = Field(default=None) + group: int | None = Field(default=None) exclusive: bool = Field( default=False, description="If answer is exclusive, it can be the only option selected", ) - importance: Optional[UPKImportance] = Field(default=None) + importance: UPKImportance | None = Field(default=None) def __hash__(self): # We don't know the question ID!! Unique within a question only! @@ -151,7 +151,7 @@ class UpkQuestionConfigurationMC(BaseModel): exclude=True, default=UpkQuestionType.MULTIPLE_CHOICE ) - max_select: Optional[int] = Field(gt=0, default=None) + max_select: int | None = Field(gt=0, default=None) class UpkQuestionConfigurationTE(BaseModel): @@ -163,7 +163,7 @@ class UpkQuestionConfigurationTE(BaseModel): ) # Sets input form attribute; not the same as regex validation - max_length: Optional[PositiveInt] = Field( + max_length: PositiveInt | None = Field( default=None, description="Maximum str length of any input. Meant as an easy, non" "regex based check.", @@ -171,7 +171,7 @@ class UpkQuestionConfigurationTE(BaseModel): # The text input box must contain this number of chars before submission # is allowed - min_length: Optional[PositiveInt] = Field( + min_length: PositiveInt | None = Field( default=None, description="Minimum str length of any input. Meant as an easy, non" "regex based check.", @@ -195,25 +195,25 @@ class UpkQuestionConfigurationSLIDER(BaseModel): ) # TODO: constraints. we don't have any of these so not wasting time on this - slider_min: Optional[float] = Field(default=None) - slider_max: Optional[float] = Field(default=None) - slider_start: Optional[float] = Field(default=None) - slider_step: Optional[float] = Field(default=None) + slider_min: float | None = Field(default=None) + slider_max: float | None = Field(default=None) + slider_start: float | None = Field(default=None) + slider_step: float | None = Field(default=None) class UpkQuestionValidation(BaseModel): model_config = ConfigDict(frozen=True) # --- UpkQuestionType.TEXT_ENTRY Options --- - patterns: Optional[List[PatternValidation]] = Field(min_length=1) + patterns: list[PatternValidation] | None = Field(min_length=1) -SelectorType = Union[ - UpkQuestionSelectorMC, - UpkQuestionSelectorTE, - UpkQuestionSelectorSLIDER, - UpkQuestionSelectorHIDDEN, -] +SelectorType = ( + UpkQuestionSelectorMC + | UpkQuestionSelectorTE + | UpkQuestionSelectorSLIDER + | UpkQuestionSelectorHIDDEN +) Configuration = Annotated[ Union[ UpkQuestionConfigurationMC, @@ -276,11 +276,11 @@ class UpkQuestion(BaseModel): ) # The id is globally unique - id: Optional[UUIDStr] = Field(default=None, alias="question_id") + id: UUIDStr | None = Field(default=None, alias="question_id") # The format is "{Source}:{question_id}" where Source is 1 or 2 chars, and # question_id is the marketplace's ID for this question. - ext_question_id: Optional[str] = Field( + ext_question_id: str | None = Field( default=None, description="what marketplace question this question links to", pattern=r"^[a-z]{1,2}\:.*", @@ -301,25 +301,25 @@ class UpkQuestion(BaseModel): # Don't set a min_length=1 here. We'll allow this to be created, but it # won't be askable with empty choices. - choices: Optional[List[UpkQuestionChoice]] = Field(default=None) + choices: list[UpkQuestionChoice] | None = Field(default=None) selector: SelectorType = Field() - configuration: Optional[Configuration] = Field(default=None) - validation: Optional[UpkQuestionValidation] = Field(default=None) - importance: Optional[UPKImportance] = Field(default=None) + configuration: Configuration | None = Field(default=None) + validation: UpkQuestionValidation | None = Field(default=None) + importance: UPKImportance | None = Field(default=None) - categories: List[Category] = Field( + categories: list[Category] = Field( default_factory=list, description="Categories associated with this question", ) - explanation_template: Optional[str] = Field( + explanation_template: str | None = Field( description="Human-readable template for explaining how a user's answer to this question affects eligibility", examples=[ "The company that administers your employer's retirement plan is {answer}." ], default=None, ) - explanation_fragment_template: Optional[str] = Field( + explanation_fragment_template: str | None = Field( description="A very short, natural-language explanation fragment that can be combined with others into a single sentence", examples=["whose employer's retirement plan is {answer}"], default=None, @@ -336,7 +336,7 @@ class UpkQuestion(BaseModel): return self.country_iso + "_" + self.language_iso @property - def source(self) -> Optional[Source]: + def source(self) -> Source | None: if self.ext_question_id: return Source(self.ext_question_id.split(":", 1)[0]) @@ -348,7 +348,7 @@ class UpkQuestion(BaseModel): @model_validator(mode="before") @classmethod - def check_configuration_type(cls, data: Dict[str, Any]) -> Dict[str, Any]: + def check_configuration_type(cls, data: dict[str, Any]) -> dict[str, Any]: # The model knows what the type of Configuration to grab depending on # the key 'type' which it expects inside the configuration object. # Here, we grab the type from the top-level model instead. @@ -441,8 +441,8 @@ class UpkQuestion(BaseModel): @field_validator("choices") @classmethod def validate_choices( - cls, choices: Optional[List[UpkQuestionChoice]] - ) -> Optional[List[UpkQuestionChoice]]: + cls, choices: list[UpkQuestionChoice] | None + ) -> list[UpkQuestionChoice] | None: if choices: ids = {x.id for x in choices} assert len(ids) == len(choices), "choices.id must be unique" @@ -507,7 +507,7 @@ class UpkQuestion(BaseModel): d.update(d.pop("importance", {})) return d - def validate_question_answer(self, answer: Tuple[str, ...]) -> Tuple[bool, str]: + def validate_question_answer(self, answer: tuple[str, ...]) -> tuple[bool, str]: """ Returns (is_valid, error_message). """ @@ -518,7 +518,7 @@ class UpkQuestion(BaseModel): else: return True, "" - def _validate_question_answer(self, answer: Tuple[str, ...]) -> None: + def _validate_question_answer(self, answer: tuple[str, ...]) -> None: """ If the question is MC, validate: - validate selector SA vs MA (1 selected vs >1 selected) @@ -578,24 +578,24 @@ class UpkQuestion(BaseModel): class UpkQuestionOut(UpkQuestion): - choices: Optional[List[UpkQuestionChoiceOut]] = Field(default=None) + choices: list[UpkQuestionChoiceOut] | None = Field(default=None) # Return both importance top-level model and extracted keys for now. # Eventually deprecate one way. - task_count: Optional[int] = Field( + task_count: int | None = Field( ge=0, default=None, examples=[47], description="The number of live Tasks that use this UPK Question", ) - task_score: Optional[float] = Field( + task_score: float | None = Field( ge=0, default=None, examples=[0.11175522477414712], description="GRL's internal ranked score for the UPK Question", ) - marketplace_task_count: Optional[Dict[Source, NonNegativeInt]] = Field( + marketplace_task_count: dict[Source, NonNegativeInt] | None = Field( default=None, examples=[{Source.DYNATA: 23, Source.SPECTRUM: 24}], description="The number of live Tasks that use this UPK Question per marketplace", @@ -649,7 +649,7 @@ def trim_options(q: UpkQuestion, max_options: int = 50) -> UpkQuestion: if q.ext_question_id.startswith("gr:") or q.ext_question_id.startswith("g:"): return q - special_choices: Set[UpkQuestionChoice] = { + special_choices: set[UpkQuestionChoice] = { c for c in q.choices if option_is_catch_all(c) } diff --git a/generalresearch/models/thl/profiling/upk_question_answer.py b/generalresearch/models/thl/profiling/upk_question_answer.py index 2eb52e1..0024e68 100644 --- a/generalresearch/models/thl/profiling/upk_question_answer.py +++ b/generalresearch/models/thl/profiling/upk_question_answer.py @@ -1,5 +1,7 @@ +from __future__ import annotations + from datetime import datetime, timezone -from typing import Any, Dict, Optional, Union +from typing import Any from uuid import uuid4 from pydantic import ( @@ -30,12 +32,12 @@ class UpkQuestionAnswer(BaseModel): user_id: PositiveInt = Field(lt=MAX_INT32) - question_id: Optional[UUIDStr] = Field( + question_id: UUIDStr | None = Field( examples=[uuid4().hex], description="The ID of the question that was asked in order to determine this", default=None, ) - session_id: Optional[UUIDStr] = Field( + session_id: UUIDStr | None = Field( examples=[uuid4().hex], description="The thl_session in which the question was asked", default=None, @@ -64,23 +66,23 @@ class UpkQuestionAnswer(BaseModel): # If the property is PropertyType.UPK_ITEM, it should have an item (and no value). # If the property is UPK_NUMERICAL or UPK_TEXT, it'll have a value (and no item). - item_id: Optional[UUIDStr] = Field( + item_id: UUIDStr | None = Field( default=None, examples=["497b1fedec464151b063cd5367643ffa"] ) - item_label: Optional[str] = Field( + item_label: str | None = Field( default=None, max_length=255, examples=["high_school_completion"] ) - value_text: Optional[str] = Field( + value_text: str | None = Field( default=None, max_length=1024, ) - value_num: Optional[float] = Field( + value_num: float | None = Field( default=None, ) @computed_field @property - def value(self) -> Optional[Union[str, float]]: + def value(self) -> str | float | None: if self.prop_type == PropertyType.UPK_ITEM: return self.item_label elif self.prop_type == PropertyType.UPK_TEXT: @@ -109,7 +111,7 @@ class UpkQuestionAnswer(BaseModel): return self - def model_dump_mysql(self) -> Dict[str, Any]: + def model_dump_mysql(self) -> dict[str, Any]: d = self.model_dump(mode="json") d["created"] = self.created return d diff --git a/generalresearch/models/thl/profiling/user_info.py b/generalresearch/models/thl/profiling/user_info.py index 15197c9..32af704 100644 --- a/generalresearch/models/thl/profiling/user_info.py +++ b/generalresearch/models/thl/profiling/user_info.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from __future__ import annotations from pydantic import BaseModel, ConfigDict, Field from pydantic.json_schema import SkipJsonSchema @@ -14,11 +14,11 @@ from generalresearch.models.thl.user import User class UserProfileKnowledgeAnswer(BaseModel): # Returns {id, label, translation} when the prop_type is an item, # and only {value} if it's a string/text (such as for postalcode) - id: Optional[str] = Field(default=None) - label: Optional[str] = Field(default=None) - translation: Optional[str] = Field(default=None) + id: str | None = Field(default=None) + label: str | None = Field(default=None) + translation: str | None = Field(default=None) - value: Optional[str] = Field(default=None) + value: str | None = Field(default=None) class UserProfileKnowledge(BaseModel): @@ -26,7 +26,7 @@ class UserProfileKnowledge(BaseModel): property_label: str = Field() translation: str = Field() - answer: List[UserProfileKnowledgeAnswer] = Field(default_factory=list) + answer: list[UserProfileKnowledgeAnswer] = Field(default_factory=list) created: AwareDatetimeISO = Field( description="When the User submitted this Profiling data" @@ -44,7 +44,7 @@ class MarketProfileKnowledge(BaseModel): question_id: str = Field(examples=["gender", "1843", "gender_plus"]) - answer: List[str] = Field( + answer: list[str] = Field( default_factory=list, examples=[["male"], ["7657644"], ["1"]] ) @@ -67,10 +67,10 @@ class MarketProfileKnowledge(BaseModel): class UserInfo(BaseModel): model_config = ConfigDict() - user: SkipJsonSchema[Optional[User]] = Field(exclude=True, default=None) + user: SkipJsonSchema[User | None] = Field(exclude=True, default=None) - user_profile_knowledge: List[UserProfileKnowledge] = Field(default_factory=list) + user_profile_knowledge: list[UserProfileKnowledge] = Field(default_factory=list) - marketplace_profile_knowledge: List[MarketProfileKnowledge] = Field( + marketplace_profile_knowledge: list[MarketProfileKnowledge] = Field( default_factory=list ) diff --git a/generalresearch/models/thl/profiling/user_question_answer.py b/generalresearch/models/thl/profiling/user_question_answer.py index b325583..0d3e37c 100644 --- a/generalresearch/models/thl/profiling/user_question_answer.py +++ b/generalresearch/models/thl/profiling/user_question_answer.py @@ -1,6 +1,8 @@ +from __future__ import annotations + import json from datetime import datetime, timedelta, timezone -from typing import Any, Dict, Iterator, Literal, Optional, Tuple, Union +from typing import Any, Iterator, Literal from pydantic import ( BaseModel, @@ -23,30 +25,30 @@ class UserQuestionAnswer(BaseModel): model_config = ConfigDict(validate_assignment=True) - user_id: Optional[PositiveInt] = Field(lt=MAX_INT32, default=None) + user_id: PositiveInt | None = Field(lt=MAX_INT32, default=None) question_id: UUIDStr = Field() - answer: Tuple[str, ...] = Field() + answer: tuple[str, ...] = Field() timestamp: AwareDatetimeISO = Field( default_factory=lambda: datetime.now(tz=timezone.utc) ) - country_iso: Union[CountryISO, Literal["xx"]] = Field() - language_iso: Union[LanguageISO, Literal["xxx"]] = Field() + country_iso: CountryISO | Literal["xx"] = Field() + language_iso: LanguageISO | Literal["xxx"] = Field() # Store a property code associated with this question_id. e.g. "gr:hispanic" or "d:192" property_code: str = Field() # Stores any question answers that are calculated from this answer - calc_answers: Optional[Dict[str, Tuple[str, ...]]] = Field(default=None) + calc_answers: dict[str, tuple[str, ...]] | None = Field(default=None) @field_validator("calc_answers") - def sorted_calc_answers(cls, calc_answers) -> Optional[Dict[str, Tuple[str, ...]]]: + def sorted_calc_answers(cls, calc_answers) -> dict[str, tuple[str, ...]] | None: if calc_answers is None: return None return {k: tuple(sorted(v)) for k, v in calc_answers.items()} @field_validator("calc_answers") - def validate_keys(cls, calc_answers) -> Optional[Dict[str, Tuple[str, ...]]]: + def validate_keys(cls, calc_answers) -> dict[str, tuple[str, ...]] | None: if calc_answers is None: return None @@ -55,7 +57,7 @@ class UserQuestionAnswer(BaseModel): ), "calc_answers expects the keys to be in format source:question_code" return calc_answers - def model_dump_mysql(self, session_id: Optional[str] = None) -> Dict[str, Any]: + def model_dump_mysql(self, session_id: str | None = None) -> dict[str, Any]: d = self.model_dump(mode="json", exclude={"calc_answers", "timestamp"}) d["answer"] = json.dumps(self.answer) # Note naming inconsistency here: calc_answer/s @@ -84,7 +86,7 @@ class UserQuestionAnswer(BaseModel): def __hash__(self) -> int: return hash((self.question_id, self.answer, self.timestamp)) - def validate_question_answer(self, question: UpkQuestion) -> Tuple[bool, str]: + def validate_question_answer(self, question: UpkQuestion) -> tuple[bool, str]: """ Returns (is_valid, error_message). """ @@ -143,7 +145,7 @@ class MarketplaceResearchProfileQuestion(BaseModel): description="# the question id/code on the marketplace", min_length=1 ) source: Source = Field() # the one or two-letter marketplace code - answer: Tuple[str, ...] = Field(min_length=1) + answer: tuple[str, ...] = Field(min_length=1) timestamp: AwareDatetimeISO = Field() country_iso: CountryISO = Field() language_iso: LanguageISO = Field() diff --git a/generalresearch/models/thl/report_task.py b/generalresearch/models/thl/report_task.py index 1abd330..d29599d 100644 --- a/generalresearch/models/thl/report_task.py +++ b/generalresearch/models/thl/report_task.py @@ -1,6 +1,8 @@ +from __future__ import annotations + import random from collections import defaultdict -from typing import Collection, List, Optional +from collections.abc import Collection from pydantic import BaseModel, ConfigDict, Field @@ -21,7 +23,7 @@ REPORT_PRIORITY = defaultdict( def prioritize_report_values( report_values: Collection[ReportValue], -) -> Optional[ReportValue]: +) -> ReportValue | None: if not report_values: return None report_values = list(set(report_values)) @@ -39,7 +41,7 @@ class ReportTask(BaseModel): examples=["app-user-9329ebd"], ) - reasons: List[ReportValue] = Field( + reasons: list[ReportValue] = Field( description=ReportValue.as_openapi_with_value_descriptions(), examples=[[3, 4]], default_factory=list, diff --git a/generalresearch/models/thl/session.py b/generalresearch/models/thl/session.py index 74ed5eb..fbaf0e7 100644 --- a/generalresearch/models/thl/session.py +++ b/generalresearch/models/thl/session.py @@ -1,8 +1,10 @@ +from __future__ import annotations + import json import logging from datetime import datetime, timedelta, timezone from decimal import Decimal -from typing import TYPE_CHECKING, Annotated, Any, Dict, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Annotated, Any from uuid import uuid4 from pydantic import ( @@ -64,7 +66,7 @@ class WallBase(BaseModel): uuid: UUIDStr = Field(default_factory=lambda: uuid4().hex) source: Source - buyer_id: Optional[str] = Field(default=None, max_length=32) + buyer_id: str | None = Field(default=None, max_length=32) req_survey_id: str = Field(max_length=32) req_cpi: Decimal = Field(decimal_places=5, lt=1000, ge=0) started: AwareDatetimeISO = Field( @@ -74,27 +76,27 @@ class WallBase(BaseModel): # These get set on creation, or updated when the wall event is finished. So # they shouldn't really ever be NULL, but you don't have to pass them in # on instantiation - survey_id: Optional[str] = Field(max_length=32, default=None) - cpi: Optional[Decimal] = Field(lt=1000, ge=0, default=None) + survey_id: str | None = Field(max_length=32, default=None) + cpi: Decimal | None = Field(lt=1000, ge=0, default=None) # Gets set when a wall is "finished" - finished: Optional[AwareDatetimeISO] = Field(default=None) - status: Optional[Status] = None - status_code_1: Optional[StatusCode1] = None - status_code_2: Optional[WallStatusCode2] = None + finished: AwareDatetimeISO | None = Field(default=None) + status: Status | None = None + status_code_1: StatusCode1 | None = None + status_code_2: WallStatusCode2 | None = None - ext_status_code_1: Optional[str] = Field(default=None, max_length=32) - ext_status_code_2: Optional[str] = Field(default=None, max_length=32) - ext_status_code_3: Optional[str] = Field(default=None, max_length=32) + ext_status_code_1: str | None = Field(default=None, max_length=32) + ext_status_code_2: str | None = Field(default=None, max_length=32) + ext_status_code_3: str | None = Field(default=None, max_length=32) - report_value: Optional[ReportValue] = None - report_notes: Optional[str] = Field(default=None, max_length=255) + report_value: ReportValue | None = None + report_notes: str | None = Field(default=None, max_length=255) # This is the most recent reconciliation status of the wall event. # Possible values: 'ac' (adjusted to complete), 'af' (adj to fail) # If a wall gets adjusted and adjusted back to its original status, the # adjusted_status = None - adjusted_status: Optional[WallAdjustedStatus] = None + adjusted_status: WallAdjustedStatus | None = None # This is not really used, it is only important if the requested CPI # doesn't match the adjusted amount, which shouldn't happen as no @@ -103,11 +105,11 @@ class WallBase(BaseModel): # - If adjusted_status = 'ac': adjusted_cpi is the amount paid (should # equal the `cpi`) # - If adjusted_status = 'af': adjusted_cpi is 0.00 - adjusted_cpi: Optional[Decimal] = Field(default=None, lt=1000, ge=0) + adjusted_cpi: Decimal | None = Field(default=None, lt=1000, ge=0) # This timestamp gets updated every time there is an adjustment. Even if # we flip-flop, this will be set (and adjusted_status will be None). - adjusted_timestamp: Optional[AwareDatetimeISO] = Field(default=None) + adjusted_timestamp: AwareDatetimeISO | None = Field(default=None) # --- Validation --- @@ -261,19 +263,17 @@ class WallBase(BaseModel): self.model_config["validate_assignment"] = True self.__class__.model_validate(self) - return None - def finish( self, status: Status, status_code_1: StatusCode1, - status_code_2: Optional[WallStatusCode2] = None, - finished: Optional[datetime] = None, - ext_status_code_1: Optional[str] = None, - ext_status_code_2: Optional[str] = None, - ext_status_code_3: Optional[str] = None, - survey_id: Optional[str] = None, - cpi: Optional[Decimal] = None, + status_code_2: WallStatusCode2 | None = None, + finished: datetime | None = None, + ext_status_code_1: str | None = None, + ext_status_code_2: str | None = None, + ext_status_code_3: str | None = None, + survey_id: str | None = None, + cpi: Decimal | None = None, ) -> None: # This is just used in tests at the moment. This needs to be adjusted. @@ -296,14 +296,12 @@ class WallBase(BaseModel): if cpi is not None: self.cpi = cpi - return None - def annotate_status_codes( self, ext_status_code_1: str, - ext_status_code_2: Optional[str] = None, - ext_status_code_3: Optional[str] = None, - finished: Optional[datetime] = None, + ext_status_code_2: str | None = None, + ext_status_code_3: str | None = None, + finished: datetime | None = None, ) -> None: # This should be called by the wall manager in order to actually update db from generalresearch import wall_status_codes @@ -326,8 +324,6 @@ class WallBase(BaseModel): finished=finished, ) - return None - def is_soft_fail(self) -> bool: from generalresearch import wall_status_codes @@ -371,8 +367,8 @@ class WallBase(BaseModel): def report( self, report_value: ReportValue, - report_notes: Optional[str] = None, - report_timestamp: Optional[AwareDatetime] = None, + report_notes: str | None = None, + report_timestamp: AwareDatetime | None = None, ) -> None: """When a wall event is reported: @@ -454,7 +450,7 @@ class Wall(WallBase): d = self.model_dump(mode="json", exclude={"elapsed"}) return json.dumps(d) - def model_dump_mysql(self, *args, **kwargs) -> Dict: + def model_dump_mysql(self, *args, **kwargs) -> dict: # Generate a dictionary representation of the model, with special handling for datetimes d = self.model_dump(mode="json", exclude={"elapsed"}, *args, **kwargs) d["started"] = self.started.replace(tzinfo=None) @@ -468,27 +464,27 @@ class Wall(WallBase): class WallOut(WallBase): # These get serialized to the enum name instead of the int value (for ease in UI) - status_code_1: Optional[Annotated[StatusCode1, EnumNameSerializer]] = Field( + status_code_1: Annotated[StatusCode1, EnumNameSerializer] | None = Field( default=None, examples=[StatusCode1.COMPLETE.name], description=StatusCode1.as_openapi_with_value_descriptions_name(), ) - status_code_2: Optional[Annotated[WallStatusCode2, EnumNameSerializer]] = Field( + status_code_2: Annotated[WallStatusCode2, EnumNameSerializer] | None = Field( default=None, examples=[None], description=WallStatusCode2.as_openapi_with_value_descriptions_name(), ) # Exclude these 3 fields - cpi: Optional[Decimal] = Field(lt=1000, ge=0, default=None, exclude=True) - req_cpi: Optional[Decimal] = Field( + cpi: Decimal | None = Field(lt=1000, ge=0, default=None, exclude=True) + req_cpi: Decimal | None = Field( decimal_places=5, lt=1000, ge=0, default=None, exclude=True ) - adjusted_cpi: Optional[Decimal] = Field(lt=1000, ge=0, default=None, exclude=True) + adjusted_cpi: Decimal | None = Field(lt=1000, ge=0, default=None, exclude=True) # user_cpi is serialized to integer cents!!! - user_cpi: Optional[Decimal] = Field( + user_cpi: Decimal | None = Field( lt=1000, ge=0, default=None, @@ -500,7 +496,7 @@ class WallOut(WallBase): examples=[123], ) - user_cpi_string: Optional[str] = Field( + user_cpi_string: str | None = Field( default=None, description="If a payout transformation is configured on this account, " "this is the amount to display to the user", @@ -588,7 +584,7 @@ class Session(BaseModel): # id will be None until db_create is called (or if this is instantiated # from an existing session) - id: Optional[int] = None + id: int | None = None uuid: UUIDStr = Field(default_factory=lambda: uuid4().hex) user: User started: AwareDatetimeISO = Field( @@ -599,25 +595,23 @@ class Session(BaseModel): # store the 4 fields: loi_min, loi_max, user_payout_min, user_payout_max # in the db, but there may be other metadata associated with the bucket # that is cached, such as the category. - clicked_bucket: Optional[Bucket] = Field(default=None) + clicked_bucket: Bucket | None = Field(default=None) - country_iso: Optional[str] = Field( - default=None, max_length=2, pattern=r"^[a-z]{2}$" - ) - device_type: Optional[DeviceType] = Field(default=None) - ip: Optional[IPvAnyAddressStr] = Field(default=None) + country_iso: str | None = Field(default=None, max_length=2, pattern=r"^[a-z]{2}$") + device_type: DeviceType | None = Field(default=None) + ip: IPvAnyAddressStr | None = Field(default=None) - url_metadata: Optional[Dict[str, str]] = Field(default=None) + url_metadata: dict[str, str] | None = Field(default=None) # Below here shouldn't be set upon initialization, or directly. - wall_events: List[Wall] = Field(default_factory=list) + wall_events: list[Wall] = Field(default_factory=list) # Gets set when a session is "finished" - finished: Optional[AwareDatetimeISO] = Field(default=None) + finished: AwareDatetimeISO | None = Field(default=None) - status: Optional[Status] = None - status_code_1: Optional[StatusCode1] = None - status_code_2: Optional[SessionStatusCode2] = None + status: Status | None = None + status_code_1: StatusCode1 | None = None + status_code_2: SessionStatusCode2 | None = None # There are two scenarios. Let's say the user payout transformation is # 40% and this session pays out $1. @@ -637,25 +631,25 @@ class Session(BaseModel): # going to the BP and ($0.40) to the user, and if the wallet is disabled, # then the whole $1 goes to the BP and $0 to the user, but the $0.40 value # is saved, so it can be displayed in the task status endpoint. - payout: Optional[Decimal] = Field(default=None, lt=1000, ge=0) - user_payout: Optional[Decimal] = Field(default=None, lt=1000, ge=0) + payout: Decimal | None = Field(default=None, lt=1000, ge=0) + user_payout: Decimal | None = Field(default=None, lt=1000, ge=0) # This is the most recent reconciliation status of the session. Generally, # we would adjust this if the last survey in the session was adjusted # from complete to incomplete. If any survey in the session was adjusted # from fail -> complete (and the user didn't already get a complete) # we'll adjust this to a complete. - adjusted_status: Optional[SessionAdjustedStatus] = None + adjusted_status: SessionAdjustedStatus | None = None # If adjusted_status = 'ac': payout = 0 and adjusted_payout is the amount paid # If adjusted_status = 'af': payout = the amount paid, adjusted_payout is 0.00 # (the `payout` never changed, only the adjusted_payout can change). - adjusted_payout: Optional[Decimal] = Field(default=None, lt=1000, ge=0) - adjusted_user_payout: Optional[Decimal] = Field(default=None, lt=1000, ge=0) + adjusted_payout: Decimal | None = Field(default=None, lt=1000, ge=0) + adjusted_user_payout: Decimal | None = Field(default=None, lt=1000, ge=0) # This timestamp gets updated every time there is an adjustment (even if # there are flip-flops). - adjusted_timestamp: Optional[AwareDatetimeISO] = Field(default=None) + adjusted_timestamp: AwareDatetimeISO | None = Field(default=None) # --- Validation --- @@ -757,7 +751,7 @@ class Session(BaseModel): @field_validator("wall_events") @classmethod - def check_wall_events(cls, wall_events: List[Wall]): + def check_wall_events(cls, wall_events: list[Wall]): # Note: this can't work on modifications as pydantic/python doesn't # know if a list is mutated. We have to run it manually, or hide # the self.wall_events attr and wrap all access @@ -831,7 +825,7 @@ class Session(BaseModel): def model_dump_mysql( self, *args, **kwargs - ) -> Dict[str, Union[str, int, datetime, float, None]]: + ) -> dict[str, str | int | datetime | float | None]: # Generate a dictionary representation of the model, with special # handling for datetimes, and nested models such as User & Bucket @@ -893,7 +887,7 @@ class Session(BaseModel): last_wall.status = Status.TIMEOUT self.status = Status.TIMEOUT - def determine_session_status(self) -> Tuple[Status, StatusCode1]: + def determine_session_status(self) -> tuple[Status, StatusCode1]: """Given a list of wall events, determine what the session status should be. If this is called, it is because the Session is *over*, or it has timed out. @@ -989,8 +983,8 @@ class Session(BaseModel): def determine_payments( self, - thl_ledger_manager: Optional["ThlLedgerManager"] = None, - ) -> Tuple[Decimal, Decimal, Decimal, Optional[Decimal]]: + thl_ledger_manager: "ThlLedgerManager" | None = None, + ) -> tuple[Decimal, Decimal, Decimal, Decimal | None]: # How much we should get paid by the MPs for all completes in this # session (usually 0 or 1 completes) thl_net: Decimal = Decimal( @@ -1012,7 +1006,7 @@ class Session(BaseModel): assert thl_ledger_manager is not None amt = thl_ledger_manager.get_user_wallet_balance(user=self.user) user_wallet_balance = Decimal(amt / 100).quantize(Decimal("0.01")) - user_pay: Optional[Decimal] = product.calculate_user_payment( + user_pay: Decimal | None = product.calculate_user_payment( bp_pay, user_wallet_balance=user_wallet_balance ) @@ -1034,7 +1028,7 @@ class Session(BaseModel): def determine_new_status_and_payouts( self, - ) -> Tuple[Status, Decimal, Optional[Decimal]]: + ) -> tuple[Status, Decimal, Decimal | None]: """Session is adjusted any time one of the wall events is. Assuming status adjustments happened on a session's wall events. Calculate if any status changes are need to the session. @@ -1229,7 +1223,7 @@ class Session(BaseModel): else: return self.payout or Decimal(0) - def get_user_payout_after_adjustment(self) -> Optional[Decimal]: + def get_user_payout_after_adjustment(self) -> Decimal | None: if self.adjusted_status is not None: return self.adjusted_user_payout else: @@ -1270,12 +1264,12 @@ def check_adjusted_status_consistent( def check_adjusted_status_wall_consistent( status: Status, - cpi: Optional[Decimal] = None, - adjusted_status: Optional[WallAdjustedStatus] = None, - adjusted_cpi: Optional[Decimal] = None, - new_adjusted_status: Optional[WallAdjustedStatus] = None, - new_adjusted_cpi: Optional[Decimal] = None, -) -> Tuple[bool, str]: + cpi: Decimal | None = None, + adjusted_status: WallAdjustedStatus | None = None, + adjusted_cpi: Decimal | None = None, + new_adjusted_status: WallAdjustedStatus | None = None, + new_adjusted_cpi: Decimal | None = None, +) -> tuple[bool, str]: """ Raises an AssertionError if inconsistent. @@ -1300,11 +1294,11 @@ def check_adjusted_status_wall_consistent( def _check_adjusted_status_wall_consistent( status: Status, - cpi: Optional[Decimal] = None, - adjusted_status: Optional[WallAdjustedStatus] = None, - adjusted_cpi: Optional[Decimal] = None, - new_adjusted_status: Optional[WallAdjustedStatus] = None, - new_adjusted_cpi: Optional[Decimal] = None, + cpi: Decimal | None = None, + adjusted_status: WallAdjustedStatus | None = None, + adjusted_cpi: Decimal | None = None, + new_adjusted_status: WallAdjustedStatus | None = None, + new_adjusted_cpi: Decimal | None = None, ) -> None: """ See check_adjusted_status_wall_consistent diff --git a/generalresearch/models/thl/soft_pair.py b/generalresearch/models/thl/soft_pair.py index b8bc1b4..6ff1165 100644 --- a/generalresearch/models/thl/soft_pair.py +++ b/generalresearch/models/thl/soft_pair.py @@ -1,6 +1,7 @@ +from __future__ import annotations + from dataclasses import dataclass from enum import Enum -from typing import Optional, Set from generalresearch.models import Source from generalresearch.models.thl.survey.condition import ( @@ -33,14 +34,14 @@ class SoftPairResult: pair_type: SoftPairResultType source: Source survey_id: str - conditions: Optional[Set[MarketplaceCondition]] = None + conditions: set[MarketplaceCondition] | None = None @property def survey_sid(self) -> str: return self.source + ":" + self.survey_id @property - def grpc_string(self) -> Optional[str]: + def grpc_string(self) -> str | None: # This is what is expected by thl-grpc in a mp_pb2.MPOpportunityIDListSoftPairing response (grpc) if self.pair_type == SoftPairResultType.UNCONDITIONAL: return self.survey_id @@ -60,7 +61,7 @@ class SoftPairResultOut: pair_type: SoftPairResultType source: Source survey_id: str - question_ids: Optional[Set[str]] = None + question_ids: set[str] | None = None @property def survey_sid(self) -> str: diff --git a/generalresearch/models/thl/stats.py b/generalresearch/models/thl/stats.py index 9e9971d..40fb29a 100644 --- a/generalresearch/models/thl/stats.py +++ b/generalresearch/models/thl/stats.py @@ -1,4 +1,4 @@ -from typing import Optional +from __future__ import annotations from pydantic import BaseModel, Field, computed_field, model_validator @@ -13,7 +13,7 @@ class StatisticalSummary(BaseModel): min: int = Field() max: int = Field() - mean: Optional[int] = Field(default=None) + mean: int | None = Field(default=None) q1: int = Field() q2: int = Field() q3: int = Field() diff --git a/generalresearch/models/thl/survey/__init__.py b/generalresearch/models/thl/survey/__init__.py index 8f27930..b6ac740 100644 --- a/generalresearch/models/thl/survey/__init__.py +++ b/generalresearch/models/thl/survey/__init__.py @@ -1,7 +1,9 @@ +from __future__ import annotations + from abc import ABC, abstractmethod from decimal import Decimal from itertools import product -from typing import Dict, List, Optional, Set, Type +from typing import Type from more_itertools import flatten from pydantic import BaseModel, Field @@ -48,22 +50,22 @@ class MarketplaceTask(BaseModel, ABC): language_iso: LanguageISO = Field() # These should be overloaded with more specific type hints - buyer_id: Optional[str] = Field(min_length=1, max_length=32, default=None) + buyer_id: str | None = Field(min_length=1, max_length=32, default=None) # This is in seconds - bid_loi: Optional[int] = Field(default=None, le=90 * 60) - bid_ir: Optional[float] = Field(default=None, ge=0, le=1) + bid_loi: int | None = Field(default=None, le=90 * 60) + bid_ir: float | None = Field(default=None, ge=0, le=1) # This should be an "abstract field", but there is no way to do that, so # just listing it here. It should be overridden by the implementation source: Source = Field() # This should also - used_question_ids: Set[str] = Field(default_factory=set) + used_question_ids: set[str] = Field(default_factory=set) # This is a "special" key to store all conditions that are used (as # "condition_hashes") throughout this survey. In the reduced # representation of this task (nearly always, for db i/o, in global_vars) # this field will be null. - conditions: Optional[Dict[str, MarketplaceCondition]] = Field(default=None) + conditions: dict[str, MarketplaceCondition] | None = Field(default=None) @property @abstractmethod @@ -80,7 +82,7 @@ class MarketplaceTask(BaseModel, ABC): @property @abstractmethod - def all_hashes(self) -> Set[str]: ... + def all_hashes(self) -> set[str]: ... @property @abstractmethod @@ -124,7 +126,7 @@ class MarketplaceTask(BaseModel, ABC): @abstractmethod def marketplace_genders( self, - ) -> Dict[Gender, Optional[MarketplaceCondition]]: + ) -> dict[Gender, MarketplaceCondition | None]: """ Mapping of generic Gender to the marketplace condition for that gender """ @@ -133,7 +135,7 @@ class MarketplaceTask(BaseModel, ABC): @property def marketplace_age_groups( self, - ) -> Dict[AgeGroup, Optional[MarketplaceCondition]]: + ) -> dict[AgeGroup, MarketplaceCondition | None]: """ Mapping of generic age groups to the marketplace condition for those ages """ @@ -147,7 +149,7 @@ class MarketplaceTask(BaseModel, ABC): } @property - def targeted_ages(self) -> Set[str]: + def targeted_ages(self) -> set[str]: assert self.conditions is not None, "conditions must be populated" cs = [self.conditions[k] for k in self.all_hashes if k in self.conditions] age_cs = [c for c in cs if c.question_id == self.age_question] @@ -166,7 +168,7 @@ class MarketplaceTask(BaseModel, ABC): return age_values @property - def targeted_age_groups(self) -> Set[AgeGroup]: + def targeted_age_groups(self) -> set[AgeGroup]: age_values = self.targeted_ages age_conditions = self.marketplace_age_groups age_targeting = set() @@ -186,7 +188,7 @@ class MarketplaceTask(BaseModel, ABC): return age_targeting @property - def targeted_genders(self) -> Set[Gender]: + def targeted_genders(self) -> set[Gender]: mp_genders = self.marketplace_genders gender_targeting = set() if mp_genders[Gender.MALE].criterion_hash in self.all_hashes: @@ -198,7 +200,7 @@ class MarketplaceTask(BaseModel, ABC): return gender_targeting @property - def demographic_targets(self) -> List[DemographicTarget]: + def demographic_targets(self) -> list[DemographicTarget]: targets = [DemographicTarget(country="*", gender="*", age_group="*")] gt = self.targeted_genders diff --git a/generalresearch/models/thl/survey/buyer.py b/generalresearch/models/thl/survey/buyer.py index b4d8fb0..6d4d7a1 100644 --- a/generalresearch/models/thl/survey/buyer.py +++ b/generalresearch/models/thl/survey/buyer.py @@ -1,7 +1,9 @@ +from __future__ import annotations + from datetime import datetime, timezone from decimal import Decimal from math import log -from typing import Annotated, Optional +from typing import Annotated from pydantic import ( BaseModel, @@ -30,9 +32,9 @@ class Buyer(BaseModel): model_config = ConfigDict(validate_assignment=True) - id: Optional[PositiveInt] = Field(default=None, exclude=True) + id: PositiveInt | None = Field(default=None, exclude=True) # todo: need to add to db - uuid: Optional[UUIDStr] = Field(default=None) + uuid: UUIDStr | None = Field(default=None) source: Source = Field( description="The marketplace this buyer is on.\n" + Source.as_openapi() @@ -42,7 +44,7 @@ class Buyer(BaseModel): max_length=128, description="The internal code on this marketplace for this buyer", ) - label: Optional[str] = Field(default=None, max_length=255) + label: str | None = Field(default=None, max_length=255) created: AwareDatetimeISO = Field( default_factory=lambda: datetime.now(tz=timezone.utc), description="When this entry was made, or when the buyer was first seen", @@ -72,7 +74,7 @@ class BuyerWithDetail(BaseModel): """For API Responses""" buyer: Buyer = Field() - activity: Optional[BuyerActivity] = Field(default=None) + activity: BuyerActivity | None = Field(default=None) class BuyerCountryStat(BaseModel): @@ -89,12 +91,12 @@ class BuyerCountryStat(BaseModel): model_config = ConfigDict(validate_assignment=False) # ---- Identity ---- - buyer_id: Optional[PositiveInt] = Field( + buyer_id: PositiveInt | None = Field( default=None, exclude=True, description="This is the pk of the Buyer object in the db", ) - country_iso: Optional[CountryISOLike] = Field( + country_iso: CountryISOLike | None = Field( default=None, description="If null, this is a weighted average across all countries", examples=["us"], diff --git a/generalresearch/models/thl/survey/condition.py b/generalresearch/models/thl/survey/condition.py index 3610750..927b7e1 100644 --- a/generalresearch/models/thl/survey/condition.py +++ b/generalresearch/models/thl/survey/condition.py @@ -1,8 +1,10 @@ +from __future__ import annotations + import hashlib from abc import ABC from enum import Enum from functools import cached_property -from typing import Any, Dict, List, Optional, Set, Tuple +from typing import Any from pydantic import ( BaseModel, @@ -60,18 +62,18 @@ class MarketplaceCondition(BaseModel, ABC): negate: bool = Field(default=False) # ---- These fields should be overridden in the implementor --- - question_id: Optional[str] = Field(frozen=True) - values: List[str] = Field() + question_id: str | None = Field(frozen=True) + values: list[str] = Field() # These question_ids get converted to list value types - _CONVERT_LIST_TO_RANGE: List[str] = PrivateAttr(default_factory=list) + _CONVERT_LIST_TO_RANGE: list[str] = PrivateAttr(default_factory=list) @field_validator("values", mode="after") - def sort_values(cls, values: List[str]): + def sort_values(cls, values: list[str]): return sorted(values) @field_validator("values", mode="after") - def check_values_lower(cls, values: List[str]): + def check_values_lower(cls, values: list[str]): assert values == [s.lower() for s in values], "values must be lowercase" return values @@ -210,7 +212,7 @@ class MarketplaceCondition(BaseModel, ABC): return sum(len(v) for v in self.values) @cached_property - def values_ranges(self) -> List[Tuple[float, float]]: + def values_ranges(self) -> list[tuple[float, float]]: assert ( self.value_type == ConditionValueType.RANGE ), "only call this method when value_type is RANGE" @@ -227,11 +229,11 @@ class MarketplaceCondition(BaseModel, ABC): return hashlib.md5(s.encode()).hexdigest()[:7] @classmethod - def from_mysql(cls, d: Dict[str, Any]) -> Self: + def from_mysql(cls, d: dict[str, Any]) -> Self: d["values"] = d["values"][1:-1].split("|") if d["values"][1:-1] else [] return cls.model_validate(d) - def to_mysql(self) -> Dict[str, str]: + def to_mysql(self) -> dict[str, str]: # This is what is stored in the xxx_criterion table d = self.model_dump( mode="json", @@ -274,9 +276,9 @@ class MarketplaceCondition(BaseModel, ABC): def evaluate_criterion( self, - user_qas: Dict[str, Set[str]], - user_groups: Optional[Set[str]] = None, - ) -> Optional[bool]: + user_qas: dict[str, set[str]], + user_groups: set[str] | None = None, + ) -> bool | None: """Given this user's MRPQs, do they "pass" this criterion? :param user_qas: user's quals. Looks like {'qid1': {'ans1', 'ans2'}} diff --git a/generalresearch/models/thl/survey/model.py b/generalresearch/models/thl/survey/model.py index 8d37af4..3794c00 100644 --- a/generalresearch/models/thl/survey/model.py +++ b/generalresearch/models/thl/survey/model.py @@ -1,6 +1,8 @@ +from __future__ import annotations + from datetime import datetime, timezone from decimal import Decimal -from typing import Any, Dict, List, Optional, Tuple +from typing import Any from pydantic import ( BaseModel, @@ -33,7 +35,7 @@ class SurveyCategoryModel(BaseModel): model_config = ConfigDict(from_attributes=True) category: Category = Field() - strength: Optional[float] = Field(default=None) + strength: float | None = Field(default=None) class SurveyEligibilityDefinition(BaseModel): @@ -45,7 +47,7 @@ class SurveyEligibilityDefinition(BaseModel): """ # References a marketplace-specific question - property_codes: Tuple[PropertyCode, ...] = Field(default_factory=tuple) + property_codes: tuple[PropertyCode, ...] = Field(default_factory=tuple) @model_validator(mode="after") def sort_question_ids(self): @@ -56,16 +58,16 @@ class SurveyEligibilityDefinition(BaseModel): class Survey(BaseModel): model_config = ConfigDict(validate_assignment=True) - id: Optional[PositiveInt] = Field(default=None, exclude=True) + id: PositiveInt | None = Field(default=None, exclude=True) source: Source = Field() survey_id: str = Field(min_length=1, max_length=32, examples=["127492892"]) - buyer_id: Optional[int] = Field( + buyer_id: int | None = Field( default=None, exclude=True, description="This is the DB's fk id" ) # ---v So the fk id can be looked up from the code - buyer_code: Optional[str] = Field( + buyer_code: str | None = Field( min_length=1, max_length=128, default=None, examples=["124"] ) @@ -79,9 +81,9 @@ class Survey(BaseModel): is_live: bool = Field(default=True) is_recontact: bool = Field(default=False) - categories: List[SurveyCategoryModel] = Field(default_factory=list) + categories: list[SurveyCategoryModel] = Field(default_factory=list) - eligibility_criteria: Optional[SurveyEligibilityDefinition] = Field(default=None) + eligibility_criteria: SurveyEligibilityDefinition | None = Field(default=None) @property def natural_key(self) -> SurveyKey: @@ -119,10 +121,10 @@ class Survey(BaseModel): class SurveyStat(BaseModel): model_config = ConfigDict(validate_assignment=True) - id: Optional[PositiveInt] = Field(exclude=True, default=None) + id: PositiveInt | None = Field(exclude=True, default=None) # ---- Identity ---- - survey_id: Optional[PositiveInt] = Field( + survey_id: PositiveInt | None = Field( default=None, exclude=True, description="This is the pk of the Survey object in the db", @@ -137,8 +139,8 @@ class SurveyStat(BaseModel): # --- For lookup / de-normalization, to avoid potentially costly # joins on marketplace_survey table --- - survey_source: Optional[Source] = Field(default=None, exclude=True) - survey_survey_id: Optional[str] = Field( + survey_source: Source | None = Field(default=None, exclude=True) + survey_survey_id: str | None = Field( default=None, exclude=True, min_length=1, max_length=32 ) survey_is_live: bool = Field(default=True, exclude=True) @@ -201,7 +203,7 @@ class SurveyStat(BaseModel): return f"{self.survey_source.value}:{self.survey_survey_id}" @property - def unique_key(self) -> Tuple[int, Optional[str], str, int]: + def unique_key(self) -> tuple[int, str | None, str, int]: return self.survey_id, self.quota_id, self.country_iso, self.version def model_dump_sql(self): @@ -219,16 +221,16 @@ class TaskActivity(BaseModel): source: Source = Field() survey_id: str = Field(min_length=1, max_length=32, examples=["127492892"]) - status_counts: Dict[Status, NonNegativeInt] = Field(default_factory=dict) - status_code_1_counts: Dict[StatusCode1, NonNegativeInt] = Field( + status_counts: dict[Status, NonNegativeInt] = Field(default_factory=dict) + status_code_1_counts: dict[StatusCode1, NonNegativeInt] = Field( default_factory=dict ) in_progress_count: NonNegativeInt = Field( default=0, description="Count of entrances that have no Status and were entered within the past 90 minutes", ) - last_complete: Optional[AwareDatetimeISO] = Field(default=None) - last_entrance: Optional[AwareDatetimeISO] = Field(default=None) + last_complete: AwareDatetimeISO | None = Field(default=None) + last_entrance: AwareDatetimeISO | None = Field(default=None) @computed_field @property @@ -243,7 +245,7 @@ class TaskActivity(BaseModel): # ---- percentages ---- @computed_field @property - def status_percentages(self) -> Dict[Status, NonNegativeFloat]: + def status_percentages(self) -> dict[Status, NonNegativeFloat]: total = self.total_finished if total == 0: return {} @@ -251,7 +253,7 @@ class TaskActivity(BaseModel): @computed_field @property - def status_code_1_percentages(self) -> Dict[StatusCode1, NonNegativeFloat]: + def status_code_1_percentages(self) -> dict[StatusCode1, NonNegativeFloat]: total = sum(self.status_code_1_counts.values()) if total == 0: return {} @@ -259,21 +261,21 @@ class TaskActivity(BaseModel): class TaskActivityPublic(BaseModel): - source: Optional[Source] = Field(exclude=True, default=None) - survey_id: Optional[str] = Field( + source: Source | None = Field(exclude=True, default=None) + survey_id: str | None = Field( min_length=1, max_length=32, examples=["127492892"], exclude=True, default=None ) - status_percentages: Dict[Status, NonNegativeFloat] = Field(default_factory=dict) - status_code_1_percentages: Dict[ + status_percentages: dict[Status, NonNegativeFloat] = Field(default_factory=dict) + status_code_1_percentages: dict[ Annotated[StatusCode1, EnumNameSerializer], NonNegativeFloat ] = Field(default_factory=dict) - last_complete: Optional[AwareDatetimeISO] = Field(default=None) - last_entrance: Optional[AwareDatetimeISO] = Field(default=None) + last_complete: AwareDatetimeISO | None = Field(default=None) + last_entrance: AwareDatetimeISO | None = Field(default=None) @field_validator("status_code_1_percentages", mode="before") - def transform_enum_name_pct(cls, value: Dict[str, Any]) -> Dict[str, Any]: + def transform_enum_name_pct(cls, value: dict[str, Any]) -> dict[str, Any]: # If we are serializing+deserializing this model (i.e. when we cache # it), this fails because we've replaced the enum value with the # name. Put it back here ... @@ -287,8 +289,8 @@ class TaskActivityPublic(BaseModel): class TaskActivityPrivate(TaskActivityPublic): - status_counts: Dict[Status, int] = Field(default_factory=dict) - status_code_1_counts: Dict[Annotated[StatusCode1, EnumNameSerializer], int] = Field( + status_counts: dict[Status, int] = Field(default_factory=dict) + status_code_1_counts: dict[Annotated[StatusCode1, EnumNameSerializer], int] = Field( default_factory=dict ) in_progress_count: NonNegativeInt = Field( @@ -297,7 +299,7 @@ class TaskActivityPrivate(TaskActivityPublic): ) @field_validator("status_code_1_counts", mode="before") - def transform_enum_name_cnt(cls, value: Dict[str, Any]) -> Dict[str, Any]: + def transform_enum_name_cnt(cls, value: dict[str, Any]) -> dict[str, Any]: # If we are serializing+deserializing this model (i.e. when we cache # it), this fails because we've replaced the enum value with the # name. Put it back here ... @@ -310,12 +312,12 @@ class TaskWithDetail(BaseModel): """For API Responses""" task: Survey = Field() - stats: List[SurveyStat] = Field(default_factory=list) - activity_global: Optional[TaskActivityPublic] = Field(default=None) - activity_product: Optional[TaskActivityPrivate] = Field(default=None) + stats: list[SurveyStat] = Field(default_factory=list) + activity_global: TaskActivityPublic | None = Field(default=None) + activity_product: TaskActivityPrivate | None = Field(default=None) class TasksWithDetail(Page): """For API Responses""" - tasks: List[TaskWithDetail] = Field(default_factory=list) + tasks: list[TaskWithDetail] = Field(default_factory=list) diff --git a/generalresearch/models/thl/survey/penalty.py b/generalresearch/models/thl/survey/penalty.py index a9c8a56..e9515d4 100644 --- a/generalresearch/models/thl/survey/penalty.py +++ b/generalresearch/models/thl/survey/penalty.py @@ -1,6 +1,8 @@ +from __future__ import annotations + import abc from datetime import datetime, timezone -from typing import List, Literal, Union +from typing import Literal from pydantic import BaseModel, ConfigDict, Field, TypeAdapter from typing_extensions import Annotated @@ -60,4 +62,4 @@ Penalty = Annotated[ Union[BPSurveyPenalty, TeamSurveyPenalty], Field(discriminator="kind"), ] -PenaltyListAdapter = TypeAdapter(List[Penalty]) +PenaltyListAdapter = TypeAdapter(list[Penalty]) diff --git a/generalresearch/models/thl/survey/task_collection.py b/generalresearch/models/thl/survey/task_collection.py index 804fb49..b09cdfb 100644 --- a/generalresearch/models/thl/survey/task_collection.py +++ b/generalresearch/models/thl/survey/task_collection.py @@ -1,7 +1,8 @@ +from __future__ import annotations + import copy import json import logging -from typing import List import pandas as pd from pandera import DataFrameSchema @@ -24,7 +25,7 @@ class TaskCollection(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) # overload this with the correct type! - items: List[MarketplaceTask] + items: list[MarketplaceTask] df: pd.DataFrame = Field(default_factory=pd.DataFrame) # overload this with the correct schema! diff --git a/generalresearch/models/thl/synchronize_global_vars.py b/generalresearch/models/thl/synchronize_global_vars.py index 72d987d..a656088 100644 --- a/generalresearch/models/thl/synchronize_global_vars.py +++ b/generalresearch/models/thl/synchronize_global_vars.py @@ -1,4 +1,4 @@ -from typing import List +from __future__ import annotations from pydantic import BaseModel, Field @@ -10,7 +10,7 @@ class SynchronizeGlobalVarsMsg(BaseModel): # Specifies the key / subkey to be acted upon. # For example ["mrpq", 123] would apply to GLOBAL_VARS["mrpq"][123] - key_path: List[str] = Field() + key_path: list[str] = Field() # e.g. GLOBAL_VARS["mrpq"].pop(123, None) pop: bool = Field(default=False) # e.g. GLOBAL_VARS["mrpq"][123].clear() diff --git a/generalresearch/models/thl/task_adjustment.py b/generalresearch/models/thl/task_adjustment.py index 9d8a04d..89a3873 100644 --- a/generalresearch/models/thl/task_adjustment.py +++ b/generalresearch/models/thl/task_adjustment.py @@ -1,6 +1,7 @@ +from __future__ import annotations + from datetime import datetime, timezone from decimal import Decimal -from typing import Optional from uuid import uuid4 from pydantic import BaseModel, ConfigDict, Field, PositiveInt, model_validator @@ -55,8 +56,8 @@ class TaskAdjustmentEvent(BaseModel): # Same thing as with adjusted_status, the amount is the "amount the cpi # is changing by"!! - amount: Optional[Decimal] = Field(lt=1000, ge=-1000, default=None) - ext_status_code: Optional[str] = Field(default=None, max_length=32) + amount: Decimal | None = Field(lt=1000, ge=-1000, default=None) + ext_status_code: str | None = Field(default=None, max_length=32) wall_uuid: UUIDStr = Field(description="The wall event being adjusted") diff --git a/generalresearch/models/thl/task_status.py b/generalresearch/models/thl/task_status.py index a7aec91..ee713a5 100644 --- a/generalresearch/models/thl/task_status.py +++ b/generalresearch/models/thl/task_status.py @@ -1,5 +1,7 @@ +from __future__ import annotations + from datetime import datetime -from typing import Annotated, Any, Dict, List, Literal, Optional +from typing import Annotated, Any, Literal from pydantic import ( BaseModel, @@ -69,12 +71,12 @@ class TaskStatusResponse(BaseModel): started: AwareDatetimeISO = Field(description="When the session was started") - finished: Optional[AwareDatetimeISO] = Field( + finished: AwareDatetimeISO | None = Field( default=None, description="When the session was finished" ) # This uses the grpc's Status enum. It gets serialized to an int. - status: Optional[Status] = Field( + status: Status | None = Field( default=None, examples=[3], description="The outcome of a session." @@ -84,14 +86,14 @@ class TaskStatusResponse(BaseModel): " - 3 - COMPLETE (the user completed the task)", ) - payout: Optional[NonNegativeInt] = Field( + payout: NonNegativeInt | None = Field( default=None, lt=100_000, examples=[500], description="The amount paid to the supplier, in integer USD cents", ) - user_payout: Optional[NonNegativeInt] = Field( + user_payout: NonNegativeInt | None = Field( default=None, lt=100_000, description="If a payout transformation is configured on this account, " @@ -99,78 +101,78 @@ class TaskStatusResponse(BaseModel): examples=[337], ) - payout_format: Optional[PayoutFormatType] = PayoutFormatOptionalField + payout_format: PayoutFormatType | None = PayoutFormatOptionalField - user_payout_string: Optional[str] = Field( + user_payout_string: str | None = Field( default=None, description="If a payout transformation is configured on this account, " "this is the amount to display to the user", examples=["3370 Points"], ) - kwargs: Dict[str, str] = Field( + kwargs: dict[str, str] = Field( default_factory=dict, description="Any extra url params used in the offerwall request will be " "passed back here", ) - status_code_1: Optional[Annotated[StatusCode1, EnumNameSerializer]] = Field( + status_code_1: Annotated[StatusCode1, EnumNameSerializer] | None = Field( default=None, examples=[StatusCode1.COMPLETE.name], description=StatusCode1.as_openapi_with_value_descriptions_name(), ) - status_code_2: Optional[Annotated[SessionStatusCode2, EnumNameSerializer]] = Field( + status_code_2: Annotated[SessionStatusCode2, EnumNameSerializer] | None = Field( default=None, examples=[None], description=SessionStatusCode2.as_openapi_with_value_descriptions_name(), ) - adjusted_status: Optional[SessionAdjustedStatus] = Field( + adjusted_status: SessionAdjustedStatus | None = Field( default=None, description=SessionAdjustedStatus.as_openapi_with_value_descriptions(), examples=[None], ) - adjusted_timestamp: Optional[AwareDatetimeISO] = Field( + adjusted_timestamp: AwareDatetimeISO | None = Field( default=None, description="When the adjusted status was last set.", examples=[None], ) - adjusted_payout: Optional[NonNegativeInt] = Field( + adjusted_payout: NonNegativeInt | None = Field( default=None, lt=100_000, description="The new payout after adjustment.", examples=[None], ) - adjusted_user_payout: Optional[NonNegativeInt] = Field( + adjusted_user_payout: NonNegativeInt | None = Field( default=None, lt=100_000, description="The new user_payout after adjustment.", examples=[None], ) - adjusted_user_payout_string: Optional[str] = Field( + adjusted_user_payout_string: str | None = Field( default=None, description="The new user_payout_string after adjustment.", examples=[None], ) # This is used for validation purposes only. It won't get serialized - payout_transformation: Optional[PayoutTransformation] = Field( + payout_transformation: PayoutTransformation | None = Field( default=None, exclude=True ) - wall_events: Optional[List[WallOut]] = Field(default=None) + wall_events: list[WallOut] | None = Field(default=None) currency: Literal["USD"] = Field(default="USD") final_status: int = Field(default=0, description="This is deprecated") # Serialize enum → int @field_serializer("status", return_type=int) - def serialize_status(self, v: Optional[Status], _info): + def serialize_status(self, v: Status | None, _info): return STATUS_MAP[v] # Accept int OR string for input, but internally store a Status enum @@ -219,11 +221,11 @@ class TaskStatusResponse(BaseModel): return v @field_validator("payout", mode="before") - def transform_payout(cls, v: Optional[NonNegativeInt]) -> NonNegativeInt: + def transform_payout(cls, v: NonNegativeInt | None) -> NonNegativeInt: return v or 0 @field_validator("kwargs", mode="after") - def sanitize_kwargs(cls, v: Optional[Dict]) -> Optional[Dict]: + def sanitize_kwargs(cls, v: dict | None) -> dict | None: if v and "clicked_timestamp" in v: try: clicked_timestamp = datetime.strptime( @@ -289,4 +291,4 @@ class TaskStatusResponse(BaseModel): class TasksStatusResponse(Page): - tasks_status: List[TaskStatusResponse] = Field(default_factory=list) + tasks_status: list[TaskStatusResponse] = Field(default_factory=list) diff --git a/generalresearch/models/thl/user.py b/generalresearch/models/thl/user.py index 852766c..34c9b35 100644 --- a/generalresearch/models/thl/user.py +++ b/generalresearch/models/thl/user.py @@ -4,7 +4,7 @@ import json import logging import re from datetime import datetime, timezone -from typing import TYPE_CHECKING, Dict, List, Optional +from typing import TYPE_CHECKING from uuid import UUID, uuid4 from pydantic import ( @@ -45,22 +45,22 @@ BPUID_ALLOWED = r"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ class User(BaseModel): model_config = ConfigDict(extra="forbid", validate_assignment=True) - user_id: Optional[PositiveInt] = Field( + user_id: PositiveInt | None = Field( default=None, lt=MAX_INT32, serialization_alias="id" ) - uuid: Optional[UUIDStr] = Field(default=None, examples=[uuid4().hex]) + uuid: UUIDStr | None = Field(default=None, examples=[uuid4().hex]) # 'product' is a Class with values that are fetched from the DB. # Initialization is deferred until it is actually needed # (see .prefetch_product()) - product: Optional[Product] = Field(default=None) + product: Product | None = Field(default=None) - product_id: Optional[UUIDStr] = Field( + product_id: UUIDStr | None = Field( default=None, examples=["4fe381fb7186416cb443a38fa66c6557"] ) - product_user_id: Optional[BPUIDStr] = Field( + product_user_id: BPUIDStr | None = Field( default=None, examples=["app-user-9329ebd"], description="A unique identifier for each user, which is set by the " @@ -72,24 +72,24 @@ class User(BaseModel): # TODO: Is it possible to protect these from ever being initialized? # - Would need to be allowed with .from_json but not User constructor directly # - Would need to allow private setters for setting from DB values - blocked: Optional[bool] = Field(default=False, strict=True) + blocked: bool | None = Field(default=False, strict=True) - created: Optional[AwareDatetimeISO] = Field( + created: AwareDatetimeISO | None = Field( default=None, description="When the user was created on the GRL platform.", ) # Note: due to cacheing, last_seen might be up to a day out of date! - last_seen: Optional[AwareDatetimeISO] = Field( + last_seen: AwareDatetimeISO | None = Field( default=None, description="When the user was last seen on, or acting on any" "part of the GRL platform.", ) # --- Prefetch Fields --- - audit_log: Optional[List[AuditLog]] = Field(default=None) - transactions: Optional[List["LedgerTransaction"]] = Field(default=None) - location_history: Optional[List["GeoIPInformation"]] = Field(default=None) + audit_log: list[AuditLog] | None = Field(default=None) + transactions: list["LedgerTransaction"] | None = Field(default=None) + location_history: list["GeoIPInformation"] | None = Field(default=None) # --- Prebuild Fields --- # session: Optional[List] = Field(default=None) @@ -199,7 +199,7 @@ class User(BaseModel): raise ValueError("product_user_id must not equal the product_id") return True - def to_dict(self) -> Dict: + def to_dict(self) -> dict: return self.model_dump(mode="python", exclude={"product"}) def to_json(self) -> str: @@ -279,16 +279,12 @@ class User(BaseModel): pm = ProductManager(pg_config=pg_config) self.product = pm.get_by_uuid(product_uuid=self.product_id) - return None - def prefetch_audit_log(self, audit_log_manager: "AuditLogManager") -> None: self.audit_log = audit_log_manager.filter_by_user_id(user_id=self.user_id) - return None def prefetch_transactions(self, thl_lm: "ThlLedgerManager") -> None: account = thl_lm.get_account_or_create_user_wallet(user=self) self.transactions = thl_lm.get_tx_filtered_by_account(account_uuid=account.uuid) - return None # def prefetch_location_history(self, user_ip_history_manager: "UserIpHistoryManager") -> None: # return user_ip_history_manager.get_user_ip_history(user_id=self.user_id) diff --git a/generalresearch/models/thl/user_iphistory.py b/generalresearch/models/thl/user_iphistory.py index 159b97b..84ee42e 100644 --- a/generalresearch/models/thl/user_iphistory.py +++ b/generalresearch/models/thl/user_iphistory.py @@ -1,6 +1,7 @@ +from __future__ import annotations + import ipaddress from datetime import datetime, timedelta, timezone -from typing import Dict, List, Optional from faker import Faker from pydantic import ( @@ -34,10 +35,10 @@ class UserIPRecord(BaseModel): ip: IPvAnyAddressStr = Field() created: AwareDatetimeISO = Field() - information: Optional[GeoIPInformation] = Field(default=None, exclude=True) + information: GeoIPInformation | None = Field(default=None, exclude=True) @property - def country_iso(self) -> Optional[CountryISOLike]: + def country_iso(self) -> CountryISOLike | None: return self.information.country_iso if self.information else None @property @@ -52,15 +53,15 @@ class UserIPRecord(BaseModel): ) @property - def user_type(self) -> Optional[UserType]: + def user_type(self) -> UserType | None: return self.information.user_type if self.information else None @property - def subdivision_1_iso(self) -> Optional[str]: + def subdivision_1_iso(self) -> str | None: return self.information.subdivision_1_iso if self.information else None @property - def subdivision_2_iso(self) -> Optional[str]: + def subdivision_2_iso(self) -> str | None: return self.information.subdivision_2_iso if self.information else None @@ -71,14 +72,12 @@ class IPRecord(BaseModel): # On a top-level, this should be an empty list if there are no forwarded_ip. # Within a forwarded_ip record, this should be None. - forwarded_ip_records: Optional[List["IPRecord"]] = Field( - default=None, description="" - ) + forwarded_ip_records: list["IPRecord"] | None = Field(default=None, description="") - information: Optional[GeoIPInformation] = Field(default=None) + information: GeoIPInformation | None = Field(default=None) @property - def forwarded_ips(self) -> Optional[List[IPvAnyAddressStr]]: + def forwarded_ips(self) -> list[IPvAnyAddressStr] | None: return ( [x.ip for x in self.forwarded_ip_records] if self.forwarded_ip_records is not None @@ -86,7 +85,7 @@ class IPRecord(BaseModel): ) def ip_changed( - self, ip: IPvAnyAddressStr, forwarded_ips: List[IPvAnyAddressStr] + self, ip: IPvAnyAddressStr, forwarded_ips: list[IPvAnyAddressStr] ) -> bool: return not (ip == self.ip and forwarded_ips == self.forwarded_ips) @@ -110,11 +109,10 @@ class IPRecord(BaseModel): x.information = res.get(x.ip) else: self.information = m.get(ip_address=self.ip) - return None # --- ORM --- @classmethod - def from_mysql(cls, d: Dict) -> Self: + def from_mysql(cls, d: dict) -> Self: created = d["created"].replace(tzinfo=timezone.utc) d["created"] = created @@ -149,22 +147,22 @@ class UserIPHistory(BaseModel): # In thl-gprc, we run "audit_ip_history()", and so a user should # get blocked after 100 IP switches or 30 unique IPs # Sorted created DESC - ips: Optional[List[UserIPRecord]] = Field( + ips: list[UserIPRecord] | None = Field( default=None, description="These are any IP addresses that came in ", max_length=101, ) - ips_ws: Optional[List[IPRecord]] = Field( + ips_ws: list[IPRecord] | None = Field( default=None, description="These are any IP addresses that came in " ) - ips_dns: Optional[List[IPRecord]] = Field( + ips_dns: list[IPRecord] | None = Field( default=None, description="These are any IP addresses that came in " ) # -- prefetch_ fields - user: Optional[User] = Field(default=None) + user: User | None = Field(default=None) @field_validator("ips", mode="after") @classmethod @@ -195,8 +193,6 @@ class UserIPHistory(BaseModel): ) self.user = um.get_user(user_id=self.user_id) - return None - def enrich_ips(self, pg_config: PostgresConfig, redis_config: RedisConfig) -> None: from generalresearch.managers.thl.ipinfo import GeoIpInfoManager diff --git a/generalresearch/models/thl/user_profile.py b/generalresearch/models/thl/user_profile.py index 98b3326..e96266a 100644 --- a/generalresearch/models/thl/user_profile.py +++ b/generalresearch/models/thl/user_profile.py @@ -1,5 +1,7 @@ +from __future__ import annotations + import hashlib -from typing import Any, Dict, List, Optional +from typing import Any from pydantic import ( BaseModel, @@ -21,19 +23,17 @@ from generalresearch.models.thl.user_streak import UserStreak class UserMetadata(BaseModel): model_config = ConfigDict(extra="forbid", validate_assignment=True) - user_id: SkipJsonSchema[Optional[PositiveInt]] = Field( + user_id: SkipJsonSchema[PositiveInt | None] = Field( exclude=True, default=None, lt=MAX_INT32 ) - email_address: Optional[EmailStr] = Field( - default=None, examples=["contact@mail.com"] - ) + email_address: EmailStr | None = Field(default=None, examples=["contact@mail.com"]) @computed_field def email_md5( self, ) -> Annotated[ - Optional[str], + str | None, Field( min_length=32, max_length=32, @@ -50,7 +50,7 @@ class UserMetadata(BaseModel): def email_sha1( self, ) -> Annotated[ - Optional[str], + str | None, Field( min_length=40, max_length=40, @@ -66,7 +66,7 @@ class UserMetadata(BaseModel): def email_sha256( self, ) -> Annotated[ - Optional[str], + str | None, Field( min_length=64, max_length=64, @@ -80,7 +80,7 @@ class UserMetadata(BaseModel): return None return hashlib.sha256(self.email_address.encode("utf-8")).hexdigest() - def to_db(self) -> Dict[str, Any]: + def to_db(self) -> dict[str, Any]: res = self.model_dump(mode="json") res["user_id"] = self.user_id return res @@ -107,7 +107,7 @@ class UserProfile(UserMetadata): user: User = Field() - marketplace_pids: Dict[Source, UUIDStr] = Field( + marketplace_pids: dict[Source, UUIDStr] = Field( default_factory=dict, description="User's PID in marketplaces", examples=[ @@ -119,4 +119,4 @@ class UserProfile(UserMetadata): ], ) - streaks: List[UserStreak] = Field(default_factory=list) + streaks: list[UserStreak] = Field(default_factory=list) diff --git a/generalresearch/models/thl/user_quality_event.py b/generalresearch/models/thl/user_quality_event.py index 2b4873a..d6ebddc 100644 --- a/generalresearch/models/thl/user_quality_event.py +++ b/generalresearch/models/thl/user_quality_event.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime, timezone from decimal import Decimal from enum import Enum -from typing import List, Literal, Optional +from typing import Literal from pydantic import BaseModel, Field, PositiveInt @@ -46,15 +46,15 @@ class AccessControlEvent(BaseModel): QualityEventType.clear_access_control_list, ] = Field() # One of user_id / (product_id, bpuid) is required. - product_id: Optional[UUIDStr] = Field( + product_id: UUIDStr | None = Field( default=None, examples=["4fe381fb7186416cb443a38fa66c6557"] ) - bpuid: Optional[BPUIDStr] = Field(default=None, examples=["app-user-9329ebd"]) - user_id: Optional[PositiveInt] = Field(default=None, lt=MAX_INT32) + bpuid: BPUIDStr | None = Field(default=None, examples=["app-user-9329ebd"]) + user_id: PositiveInt | None = Field(default=None, lt=MAX_INT32) class AccessControlEventBody(BaseModel): - events: List[AccessControlEvent] = Field(max_length=100, min_length=1) + events: list[AccessControlEvent] = Field(max_length=100, min_length=1) class TaskAdjustmentEvent(BaseModel): @@ -70,21 +70,21 @@ class TaskAdjustmentEvent(BaseModel): # Only MID is needed to populate all the following, however we can pass them in order # to perform validation. If any disagree, an error should be raised. - survey_id: Optional[str] = Field(max_length=32, default=None) - amount: Optional[Decimal] = Field( + survey_id: str | None = Field(max_length=32, default=None) + amount: Decimal | None = Field( description="If negative, the status should adjusted to incomplete", default=None, ) - event_time: Optional[AwareDatetimeISO] = Field( + event_time: AwareDatetimeISO | None = Field( description="This is when the original wall event was started", default=None, ) - product_id: Optional[UUIDStr] = Field( + product_id: UUIDStr | None = Field( default=None, examples=["4fe381fb7186416cb443a38fa66c6557"] ) - bpuid: Optional[BPUIDStr] = Field(default=None, examples=["app-user-9329ebd"]) - user_id: Optional[PositiveInt] = Field(default=None, lt=MAX_INT32) + bpuid: BPUIDStr | None = Field(default=None, examples=["app-user-9329ebd"]) + user_id: PositiveInt | None = Field(default=None, lt=MAX_INT32) class TaskAdjustmentEventBody(BaseModel): - events: List[TaskAdjustmentEvent] = Field(max_length=100, min_length=1) + events: list[TaskAdjustmentEvent] = Field(max_length=100, min_length=1) diff --git a/generalresearch/models/thl/user_streak.py b/generalresearch/models/thl/user_streak.py index 5d13bc5..278809b 100644 --- a/generalresearch/models/thl/user_streak.py +++ b/generalresearch/models/thl/user_streak.py @@ -1,6 +1,7 @@ +from __future__ import annotations + from datetime import date, datetime, timedelta from enum import Enum -from typing import Optional, Tuple import pandas as pd from pydantic import ( @@ -63,7 +64,7 @@ class UserStreak(BaseModel): ser_json_timedelta="float", validate_assignment=True, extra="forbid" ) - user_id: SkipJsonSchema[Optional[PositiveInt]] = Field( + user_id: SkipJsonSchema[PositiveInt | None] = Field( exclude=True, default=None, lt=MAX_INT32 ) country_iso: CountryISO = Field() @@ -75,7 +76,7 @@ class UserStreak(BaseModel): current_streak: NonNegativeInt = Field() longest_streak: NonNegativeInt = Field() state: StreakState = Field() - last_fulfilled_period_start: Optional[date] = Field(default=None) + last_fulfilled_period_start: date | None = Field(default=None) @computed_field() @property @@ -92,17 +93,17 @@ class UserStreak(BaseModel): @computed_field() @property - def current_period_bounds(self) -> Tuple[AwareDatetime, AwareDatetime]: + def current_period_bounds(self) -> tuple[AwareDatetime, AwareDatetime]: return self.get_period_bounds(datetime.now(tz=self.timezone).date()) @computed_field() @property - def last_fulfilled_period_bounds(self) -> Optional[Tuple[datetime, datetime]]: + def last_fulfilled_period_bounds(self) -> tuple[datetime, datetime] | None: return self.get_period_bounds(self.last_fulfilled_period_start) @computed_field() @property - def time_remaining_in_period(self) -> Optional[timedelta]: + def time_remaining_in_period(self) -> timedelta | None: # Time left to continue your streak if self.state in {StreakState.BROKEN, StreakState.ACTIVE}: return None @@ -129,9 +130,7 @@ class UserStreak(BaseModel): ), "Current streak can't be longer than longest streak" return self - def get_period_bounds( - self, start_date: date - ) -> Optional[Tuple[datetime, datetime]]: + def get_period_bounds(self, start_date: date) -> tuple[datetime, datetime] | None: """ Returns (period_start_local, period_end_local) Both timezone-aware. diff --git a/generalresearch/models/thl/userhealth.py b/generalresearch/models/thl/userhealth.py index fd275de..e556dc8 100644 --- a/generalresearch/models/thl/userhealth.py +++ b/generalresearch/models/thl/userhealth.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from datetime import datetime, timezone from enum import Enum from typing import Dict, Optional diff --git a/generalresearch/models/thl/wallet/cashout_method.py b/generalresearch/models/thl/wallet/cashout_method.py index c04bd66..59cf721 100644 --- a/generalresearch/models/thl/wallet/cashout_method.py +++ b/generalresearch/models/thl/wallet/cashout_method.py @@ -4,7 +4,7 @@ import hashlib import logging from datetime import datetime, timezone from enum import Enum -from typing import Any, Dict, List, Literal, Optional, Union +from typing import Any, Literal from pydantic import ( BaseModel, @@ -62,22 +62,22 @@ class CashoutMethodBase(BaseModel): default="USD", description="The currency of the cashout. Only USD is supported.", ) - original_currency: Optional[Currency] = Field( + original_currency: Currency | None = Field( default=None, description="The base currency of the money paid out. This is used for " "e.g. sending an Amazon UK gift card", ) # This also is used for the PayoutEvent.request_data - data: Union[ - PaypalCashoutMethodData, - TangoCashoutMethodData, - CashMailCashoutMethodData, - AmtCashoutMethodData, - ] = Field(discriminator="type") + data: ( + PaypalCashoutMethodData + | TangoCashoutMethodData + | CashMailCashoutMethodData + | AmtCashoutMethodData + ) = Field(discriminator="type") description: str = Field( description="The description of the cashout method.", default="" ) - image_url: Optional[HttpsUrlStr] = Field( + image_url: HttpsUrlStr | None = Field( description="Link to an image to display", default=None ) max_value: PositiveInt = Field( @@ -93,18 +93,18 @@ class CashoutMethodBase(BaseModel): type: PayoutType = Field( description=PayoutType.as_openapi_with_value_descriptions(), ) - ext_id: Optional[str] = Field( + ext_id: str | None = Field( default=None, description="An external ID. Can be shown to a user to disambiguate " "a user's possibly multiple methods", ) - usd_exchange_rate: Optional[float] = Field(default=None) - max_value_usd: Optional[USDCent] = Field( + usd_exchange_rate: float | None = Field(default=None) + max_value_usd: USDCent | None = Field( default=None, description="(In lowest unit of USD), " "The maximum amount that can be cashed out in one transaction.", ) - min_value_usd: Optional[USDCent] = Field( + min_value_usd: USDCent | None = Field( default=None, description="(In lowest unit of USD), " "The minimum amount that can be cashed out in one transaction.", @@ -138,7 +138,7 @@ class CashoutMethodBase(BaseModel): class CashoutMethod(CashoutMethodBase): - user: Optional[User] = Field( + user: User | None = Field( default=None, description="If set, this cashout method is custom for this user. For example" "a user may have a paypal cashout method with their paypal" @@ -163,11 +163,11 @@ class CashoutMethod(CashoutMethodBase): class CashoutMethodOut(CashoutMethodBase): - product_id: Optional[UUIDStr] = Field( + product_id: UUIDStr | None = Field( default=None, examples=["4fe381fb7186416cb443a38fa66c6557"] ) - product_user_id: Optional[str] = Field( + product_user_id: str | None = Field( default=None, min_length=3, max_length=128, @@ -189,12 +189,12 @@ class CashoutMethodOut(CashoutMethodBase): class USDeliveryAddress(BaseModel): name_or_attn: str = Field(min_length=1, max_length=50) - company: Optional[str] = Field( + company: str | None = Field( default=None, min_length=1, max_length=50, ) - phone_number: Optional[str] = Field( + phone_number: str | None = Field( default=None, min_length=10, max_length=10, @@ -231,13 +231,13 @@ class TangoCashoutMethodData(BaseModel): type: Literal[PayoutType.TANGO] = Field(default=PayoutType.TANGO) utid: str = Field(description="tango utid") # TODO: Can't be CountryISOLike because it appears to be allcaps - countries: List[str] = Field() + countries: list[str] = Field() value_type: Literal["variable", "fixed"] = Field() disclaimer: str = Field(default="") terms: str = Field(default="") @field_validator("countries", mode="after") - def countries_case(cls, countries: List[str]) -> List[str]: + def countries_case(cls, countries: list[str]) -> list[str]: return [x.lower() for x in countries] @@ -246,7 +246,7 @@ class AmtCashoutMethodData(BaseModel): class CashoutMethodsResponse(StatusResponse): - cashout_methods: List[CashoutMethodOut] = Field() + cashout_methods: list[CashoutMethodOut] = Field() class DeliveryStatus(str, Enum): @@ -279,37 +279,37 @@ class ShippingMethod(str, Enum): # This goes in the PayoutEvent.order_data class CashMailOrderData(BaseModel): type: Literal[PayoutType.CASH_IN_MAIL] = Field(default=PayoutType.CASH_IN_MAIL) - shipping_cost: Optional[PositiveInt] = Field( + shipping_cost: PositiveInt | None = Field( description="(USD cents) The shipping cost. This amount get charged to the BP.", strict=True, ) - tracking_number: Optional[str] = Field( + tracking_number: str | None = Field( default=None, min_length=1, max_length=50, ) - shipping_method: Optional[ShippingMethod] = Field( + shipping_method: ShippingMethod | None = Field( default=None, min_length=1, max_length=50, description="Standard, express, etc.", ) - carrier: Optional[ShippingCarrier] = Field( + carrier: ShippingCarrier | None = Field( default=None, min_length=1, max_length=50, description="Name of the shipping company, e.g., USPS, FedEx, DHL", ) - ship_date: Optional[AwareDatetimeISO] = Field(default=None) - estimated_delivery_date: Optional[AwareDatetimeISO] = Field(default=None) - delivery_status: Optional[DeliveryStatus] = Field( + ship_date: AwareDatetimeISO | None = Field(default=None) + estimated_delivery_date: AwareDatetimeISO | None = Field(default=None) + delivery_status: DeliveryStatus | None = Field( default=None, min_length=1, max_length=50, description="Current status of delivery, e.g., pending, in " "transit, delivered", ) - last_updated: Optional[AwareDatetimeISO] = Field( + last_updated: AwareDatetimeISO | None = Field( default=None, description="Timestamp of the last status update", ) @@ -364,7 +364,7 @@ class CashoutRequestInfo(BaseModel): Payout. This is used only in the API response. """ - id: Optional[UUIDStr] = Field( + id: UUIDStr | None = Field( description="Unique ID for this cashout. This may be NULL if the " "status is REJECTED or FAILED, which may happen if the " "request is invalid.", @@ -374,13 +374,13 @@ class CashoutRequestInfo(BaseModel): description="This is the name of the cashout method.", examples=["Visa® Prepaid Card USD"], ) - message: Optional[str] = Field(default=None) - status: Optional[PayoutStatus] = Field( + message: str | None = Field(default=None) + status: PayoutStatus | None = Field( default=PayoutStatus.PENDING, description=PayoutStatus.as_openapi(), examples=[PayoutStatus.PENDING], ) - transaction_info: Optional[Dict[str, Any]] = Field(default=None) + transaction_info: dict[str, Any] | None = Field(default=None) class CashoutRequestResponse(StatusResponse): diff --git a/generalresearch/models/thl/wallet/payout.py b/generalresearch/models/thl/wallet/payout.py index 72aea61..4b1f5a2 100644 --- a/generalresearch/models/thl/wallet/payout.py +++ b/generalresearch/models/thl/wallet/payout.py @@ -1,6 +1,9 @@ +from __future__ import annotations + import json +from collections.abc import Collection from datetime import datetime, timezone -from typing import Any, Collection, Dict, List, Optional, Union +from typing import Any from uuid import uuid4 from pydantic import ( @@ -35,8 +38,8 @@ class PayoutEvent(BaseModel, validate_assignment=True): # These two fields are copied here from the LedgerAccount through the # debit_account_uuid for convenience. They will get populated if the # PayoutEventManager retrieves a PayoutEvent from the db. - account_reference_type: Optional[str] = Field(default=None) - account_reference_uuid: Optional[UUIDStr] = Field(default=None) + account_reference_type: str | None = Field(default=None) + account_reference_uuid: UUIDStr | None = Field(default=None) # References a row in the account_cashoutmethod table. This is the # cashout method that was used to request this payout. (A cashout is @@ -46,7 +49,7 @@ class PayoutEvent(BaseModel, validate_assignment=True): # By default, this will just be the cashout_method.name. This also is # populated from the db and so does not need to be set (there is no # `description` field in event_payout) - description: Optional[str] = Field(default=None) + description: str | None = Field(default=None) created: AwareDatetimeISO = Field( default_factory=lambda: datetime.now(tz=timezone.utc) ) @@ -67,18 +70,18 @@ class PayoutEvent(BaseModel, validate_assignment=True): ) # Used for holding an external, payout-type-specific identifier - ext_ref_id: Optional[str] = Field(default=None) + ext_ref_id: str | None = Field(default=None) payout_type: PayoutType = Field( description=PayoutType.as_openapi(), examples=[PayoutType.ACH] ) # Stores payout-type-specific information that is used to request this # payout from the external provider. - request_data: Dict[str, Any] = Field(default_factory=dict) + request_data: dict[str, Any] = Field(default_factory=dict) # Stores payout-type-specific order information that is returned from # the external payout provider. - order_data: Optional[Union[Dict[str, Any], CashMailOrderData]] = Field(default=None) + order_data: dict[str, Any] | CashMailOrderData | None = Field(default=None) @field_validator("payout_type", mode="before") @classmethod @@ -93,8 +96,8 @@ class PayoutEvent(BaseModel, validate_assignment=True): def update( self, status: PayoutStatus, - ext_ref_id: Optional[str] = None, - order_data: Optional[Dict[str, Any]] = None, + ext_ref_id: str | None = None, + order_data: dict[str, Any] | None = None, ) -> None: # These 3 things are the only modifiable attributes self.check_status_change_allowed(status) @@ -128,7 +131,7 @@ class PayoutEvent(BaseModel, validate_assignment=True): else: raise ValueError("this shouldn't happen") - def model_dump_mysql(self, *args, **kwargs) -> Dict[str, Any]: + def model_dump_mysql(self, *args, **kwargs) -> dict[str, Any]: d = self.model_dump(mode="json", *args, **kwargs) if "created" in d: d["created"] = self.created.replace(tzinfo=None) @@ -166,7 +169,7 @@ class BPPayoutEvent(BaseModel): examples=[531], ) - status: Optional[PayoutStatus] = Field( + status: PayoutStatus | None = Field( default=PayoutStatus.PENDING, description=PayoutStatus.as_openapi(), examples=[PayoutStatus.COMPLETE], @@ -186,9 +189,9 @@ class BPPayoutEvent(BaseModel): @staticmethod def from_pe( payout_events: Collection[PayoutEvent], - account_product_mapping: Dict[str, str], - order_by="ASC", - ) -> List["BPPayoutEvent"]: + account_product_mapping: dict[str, str], + order_by: str = "ASC", + ) -> list["BPPayoutEvent"]: res = [] for pe in payout_events: bp_pe = BPPayoutEvent.model_validate( -- cgit v1.2.3