strapi-kit 0.0.2__py3-none-any.whl → 0.0.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,6 +36,7 @@ from __future__ import annotations
36
36
  import copy
37
37
  from typing import Any
38
38
 
39
+ from strapi_kit.exceptions import ValidationError
39
40
  from strapi_kit.models.enums import PublicationState, SortDirection
40
41
  from strapi_kit.models.request.fields import FieldSelection
41
42
  from strapi_kit.models.request.filters import FilterBuilder
@@ -220,7 +221,9 @@ class StrapiQuery:
220
221
  Self for method chaining
221
222
 
222
223
  Raises:
223
- ValueError: If mixing page-based and offset-based parameters
224
+ strapi_kit.exceptions.ValidationError: If mixing page-based and
225
+ offset-based parameters, or if pagination values are invalid
226
+ (page < 1, page_size < 1, start < 0, limit < 1)
224
227
 
225
228
  Examples:
226
229
  >>> # Page-based
@@ -234,15 +237,15 @@ class StrapiQuery:
234
237
  offset_based = start is not None or limit is not None
235
238
 
236
239
  if page_based and offset_based:
237
- raise ValueError("Cannot mix page-based and offset-based pagination")
240
+ raise ValidationError("Cannot mix page-based and offset-based pagination")
238
241
 
239
242
  if page_based:
240
243
  # Validate page value if explicitly provided
241
244
  if page is not None and page < 1:
242
- raise ValueError("page must be >= 1")
245
+ raise ValidationError("page must be >= 1")
243
246
  # Validate page_size value if explicitly provided
244
247
  if page_size is not None and page_size < 1:
245
- raise ValueError("page_size must be >= 1")
248
+ raise ValidationError("page_size must be >= 1")
246
249
  self._pagination = PagePagination(
247
250
  page=1 if page is None else page,
248
251
  page_size=25 if page_size is None else page_size,
@@ -251,10 +254,10 @@ class StrapiQuery:
251
254
  elif offset_based:
252
255
  # Validate start value if explicitly provided
253
256
  if start is not None and start < 0:
254
- raise ValueError("start must be >= 0")
257
+ raise ValidationError("start must be >= 0")
255
258
  # Validate limit value if explicitly provided
256
259
  if limit is not None and limit < 1:
257
- raise ValueError("limit must be >= 1")
260
+ raise ValidationError("limit must be >= 1")
258
261
  self._pagination = OffsetPagination(
259
262
  start=0 if start is None else start,
260
263
  limit=25 if limit is None else limit,
@@ -5,10 +5,12 @@ and response normalization across sync and async clients.
5
5
  """
6
6
 
7
7
  import json
8
+ import mimetypes
8
9
  from pathlib import Path
9
10
  from typing import IO, Any, Literal
10
11
  from urllib.parse import urljoin, urlparse
11
12
 
13
+ from strapi_kit.exceptions import MediaError
12
14
  from strapi_kit.models.response.media import MediaFile
13
15
 
14
16
 
@@ -43,19 +45,24 @@ class UploadPayload:
43
45
  self._file_handle: IO[bytes] | None = None
44
46
 
45
47
  @property
46
- def files_tuple(self) -> tuple[str, IO[bytes], None]:
48
+ def files_tuple(self) -> tuple[str, IO[bytes], str]:
47
49
  """Get the files tuple for httpx multipart upload.
48
50
 
49
51
  Returns:
50
- Tuple of (filename, file_handle, content_type)
51
- Content type is None to let httpx auto-detect MIME type.
52
+ Tuple of (filename, file_handle, mime_type) where:
53
+ - filename: The actual file name from the path
54
+ - file_handle: Open file handle for reading
55
+ - mime_type: Detected MIME type or 'application/octet-stream' as fallback
52
56
 
53
57
  Raises:
54
- RuntimeError: If accessed outside of context manager
58
+ MediaError: If accessed outside of context manager
55
59
  """
56
60
  if self._file_handle is None:
57
- raise RuntimeError("UploadPayload must be used as a context manager")
58
- return ("file", self._file_handle, None)
61
+ raise MediaError("UploadPayload must be used as a context manager")
62
+ filename = self._file_path.name
63
+ mime_type, _ = mimetypes.guess_type(filename)
64
+ mime_type = mime_type or "application/octet-stream"
65
+ return (filename, self._file_handle, mime_type)
59
66
 
60
67
  @property
61
68
  def data(self) -> dict[str, Any] | None:
@@ -7,6 +7,7 @@ allowing memory-efficient iteration over large datasets.
7
7
  from collections.abc import AsyncGenerator, Generator
8
8
  from typing import TYPE_CHECKING
9
9
 
10
+ from ..exceptions import ValidationError
10
11
  from ..models import StrapiQuery
11
12
  from ..models.response.normalized import NormalizedEntity
12
13
 
@@ -36,7 +37,7 @@ def stream_entities(
36
37
  NormalizedEntity objects one at a time
37
38
 
38
39
  Raises:
39
- ValueError: If page_size < 1
40
+ ValidationError: If page_size < 1
40
41
 
41
42
  Example:
42
43
  >>> with SyncClient(config) as client:
@@ -45,7 +46,7 @@ def stream_entities(
45
46
  ... # Process one at a time without loading all into memory
46
47
  """
47
48
  if page_size < 1:
48
- raise ValueError("page_size must be >= 1")
49
+ raise ValidationError("page_size must be >= 1")
49
50
 
50
51
  current_page = 1
51
52
 
@@ -100,7 +101,7 @@ async def stream_entities_async(
100
101
  NormalizedEntity objects one at a time
101
102
 
102
103
  Raises:
103
- ValueError: If page_size < 1
104
+ ValidationError: If page_size < 1
104
105
 
105
106
  Example:
106
107
  >>> async with AsyncClient(config) as client:
@@ -109,7 +110,7 @@ async def stream_entities_async(
109
110
  ... # Process asynchronously without loading all into memory
110
111
  """
111
112
  if page_size < 1:
112
- raise ValueError("page_size must be >= 1")
113
+ raise ValidationError("page_size must be >= 1")
113
114
 
114
115
  current_page = 1
115
116
 
@@ -3,13 +3,32 @@
3
3
  This package contains helper utilities including:
4
4
  - Rate limiting
5
5
  - UID handling
6
+ - SEO detection
6
7
  """
7
8
 
8
9
  from strapi_kit.utils.rate_limiter import AsyncTokenBucketRateLimiter, TokenBucketRateLimiter
9
- from strapi_kit.utils.uid import uid_to_endpoint
10
+ from strapi_kit.utils.seo import SEOConfiguration, detect_seo_configuration
11
+ from strapi_kit.utils.uid import (
12
+ api_id_to_singular,
13
+ extract_model_name,
14
+ is_api_content_type,
15
+ uid_to_admin_url,
16
+ uid_to_api_id,
17
+ uid_to_endpoint,
18
+ )
10
19
 
11
20
  __all__ = [
21
+ # Rate limiting
12
22
  "TokenBucketRateLimiter",
13
23
  "AsyncTokenBucketRateLimiter",
24
+ # UID utilities
14
25
  "uid_to_endpoint",
26
+ "uid_to_api_id",
27
+ "api_id_to_singular",
28
+ "uid_to_admin_url",
29
+ "extract_model_name",
30
+ "is_api_content_type",
31
+ # SEO utilities
32
+ "detect_seo_configuration",
33
+ "SEOConfiguration",
15
34
  ]
@@ -9,6 +9,8 @@ import logging
9
9
  import threading
10
10
  import time
11
11
 
12
+ from strapi_kit.exceptions import ValidationError
13
+
12
14
  logger = logging.getLogger(__name__)
13
15
 
14
16
 
@@ -37,7 +39,7 @@ class TokenBucketRateLimiter:
37
39
  capacity: Bucket capacity (defaults to rate for 1 second burst)
38
40
  """
39
41
  if rate <= 0:
40
- raise ValueError("Rate must be positive")
42
+ raise ValidationError("Rate must be positive")
41
43
 
42
44
  self._rate = rate
43
45
  self._capacity = capacity if capacity is not None else rate
@@ -121,7 +123,7 @@ class AsyncTokenBucketRateLimiter:
121
123
  capacity: Bucket capacity (defaults to rate for 1 second burst)
122
124
  """
123
125
  if rate <= 0:
124
- raise ValueError("Rate must be positive")
126
+ raise ValidationError("Rate must be positive")
125
127
 
126
128
  self._rate = rate
127
129
  self._capacity = capacity if capacity is not None else rate
@@ -0,0 +1,294 @@
1
+ """SEO configuration detection utilities.
2
+
3
+ This module provides functions for detecting SEO configurations
4
+ in Strapi content type schemas.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Literal
9
+
10
+ from ..models.schema import ContentTypeSchema
11
+
12
+
13
+ @dataclass
14
+ class SEOConfiguration:
15
+ """SEO configuration detected from a content type schema.
16
+
17
+ Attributes:
18
+ has_seo: Whether SEO configuration was detected
19
+ seo_type: Type of SEO setup - "component" (shared SEO component),
20
+ "flat" (individual fields), or None if not detected
21
+ seo_field_name: Name of the SEO field/component (e.g., "seo", "meta")
22
+ seo_component_uid: Component UID if seo_type is "component"
23
+ fields: Mapping of SEO purpose to field path
24
+ (e.g., {"title": "seo.metaTitle", "description": "seo.metaDescription"})
25
+ """
26
+
27
+ has_seo: bool = False
28
+ seo_type: Literal["component", "flat"] | None = None
29
+ seo_field_name: str | None = None
30
+ seo_component_uid: str | None = None
31
+ fields: dict[str, str] = field(default_factory=dict)
32
+
33
+
34
+ # Common SEO field name patterns (case-insensitive)
35
+ _SEO_COMPONENT_NAMES = {"seo", "meta", "metadata", "metatags", "seometa"}
36
+
37
+ # Common SEO field patterns for component detection
38
+ _SEO_COMPONENT_UIDS = {
39
+ "shared.seo",
40
+ "seo.seo",
41
+ "shared.meta",
42
+ "shared.metadata",
43
+ }
44
+
45
+ # Flat SEO field patterns (field_name -> purpose)
46
+ _FLAT_SEO_FIELD_PATTERNS: dict[str, str] = {
47
+ "metatitle": "title",
48
+ "meta_title": "title",
49
+ "seotitle": "title",
50
+ "seo_title": "title",
51
+ "ogtitle": "og_title",
52
+ "og_title": "og_title",
53
+ "metadescription": "description",
54
+ "meta_description": "description",
55
+ "seodescription": "description",
56
+ "seo_description": "description",
57
+ "ogdescription": "og_description",
58
+ "og_description": "og_description",
59
+ "metakeywords": "keywords",
60
+ "meta_keywords": "keywords",
61
+ "seokeywords": "keywords",
62
+ "seo_keywords": "keywords",
63
+ "metaimage": "image",
64
+ "meta_image": "image",
65
+ "seoimage": "image",
66
+ "seo_image": "image",
67
+ "ogimage": "og_image",
68
+ "og_image": "og_image",
69
+ "canonicalurl": "canonical_url",
70
+ "canonical_url": "canonical_url",
71
+ "canonical": "canonical_url",
72
+ "noindex": "no_index",
73
+ "no_index": "no_index",
74
+ "nofollow": "no_follow",
75
+ "no_follow": "no_follow",
76
+ "robots": "robots",
77
+ }
78
+
79
+
80
+ def detect_seo_configuration(
81
+ schema: ContentTypeSchema | dict[str, Any],
82
+ ) -> SEOConfiguration:
83
+ """Detect SEO configuration in a content type schema.
84
+
85
+ Analyzes the schema to detect:
86
+ 1. SEO components (shared.seo, seo.seo, etc.)
87
+ 2. Flat SEO fields (metaTitle, meta_description, etc.)
88
+
89
+ Args:
90
+ schema: Content type schema (ContentTypeSchema or raw dict)
91
+
92
+ Returns:
93
+ SEOConfiguration with detection results
94
+
95
+ Examples:
96
+ >>> # Schema with SEO component
97
+ >>> schema = ContentTypeSchema(
98
+ ... uid="api::article.article",
99
+ ... display_name="Article",
100
+ ... fields={
101
+ ... "seo": FieldSchema(type=FieldType.COMPONENT),
102
+ ... }
103
+ ... )
104
+ >>> config = detect_seo_configuration(schema)
105
+ >>> config.has_seo
106
+ True
107
+ >>> config.seo_type
108
+ 'component'
109
+
110
+ >>> # Schema with flat SEO fields
111
+ >>> schema_dict = {
112
+ ... "uid": "api::page.page",
113
+ ... "attributes": {
114
+ ... "metaTitle": {"type": "string"},
115
+ ... "metaDescription": {"type": "text"},
116
+ ... }
117
+ ... }
118
+ >>> config = detect_seo_configuration(schema_dict)
119
+ >>> config.has_seo
120
+ True
121
+ >>> config.seo_type
122
+ 'flat'
123
+ """
124
+ config = SEOConfiguration()
125
+
126
+ # Extract attributes from schema
127
+ if isinstance(schema, ContentTypeSchema):
128
+ attributes = {name: _field_to_dict(field) for name, field in schema.fields.items()}
129
+ elif isinstance(schema, dict):
130
+ # Handle both {"fields": ...} and {"attributes": ...} formats
131
+ attributes = schema.get("attributes") or schema.get("fields") or {}
132
+ else:
133
+ return config
134
+
135
+ # First, try to detect SEO component
136
+ component_result = _detect_seo_component(attributes)
137
+ if component_result:
138
+ config.has_seo = True
139
+ config.seo_type = "component"
140
+ config.seo_field_name = component_result["field_name"]
141
+ config.seo_component_uid = component_result.get("component_uid")
142
+ config.fields = component_result.get("fields", {})
143
+ return config
144
+
145
+ # If no component, look for flat SEO fields
146
+ flat_result = _detect_flat_seo_fields(attributes)
147
+ if flat_result:
148
+ config.has_seo = True
149
+ config.seo_type = "flat"
150
+ config.fields = flat_result
151
+ return config
152
+
153
+ return config
154
+
155
+
156
+ def _field_to_dict(field_schema: Any) -> dict[str, Any]:
157
+ """Convert a FieldSchema to dict for processing.
158
+
159
+ Args:
160
+ field_schema: FieldSchema instance
161
+
162
+ Returns:
163
+ Dictionary representation
164
+ """
165
+ if hasattr(field_schema, "type"):
166
+ result: dict[str, Any] = {"type": field_schema.type.value}
167
+ if hasattr(field_schema, "target") and field_schema.target:
168
+ result["component"] = field_schema.target
169
+ return result
170
+ return {}
171
+
172
+
173
+ def _detect_seo_component(attributes: dict[str, Any]) -> dict[str, Any] | None:
174
+ """Detect SEO component in attributes.
175
+
176
+ Args:
177
+ attributes: Field attributes dictionary
178
+
179
+ Returns:
180
+ Detection result or None if not found
181
+ """
182
+ for field_name, field_config in attributes.items():
183
+ # Check if field type is component
184
+ field_type = _get_field_type(field_config)
185
+ if field_type != "component":
186
+ continue
187
+
188
+ # Check if field name suggests SEO
189
+ field_name_lower = field_name.lower()
190
+ is_seo_name = field_name_lower in _SEO_COMPONENT_NAMES
191
+
192
+ # Check if component UID suggests SEO
193
+ component_uid = _get_component_uid(field_config)
194
+ is_seo_component = False
195
+ if component_uid:
196
+ component_uid_lower = component_uid.lower()
197
+ is_seo_component = (
198
+ any(seo_uid in component_uid_lower for seo_uid in _SEO_COMPONENT_UIDS)
199
+ or "seo" in component_uid_lower
200
+ )
201
+
202
+ if is_seo_name or is_seo_component:
203
+ # Build field mappings based on common SEO component structure
204
+ fields = _build_component_field_mappings(field_name)
205
+ return {
206
+ "field_name": field_name,
207
+ "component_uid": component_uid,
208
+ "fields": fields,
209
+ }
210
+
211
+ return None
212
+
213
+
214
+ def _detect_flat_seo_fields(attributes: dict[str, Any]) -> dict[str, str] | None:
215
+ """Detect flat SEO fields in attributes.
216
+
217
+ Args:
218
+ attributes: Field attributes dictionary
219
+
220
+ Returns:
221
+ Field mappings or None if not found
222
+ """
223
+ fields: dict[str, str] = {}
224
+
225
+ for field_name, _field_config in attributes.items():
226
+ field_name_lower = field_name.lower().replace("-", "_")
227
+
228
+ # Check if field name matches known SEO patterns
229
+ if field_name_lower in _FLAT_SEO_FIELD_PATTERNS:
230
+ purpose = _FLAT_SEO_FIELD_PATTERNS[field_name_lower]
231
+ fields[purpose] = field_name
232
+
233
+ return fields if fields else None
234
+
235
+
236
+ def _get_field_type(field_config: Any) -> str | None:
237
+ """Extract field type from field configuration.
238
+
239
+ Args:
240
+ field_config: Field configuration (dict or object)
241
+
242
+ Returns:
243
+ Field type string or None
244
+ """
245
+ if isinstance(field_config, dict):
246
+ return field_config.get("type")
247
+ if hasattr(field_config, "type"):
248
+ field_type = field_config.type
249
+ return field_type.value if hasattr(field_type, "value") else str(field_type)
250
+ return None
251
+
252
+
253
+ def _get_component_uid(field_config: Any) -> str | None:
254
+ """Extract component UID from field configuration.
255
+
256
+ Args:
257
+ field_config: Field configuration (dict or object)
258
+
259
+ Returns:
260
+ Component UID string or None
261
+ """
262
+ if isinstance(field_config, dict):
263
+ component = field_config.get("component")
264
+ return str(component) if component else None
265
+ if hasattr(field_config, "target"):
266
+ target = field_config.target
267
+ return str(target) if target else None
268
+ return None
269
+
270
+
271
+ def _build_component_field_mappings(field_name: str) -> dict[str, str]:
272
+ """Build standard field mappings for an SEO component.
273
+
274
+ Args:
275
+ field_name: Name of the SEO component field
276
+
277
+ Returns:
278
+ Field mappings with component prefix
279
+ """
280
+ return {
281
+ "title": f"{field_name}.metaTitle",
282
+ "description": f"{field_name}.metaDescription",
283
+ "keywords": f"{field_name}.keywords",
284
+ "image": f"{field_name}.metaImage",
285
+ "canonical_url": f"{field_name}.canonicalURL",
286
+ "og_title": f"{field_name}.ogTitle",
287
+ "og_description": f"{field_name}.ogDescription",
288
+ "og_image": f"{field_name}.ogImage",
289
+ "twitter_title": f"{field_name}.twitterTitle",
290
+ "twitter_description": f"{field_name}.twitterDescription",
291
+ "twitter_image": f"{field_name}.twitterImage",
292
+ "robots": f"{field_name}.robots",
293
+ "structured_data": f"{field_name}.structuredData",
294
+ }
strapi_kit/utils/uid.py CHANGED
@@ -4,6 +4,29 @@ This module provides centralized functions for handling Strapi content type UIDs
4
4
  including conversion to API endpoints with proper pluralization.
5
5
  """
6
6
 
7
+ # Irregular plurals mapping (plural -> singular)
8
+ _IRREGULAR_PLURALS: dict[str, str] = {
9
+ "people": "person",
10
+ "children": "child",
11
+ "men": "man",
12
+ "women": "woman",
13
+ "feet": "foot",
14
+ "teeth": "tooth",
15
+ "geese": "goose",
16
+ "mice": "mouse",
17
+ "oxen": "ox",
18
+ "indices": "index",
19
+ "matrices": "matrix",
20
+ "vertices": "vertex",
21
+ "analyses": "analysis",
22
+ "crises": "crisis",
23
+ "theses": "thesis",
24
+ "phenomena": "phenomenon",
25
+ "criteria": "criterion",
26
+ "data": "datum",
27
+ "media": "medium",
28
+ }
29
+
7
30
 
8
31
  def uid_to_endpoint(uid: str) -> str:
9
32
  """Convert content type UID to API endpoint.
@@ -86,3 +109,109 @@ def is_api_content_type(uid: str) -> bool:
86
109
  False
87
110
  """
88
111
  return uid.startswith("api::")
112
+
113
+
114
+ def api_id_to_singular(api_id: str) -> str:
115
+ """Convert plural API ID to singular form.
116
+
117
+ Handles common English pluralization patterns and irregular plurals.
118
+ For custom pluralization, you may need to handle edge cases manually.
119
+
120
+ Args:
121
+ api_id: Plural API ID (e.g., "articles", "categories", "people")
122
+
123
+ Returns:
124
+ Singular form (e.g., "article", "category", "person")
125
+
126
+ Examples:
127
+ >>> api_id_to_singular("articles")
128
+ 'article'
129
+ >>> api_id_to_singular("categories")
130
+ 'category'
131
+ >>> api_id_to_singular("classes")
132
+ 'class'
133
+ >>> api_id_to_singular("people")
134
+ 'person'
135
+ >>> api_id_to_singular("children")
136
+ 'child'
137
+ """
138
+ # Normalize to lowercase for comparison
139
+ name = api_id.lower()
140
+
141
+ # Check irregular plurals first
142
+ if name in _IRREGULAR_PLURALS:
143
+ return _IRREGULAR_PLURALS[name]
144
+
145
+ # Handle -ies -> -y (categories -> category)
146
+ if name.endswith("ies"):
147
+ return name[:-3] + "y"
148
+
149
+ # Handle -zzes specifically
150
+ # Words with single z double it when pluralized: quiz -> quizzes (remove -zes, keep 1 z)
151
+ # Words with double z just add es: buzz -> buzzes, fizz -> fizzes (remove -es, keep zz)
152
+ if name.endswith("zzes"):
153
+ # Common double-z words: buzz, fizz, fuzz, jazz, razz, etc. (pattern: consonant + vowel + zz)
154
+ # Common single-z words that double: quiz, whiz (pattern: vowel + i + z or similar)
155
+ # Heuristic: 4-letter bases (buzz, fizz, jazz, fuzz) become 6-letter plurals
156
+ # 4-letter bases like quiz become 7-letter plurals
157
+ # So length 6 -> likely double-z base (remove -es)
158
+ # length 7+ -> likely single-z base that was doubled (remove -zes)
159
+ if len(name) <= 6:
160
+ return name[:-2] # buzzes -> buzz, fizzes -> fizz
161
+ else:
162
+ return name[:-3] # quizzes -> quiz, whizzes -> whiz
163
+
164
+ # Handle -es for words ending in s, x, z, ch, sh (classes -> class, buses -> bus)
165
+ if name.endswith("es"):
166
+ base = name[:-2]
167
+ if base.endswith(("s", "x", "z", "ch", "sh")):
168
+ return base
169
+
170
+ # Handle standard -s removal (articles -> article)
171
+ if name.endswith("s") and len(name) > 1:
172
+ return name[:-1]
173
+
174
+ # Already singular or unrecognized
175
+ return name
176
+
177
+
178
+ def uid_to_admin_url(
179
+ uid: str,
180
+ base_url: str,
181
+ kind: str = "collectionType",
182
+ ) -> str:
183
+ """Build Strapi admin panel URL from content type UID.
184
+
185
+ Args:
186
+ uid: Content type UID (e.g., "api::article.article")
187
+ base_url: Strapi base URL (e.g., "http://localhost:1337")
188
+ kind: Content type kind - "collectionType" or "singleType"
189
+
190
+ Returns:
191
+ Admin panel URL for the content type
192
+
193
+ Examples:
194
+ >>> uid_to_admin_url("api::article.article", "http://localhost:1337")
195
+ 'http://localhost:1337/admin/content-manager/collection-types/api::article.article'
196
+
197
+ >>> uid_to_admin_url(
198
+ ... "api::homepage.homepage",
199
+ ... "http://localhost:1337",
200
+ ... kind="singleType"
201
+ ... )
202
+ 'http://localhost:1337/admin/content-manager/single-types/api::homepage.homepage'
203
+ """
204
+ # Remove trailing slash from base_url
205
+ base_url = base_url.rstrip("/")
206
+
207
+ # Determine the path segment based on kind
208
+ if kind == "singleType":
209
+ type_segment = "single-types"
210
+ else:
211
+ type_segment = "collection-types"
212
+
213
+ return f"{base_url}/admin/content-manager/{type_segment}/{uid}"
214
+
215
+
216
+ # Alias for clarity - uid_to_endpoint already exists, this makes the intent explicit
217
+ uid_to_api_id = uid_to_endpoint