strapi-kit 0.0.2__py3-none-any.whl → 0.0.3__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.
@@ -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,98 @@ 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 -es for words ending in s, x, z, ch, sh (classes -> class)
150
+ if name.endswith("es"):
151
+ # Check if removing -es gives a word ending in s, x, z, ch, sh
152
+ base = name[:-2]
153
+ if base.endswith(("s", "x", "z", "ch", "sh")):
154
+ return base
155
+ # Also handle -ses, -zes (buses -> bus, quizzes -> quiz)
156
+ if name.endswith("ses") or name.endswith("zes"):
157
+ return name[:-2]
158
+
159
+ # Handle standard -s removal (articles -> article)
160
+ if name.endswith("s") and len(name) > 1:
161
+ return name[:-1]
162
+
163
+ # Already singular or unrecognized
164
+ return name
165
+
166
+
167
+ def uid_to_admin_url(
168
+ uid: str,
169
+ base_url: str,
170
+ kind: str = "collectionType",
171
+ ) -> str:
172
+ """Build Strapi admin panel URL from content type UID.
173
+
174
+ Args:
175
+ uid: Content type UID (e.g., "api::article.article")
176
+ base_url: Strapi base URL (e.g., "http://localhost:1337")
177
+ kind: Content type kind - "collectionType" or "singleType"
178
+
179
+ Returns:
180
+ Admin panel URL for the content type
181
+
182
+ Examples:
183
+ >>> uid_to_admin_url("api::article.article", "http://localhost:1337")
184
+ 'http://localhost:1337/admin/content-manager/collection-types/api::article.article'
185
+
186
+ >>> uid_to_admin_url(
187
+ ... "api::homepage.homepage",
188
+ ... "http://localhost:1337",
189
+ ... kind="singleType"
190
+ ... )
191
+ 'http://localhost:1337/admin/content-manager/single-types/api::homepage.homepage'
192
+ """
193
+ # Remove trailing slash from base_url
194
+ base_url = base_url.rstrip("/")
195
+
196
+ # Determine the path segment based on kind
197
+ if kind == "singleType":
198
+ type_segment = "single-types"
199
+ else:
200
+ type_segment = "collection-types"
201
+
202
+ return f"{base_url}/admin/content-manager/{type_segment}/{uid}"
203
+
204
+
205
+ # Alias for clarity - uid_to_endpoint already exists, this makes the intent explicit
206
+ uid_to_api_id = uid_to_endpoint