agentberlin 0.166.0__tar.gz → 0.167.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. {agentberlin-0.166.0 → agentberlin-0.167.0}/PKG-INFO +91 -1
  2. {agentberlin-0.166.0 → agentberlin-0.167.0}/README.md +90 -0
  3. {agentberlin-0.166.0 → agentberlin-0.167.0}/pyproject.toml +1 -1
  4. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/__init__.py +1 -1
  5. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/_http.py +1 -1
  6. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/client.py +8 -0
  7. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/__init__.py +16 -0
  8. agentberlin-0.167.0/src/agentberlin/models/linkedin_ads.py +128 -0
  9. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/readme.yaml +88 -0
  10. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/__init__.py +2 -0
  11. agentberlin-0.167.0/src/agentberlin/resources/linkedin_ads.py +183 -0
  12. agentberlin-0.167.0/tests/test_linkedin_ads.py +200 -0
  13. {agentberlin-0.166.0 → agentberlin-0.167.0}/.gitignore +0 -0
  14. {agentberlin-0.166.0 → agentberlin-0.167.0}/LICENSE +0 -0
  15. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/cli.py +0 -0
  16. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/config.py +0 -0
  17. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/exceptions.py +0 -0
  18. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/ai_mentions.py +0 -0
  19. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/ai_search.py +0 -0
  20. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/amplitude.py +0 -0
  21. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/analytics.py +0 -0
  22. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/backlinks.py +0 -0
  23. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/bing_search.py +0 -0
  24. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/bing_webmaster.py +0 -0
  25. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/brand.py +0 -0
  26. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/crawls.py +0 -0
  27. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/email_marketing.py +0 -0
  28. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/ga4.py +0 -0
  29. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/google_ads.py +0 -0
  30. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/google_maps.py +0 -0
  31. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/google_search.py +0 -0
  32. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/google_trends.py +0 -0
  33. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/gsc.py +0 -0
  34. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/linkedin.py +0 -0
  35. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/market.py +0 -0
  36. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/microsoft_ads.py +0 -0
  37. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/openai_ads.py +0 -0
  38. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/pagespeed.py +0 -0
  39. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/posthog.py +0 -0
  40. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/reddit.py +0 -0
  41. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/models/search.py +0 -0
  42. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/py.typed +0 -0
  43. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/ai_mentions.py +0 -0
  44. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/ai_search.py +0 -0
  45. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/amplitude.py +0 -0
  46. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/backlinks.py +0 -0
  47. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/bing_search.py +0 -0
  48. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/bing_webmaster.py +0 -0
  49. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/brand.py +0 -0
  50. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/crawls.py +0 -0
  51. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/email_marketing.py +0 -0
  52. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/ga4.py +0 -0
  53. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/google_ads.py +0 -0
  54. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/google_maps.py +0 -0
  55. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/google_search.py +0 -0
  56. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/google_trends.py +0 -0
  57. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/gsc.py +0 -0
  58. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/keywords.py +0 -0
  59. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/linkedin.py +0 -0
  60. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/market.py +0 -0
  61. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/microsoft_ads.py +0 -0
  62. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/openai_ads.py +0 -0
  63. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/pages.py +0 -0
  64. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/pagespeed.py +0 -0
  65. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/posthog.py +0 -0
  66. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/resources/reddit.py +0 -0
  67. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/session.py +0 -0
  68. {agentberlin-0.166.0 → agentberlin-0.167.0}/src/agentberlin/utils.py +0 -0
  69. {agentberlin-0.166.0 → agentberlin-0.167.0}/tests/__init__.py +0 -0
  70. {agentberlin-0.166.0 → agentberlin-0.167.0}/tests/conftest.py +0 -0
  71. {agentberlin-0.166.0 → agentberlin-0.167.0}/tests/test_ai_mentions.py +0 -0
  72. {agentberlin-0.166.0 → agentberlin-0.167.0}/tests/test_ai_search.py +0 -0
  73. {agentberlin-0.166.0 → agentberlin-0.167.0}/tests/test_backlinks.py +0 -0
  74. {agentberlin-0.166.0 → agentberlin-0.167.0}/tests/test_cli_brand.py +0 -0
  75. {agentberlin-0.166.0 → agentberlin-0.167.0}/tests/test_cli_findings.py +0 -0
  76. {agentberlin-0.166.0 → agentberlin-0.167.0}/tests/test_cli_report.py +0 -0
  77. {agentberlin-0.166.0 → agentberlin-0.167.0}/tests/test_cli_sdk_docs.py +0 -0
  78. {agentberlin-0.166.0 → agentberlin-0.167.0}/tests/test_cli_workflow.py +0 -0
  79. {agentberlin-0.166.0 → agentberlin-0.167.0}/tests/test_cli_workflow_validate.py +0 -0
  80. {agentberlin-0.166.0 → agentberlin-0.167.0}/tests/test_client.py +0 -0
  81. {agentberlin-0.166.0 → agentberlin-0.167.0}/tests/test_crawls.py +0 -0
  82. {agentberlin-0.166.0 → agentberlin-0.167.0}/tests/test_email_marketing.py +0 -0
  83. {agentberlin-0.166.0 → agentberlin-0.167.0}/tests/test_exceptions.py +0 -0
  84. {agentberlin-0.166.0 → agentberlin-0.167.0}/tests/test_google_ads.py +0 -0
  85. {agentberlin-0.166.0 → agentberlin-0.167.0}/tests/test_gsc.py +0 -0
  86. {agentberlin-0.166.0 → agentberlin-0.167.0}/tests/test_linkedin.py +0 -0
  87. {agentberlin-0.166.0 → agentberlin-0.167.0}/tests/test_market.py +0 -0
  88. {agentberlin-0.166.0 → agentberlin-0.167.0}/tests/test_models.py +0 -0
  89. {agentberlin-0.166.0 → agentberlin-0.167.0}/tests/test_openai_ads.py +0 -0
  90. {agentberlin-0.166.0 → agentberlin-0.167.0}/tests/test_pages.py +0 -0
  91. {agentberlin-0.166.0 → agentberlin-0.167.0}/tests/test_reddit.py +0 -0
  92. {agentberlin-0.166.0 → agentberlin-0.167.0}/tests/test_retry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentberlin
3
- Version: 0.166.0
3
+ Version: 0.167.0
4
4
  Summary: Python SDK for Agent Berlin - AI-powered SEO and AEO automation
5
5
  Project-URL: Homepage, https://agentberlin.ai
6
6
  Project-URL: Documentation, https://docs.agentberlin.ai/sdk/python
@@ -77,6 +77,7 @@ client = AgentBerlin()
77
77
  - **google_ads**: `search_terms()`, `keywords()`, `campaigns()`, `ad_groups()`, `landing_pages()`, `coverage()` - Google Ads performance reports (read-only, nightly-synced warehouse; cost values in micros)
78
78
  - **openai_ads**: `account()`, `campaigns()`, `ad_groups()`, `ads()`, `list_campaigns()`, `record_hint_set()`, `list_hint_sets()` - OpenAI (ChatGPT) Ads performance insights read live from the connected Advertiser API key (read-only; six metrics only; no keywords/search terms; insights money in account currency, not micros), plus recording/recall of generated ad-group context_hints for history
79
79
  - **microsoft_ads**: `search_terms()`, `keywords()`, `campaigns()`, `ad_groups()`, `landing_pages()`, `share_of_voice()`, `coverage()` - Microsoft Advertising performance reports (read-only, nightly-synced warehouse; money values are decimal, not micros; share-of-voice keeps ~6 months of history)
80
+ - **linkedin_ads**: `account()`, `campaign_groups()`, `campaigns()`, `creatives()`, `list_campaigns()` - LinkedIn Ads (paid social) performance insights read live from the connected ad account via the /adAnalytics finder (read-only; no keywords/search terms; `cost_in_local_currency` in account currency, not micros)
80
81
 
81
82
  ## API Reference
82
83
 
@@ -3597,3 +3598,92 @@ cov = client.microsoft_ads.coverage(
3597
3598
  )
3598
3599
  print(f"Search terms cover {cov.coverage.coverage_ratio:.0%} of spend")
3599
3600
  ```
3601
+
3602
+ ### linkedin_ads
3603
+
3604
+ LinkedIn Ads (paid social) performance for the project's connected ad account: account, campaign-group, campaign, and creative insights, plus a campaign listing. Read-only — the SDK never mutates campaigns or any other account state. Data is read LIVE via LinkedIn's /adAnalytics finder on every call (no warehouse, so no `data_as_of` staleness). This is paid SOCIAL, not paid search: there are NO keywords and NO search terms. Money (`cost_in_local_currency`) is a DECIMAL in the account currency in `meta.currency_code` — not micros, so no division is needed; all IDs are URNs kept as strings.
3605
+
3606
+
3607
+ #### client.linkedin_ads.account()
3608
+
3609
+ Get account-level performance totals (one row per time bucket).
3610
+
3611
+ Signature:
3612
+ ```python
3613
+ result = client.linkedin_ads.account(
3614
+ start_date="2026-06-01", # Optional: YYYY-MM-DD (default 30 days ago)
3615
+ end_date="2026-06-30", # Optional: YYYY-MM-DD (default today)
3616
+ granularity="daily", # Optional: daily (default) | monthly | yearly | all
3617
+ )
3618
+ ```
3619
+
3620
+ Returns LinkedInAdsInsightsResponse with:
3621
+ - rows - List of LinkedInAdsInsightRow objects
3622
+ - row_count - Number of rows returned
3623
+ - meta - LinkedInAdsMeta (source, account_id, currency_code)
3624
+
3625
+ Each LinkedInAdsInsightRow has:
3626
+ - pivot_value - URN of the pivoted entity (empty at account level)
3627
+ - start / end - Time-bucket bounds (YYYY-MM-DD)
3628
+ - impressions / clicks
3629
+ - cost_in_local_currency / cost_in_usd - decimal, not micros
3630
+ - conversions / one_click_leads / likes / comments / shares / follows
3631
+
3632
+ #### client.linkedin_ads.campaign_groups()
3633
+
3634
+ Per-campaign-group performance (one row per group per time bucket). Same
3635
+ args and shape as `account()`; each row's `pivot_value` is the campaign
3636
+ group URN.
3637
+
3638
+ #### client.linkedin_ads.campaigns()
3639
+
3640
+ Per-campaign performance (one row per campaign per time bucket). Same
3641
+ args and shape as `account()`; each row's `pivot_value` is the campaign
3642
+ URN. Pair with `list_campaigns()` for names, budgets, and type.
3643
+
3644
+ Example:
3645
+ ```python
3646
+ report = client.linkedin_ads.campaigns(
3647
+ start_date="2026-06-01",
3648
+ end_date="2026-06-30",
3649
+ )
3650
+ for row in report.rows:
3651
+ print(f"{row.pivot_value}: {row.clicks} clicks, "
3652
+ f"{row.cost_in_local_currency:.2f} {report.meta.currency_code}")
3653
+ ```
3654
+
3655
+ #### client.linkedin_ads.creatives()
3656
+
3657
+ Per-creative performance (one row per creative per time bucket). Same
3658
+ args and shape as `account()`; each row's `pivot_value` is the creative
3659
+ URN.
3660
+
3661
+ #### client.linkedin_ads.list_campaigns()
3662
+
3663
+ List the connected account's campaigns with metadata for interpreting
3664
+ the insight rows.
3665
+
3666
+ Signature:
3667
+ ```python
3668
+ result = client.linkedin_ads.list_campaigns()
3669
+ ```
3670
+
3671
+ Returns LinkedInAdsCampaignsResponse with:
3672
+ - rows - List of LinkedInAdsCampaignRow objects
3673
+ - row_count - Number of campaigns returned
3674
+ - meta - LinkedInAdsMeta (source, account_id, currency_code)
3675
+
3676
+ Each LinkedInAdsCampaignRow has:
3677
+ - id / name / status
3678
+ - type - SPONSORED_UPDATES | TEXT_AD | SPONSORED_INMAILS | DYNAMIC
3679
+ - cost_type - CPC | CPM | CPV
3680
+ - daily_budget / total_budget / unit_cost - decimal-string money objects
3681
+ in the account currency (amount + currency_code)
3682
+ - campaign_group - the parent campaign group URN
3683
+ - audience_expansion_enabled / run_schedule / targeting_criteria
3684
+
3685
+ Example:
3686
+ ```python
3687
+ for c in client.linkedin_ads.list_campaigns().rows:
3688
+ print(f"{c.name} [{c.status}] type={c.type}")
3689
+ ```
@@ -41,6 +41,7 @@ client = AgentBerlin()
41
41
  - **google_ads**: `search_terms()`, `keywords()`, `campaigns()`, `ad_groups()`, `landing_pages()`, `coverage()` - Google Ads performance reports (read-only, nightly-synced warehouse; cost values in micros)
42
42
  - **openai_ads**: `account()`, `campaigns()`, `ad_groups()`, `ads()`, `list_campaigns()`, `record_hint_set()`, `list_hint_sets()` - OpenAI (ChatGPT) Ads performance insights read live from the connected Advertiser API key (read-only; six metrics only; no keywords/search terms; insights money in account currency, not micros), plus recording/recall of generated ad-group context_hints for history
43
43
  - **microsoft_ads**: `search_terms()`, `keywords()`, `campaigns()`, `ad_groups()`, `landing_pages()`, `share_of_voice()`, `coverage()` - Microsoft Advertising performance reports (read-only, nightly-synced warehouse; money values are decimal, not micros; share-of-voice keeps ~6 months of history)
44
+ - **linkedin_ads**: `account()`, `campaign_groups()`, `campaigns()`, `creatives()`, `list_campaigns()` - LinkedIn Ads (paid social) performance insights read live from the connected ad account via the /adAnalytics finder (read-only; no keywords/search terms; `cost_in_local_currency` in account currency, not micros)
44
45
 
45
46
  ## API Reference
46
47
 
@@ -3561,3 +3562,92 @@ cov = client.microsoft_ads.coverage(
3561
3562
  )
3562
3563
  print(f"Search terms cover {cov.coverage.coverage_ratio:.0%} of spend")
3563
3564
  ```
3565
+
3566
+ ### linkedin_ads
3567
+
3568
+ LinkedIn Ads (paid social) performance for the project's connected ad account: account, campaign-group, campaign, and creative insights, plus a campaign listing. Read-only — the SDK never mutates campaigns or any other account state. Data is read LIVE via LinkedIn's /adAnalytics finder on every call (no warehouse, so no `data_as_of` staleness). This is paid SOCIAL, not paid search: there are NO keywords and NO search terms. Money (`cost_in_local_currency`) is a DECIMAL in the account currency in `meta.currency_code` — not micros, so no division is needed; all IDs are URNs kept as strings.
3569
+
3570
+
3571
+ #### client.linkedin_ads.account()
3572
+
3573
+ Get account-level performance totals (one row per time bucket).
3574
+
3575
+ Signature:
3576
+ ```python
3577
+ result = client.linkedin_ads.account(
3578
+ start_date="2026-06-01", # Optional: YYYY-MM-DD (default 30 days ago)
3579
+ end_date="2026-06-30", # Optional: YYYY-MM-DD (default today)
3580
+ granularity="daily", # Optional: daily (default) | monthly | yearly | all
3581
+ )
3582
+ ```
3583
+
3584
+ Returns LinkedInAdsInsightsResponse with:
3585
+ - rows - List of LinkedInAdsInsightRow objects
3586
+ - row_count - Number of rows returned
3587
+ - meta - LinkedInAdsMeta (source, account_id, currency_code)
3588
+
3589
+ Each LinkedInAdsInsightRow has:
3590
+ - pivot_value - URN of the pivoted entity (empty at account level)
3591
+ - start / end - Time-bucket bounds (YYYY-MM-DD)
3592
+ - impressions / clicks
3593
+ - cost_in_local_currency / cost_in_usd - decimal, not micros
3594
+ - conversions / one_click_leads / likes / comments / shares / follows
3595
+
3596
+ #### client.linkedin_ads.campaign_groups()
3597
+
3598
+ Per-campaign-group performance (one row per group per time bucket). Same
3599
+ args and shape as `account()`; each row's `pivot_value` is the campaign
3600
+ group URN.
3601
+
3602
+ #### client.linkedin_ads.campaigns()
3603
+
3604
+ Per-campaign performance (one row per campaign per time bucket). Same
3605
+ args and shape as `account()`; each row's `pivot_value` is the campaign
3606
+ URN. Pair with `list_campaigns()` for names, budgets, and type.
3607
+
3608
+ Example:
3609
+ ```python
3610
+ report = client.linkedin_ads.campaigns(
3611
+ start_date="2026-06-01",
3612
+ end_date="2026-06-30",
3613
+ )
3614
+ for row in report.rows:
3615
+ print(f"{row.pivot_value}: {row.clicks} clicks, "
3616
+ f"{row.cost_in_local_currency:.2f} {report.meta.currency_code}")
3617
+ ```
3618
+
3619
+ #### client.linkedin_ads.creatives()
3620
+
3621
+ Per-creative performance (one row per creative per time bucket). Same
3622
+ args and shape as `account()`; each row's `pivot_value` is the creative
3623
+ URN.
3624
+
3625
+ #### client.linkedin_ads.list_campaigns()
3626
+
3627
+ List the connected account's campaigns with metadata for interpreting
3628
+ the insight rows.
3629
+
3630
+ Signature:
3631
+ ```python
3632
+ result = client.linkedin_ads.list_campaigns()
3633
+ ```
3634
+
3635
+ Returns LinkedInAdsCampaignsResponse with:
3636
+ - rows - List of LinkedInAdsCampaignRow objects
3637
+ - row_count - Number of campaigns returned
3638
+ - meta - LinkedInAdsMeta (source, account_id, currency_code)
3639
+
3640
+ Each LinkedInAdsCampaignRow has:
3641
+ - id / name / status
3642
+ - type - SPONSORED_UPDATES | TEXT_AD | SPONSORED_INMAILS | DYNAMIC
3643
+ - cost_type - CPC | CPM | CPV
3644
+ - daily_budget / total_budget / unit_cost - decimal-string money objects
3645
+ in the account currency (amount + currency_code)
3646
+ - campaign_group - the parent campaign group URN
3647
+ - audience_expansion_enabled / run_schedule / targeting_criteria
3648
+
3649
+ Example:
3650
+ ```python
3651
+ for c in client.linkedin_ads.list_campaigns().rows:
3652
+ print(f"{c.name} [{c.status}] type={c.type}")
3653
+ ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentberlin"
3
- version = "0.166.0"
3
+ version = "0.167.0"
4
4
  description = "Python SDK for Agent Berlin - AI-powered SEO and AEO automation"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -39,7 +39,7 @@ from .exceptions import (
39
39
  )
40
40
  from .utils import get_project_domain
41
41
 
42
- __version__ = "0.166.0"
42
+ __version__ = "0.167.0"
43
43
 
44
44
  __all__ = [
45
45
  "AgentBerlin",
@@ -18,7 +18,7 @@ from .exceptions import (
18
18
  AgentBerlinServerError,
19
19
  )
20
20
 
21
- __version__ = "0.166.0"
21
+ __version__ = "0.167.0"
22
22
  logger = logging.getLogger("agentberlin")
23
23
 
24
24
 
@@ -42,6 +42,7 @@ from .resources.google_trends import GoogleTrendsResource
42
42
  from .resources.gsc import GSCResource
43
43
  from .resources.keywords import KeywordsResource
44
44
  from .resources.linkedin import LinkedInResource
45
+ from .resources.linkedin_ads import LinkedInAdsResource
45
46
  from .resources.market import MarketResource
46
47
  from .resources.microsoft_ads import MicrosoftAdsResource
47
48
  from .resources.pages import PagesResource
@@ -139,6 +140,12 @@ class AgentBerlin:
139
140
  coverage metric, served from Berlin's nightly-synced warehouse.
140
141
  Money values are decimal in `meta.currency_code` (not micros);
141
142
  share-of-voice keeps only ~6 months of history.
143
+ linkedin_ads: LinkedIn Ads (paid social) performance resource
144
+ (read-only) — account/campaign-group/campaign/creative insights
145
+ plus a campaign listing, read live from the connected ad account
146
+ via the /adAnalytics finder. No keywords or search terms;
147
+ `cost_in_local_currency` is decimal in `meta.currency_code`, not
148
+ micros.
142
149
  """
143
150
 
144
151
  def __init__(
@@ -220,6 +227,7 @@ class AgentBerlin:
220
227
  self.google_ads = GoogleAdsResource(self._http)
221
228
  self.openai_ads = OpenAIAdsResource(self._http)
222
229
  self.microsoft_ads = MicrosoftAdsResource(self._http)
230
+ self.linkedin_ads = LinkedInAdsResource(self._http)
223
231
 
224
232
  def __repr__(self) -> str:
225
233
  return f"AgentBerlin(base_url='{self._config.base_url}')"
@@ -144,6 +144,15 @@ from .openai_ads import (
144
144
  OpenAIAdsInsightsResponse,
145
145
  OpenAIAdsMeta,
146
146
  )
147
+ from .linkedin_ads import (
148
+ LinkedInAdsCampaignRow,
149
+ LinkedInAdsCampaignsResponse,
150
+ LinkedInAdsInsightRow,
151
+ LinkedInAdsInsightsResponse,
152
+ LinkedInAdsMeta,
153
+ LinkedInAdsMoney,
154
+ LinkedInAdsRunSchedule,
155
+ )
147
156
  from .google_maps import (
148
157
  GoogleMapsPlace,
149
158
  GoogleMapsReview,
@@ -409,6 +418,13 @@ __all__ = [
409
418
  "OpenAIAdsInsightRow",
410
419
  "OpenAIAdsInsightsResponse",
411
420
  "OpenAIAdsMeta",
421
+ "LinkedInAdsCampaignRow",
422
+ "LinkedInAdsCampaignsResponse",
423
+ "LinkedInAdsInsightRow",
424
+ "LinkedInAdsInsightsResponse",
425
+ "LinkedInAdsMeta",
426
+ "LinkedInAdsMoney",
427
+ "LinkedInAdsRunSchedule",
412
428
  # Google Maps
413
429
  "GoogleMapsPlace",
414
430
  "GoogleMapsReview",
@@ -0,0 +1,128 @@
1
+ """Pydantic models for LinkedIn Ads (Marketing API) responses.
2
+
3
+ LinkedIn Ads reads are served LIVE from the project's connected ad account via
4
+ the /adAnalytics finder (there is no warehouse — the finder is synchronous, and
5
+ LinkedIn is outside the SEM warehouse plan). Insights money
6
+ (``cost_in_local_currency``) is a decimal in the account currency
7
+ (``meta.currency_code``), NOT micros. Campaign budgets are decimal-string money
8
+ objects, also not micros.
9
+
10
+ LinkedIn Ads is paid social: there are no keywords and no search terms. Pivots
11
+ are account / campaign_group / campaign / creative, and ids are URNs kept as
12
+ opaque strings.
13
+ """
14
+
15
+ from typing import Any, List, Optional
16
+
17
+ from pydantic import BaseModel, ConfigDict, Field
18
+
19
+
20
+ class LinkedInAdsMeta(BaseModel):
21
+ """Metadata attached to every LinkedIn Ads response.
22
+
23
+ Attributes:
24
+ source: Where the data came from — always "linkedin_ads".
25
+ account_id: The connected sponsored ad-account id (numeric).
26
+ currency_code: Account currency. Insights ``cost_in_local_currency`` is
27
+ in this currency (not micros).
28
+ """
29
+
30
+ source: str
31
+ account_id: Optional[str] = None
32
+ currency_code: Optional[str] = None
33
+
34
+
35
+ class LinkedInAdsMoney(BaseModel):
36
+ """A LinkedIn money amount — a decimal string in ``currency_code`` (not
37
+ micros)."""
38
+
39
+ model_config = ConfigDict(populate_by_name=True)
40
+
41
+ amount: Optional[str] = None
42
+ currency_code: Optional[str] = Field(default=None, alias="currencyCode")
43
+
44
+
45
+ class LinkedInAdsRunSchedule(BaseModel):
46
+ """An entity's flight window (epoch-millis). ``end`` of 0/None means
47
+ run-until-budget-spent."""
48
+
49
+ start: Optional[int] = None
50
+ end: Optional[int] = None
51
+
52
+
53
+ class LinkedInAdsInsightRow(BaseModel):
54
+ """One /adAnalytics row at the requested pivot.
55
+
56
+ ``pivot_value`` is the URN of the pivoted entity (campaign / creative / ...),
57
+ empty at account pivot. ``start``/``end`` bound the time bucket (YYYY-MM-DD).
58
+ Money is in the account currency, not micros.
59
+ """
60
+
61
+ pivot_value: Optional[str] = None
62
+ start: Optional[str] = None
63
+ end: Optional[str] = None
64
+ impressions: int = 0
65
+ clicks: int = 0
66
+ cost_in_local_currency: float = 0.0
67
+ cost_in_usd: float = 0.0
68
+ conversions: float = 0.0
69
+ one_click_leads: float = 0.0
70
+ likes: int = 0
71
+ comments: int = 0
72
+ shares: int = 0
73
+ follows: int = 0
74
+
75
+
76
+ class LinkedInAdsInsightsResponse(BaseModel):
77
+ """Response from the insights endpoint.
78
+
79
+ Attributes:
80
+ rows: One row per entity at the requested pivot (per time bucket).
81
+ row_count: Number of rows returned.
82
+ meta: Account metadata (see LinkedInAdsMeta). Data is live — there is no
83
+ ``data_as_of`` staleness stamp.
84
+ """
85
+
86
+ rows: List[LinkedInAdsInsightRow] = Field(default_factory=list)
87
+ row_count: int = 0
88
+ meta: LinkedInAdsMeta
89
+
90
+
91
+ class LinkedInAdsCampaignRow(BaseModel):
92
+ """One campaign in the connected ad account.
93
+
94
+ ``type`` is the LinkedIn campaign type (SPONSORED_UPDATES, TEXT_AD, ...);
95
+ ``cost_type`` is CPC / CPM / CPV. Budgets are decimal-string money objects
96
+ in the account currency. ``targeting_criteria`` is the raw Rest.li targeting
97
+ object (kept opaque).
98
+ """
99
+
100
+ id: str
101
+ account: Optional[str] = None
102
+ campaign_group: Optional[str] = None
103
+ name: str
104
+ status: str
105
+ type: Optional[str] = None
106
+ cost_type: Optional[str] = None
107
+ format: Optional[str] = None
108
+ objective_type: Optional[str] = None
109
+ daily_budget: Optional[LinkedInAdsMoney] = None
110
+ total_budget: Optional[LinkedInAdsMoney] = None
111
+ unit_cost: Optional[LinkedInAdsMoney] = None
112
+ audience_expansion_enabled: bool = False
113
+ run_schedule: LinkedInAdsRunSchedule = Field(default_factory=LinkedInAdsRunSchedule)
114
+ targeting_criteria: Optional[Any] = None
115
+
116
+
117
+ class LinkedInAdsCampaignsResponse(BaseModel):
118
+ """Response from the campaigns list endpoint.
119
+
120
+ Attributes:
121
+ rows: The account's campaigns.
122
+ row_count: Number of campaigns returned.
123
+ meta: Account metadata (see LinkedInAdsMeta).
124
+ """
125
+
126
+ rows: List[LinkedInAdsCampaignRow] = Field(default_factory=list)
127
+ row_count: int = 0
128
+ meta: LinkedInAdsMeta
@@ -51,6 +51,7 @@ introduction: |
51
51
  - **google_ads**: `search_terms()`, `keywords()`, `campaigns()`, `ad_groups()`, `landing_pages()`, `coverage()` - Google Ads performance reports (read-only, nightly-synced warehouse; cost values in micros)
52
52
  - **openai_ads**: `account()`, `campaigns()`, `ad_groups()`, `ads()`, `list_campaigns()`, `record_hint_set()`, `list_hint_sets()` - OpenAI (ChatGPT) Ads performance insights read live from the connected Advertiser API key (read-only; six metrics only; no keywords/search terms; insights money in account currency, not micros), plus recording/recall of generated ad-group context_hints for history
53
53
  - **microsoft_ads**: `search_terms()`, `keywords()`, `campaigns()`, `ad_groups()`, `landing_pages()`, `share_of_voice()`, `coverage()` - Microsoft Advertising performance reports (read-only, nightly-synced warehouse; money values are decimal, not micros; share-of-voice keeps ~6 months of history)
54
+ - **linkedin_ads**: `account()`, `campaign_groups()`, `campaigns()`, `creatives()`, `list_campaigns()` - LinkedIn Ads (paid social) performance insights read live from the connected ad account via the /adAnalytics finder (read-only; no keywords/search terms; `cost_in_local_currency` in account currency, not micros)
54
55
 
55
56
  resources:
56
57
  ga4:
@@ -3492,3 +3493,90 @@ resources:
3492
3493
  )
3493
3494
  print(f"Search terms cover {cov.coverage.coverage_ratio:.0%} of spend")
3494
3495
  ```
3496
+
3497
+ linkedin_ads:
3498
+ description: >
3499
+ LinkedIn Ads (paid social) performance for the project's connected ad
3500
+ account: account, campaign-group, campaign, and creative insights, plus a
3501
+ campaign listing. Read-only — the SDK never mutates campaigns or any other
3502
+ account state. Data is read LIVE via LinkedIn's /adAnalytics finder on
3503
+ every call (no warehouse, so no `data_as_of` staleness). This is paid
3504
+ SOCIAL, not paid search: there are NO keywords and NO search terms. Money
3505
+ (`cost_in_local_currency`) is a DECIMAL in the account currency in
3506
+ `meta.currency_code` — not micros, so no division is needed; all IDs are
3507
+ URNs kept as strings.
3508
+ methods:
3509
+ account: |
3510
+ Get account-level performance totals (one row per time bucket).
3511
+
3512
+ Signature:
3513
+ ```python
3514
+ result = client.linkedin_ads.account(
3515
+ start_date="2026-06-01", # Optional: YYYY-MM-DD (default 30 days ago)
3516
+ end_date="2026-06-30", # Optional: YYYY-MM-DD (default today)
3517
+ granularity="daily", # Optional: daily (default) | monthly | yearly | all
3518
+ )
3519
+ ```
3520
+
3521
+ Returns LinkedInAdsInsightsResponse with:
3522
+ - rows - List of LinkedInAdsInsightRow objects
3523
+ - row_count - Number of rows returned
3524
+ - meta - LinkedInAdsMeta (source, account_id, currency_code)
3525
+
3526
+ Each LinkedInAdsInsightRow has:
3527
+ - pivot_value - URN of the pivoted entity (empty at account level)
3528
+ - start / end - Time-bucket bounds (YYYY-MM-DD)
3529
+ - impressions / clicks
3530
+ - cost_in_local_currency / cost_in_usd - decimal, not micros
3531
+ - conversions / one_click_leads / likes / comments / shares / follows
3532
+ campaign_groups: |
3533
+ Per-campaign-group performance (one row per group per time bucket). Same
3534
+ args and shape as `account()`; each row's `pivot_value` is the campaign
3535
+ group URN.
3536
+ campaigns: |
3537
+ Per-campaign performance (one row per campaign per time bucket). Same
3538
+ args and shape as `account()`; each row's `pivot_value` is the campaign
3539
+ URN. Pair with `list_campaigns()` for names, budgets, and type.
3540
+
3541
+ Example:
3542
+ ```python
3543
+ report = client.linkedin_ads.campaigns(
3544
+ start_date="2026-06-01",
3545
+ end_date="2026-06-30",
3546
+ )
3547
+ for row in report.rows:
3548
+ print(f"{row.pivot_value}: {row.clicks} clicks, "
3549
+ f"{row.cost_in_local_currency:.2f} {report.meta.currency_code}")
3550
+ ```
3551
+ creatives: |
3552
+ Per-creative performance (one row per creative per time bucket). Same
3553
+ args and shape as `account()`; each row's `pivot_value` is the creative
3554
+ URN.
3555
+ list_campaigns: |
3556
+ List the connected account's campaigns with metadata for interpreting
3557
+ the insight rows.
3558
+
3559
+ Signature:
3560
+ ```python
3561
+ result = client.linkedin_ads.list_campaigns()
3562
+ ```
3563
+
3564
+ Returns LinkedInAdsCampaignsResponse with:
3565
+ - rows - List of LinkedInAdsCampaignRow objects
3566
+ - row_count - Number of campaigns returned
3567
+ - meta - LinkedInAdsMeta (source, account_id, currency_code)
3568
+
3569
+ Each LinkedInAdsCampaignRow has:
3570
+ - id / name / status
3571
+ - type - SPONSORED_UPDATES | TEXT_AD | SPONSORED_INMAILS | DYNAMIC
3572
+ - cost_type - CPC | CPM | CPV
3573
+ - daily_budget / total_budget / unit_cost - decimal-string money objects
3574
+ in the account currency (amount + currency_code)
3575
+ - campaign_group - the parent campaign group URN
3576
+ - audience_expansion_enabled / run_schedule / targeting_criteria
3577
+
3578
+ Example:
3579
+ ```python
3580
+ for c in client.linkedin_ads.list_campaigns().rows:
3581
+ print(f"{c.name} [{c.status}] type={c.type}")
3582
+ ```
@@ -20,6 +20,7 @@ from .linkedin import LinkedInResource
20
20
  from .market import MarketResource
21
21
  from .openai_ads import OpenAIAdsResource
22
22
  from .microsoft_ads import MicrosoftAdsResource
23
+ from .linkedin_ads import LinkedInAdsResource
23
24
  from .pages import PagesResource
24
25
  from .pagespeed import PageSpeedResource
25
26
  from .posthog import PostHogResource
@@ -46,6 +47,7 @@ __all__ = [
46
47
  "MarketResource",
47
48
  "OpenAIAdsResource",
48
49
  "MicrosoftAdsResource",
50
+ "LinkedInAdsResource",
49
51
  "PagesResource",
50
52
  "PageSpeedResource",
51
53
  "PostHogResource",
@@ -0,0 +1,183 @@
1
+ """LinkedIn Ads (Marketing API) resource for Agent Berlin SDK."""
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+ from .._http import HTTPClient
6
+ from ..models.linkedin_ads import (
7
+ LinkedInAdsCampaignsResponse,
8
+ LinkedInAdsInsightsResponse,
9
+ )
10
+ from ..utils import get_project_domain
11
+
12
+
13
+ class LinkedInAdsResource:
14
+ """Resource for LinkedIn Ads (paid social) performance data (read-only).
15
+
16
+ Serves campaign performance for the project's connected LinkedIn ad account.
17
+ Data is read LIVE from the customer's own connected account via the
18
+ /adAnalytics finder on each call — there is no warehouse, because the finder
19
+ is synchronous. This resource is read-only: the SDK never mutates campaigns
20
+ or any account state.
21
+
22
+ LinkedIn Ads is paid social — there are NO keywords and NO search terms.
23
+ Targeting is job title / function / seniority / industry / geo facets, and
24
+ reporting money (``cost_in_local_currency``) is a decimal in the account
25
+ currency reported in ``meta.currency_code`` (NOT micros).
26
+
27
+ The insights methods mirror the platform's pivots: ``account()``,
28
+ ``campaign_groups()``, ``campaigns()``, and ``creatives()``. Use
29
+ ``list_campaigns()`` to fetch campaign metadata (names, statuses, budgets,
30
+ type) for interpreting the insight rows.
31
+
32
+ Example:
33
+ # Last 30 days of per-campaign performance
34
+ report = client.linkedin_ads.campaigns()
35
+ for row in report.rows:
36
+ print(f"{row.pivot_value}: {row.clicks} clicks, "
37
+ f"{row.cost_in_local_currency:.2f} {report.meta.currency_code}")
38
+ """
39
+
40
+ def __init__(self, http: HTTPClient) -> None:
41
+ self._http = http
42
+
43
+ def _insights(
44
+ self,
45
+ level: str,
46
+ start_date: Optional[str],
47
+ end_date: Optional[str],
48
+ granularity: Optional[str],
49
+ ) -> LinkedInAdsInsightsResponse:
50
+ payload: Dict[str, Any] = {
51
+ "project_domain": get_project_domain(),
52
+ "level": level,
53
+ }
54
+ if start_date is not None:
55
+ payload["start_date"] = start_date
56
+ if end_date is not None:
57
+ payload["end_date"] = end_date
58
+ if granularity is not None:
59
+ payload["granularity"] = granularity
60
+ data = self._http.post("/linkedin-ads/insights", json=payload)
61
+ return LinkedInAdsInsightsResponse.model_validate(data)
62
+
63
+ def account(
64
+ self,
65
+ start_date: Optional[str] = None,
66
+ end_date: Optional[str] = None,
67
+ *,
68
+ granularity: Optional[str] = None,
69
+ ) -> LinkedInAdsInsightsResponse:
70
+ """Get account-level performance totals (one row per time bucket).
71
+
72
+ Read live from the connected LinkedIn ad account.
73
+
74
+ Args:
75
+ start_date: Start date in YYYY-MM-DD format. Defaults to 30 days ago.
76
+ end_date: End date in YYYY-MM-DD format. Defaults to today.
77
+ granularity: Time bucket — daily (default), monthly, yearly, or all.
78
+
79
+ Returns:
80
+ LinkedInAdsInsightsResponse with rows (impressions, clicks,
81
+ cost_in_local_currency, conversions, engagement), row_count, and
82
+ meta. Money is in ``meta.currency_code``, not micros.
83
+
84
+ Raises:
85
+ AgentBerlinNotFoundError: If no LinkedIn Ads integration is connected
86
+ for the domain.
87
+ AgentBerlinAPIError: If the request is invalid or access was revoked
88
+ (reconnect the integration).
89
+ """
90
+ return self._insights("account", start_date, end_date, granularity)
91
+
92
+ def campaign_groups(
93
+ self,
94
+ start_date: Optional[str] = None,
95
+ end_date: Optional[str] = None,
96
+ *,
97
+ granularity: Optional[str] = None,
98
+ ) -> LinkedInAdsInsightsResponse:
99
+ """Get per-campaign-group performance (one row per group per bucket).
100
+
101
+ Read live. Each row's ``pivot_value`` is the campaign group URN.
102
+
103
+ Args:
104
+ start_date: Start date in YYYY-MM-DD format. Defaults to 30 days ago.
105
+ end_date: End date in YYYY-MM-DD format. Defaults to today.
106
+ granularity: Time bucket — daily (default), monthly, yearly, or all.
107
+
108
+ Returns:
109
+ LinkedInAdsInsightsResponse with per-campaign-group rows, row_count,
110
+ and meta.
111
+ """
112
+ return self._insights("campaign_group", start_date, end_date, granularity)
113
+
114
+ def campaigns(
115
+ self,
116
+ start_date: Optional[str] = None,
117
+ end_date: Optional[str] = None,
118
+ *,
119
+ granularity: Optional[str] = None,
120
+ ) -> LinkedInAdsInsightsResponse:
121
+ """Get per-campaign performance (one row per campaign per time bucket).
122
+
123
+ Read live. Each row's ``pivot_value`` is the campaign URN; use
124
+ ``list_campaigns()`` for budgets and type.
125
+
126
+ Args:
127
+ start_date: Start date in YYYY-MM-DD format. Defaults to 30 days ago.
128
+ end_date: End date in YYYY-MM-DD format. Defaults to today.
129
+ granularity: Time bucket — daily (default), monthly, yearly, or all.
130
+
131
+ Returns:
132
+ LinkedInAdsInsightsResponse with per-campaign rows, row_count, and
133
+ meta. Money is in ``meta.currency_code``, not micros.
134
+ """
135
+ return self._insights("campaign", start_date, end_date, granularity)
136
+
137
+ def creatives(
138
+ self,
139
+ start_date: Optional[str] = None,
140
+ end_date: Optional[str] = None,
141
+ *,
142
+ granularity: Optional[str] = None,
143
+ ) -> LinkedInAdsInsightsResponse:
144
+ """Get per-creative performance (one row per creative per time bucket).
145
+
146
+ Read live. Each row's ``pivot_value`` is the creative URN.
147
+
148
+ Args:
149
+ start_date: Start date in YYYY-MM-DD format. Defaults to 30 days ago.
150
+ end_date: End date in YYYY-MM-DD format. Defaults to today.
151
+ granularity: Time bucket — daily (default), monthly, yearly, or all.
152
+
153
+ Returns:
154
+ LinkedInAdsInsightsResponse with per-creative rows, row_count, and
155
+ meta.
156
+ """
157
+ return self._insights("creative", start_date, end_date, granularity)
158
+
159
+ def list_campaigns(self) -> LinkedInAdsCampaignsResponse:
160
+ """List the connected account's campaigns with their metadata.
161
+
162
+ Read live. Returns names, statuses, type, cost type, and budgets — the
163
+ context for interpreting the insights rows (which carry only pivot URNs
164
+ and metrics).
165
+
166
+ Returns:
167
+ LinkedInAdsCampaignsResponse with campaign rows (id, name, status,
168
+ type, cost_type, budgets, ...), row_count, and meta. Budgets are
169
+ decimal-string money objects in ``meta.currency_code``.
170
+
171
+ Raises:
172
+ AgentBerlinNotFoundError: If no LinkedIn Ads integration is connected
173
+ for the domain.
174
+ AgentBerlinAPIError: If access was revoked (reconnect the
175
+ integration).
176
+
177
+ Example:
178
+ for c in client.linkedin_ads.list_campaigns().rows:
179
+ print(f"{c.name} [{c.status}] type={c.type}")
180
+ """
181
+ payload = {"project_domain": get_project_domain()}
182
+ data = self._http.post("/linkedin-ads/campaigns", json=payload)
183
+ return LinkedInAdsCampaignsResponse.model_validate(data)
@@ -0,0 +1,200 @@
1
+ """Tests for LinkedIn Ads (paid social) resource and models."""
2
+
3
+ import os
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ import pytest
7
+
8
+ from agentberlin.models import (
9
+ LinkedInAdsCampaignRow,
10
+ LinkedInAdsCampaignsResponse,
11
+ LinkedInAdsInsightRow,
12
+ LinkedInAdsInsightsResponse,
13
+ LinkedInAdsMeta,
14
+ LinkedInAdsMoney,
15
+ )
16
+ from agentberlin.resources.linkedin_ads import LinkedInAdsResource
17
+
18
+ META = {
19
+ "source": "linkedin_ads",
20
+ "account_id": "512345678",
21
+ "currency_code": "USD",
22
+ }
23
+
24
+
25
+ class TestLinkedInAdsModels:
26
+ """Tests for LinkedIn Ads Pydantic models."""
27
+
28
+ def test_meta(self):
29
+ meta = LinkedInAdsMeta.model_validate(META)
30
+ assert meta.source == "linkedin_ads"
31
+ assert meta.account_id == "512345678"
32
+ assert meta.currency_code == "USD"
33
+
34
+ def test_meta_minimal(self):
35
+ """Only source is required; the rest default to None."""
36
+ meta = LinkedInAdsMeta.model_validate({"source": "linkedin_ads"})
37
+ assert meta.source == "linkedin_ads"
38
+ assert meta.account_id is None
39
+ assert meta.currency_code is None
40
+
41
+ def test_insight_row_full(self):
42
+ row = LinkedInAdsInsightRow.model_validate(
43
+ {
44
+ "pivot_value": "urn:li:sponsoredCampaign:777",
45
+ "start": "2026-07-01",
46
+ "end": "2026-07-01",
47
+ "impressions": 1000,
48
+ "clicks": 25,
49
+ "cost_in_local_currency": 12.5,
50
+ "cost_in_usd": 12.5,
51
+ "conversions": 3,
52
+ "one_click_leads": 1,
53
+ "likes": 4,
54
+ "comments": 2,
55
+ "shares": 1,
56
+ "follows": 0,
57
+ }
58
+ )
59
+ assert row.pivot_value == "urn:li:sponsoredCampaign:777"
60
+ assert row.impressions == 1000
61
+ assert row.cost_in_local_currency == 12.5
62
+ assert row.conversions == 3
63
+
64
+ def test_insight_row_minimal(self):
65
+ """Metrics default to 0 and identity fields to None when omitted."""
66
+ row = LinkedInAdsInsightRow.model_validate({})
67
+ assert row.pivot_value is None
68
+ assert row.impressions == 0
69
+ assert row.cost_in_local_currency == 0.0
70
+
71
+ def test_insights_response_empty_rows(self):
72
+ resp = LinkedInAdsInsightsResponse.model_validate({"row_count": 0, "meta": META})
73
+ assert resp.rows == []
74
+ assert resp.row_count == 0
75
+ assert resp.meta.source == "linkedin_ads"
76
+
77
+ def test_campaign_row_with_money(self):
78
+ row = LinkedInAdsCampaignRow.model_validate(
79
+ {
80
+ "id": "500001",
81
+ "name": "Q3 Brand",
82
+ "status": "ACTIVE",
83
+ "type": "SPONSORED_UPDATES",
84
+ "cost_type": "CPC",
85
+ "daily_budget": {"amount": "50", "currencyCode": "USD"},
86
+ "campaign_group": "urn:li:sponsoredCampaignGroup:9001",
87
+ "audience_expansion_enabled": True,
88
+ "run_schedule": {"start": 1700000000000},
89
+ }
90
+ )
91
+ assert row.id == "500001"
92
+ assert row.type == "SPONSORED_UPDATES"
93
+ assert isinstance(row.daily_budget, LinkedInAdsMoney)
94
+ assert row.daily_budget.amount == "50"
95
+ # currencyCode (camelCase from the Go wire shape) maps onto currency_code.
96
+ assert row.daily_budget.currency_code == "USD"
97
+ assert row.audience_expansion_enabled is True
98
+ assert row.run_schedule.start == 1700000000000
99
+
100
+ def test_campaign_row_defaults(self):
101
+ row = LinkedInAdsCampaignRow.model_validate({"id": "2", "name": "X", "status": "DRAFT"})
102
+ assert row.daily_budget is None
103
+ assert row.audience_expansion_enabled is False
104
+ assert row.run_schedule.start is None
105
+
106
+
107
+ class TestLinkedInAdsResource:
108
+ """Tests for LinkedInAdsResource methods."""
109
+
110
+ @pytest.fixture
111
+ def mock_http(self):
112
+ return MagicMock()
113
+
114
+ @pytest.fixture
115
+ def linkedin_ads_resource(self, mock_http):
116
+ return LinkedInAdsResource(mock_http)
117
+
118
+ @pytest.fixture(autouse=True)
119
+ def mock_project_domain(self):
120
+ with patch.dict(os.environ, {"PROJECT_DOMAIN": "example.com"}):
121
+ yield
122
+
123
+ def _insights_payload(self, mock_http):
124
+ mock_http.post.return_value = {
125
+ "rows": [
126
+ {
127
+ "pivot_value": "urn:li:sponsoredCampaign:777",
128
+ "impressions": 1000,
129
+ "clicks": 25,
130
+ "cost_in_local_currency": 12.5,
131
+ }
132
+ ],
133
+ "row_count": 1,
134
+ "meta": META,
135
+ }
136
+
137
+ def test_account_minimal_omits_optional_params(self, linkedin_ads_resource, mock_http):
138
+ """No dates/granularity => only project_domain + level sent."""
139
+ self._insights_payload(mock_http)
140
+
141
+ result = linkedin_ads_resource.account()
142
+
143
+ mock_http.post.assert_called_once_with(
144
+ "/linkedin-ads/insights",
145
+ json={"project_domain": "example.com", "level": "account"},
146
+ )
147
+ assert len(result.rows) == 1
148
+ assert result.rows[0].pivot_value == "urn:li:sponsoredCampaign:777"
149
+ assert result.meta.currency_code == "USD"
150
+
151
+ def test_campaigns_with_all_params(self, linkedin_ads_resource, mock_http):
152
+ self._insights_payload(mock_http)
153
+
154
+ linkedin_ads_resource.campaigns(
155
+ start_date="2026-07-01",
156
+ end_date="2026-07-31",
157
+ granularity="daily",
158
+ )
159
+
160
+ mock_http.post.assert_called_once_with(
161
+ "/linkedin-ads/insights",
162
+ json={
163
+ "project_domain": "example.com",
164
+ "level": "campaign",
165
+ "start_date": "2026-07-01",
166
+ "end_date": "2026-07-31",
167
+ "granularity": "daily",
168
+ },
169
+ )
170
+
171
+ def test_pivot_levels(self, linkedin_ads_resource, mock_http):
172
+ """Each insights method sends its own pivot level."""
173
+ for method, level in [
174
+ (linkedin_ads_resource.account, "account"),
175
+ (linkedin_ads_resource.campaign_groups, "campaign_group"),
176
+ (linkedin_ads_resource.campaigns, "campaign"),
177
+ (linkedin_ads_resource.creatives, "creative"),
178
+ ]:
179
+ self._insights_payload(mock_http)
180
+ mock_http.post.reset_mock()
181
+ method()
182
+ _, kwargs = mock_http.post.call_args
183
+ assert kwargs["json"]["level"] == level
184
+
185
+ def test_list_campaigns(self, linkedin_ads_resource, mock_http):
186
+ mock_http.post.return_value = {
187
+ "rows": [{"id": "1", "name": "C", "status": "ACTIVE", "type": "SPONSORED_UPDATES"}],
188
+ "row_count": 1,
189
+ "meta": META,
190
+ }
191
+
192
+ result = linkedin_ads_resource.list_campaigns()
193
+
194
+ mock_http.post.assert_called_once_with(
195
+ "/linkedin-ads/campaigns",
196
+ json={"project_domain": "example.com"},
197
+ )
198
+ assert isinstance(result, LinkedInAdsCampaignsResponse)
199
+ assert result.rows[0].name == "C"
200
+ assert result.rows[0].type == "SPONSORED_UPDATES"
File without changes
File without changes