pafer 0.9.7__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.
pafer/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+
2
+ from .pafer import HTMLParser
3
+ from .config import Config, DEFAULT_CONFIG, load_config, save_config
4
+ from .exceptions import (
5
+ HTMLParserError,
6
+ SanitizationError,
7
+ SelectorSyntaxError,
8
+ ConfigLoadError
9
+ )
10
+ from .utils import minify_html, is_probably_html
11
+
12
+ from . import cache, feed, exporters
13
+
14
+ __all__ = [
15
+ 'HTMLParser',
16
+ 'Config',
17
+ 'DEFAULT_CONFIG',
18
+ 'load_config',
19
+ 'save_config',
20
+ 'minify_html',
21
+ 'is_probably_html',
22
+ 'cache',
23
+ 'feed',
24
+ 'exporters',
25
+ 'HTMLParserError'
26
+ ]
pafer/cache.py ADDED
@@ -0,0 +1,50 @@
1
+ """
2
+ In-memory caching system for parsed documents.
3
+ """
4
+
5
+ import time
6
+ from collections import OrderedDict
7
+ from typing import Any, Dict, Optional, Tuple
8
+
9
+
10
+ class LRUCache:
11
+ """In-memory LRU cache with TTL support for parsed documents."""
12
+
13
+ def __init__(self, max_size: int = 100, ttl_seconds: int = 300):
14
+ self.max_size = max_size
15
+ self.ttl = ttl_seconds
16
+ self._cache: Dict[str, Tuple[Any, float]] = OrderedDict()
17
+
18
+ def get(self, key: str) -> Optional[Any]:
19
+ entry = self._cache.get(key)
20
+ if entry is None:
21
+ return None
22
+ value, timestamp = entry
23
+ if time.time() - timestamp >= self.ttl:
24
+ del self._cache[key]
25
+ return None
26
+ self._cache.move_to_end(key)
27
+ return value
28
+
29
+ def set(self, key: str, value: Any) -> None:
30
+ self._purge_expired()
31
+ if key in self._cache:
32
+ self._cache.move_to_end(key)
33
+ self._cache[key] = (value, time.time())
34
+ while len(self._cache) > self.max_size:
35
+ self._cache.popitem(last=False)
36
+
37
+ def _purge_expired(self) -> None:
38
+ now = time.time()
39
+ expired = [
40
+ key for key, (_, timestamp) in self._cache.items()
41
+ if now - timestamp >= self.ttl
42
+ ]
43
+ for key in expired:
44
+ del self._cache[key]
45
+
46
+ def clear(self) -> None:
47
+ self._cache.clear()
48
+
49
+ def __len__(self) -> int:
50
+ return len(self._cache)
pafer/cli.py ADDED
@@ -0,0 +1,52 @@
1
+ """
2
+ Command line interface for HTMLParser.
3
+ """
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from .pafer import HTMLParser
9
+
10
+
11
+ def run_cli():
12
+ pafer_cli = argparse.ArgumentParser(
13
+ description='HTMLParser - Enterprise HTML Processing Tool',
14
+ epilog='Example: htmlpafer parse -f input.html --minify'
15
+ )
16
+
17
+ subpafers = pafer_cli.add_subpafers(dest='command', help='Available commands')
18
+
19
+ parse_cmd = subpafers.add_pafer('parse', help='Parse an HTML file')
20
+ parse_cmd.add_argument('-f', '--file', required=True, help='Path to HTML file')
21
+ parse_cmd.add_argument('--minify', action='store_true', help='Minify output')
22
+
23
+ sanit_cmd = subpafers.add_pafer('sanitize', help='Sanitize an HTML file')
24
+ sanit_cmd.add_argument('-f', '--file', required=True, help='Path to HTML file')
25
+
26
+ args = pafer_cli.parse_args()
27
+
28
+ if args.command == 'parse':
29
+ try:
30
+ with open(args.file, 'r') as f:
31
+ html = f.read()
32
+
33
+ p = HTMLParser()
34
+ if args.minify:
35
+ html = p.minify(html)
36
+
37
+ result = p.parse(html)
38
+ print(f"[+] Parsed {len(result['tags'])} tags successfully.")
39
+
40
+ except FileNotFoundError:
41
+ print(f"[-] Error: File '{args.file}' not found.")
42
+ sys.exit(1)
43
+
44
+ elif args.command == 'sanitize':
45
+ print('[+] Sanitization not implemented in CLI yet.')
46
+
47
+ else:
48
+ pafer_cli.print_help()
49
+
50
+
51
+ if __name__ == '__main__':
52
+ run_cli()
pafer/config.py ADDED
@@ -0,0 +1,115 @@
1
+ """
2
+ Configuration loading and persistence for the pafer package.
3
+ """
4
+
5
+ import os
6
+ import pickle
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import Any, Dict, Optional, Union
10
+
11
+ from .exceptions import ConfigLoadError
12
+
13
+ PathLike = Union[str, os.PathLike]
14
+
15
+ DEFAULT_CONFIG: Dict[str, Any] = {
16
+ 'parse_tables': True,
17
+ 'strip_whitespace': True,
18
+ 'encoding': 'utf-8',
19
+ 'max_depth': 100,
20
+ 'default_tag': 'div',
21
+ 'preserve_comments': False,
22
+ 'strict_mode': False,
23
+ 'remove_comments': True,
24
+ 'allowed_tags': {'p', 'br', 'b', 'i', 'a', 'ul', 'ol', 'li', 'div', 'span'},
25
+ 'max_file_size_mb': 10,
26
+ 'cache_size': 50
27
+ }
28
+
29
+ def _sanitize_value(value: Any) -> Any:
30
+ """Recursively verify that a value only contains allowed plain types."""
31
+ if isinstance(value, (str, bytes, int, float, bool, type(None))):
32
+ return value
33
+ if isinstance(value, dict):
34
+ return {str(key): _sanitize_value(item) for key, item in value.items()}
35
+ if isinstance(value, (list, tuple)):
36
+ return [_sanitize_value(item) for item in value]
37
+ if isinstance(value, (set, frozenset)):
38
+ return {_sanitize_value(item) for item in value}
39
+ raise ConfigLoadError(
40
+ f'disallowed type {type(value).__name__!r} in config file; '
41
+ 'only plain data types are allowed'
42
+ )
43
+
44
+
45
+ class Config:
46
+ """Configuration holder that layers user settings over DEFAULT_CONFIG."""
47
+
48
+ def __init__(self, settings: Optional[Dict[str, Any]] = None):
49
+ self._settings: Dict[str, Any] = {**DEFAULT_CONFIG, **(settings or {})}
50
+
51
+ def get(self, key: str, default: Any = None) -> Any:
52
+ return self._settings.get(key, default)
53
+
54
+ def update(self, settings: Dict[str, Any]) -> 'Config':
55
+ """Return a new Config with extra settings layered on top of this one."""
56
+ return Config({**self._settings, **(settings or {})})
57
+
58
+ def to_dict(self) -> Dict[str, Any]:
59
+ return dict(self._settings)
60
+
61
+ @classmethod
62
+ def load_default(cls) -> 'Config':
63
+ """Return a Config built from DEFAULT_CONFIG without reading any file."""
64
+ return cls()
65
+
66
+ def __eq__(self, other: Any) -> bool:
67
+ if isinstance(other, Config):
68
+ return self._settings == other._settings
69
+ return NotImplemented
70
+
71
+ def __repr__(self) -> str:
72
+ return f'Config({self._settings})'
73
+
74
+
75
+ def _read_pkl(path: PathLike) -> Dict[str, Any]:
76
+ """Read a pickled dict from path."""
77
+ try:
78
+ with open(path, 'rb') as handle:
79
+ data = pickle.load(handle)
80
+ except ConfigLoadError:
81
+ raise
82
+ except Exception as exc:
83
+ raise ConfigLoadError(f"failed to read config file '{path}': {exc}") from exc
84
+ if not isinstance(data, dict):
85
+ raise ConfigLoadError(
86
+ f"config file '{path}' must contain a dict, "
87
+ f"got {type(data).__name__}"
88
+ )
89
+ return _sanitize_value(data)
90
+
91
+
92
+ def load_config(path: PathLike) -> Config:
93
+ """Load a Config from a caller-supplied pickle file."""
94
+ return Config(_read_pkl(path))
95
+
96
+
97
+ def save_config(config: Config, path: PathLike) -> Path:
98
+ """Persist a Config to path atomically."""
99
+ settings = config.to_dict()
100
+ target = Path(path)
101
+ if not target.parent.exists():
102
+ target.parent.mkdir(parents=True, exist_ok=True)
103
+
104
+ fd, tmp_name = tempfile.mkstemp(dir=str(target.parent), suffix='.pkl.tmp')
105
+ try:
106
+ with os.fdopen(fd, 'wb') as handle:
107
+ pickle.dump(settings, handle, protocol=pickle.HIGHEST_PROTOCOL)
108
+ os.replace(tmp_name, str(target))
109
+ except Exception:
110
+ try:
111
+ os.unlink(tmp_name)
112
+ except OSError:
113
+ pass
114
+ raise
115
+ return target
pafer/exceptions.py ADDED
@@ -0,0 +1,18 @@
1
+ """
2
+ Custom exceptions for HTMLParser.
3
+ """
4
+
5
+ class HTMLParserError(Exception):
6
+ """Base exception for all HTMLParser errors."""
7
+
8
+
9
+ class SanitizationError(HTMLParserError):
10
+ """Raised when HTML sanitization fails or encounters malicious patterns."""
11
+
12
+
13
+ class SelectorSyntaxError(HTMLParserError):
14
+ """Raised when a CSS selector is invalid."""
15
+
16
+
17
+ class ConfigLoadError(HTMLParserError):
18
+ """Raised when configuration cannot be loaded."""
pafer/exporters.py ADDED
@@ -0,0 +1,23 @@
1
+ """
2
+ Export parsed HTML data to standard formats.
3
+ """
4
+
5
+ import csv
6
+ import io
7
+ import json
8
+ from typing import Any, Dict, List
9
+
10
+
11
+ class DataExporter:
12
+ """Export parsed HTML data to standard formats."""
13
+
14
+ @staticmethod
15
+ def to_csv(rows: List[List[str]]) -> str:
16
+ output = io.StringIO()
17
+ writer = csv.writer(output)
18
+ writer.writerows(rows)
19
+ return output.getvalue()
20
+
21
+ @staticmethod
22
+ def to_json(data: Dict[str, Any], indent: int = 2) -> str:
23
+ return json.dumps(data, indent=indent, default=str)
pafer/feed.py ADDED
@@ -0,0 +1,23 @@
1
+ """
2
+ RSS feed item extraction.
3
+ """
4
+
5
+ import re
6
+ from typing import Dict, List
7
+
8
+
9
+ class FeedParser:
10
+
11
+ def __init__(self):
12
+ self.item_pattern = re.compile(r'<item.*?>(.*?)</item>', re.DOTALL)
13
+ self.tag_pattern = re.compile(r'<(title|link|description|pubDate)>(.*?)</\1>', re.DOTALL)
14
+
15
+ def parse_rss(self, xml_content: str) -> List[Dict]:
16
+ """Extract <item> entries as dicts of their child tags."""
17
+ items = []
18
+ for match in self.item_pattern.finditer(xml_content):
19
+ tags = dict(self.tag_pattern.findall(match.group(1)))
20
+ if 'description' in tags:
21
+ tags['description'] = re.sub(r'<[^>]+>', '', tags['description'])
22
+ items.append(tags)
23
+ return items
pafer/pafer.py ADDED
@@ -0,0 +1,126 @@
1
+ """
2
+ HTML Parser implementation with sanitization and selectors.
3
+ """
4
+
5
+ import re
6
+ from typing import List, Dict, Optional, Set
7
+
8
+ from .config import Config
9
+ from .utils import minify_html, extract_doctype
10
+
11
+
12
+ class HTMLParser:
13
+ """Parse, extract, and sanitize HTML content."""
14
+
15
+ def __init__(self, config: Optional[Config] = None, use_cache: bool = False):
16
+ self.config = config or Config()
17
+ self._tag_pattern = re.compile(r'<(\w+)([^>]*)>(.*?)</\1>', re.DOTALL)
18
+ self._attr_pattern = re.compile(r'(\w+)=["\']([^"\']*)["\']')
19
+
20
+ self.cache = None
21
+ if use_cache:
22
+ from .cache import LRUCache
23
+ self.cache = LRUCache(max_size=self.config.get('cache_size', 50))
24
+
25
+ def parse(self, html: str) -> Dict:
26
+ """Parse HTML and return structured data."""
27
+ if self.cache:
28
+ cached = self.cache.get(html)
29
+ if cached:
30
+ return cached
31
+
32
+ result = {
33
+ 'tags': [],
34
+ 'text': [],
35
+ 'metadata': {},
36
+ 'doctype': extract_doctype(html)
37
+ }
38
+
39
+ if self.config.get('strip_whitespace', True):
40
+ html = re.sub(r'\s+', ' ', html).strip()
41
+
42
+ for match in self._tag_pattern.finditer(html):
43
+ tag_name, attrs_str, content = match.groups()
44
+ attrs = dict(self._attr_pattern.findall(attrs_str))
45
+
46
+ tag_data = {
47
+ 'name': tag_name,
48
+ 'attributes': attrs,
49
+ 'content': content.strip() if self.config.get('strip_whitespace') else content
50
+ }
51
+
52
+ if self.config.get('parse_tables', True) and tag_name == 'table':
53
+ tag_data['rows'] = self._parse_table(content)
54
+
55
+ result['tags'].append(tag_data)
56
+
57
+ title_match = re.search(r'<title>(.*?)</title>', html, re.IGNORECASE)
58
+ if title_match:
59
+ result['metadata']['title'] = title_match.group(1)
60
+
61
+ if self.cache:
62
+ self.cache.set(html, result)
63
+ return result
64
+
65
+ def select(self, html: str, selector: str) -> List[Dict]:
66
+ """Basic CSS selector simulation (ID, class and tag selectors)."""
67
+ if selector.startswith('#'):
68
+ attr_search = f'id="{selector[1:]}"'
69
+ elif selector.startswith('.'):
70
+ attr_search = f'class="{selector[1:]}"'
71
+ else:
72
+ attr_search = f'<{selector} '
73
+
74
+ results = []
75
+ for match in self._tag_pattern.finditer(html):
76
+ tag_name, attrs_str, content = match.groups()
77
+ if attr_search in attrs_str or attr_search in f'<{tag_name} ':
78
+ results.append({
79
+ 'tag': tag_name,
80
+ 'attrs': dict(self._attr_pattern.findall(attrs_str)),
81
+ 'inner_html': content
82
+ })
83
+ return results
84
+
85
+ def sanitize(self, html: str, allowed_tags: Optional[Set[str]] = None) -> str:
86
+ """Strip out tags that aren't in the allowed list."""
87
+ if allowed_tags is None:
88
+ allowed_tags = self.config.get('allowed_tags') or {
89
+ 'p', 'br', 'b', 'i', 'a', 'ul', 'ol', 'li', 'div', 'span'
90
+ }
91
+
92
+ def replace_tag(match):
93
+ tag = match.group(1)
94
+ return match.group(0) if tag.lower() in allowed_tags else ''
95
+
96
+ cleaned = re.sub(r'</(\w+)>', replace_tag, html, flags=re.IGNORECASE)
97
+ cleaned = re.sub(r'<(\w+)([^>]*)>', replace_tag, cleaned, flags=re.IGNORECASE)
98
+ return cleaned
99
+
100
+ def minify(self, html: str) -> str:
101
+ """Minify HTML based on config settings."""
102
+ return minify_html(html, remove_comments=self.config.get('remove_comments', True))
103
+
104
+ def _parse_table(self, table_html: str) -> List[List[str]]:
105
+ rows = []
106
+ row_pattern = re.compile(r'<tr[^>]*>(.*?)</tr>', re.DOTALL)
107
+ cell_pattern = re.compile(r'<t[dh][^>]*>(.*?)</t[dh]>', re.DOTALL)
108
+ for row_match in row_pattern.finditer(table_html):
109
+ cells = [c.strip() for c in cell_pattern.findall(row_match.group(1))]
110
+ if cells:
111
+ rows.append(cells)
112
+ return rows
113
+
114
+ def extract_links(self, html: str) -> List[Dict]:
115
+ """Extract all links from HTML."""
116
+ links = []
117
+ link_pattern = re.compile(r'<a\s+([^>]*)>(.*?)</a>', re.DOTALL)
118
+ for match in link_pattern.finditer(html):
119
+ attrs = dict(self._attr_pattern.findall(match.group(1)))
120
+ if 'href' in attrs:
121
+ links.append({
122
+ 'url': attrs['href'],
123
+ 'text': match.group(2).strip(),
124
+ 'attributes': {k: v for k, v in attrs.items() if k != 'href'}
125
+ })
126
+ return links
pafer/utils.py ADDED
@@ -0,0 +1,29 @@
1
+ """
2
+ Utility functions for HTML processing.
3
+ """
4
+
5
+ import re
6
+ from typing import Optional
7
+
8
+
9
+ def minify_html(html: str, remove_comments: bool = True) -> str:
10
+ """Minify HTML by removing unnecessary whitespace and optional comments."""
11
+ if remove_comments:
12
+ html = re.sub(r'<!--(.*?)-->', '', html, flags=re.DOTALL)
13
+
14
+ html = re.sub(r'[ \t]+', ' ', html)
15
+ html = re.sub(r'\s+<(div|p|h[1-6]|br|hr|ul|ol|li|table|tr|td|th|head|body|html)', r'<\1', html)
16
+ html = re.sub(r'</(div|p|h[1-6]|ul|ol|li|table|tr|td|th|head|body|html)>\s+', r'</\1>', html)
17
+ return html.strip()
18
+
19
+
20
+ def extract_doctype(html: str) -> Optional[str]:
21
+ """Extract the DOCTYPE declaration from HTML."""
22
+ match = re.search(r'<!DOCTYPE\s+([^>]+)>', html, re.IGNORECASE)
23
+ return match.group(0) if match else None
24
+
25
+
26
+ def is_probably_html(content: str) -> bool:
27
+ """Heuristic check to determine if string is HTML."""
28
+ html_indicators = [r'<html', r'<head', r'<body', r'<div', r'<!DOCTYPE']
29
+ return any(re.search(pattern, content, re.IGNORECASE) for pattern in html_indicators)
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: pafer
3
+ Version: 0.9.7
4
+ Summary: Regex-based HTML parsing.
5
+ Author-email: werson <werson.tech@gmail.com>
6
+ License: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Programming Language :: Python :: 3.8
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: User Interfaces
19
+ Requires-Python: >=3.8
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest; extra == "dev"
24
+ Requires-Dist: pytest-cov; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ ![Python](https://img.shields.io/badge/python-3.8%2B-blue)
28
+ ![Dependencies](https://img.shields.io/badge/dependencies-none-success)
29
+ ![Tests](https://img.shields.io/badge/tests-pytest-informational)
30
+
31
+ ## Features
32
+
33
+ - Parse HTML into structured tag / text / metadata results
34
+ - CSS-style selectors (`#id`, `.class`, tag name)
35
+ - Whitelist-based sanitization
36
+ - Minification with comment stripping
37
+ - Link and table extraction
38
+ - LRU result caching with TTL
39
+ - RSS 2.0 feed parsing
40
+ - JSON / CSV export
41
+ - CLI tool
42
+ - Standard library only, no third-party dependencies
43
+
44
+ ## Installation
45
+
46
+ ```
47
+ pip install pafer
48
+ ```
49
+
50
+ ## Quickstart
51
+
52
+ ```python
53
+ from pafer import HTMLParser
54
+
55
+ html = '<div class="post"><h1>Title</h1><a href="/about">About</a></div>'
56
+
57
+ p = HTMLParser()
58
+ result = p.parse(html)
59
+
60
+ result['tags'] # list of {name, attributes, content, rows?}
61
+ result['metadata'] # {'title': ...}
62
+ result['doctype'] # '<!DOCTYPE ...>' or None
63
+
64
+ p.select(html, '.post') # match by #id, .class or tag name
65
+ p.sanitize(html) # keep only allowed tags
66
+ p.minify(html) # collapse whitespace, strip comments
67
+ p.extract_links(html) # [{'url': ..., 'text': ..., 'attributes': ...}]
68
+ ```
69
+
70
+ Enable the parsed-result cache with `HTMLParser(use_cache=True)`.
71
+
72
+ ## Configuration
73
+
74
+ ```python
75
+ from pafer import Config, load_config, save_config
76
+
77
+ cfg = Config({'strip_whitespace': False, 'cache_size': 100})
78
+ save_config(cfg, 'my.pkl')
79
+ cfg = load_config('my.pkl')
80
+ ```
81
+
82
+ ### Default settings
83
+
84
+ | Key | Default | Purpose |
85
+ |---|---|---|
86
+ | `parse_tables` | `True` | extract table rows while parsing |
87
+ | `strip_whitespace` | `True` | collapse whitespace |
88
+ | `encoding` | `utf-8` | decode bytes input |
89
+ | `max_depth` | `100` | maximum element nesting |
90
+ | `default_tag` | `div` | fallback tag |
91
+ | `preserve_comments` | `False` | keep comments in the tree |
92
+ | `strict_mode` | `False` | raise on malformed HTML |
93
+ | `remove_comments` | `True` | strip comments when minifying |
94
+ | `allowed_tags` | `p, br, b, i, a, ul, ol, li, div, span` | sanitize whitelist |
95
+ | `max_file_size_mb` | `10` | reject oversized input |
96
+ | `cache_size` | `50` | cache capacity |
97
+
98
+ ## Caching
99
+
100
+ `pafer.cache.LRUCache` is an in-memory LRU cache with TTL expiry:
101
+
102
+ ```python
103
+ from pafer.cache import LRUCache
104
+
105
+ cache = LRUCache(max_size=100, ttl_seconds=300)
106
+ cache.set('key', value)
107
+ cache.get('key')
108
+ ```
109
+
110
+ ## RSS feeds
111
+
112
+ ```python
113
+ from pafer.feed import FeedParser
114
+
115
+ items = FeedParser().parse_rss(xml_content)
116
+ items[0]['title'] # 'title', 'link', 'description', 'pubDate'
117
+ ```
118
+
119
+ ## Exporting
120
+
121
+ ```python
122
+ from pafer.exporters import DataExporter
123
+
124
+ DataExporter.to_json(data) # JSON string with 2-space indent
125
+ DataExporter.to_csv(rows) # CSV from a list of rows
126
+ ```
127
+
128
+ ## CLI
129
+
130
+ ```
131
+ python -m pafer.cli parse -f input.html --minify
132
+ python -m pafer.cli sanitize -f input.html
133
+ ```
134
+
135
+ ## Exceptions
136
+
137
+ `HTMLParserError` (base), `SanitizationError`, `SelectorSyntaxError`, `ConfigLoadError`.
138
+
139
+ ## LICENSE
140
+
141
+ MIT - Enjoy
142
+
@@ -0,0 +1,14 @@
1
+ pafer/__init__.py,sha256=qfif1-tLgzb_L6E5dXvFrjr7KLHlUzlN-enrUQz-LSw,518
2
+ pafer/cache.py,sha256=_jLK3Cy9gZeKqdGqQGZ2lT3hGTyuMU9BB4Lb0MWWxrg,1442
3
+ pafer/cli.py,sha256=uUha1YFXnHtNYsFkWIyomjewOlvv9GAoXt1fDjIHREM,1459
4
+ pafer/config.py,sha256=RJ0lXrBrlbpW_SiUD1K5QGuyvm7wwdfH-3WAbJkcYG8,3669
5
+ pafer/exceptions.py,sha256=ThlTAu9APDe6eASRgJD9DA4L314GTLZd12w39mX1Sjk,444
6
+ pafer/exporters.py,sha256=fg_4ghpDFQQpcVp3y5dMIfszOqKJENhpKj2qEnSyfVk,537
7
+ pafer/feed.py,sha256=JiqfAMTCOWpVbqe--WXmwlSmcZK4gP36_HZIwtVAvtY,738
8
+ pafer/pafer.py,sha256=Lg6cHaiEakNvD8jzYFc_SrLwHXafLCTXX5bTaRLzBrA,4690
9
+ pafer/utils.py,sha256=ztX76pEHUFHQbFO5VXIUxfGrAVLU1QOAMOviA9AaC1U,1073
10
+ pafer-0.9.7.dist-info/licenses/LICENSE,sha256=v9KUTIwaghzDXke5a-bW9MVCQ27384A6Kse-TOKAIjc,1083
11
+ pafer-0.9.7.dist-info/METADATA,sha256=zLrmLrvkEF2SHiJMiytczEor-f5poTt2soQYL6qAuXA,4053
12
+ pafer-0.9.7.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
13
+ pafer-0.9.7.dist-info/top_level.txt,sha256=j9GNkUuPhxx2Smbk3kCwy7rbdq09_NjRkZ-suhl0glQ,6
14
+ pafer-0.9.7.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pafer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ pafer