moosey-cms 0.3.0__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.
@@ -0,0 +1 @@
1
+ 3.12
moosey_cms/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """
2
+ Copyright (c) 2026 Anthony Mugendi
3
+
4
+ This software is released under the MIT License.
5
+ https://opensource.org/licenses/MIT
6
+ """
7
+
8
+
9
+
10
+ from .main import init_cms
11
+
12
+ from .cache import *
moosey_cms/cache.py ADDED
@@ -0,0 +1,68 @@
1
+ """
2
+ Copyright (c) 2026 Anthony Mugendi
3
+ This software is released under the MIT License.
4
+ """
5
+
6
+ from cachetools import TTLCache, cached
7
+ from cachetools.keys import hashkey
8
+ from functools import wraps
9
+
10
+ # Cache with TTL of 30 days
11
+ cache = TTLCache(maxsize=1000, ttl=3600 * 24 * 30)
12
+
13
+ def clear_cache():
14
+ cache.clear()
15
+
16
+
17
+ # clear once even for multiple filess
18
+ @cached(TTLCache(maxsize=100, ttl=5))
19
+ def clear_cache_on_file_change(file_path, event_type):
20
+ # print(f"File {file_path} changed.")
21
+ clear_cache()
22
+
23
+ # --- HELPER: Convert mutable types to immutable (hashable) ---
24
+ def make_hashable(value):
25
+ """
26
+ Recursively converts dictionaries to sorted tuples of items,
27
+ and lists to tuples. This allows them to be hashed for caching.
28
+ """
29
+ if isinstance(value, dict):
30
+ return tuple(sorted((k, make_hashable(v)) for k, v in value.items()))
31
+ elif isinstance(value, (list, set)):
32
+ return tuple(make_hashable(v) for v in value)
33
+ return value
34
+
35
+ def cache_fn(cache=cache, debug=True, exclude_args=None):
36
+ """
37
+ exclude_args: list of argument indices or keyword names to ignore
38
+ """
39
+ if exclude_args is None:
40
+ exclude_args = ['templates']
41
+
42
+ def decorator(func):
43
+ @wraps(func)
44
+ def wrapper(*args, **kwargs):
45
+ # Filter args for hashing
46
+ args_to_hash = tuple(
47
+ make_hashable(a) for i, a in enumerate(args)
48
+ if i not in exclude_args
49
+ )
50
+ # Filter kwargs for hashing (e.g. ignore 'templates')
51
+ kwargs_to_hash = {
52
+ k: make_hashable(v) for k, v in kwargs.items()
53
+ if k not in exclude_args
54
+ }
55
+
56
+ key = hashkey(*args_to_hash, **kwargs_to_hash)
57
+
58
+ if key in cache:
59
+ if debug:
60
+ print(' '*4, f'> Cache Hit For: "{func.__name__}"')
61
+ return cache[key]
62
+
63
+ result = func(*args, **kwargs)
64
+ cache[key] = result
65
+ return result
66
+ return wrapper
67
+ return decorator
68
+
@@ -0,0 +1,31 @@
1
+ from watchdog.observers import Observer
2
+ from watchdog.events import FileSystemEventHandler
3
+
4
+
5
+ class FileChangeHandler(FileSystemEventHandler):
6
+ def __init__(self, callback):
7
+ self.callback = callback
8
+ self.ts =0
9
+
10
+ def on_any_event(self, event):
11
+ if event.is_directory:
12
+ return
13
+
14
+ if event.event_type == "modified" or event.event_type == "created":
15
+ # Trigger the callback
16
+ self.callback(event.src_path, event.event_type)
17
+
18
+
19
+ def start_watching(path, callback):
20
+ """
21
+ Starts the watcher in a background thread and returns immediately.
22
+ """
23
+ event_handler = FileChangeHandler(callback)
24
+ observer = Observer()
25
+ observer.schedule(event_handler, path, recursive=True)
26
+
27
+ # This spawns a new thread, so it won't block your main code
28
+ observer.start()
29
+
30
+ # Return the observer so you can stop it nicely on server shutdown
31
+ return observer
moosey_cms/filters.py ADDED
@@ -0,0 +1,522 @@
1
+ """
2
+ Jinja2 template filters for content management.
3
+ Usage: Import and register with Jinja2Templates environment.
4
+ """
5
+
6
+ from datetime import datetime
7
+ from typing import Any
8
+ import re
9
+ import math
10
+
11
+ from .seo import seo_tags
12
+
13
+ # ============================================================================
14
+ # DATE & TIME FILTERS
15
+ # ============================================================================
16
+
17
+ def fancy_date(dt):
18
+ """Format date as '13th Jan, 2026 at 6:00 PM'"""
19
+ if not dt:
20
+ return ""
21
+
22
+ day = dt.day
23
+ if 10 <= day % 100 <= 20:
24
+ suffix = 'th'
25
+ else:
26
+ suffix = {1: 'st', 2: 'nd', 3: 'rd'}.get(day % 10, 'th')
27
+
28
+ formatted = dt.strftime(f'%d{suffix} %b, %Y at %I:%M %p')
29
+ # Remove leading zero from hour if present
30
+ parts = formatted.split('at ')
31
+ if len(parts) == 2 and parts[1][0] == '0':
32
+ formatted = parts[0] + 'at ' + parts[1][1:]
33
+ return formatted
34
+
35
+
36
+ def short_date(dt):
37
+ """Format date as 'Jan 13, 2026'"""
38
+ if not dt:
39
+ return ""
40
+ return dt.strftime('%b %d, %Y')
41
+
42
+
43
+ def iso_date(dt):
44
+ """Format date as '2026-01-13'"""
45
+ if not dt:
46
+ return ""
47
+ return dt.strftime('%Y-%m-%d')
48
+
49
+
50
+ def relative_time(dt):
51
+ """Format date as relative time (e.g., '2 hours ago', 'yesterday')"""
52
+ if not dt:
53
+ return ""
54
+
55
+ now = datetime.now()
56
+ diff = now - dt
57
+
58
+ seconds = diff.total_seconds()
59
+
60
+ if seconds < 60:
61
+ return "just now"
62
+ elif seconds < 3600:
63
+ minutes = int(seconds / 60)
64
+ return f"{minutes} minute{'s' if minutes != 1 else ''} ago"
65
+ elif seconds < 86400:
66
+ hours = int(seconds / 3600)
67
+ return f"{hours} hour{'s' if hours != 1 else ''} ago"
68
+ elif seconds < 172800:
69
+ return "yesterday"
70
+ elif seconds < 604800:
71
+ days = int(seconds / 86400)
72
+ return f"{days} days ago"
73
+ elif seconds < 2592000:
74
+ weeks = int(seconds / 604800)
75
+ return f"{weeks} week{'s' if weeks != 1 else ''} ago"
76
+ elif seconds < 31536000:
77
+ months = int(seconds / 2592000)
78
+ return f"{months} month{'s' if months != 1 else ''} ago"
79
+ else:
80
+ years = int(seconds / 31536000)
81
+ return f"{years} year{'s' if years != 1 else ''} ago"
82
+
83
+
84
+ def time_only(dt):
85
+ """Format as time only '6:00 PM'"""
86
+ if not dt:
87
+ return ""
88
+ formatted = dt.strftime('%I:%M %p')
89
+ if formatted[0] == '0':
90
+ formatted = formatted[1:]
91
+ return formatted
92
+
93
+
94
+ # ============================================================================
95
+ # CURRENCY FILTERS
96
+ # ============================================================================
97
+
98
+ def currency(value, code='USD', symbol='$'):
99
+ """Format number as currency '$1,234.56' using pycountry for currency info"""
100
+ if value is None:
101
+ return ""
102
+
103
+ try:
104
+ value = float(value)
105
+
106
+ try:
107
+ import pycountry
108
+
109
+ try:
110
+ currency_obj = pycountry.currencies.get(alpha_3=code.upper())
111
+ decimals = 0 if currency_obj and int(currency_obj.numeric) == 392 else 2
112
+ except (LookupError, AttributeError):
113
+ decimals = 2
114
+ except ImportError:
115
+ decimals = 0 if code.upper() == 'JPY' else 2
116
+
117
+ symbols = {
118
+ 'USD': '$', 'EUR': '€', 'GBP': '£', 'JPY': '¥',
119
+ 'CNY': '¥', 'INR': '₹', 'KES': 'KSh', 'NGN': '₦',
120
+ 'ZAR': 'R', 'AUD': 'A$', 'CAD': 'C$', 'CHF': 'Fr',
121
+ 'BRL': 'R$', 'MXN': '$', 'RUB': '₽', 'TRY': '₺',
122
+ 'SEK': 'kr', 'NOK': 'kr', 'DKK': 'kr', 'PLN': 'zł',
123
+ 'AED': 'د.إ', 'SAR': 'ر.س', 'EGP': 'E£', 'THB': '฿',
124
+ 'SGD': 'S$', 'HKD': 'HK$', 'KRW': '₩', 'IDR': 'Rp',
125
+ 'PHP': '₱', 'VND': '₫', 'MYR': 'RM', 'PKR': '₨',
126
+ }
127
+
128
+ symbol = symbols.get(code.upper(), symbol)
129
+
130
+ if decimals == 0:
131
+ formatted = f"{int(value):,}"
132
+ else:
133
+ formatted = f"{value:,.{decimals}f}"
134
+
135
+ return f"{symbol}{formatted}"
136
+ except (ValueError, TypeError):
137
+ return str(value)
138
+
139
+
140
+ def compact_currency(value, code='USD'):
141
+ """Format large numbers compactly '$1.2M', '$45K'"""
142
+ if value is None:
143
+ return ""
144
+
145
+ try:
146
+ value = float(value)
147
+
148
+ symbols = {
149
+ 'USD': '$', 'EUR': '€', 'GBP': '£', 'JPY': '¥',
150
+ 'CNY': '¥', 'INR': '₹', 'KES': 'KSh', 'NGN': '₦',
151
+ 'ZAR': 'R', 'AUD': 'A$', 'CAD': 'C$', 'CHF': 'Fr'
152
+ }
153
+
154
+ symbol = symbols.get(code.upper(), '$')
155
+
156
+ if value >= 1_000_000_000:
157
+ return f"{symbol}{value/1_000_000_000:.1f}B"
158
+ elif value >= 1_000_000:
159
+ return f"{symbol}{value/1_000_000:.1f}M"
160
+ elif value >= 1_000:
161
+ return f"{symbol}{value/1_000:.1f}K"
162
+ else:
163
+ return f"{symbol}{value:.2f}"
164
+ except (ValueError, TypeError):
165
+ return str(value)
166
+
167
+
168
+ # ============================================================================
169
+ # COUNTRY & LOCALE FILTERS
170
+ # ============================================================================
171
+
172
+ def country_flag(country_code):
173
+ """Convert ISO 3166-1 alpha-2 or alpha-3 country code to emoji flag"""
174
+ if not country_code:
175
+ return ""
176
+
177
+ try:
178
+ import pycountry
179
+
180
+ country_code = country_code.strip().upper()
181
+
182
+ if len(country_code) == 2:
183
+ alpha_2 = country_code
184
+ elif len(country_code) == 3:
185
+ country = pycountry.countries.get(alpha_3=country_code)
186
+ alpha_2 = country.alpha_2 if country else None
187
+ else:
188
+ return ""
189
+
190
+ if alpha_2 and len(alpha_2) == 2:
191
+ return ''.join(chr(ord(c) + 127397) for c in alpha_2)
192
+
193
+ return ""
194
+ except (ImportError, LookupError, AttributeError):
195
+ if len(country_code) == 2:
196
+ country_code = country_code.upper()
197
+ return ''.join(chr(ord(c) + 127397) for c in country_code)
198
+ return ""
199
+
200
+
201
+ def country_name(country_code):
202
+ """Convert country code (alpha-2 or alpha-3) to full name using pycountry"""
203
+ if not country_code:
204
+ return ""
205
+
206
+ try:
207
+ import pycountry
208
+
209
+ country_code = country_code.strip().upper()
210
+
211
+ if len(country_code) == 2:
212
+ country = pycountry.countries.get(alpha_2=country_code)
213
+ elif len(country_code) == 3:
214
+ country = pycountry.countries.get(alpha_3=country_code)
215
+ else:
216
+ results = pycountry.countries.search_fuzzy(country_code)
217
+ country = results[0] if results else None
218
+
219
+ return country.name if country else country_code
220
+ except ImportError:
221
+ fallback = {
222
+ 'US': 'United States', 'GB': 'United Kingdom', 'CA': 'Canada',
223
+ 'AU': 'Australia', 'DE': 'Germany', 'FR': 'France', 'IT': 'Italy',
224
+ 'ES': 'Spain', 'JP': 'Japan', 'CN': 'China', 'IN': 'India',
225
+ 'BR': 'Brazil', 'MX': 'Mexico', 'KE': 'Kenya', 'NG': 'Nigeria',
226
+ 'ZA': 'South Africa', 'EG': 'Egypt', 'GH': 'Ghana', 'TZ': 'Tanzania',
227
+ }
228
+ return fallback.get(country_code.upper(), country_code)
229
+ except (LookupError, AttributeError):
230
+ return country_code
231
+
232
+
233
+ def language_name(language_code):
234
+ """Convert language code (alpha-2 or alpha-3) to full name using pycountry"""
235
+ if not language_code:
236
+ return ""
237
+
238
+ try:
239
+ import pycountry
240
+
241
+ language_code = language_code.strip().lower()
242
+
243
+ if len(language_code) == 2:
244
+ language = pycountry.languages.get(alpha_2=language_code)
245
+ elif len(language_code) == 3:
246
+ language = pycountry.languages.get(alpha_3=language_code)
247
+ if not language:
248
+ language = pycountry.languages.get(bibliographic=language_code)
249
+ else:
250
+ results = pycountry.languages.search_fuzzy(language_code)
251
+ language = results[0] if results else None
252
+
253
+ return language.name if language else language_code
254
+ except ImportError:
255
+ fallback = {
256
+ 'en': 'English', 'es': 'Spanish', 'fr': 'French', 'de': 'German',
257
+ 'it': 'Italian', 'pt': 'Portuguese', 'ru': 'Russian', 'ja': 'Japanese',
258
+ 'zh': 'Chinese', 'ar': 'Arabic', 'hi': 'Hindi', 'sw': 'Swahili',
259
+ }
260
+ return fallback.get(language_code.lower(), language_code)
261
+ except (LookupError, AttributeError):
262
+ return language_code
263
+
264
+
265
+ def currency_name(currency_code):
266
+ """Convert currency code to full name using pycountry"""
267
+ if not currency_code:
268
+ return ""
269
+
270
+ try:
271
+ import pycountry
272
+
273
+ currency = pycountry.currencies.get(alpha_3=currency_code.upper())
274
+ return currency.name if currency else currency_code.upper()
275
+ except ImportError:
276
+ fallback = {
277
+ 'USD': 'US Dollar', 'EUR': 'Euro', 'GBP': 'Pound Sterling',
278
+ 'JPY': 'Yen', 'CNY': 'Yuan Renminbi', 'INR': 'Indian Rupee',
279
+ 'KES': 'Kenyan Shilling', 'NGN': 'Naira', 'ZAR': 'Rand',
280
+ }
281
+ return fallback.get(currency_code.upper(), currency_code.upper())
282
+ except (LookupError, AttributeError):
283
+ return currency_code.upper()
284
+
285
+
286
+ # ============================================================================
287
+ # TEXT FORMATTING FILTERS
288
+ # ============================================================================
289
+
290
+ def truncate_words(text, count=50, suffix='...'):
291
+ """Truncate text to specified word count"""
292
+ if not text:
293
+ return ""
294
+
295
+ words = text.split()
296
+ if len(words) <= count:
297
+ return text
298
+
299
+ return ' '.join(words[:count]) + suffix
300
+
301
+
302
+ def reading_time(text, wpm=200):
303
+ """Calculate reading time in minutes"""
304
+ if not text:
305
+ return "0 min read"
306
+
307
+ word_count = len(text.split())
308
+ minutes = max(1, round(word_count / wpm))
309
+
310
+ return f"{minutes} min read"
311
+
312
+
313
+ def slugify(text):
314
+ """Convert text to URL-friendly slug"""
315
+ if not text:
316
+ return ""
317
+
318
+ text = text.lower()
319
+ text = re.sub(r'[^\w\s-]', '', text)
320
+ text = re.sub(r'[-\s]+', '-', text)
321
+ return text.strip('-')
322
+
323
+
324
+ def title_case(text):
325
+ """Convert to title case, preserving acronyms"""
326
+ if not text:
327
+ return ""
328
+
329
+ small_words = {'a', 'an', 'and', 'as', 'at', 'but', 'by', 'for',
330
+ 'in', 'of', 'on', 'or', 'the', 'to', 'up', 'via'}
331
+
332
+ words = text.split()
333
+ result = []
334
+
335
+ for i, word in enumerate(words):
336
+ if i == 0 or i == len(words) - 1:
337
+ result.append(word.capitalize())
338
+ elif word.isupper() and len(word) > 1:
339
+ result.append(word)
340
+ elif word.lower() in small_words:
341
+ result.append(word.lower())
342
+ else:
343
+ result.append(word.capitalize())
344
+
345
+ return ' '.join(result)
346
+
347
+
348
+ def excerpt(text, length=150, suffix='...'):
349
+ """Create excerpt from text, breaking at sentence"""
350
+ if not text or len(text) <= length:
351
+ return text
352
+
353
+ truncated = text[:length]
354
+ last_period = truncated.rfind('.')
355
+ last_question = truncated.rfind('?')
356
+ last_exclamation = truncated.rfind('!')
357
+
358
+ break_point = max(last_period, last_question, last_exclamation)
359
+
360
+ if break_point > length * 0.6:
361
+ return text[:break_point + 1]
362
+ else:
363
+ last_space = truncated.rfind(' ')
364
+ if last_space > 0:
365
+ return truncated[:last_space] + suffix
366
+ return truncated + suffix
367
+
368
+
369
+ def smart_quotes(text):
370
+ """Convert straight quotes to smart/curly quotes"""
371
+ if not text:
372
+ return ""
373
+
374
+ text = re.sub(r'(\s|^)"', '\u201c', text)
375
+ text = re.sub(r'"(\s|$|[,.;:!?])', '\u201d', text)
376
+ text = re.sub(r"(\s|^)'", '\u2018', text)
377
+ text = re.sub(r"'(\s|$|[,.;:!?])", '\u2019', text)
378
+
379
+ return text
380
+
381
+
382
+ # ============================================================================
383
+ # NUMBER FORMATTING FILTERS
384
+ # ============================================================================
385
+
386
+ def number_format(value, decimals=0):
387
+ """Format number with thousand separators"""
388
+ if value is None:
389
+ return ""
390
+
391
+ try:
392
+ value = float(value)
393
+ if decimals == 0:
394
+ return f"{int(value):,}"
395
+ else:
396
+ return f"{value:,.{decimals}f}"
397
+ except (ValueError, TypeError):
398
+ return str(value)
399
+
400
+
401
+ def percentage(value, decimals=1):
402
+ """Format as percentage"""
403
+ if value is None:
404
+ return ""
405
+
406
+ try:
407
+ value = float(value)
408
+ return f"{value:.{decimals}f}%"
409
+ except (ValueError, TypeError):
410
+ return str(value)
411
+
412
+
413
+ def ordinal(value):
414
+ """Convert number to ordinal (1st, 2nd, 3rd)"""
415
+ if value is None:
416
+ return ""
417
+
418
+ try:
419
+ value = int(value)
420
+ if 10 <= value % 100 <= 20:
421
+ suffix = 'th'
422
+ else:
423
+ suffix = {1: 'st', 2: 'nd', 3: 'rd'}.get(value % 10, 'th')
424
+ return f"{value}{suffix}"
425
+ except (ValueError, TypeError):
426
+ return str(value)
427
+
428
+
429
+ # ============================================================================
430
+ # FILE SIZE FILTERS
431
+ # ============================================================================
432
+
433
+ def filesize(bytes_value):
434
+ """Format bytes as human-readable file size"""
435
+ if bytes_value is None:
436
+ return ""
437
+
438
+ try:
439
+ bytes_value = float(bytes_value)
440
+
441
+ for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
442
+ if bytes_value < 1024.0:
443
+ return f"{bytes_value:.1f} {unit}"
444
+ bytes_value /= 1024.0
445
+
446
+ return f"{bytes_value:.1f} PB"
447
+ except (ValueError, TypeError):
448
+ return str(bytes_value)
449
+
450
+
451
+ # ============================================================================
452
+ # UTILITY FILTERS
453
+ # ============================================================================
454
+
455
+ def default_if_none(value, default=""):
456
+ """Return default value if None"""
457
+ return default if value is None else value
458
+
459
+
460
+ def yesno(value, yes="Yes", no="No"):
461
+ """Convert boolean to yes/no text"""
462
+ return yes if value else no
463
+
464
+ def read_time(text: str) -> str:
465
+ if not text:
466
+ return "0 min read"
467
+
468
+ word_count = len(text.split())
469
+ # Average reading speed is 200 wpm
470
+ minutes = math.ceil(word_count / 200)
471
+ if minutes <= 1:
472
+ return "1 min read"
473
+ return f"{minutes} min read"
474
+
475
+ # ============================================================================
476
+ # REGISTRATION FUNCTION
477
+ # ============================================================================
478
+
479
+ def register_filters(jinja_env):
480
+ """
481
+ Register all filters with a Jinja2 environment.
482
+
483
+ Usage:
484
+ from fastapi.templating import Jinja2Templates
485
+ from . import filters
486
+
487
+ templates = Jinja2Templates(directory="templates")
488
+ filters.register_filters(templates.env)
489
+ """
490
+ filters_dict = {
491
+ 'fancy_date': fancy_date,
492
+ 'short_date': short_date,
493
+ 'iso_date': iso_date,
494
+ 'relative_time': relative_time,
495
+ 'time_only': time_only,
496
+ 'currency': currency,
497
+ 'compact_currency': compact_currency,
498
+ 'country_flag': country_flag,
499
+ 'country_name': country_name,
500
+ 'language_name': language_name,
501
+ 'currency_name': currency_name,
502
+ 'truncate_words': truncate_words,
503
+ 'reading_time': reading_time,
504
+ 'slugify': slugify,
505
+ 'title_case': title_case,
506
+ 'excerpt': excerpt,
507
+ 'smart_quotes': smart_quotes,
508
+ 'number_format': number_format,
509
+ 'percentage': percentage,
510
+ 'ordinal': ordinal,
511
+ 'filesize': filesize,
512
+ 'default_if_none': default_if_none,
513
+ 'yesno': yesno,
514
+ 'read_time':read_time
515
+ }
516
+
517
+ for name, func in filters_dict.items():
518
+ jinja_env.filters[name] = func
519
+
520
+ jinja_env.globals['seo'] = seo_tags
521
+
522
+ return jinja_env