agoras-common 2.0.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.
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: agoras-common
3
+ Version: 2.0.0
4
+ Summary: Common utilities and logging for Agoras
5
+ Home-page: https://github.com/LuisAlejandro/agoras
6
+ Author: Luis Alejandro Martínez Faneyth
7
+ Author-email: luis@luisalejandro.org
8
+ Keywords: utilities,logging,social networks
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
12
+ Classifier: Natural Language :: English
13
+ Classifier: Programming Language :: Python :: 3
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: Programming Language :: Python :: 3.14
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/x-rst
21
+ Requires-Dist: requests==2.33.1
22
+ Requires-Dist: beautifulsoup4==4.14.3
23
+ Dynamic: author
24
+ Dynamic: author-email
25
+ Dynamic: classifier
26
+ Dynamic: description
27
+ Dynamic: description-content-type
28
+ Dynamic: home-page
29
+ Dynamic: keywords
30
+ Dynamic: requires-dist
31
+ Dynamic: requires-python
32
+ Dynamic: summary
33
+
34
+ agoras-common
35
+ =============
36
+
37
+ Low-level utilities, logging, and shared constants for the Agoras ecosystem.
38
+
39
+ Installation
40
+ ------------
41
+
42
+ .. code-block:: bash
43
+
44
+ pip install agoras-common
45
+
46
+ Contents
47
+ --------
48
+
49
+ - **Version Info**: ``__version__``, ``__author__``, ``__email__``, ``__url__``, ``__description__``
50
+ - **Utilities**: Helper functions for URL manipulation, metadata parsing
51
+ - **Logger**: Centralized logging configuration
52
+
53
+ Usage
54
+ -----
55
+
56
+ .. code-block:: python
57
+
58
+ from agoras.common import __version__, logger, add_url_timestamp, parse_metatags
59
+
60
+ # Version info
61
+ print(f"Agoras version: {__version__}")
62
+
63
+ # Logger
64
+ logger.start()
65
+ logger.loglevel('INFO')
66
+ logger.info("Hello from Agoras!")
67
+
68
+ # URL utilities
69
+ timestamped_url = add_url_timestamp('https://example.com', '20260110')
70
+ print(timestamped_url) # https://example.com?t=20260110
71
+
72
+ # Metatag parsing
73
+ metatags = parse_metatags('https://example.com')
74
+ print(metatags['title'], metatags['image'])
75
+
76
+ Dependencies
77
+ ------------
78
+
79
+ None (pure Python utilities)
@@ -0,0 +1,46 @@
1
+ agoras-common
2
+ =============
3
+
4
+ Low-level utilities, logging, and shared constants for the Agoras ecosystem.
5
+
6
+ Installation
7
+ ------------
8
+
9
+ .. code-block:: bash
10
+
11
+ pip install agoras-common
12
+
13
+ Contents
14
+ --------
15
+
16
+ - **Version Info**: ``__version__``, ``__author__``, ``__email__``, ``__url__``, ``__description__``
17
+ - **Utilities**: Helper functions for URL manipulation, metadata parsing
18
+ - **Logger**: Centralized logging configuration
19
+
20
+ Usage
21
+ -----
22
+
23
+ .. code-block:: python
24
+
25
+ from agoras.common import __version__, logger, add_url_timestamp, parse_metatags
26
+
27
+ # Version info
28
+ print(f"Agoras version: {__version__}")
29
+
30
+ # Logger
31
+ logger.start()
32
+ logger.loglevel('INFO')
33
+ logger.info("Hello from Agoras!")
34
+
35
+ # URL utilities
36
+ timestamped_url = add_url_timestamp('https://example.com', '20260110')
37
+ print(timestamped_url) # https://example.com?t=20260110
38
+
39
+ # Metatag parsing
40
+ metatags = parse_metatags('https://example.com')
41
+ print(metatags['title'], metatags['image'])
42
+
43
+ Dependencies
44
+ ------------
45
+
46
+ None (pure Python utilities)
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ #
4
+ # Please refer to AUTHORS.rst for a complete list of Copyright holders.
5
+ # Copyright (C) 2022-2026, Agoras Developers.
6
+
7
+ # This program is free software: you can redistribute it and/or modify
8
+ # it under the terms of the GNU General Public License as published by
9
+ # the Free Software Foundation, either version 3 of the License, or
10
+ # (at your option) any later version.
11
+
12
+ # This program is distributed in the hope that it will be useful,
13
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
14
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
+ # GNU General Public License for more details.
16
+
17
+ # You should have received a copy of the GNU General Public License
18
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
19
+
20
+ from setuptools import find_namespace_packages, setup
21
+
22
+ setup(
23
+ name='agoras-common',
24
+ version='2.0.0',
25
+ author='Luis Alejandro Martínez Faneyth',
26
+ author_email='luis@luisalejandro.org',
27
+ url='https://github.com/LuisAlejandro/agoras',
28
+ description='Common utilities and logging for Agoras',
29
+ long_description=open('README.rst').read(),
30
+ long_description_content_type='text/x-rst',
31
+ packages=find_namespace_packages(where='src'),
32
+ package_dir={'': 'src'},
33
+ python_requires='>=3.10',
34
+ install_requires=[
35
+ 'requests==2.33.1',
36
+ 'beautifulsoup4==4.14.3',
37
+ ],
38
+ classifiers=[
39
+ 'Development Status :: 5 - Production/Stable',
40
+ 'Intended Audience :: Developers',
41
+ 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
42
+ 'Natural Language :: English',
43
+ 'Programming Language :: Python :: 3',
44
+ 'Programming Language :: Python :: 3.10',
45
+ 'Programming Language :: Python :: 3.11',
46
+ 'Programming Language :: Python :: 3.12',
47
+ 'Programming Language :: Python :: 3.13',
48
+ 'Programming Language :: Python :: 3.14',
49
+ ],
50
+ keywords=['utilities', 'logging', 'social networks'],
51
+ zip_safe=False,
52
+ )
@@ -0,0 +1,3 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Namespace package for agoras
3
+ __path__ = __import__('pkgutil').extend_path(__path__, __name__)
@@ -0,0 +1,45 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Please refer to AUTHORS.rst for a complete list of Copyright holders.
4
+ # Copyright (C) 2022-2026, Agoras Developers.
5
+
6
+ # This program is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU General Public License for more details.
15
+
16
+ # You should have received a copy of the GNU General Public License
17
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
18
+ """
19
+ agoras.common
20
+ =============
21
+
22
+ Common utilities, logging, and shared constants for Agoras.
23
+
24
+ This package provides low-level utilities used throughout the Agoras ecosystem:
25
+ - Version and metadata information
26
+ - Logging infrastructure
27
+ - URL manipulation utilities
28
+ - Web scraping utilities
29
+ """
30
+
31
+ from .logger import ControlableLogger, logger
32
+ from .utils import add_url_timestamp, parse_metatags
33
+ from .version import __author__, __description__, __email__, __url__, __version__
34
+
35
+ __all__ = [
36
+ '__version__',
37
+ '__author__',
38
+ '__email__',
39
+ '__url__',
40
+ '__description__',
41
+ 'logger',
42
+ 'ControlableLogger',
43
+ 'add_url_timestamp',
44
+ 'parse_metatags',
45
+ ]
@@ -0,0 +1,134 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Please refer to AUTHORS.rst for a complete list of Copyright holders.
4
+ # Copyright (C) 2022-2026, Agoras Developers.
5
+
6
+ # This program is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU General Public License for more details.
15
+
16
+ # You should have received a copy of the GNU General Public License
17
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
18
+ """
19
+ ``agoras.core.logger`` is the global application logging module.
20
+
21
+ All modules use the same global logging object. No messages will be emitted
22
+ until the logger is started.
23
+ """
24
+
25
+ import logging
26
+ import sys
27
+ from typing import cast
28
+
29
+ levelNames = {
30
+ 'CRITICAL': 50,
31
+ 'ERROR': 40,
32
+ 'WARN': 30,
33
+ 'WARNING': 30,
34
+ 'INFO': 20,
35
+ 'DEBUG': 10,
36
+ 'NOTSET': 0,
37
+ }
38
+
39
+
40
+ class ControlableLogger(logging.Logger):
41
+ """
42
+ This class represents a logger object that can be started and stopped.
43
+
44
+ It has a start method which allows you to specify a logging level.
45
+ The stop method halts output.
46
+ """
47
+
48
+ def __init__(self, name=''):
49
+ """
50
+ Initialize this ``ControlableLogger``.
51
+
52
+ The name defaults to the application name. Loggers with the same name
53
+ refer to the same underlying object. Names are hierarchical, e.g.
54
+ 'parent.child' defines a logger that is a descendant of 'parent'.
55
+
56
+ :param name: a string containing the logger name.
57
+ :return: a ``ControlableLogger`` instance.
58
+
59
+ .. versionadded:: 0.1.0
60
+ """
61
+ # Initializing parent class
62
+ super(ControlableLogger, self).__init__(name)
63
+
64
+ self.parent = logging.root
65
+
66
+ #: Attribute ``disabled`` (boolean): Stores the current status of the
67
+ #: logger.
68
+ self.disabled = True
69
+ self.propagate = False
70
+
71
+ #: Attribute ``formatstring`` (string): Stores the string that
72
+ #: will be used to format the logger output.
73
+ self.formatstring = '[%(levelname)s] %(message)s'
74
+
75
+ def start(self, filename=None):
76
+ """
77
+ Start logging with this logger.
78
+
79
+ Until the logger is started, no messages will be emitted. This applies
80
+ to all loggers with the same name and any child loggers.
81
+
82
+ .. versionadded:: 0.1.0
83
+ """
84
+ if self.disabled:
85
+ sh = logging.StreamHandler(sys.stdout)
86
+ sh.setFormatter(logging.Formatter(self.formatstring))
87
+ self.addHandler(sh)
88
+ if filename:
89
+ fh = logging.FileHandler(filename, mode='w')
90
+ fh.setFormatter(logging.Formatter(self.formatstring))
91
+ self.addHandler(fh)
92
+ self.disabled = False
93
+
94
+ def stop(self):
95
+ """
96
+ Stop logging with this logger.
97
+
98
+ Remove available handlers and set disabled attribute to ``True``.
99
+
100
+ .. versionadded:: 0.1.0
101
+ """
102
+ if not self.disabled:
103
+ for h in list(self.handlers):
104
+ self.removeHandler(h)
105
+ self.disabled = True
106
+
107
+ def loglevel(self, level='INFO'):
108
+ """
109
+ Set the log level for this logger.
110
+
111
+ Messages less than the given priority level will be ignored. The
112
+ default level is 'INFO', which conforms to the *nix convention that
113
+ a successful run should produce no diagnostic output. Available levels
114
+ and their suggested meanings:
115
+
116
+ * ``NOTSET``: all messages are processed.
117
+ * ``DEBUG``: output useful for developers.
118
+ * ``INFO``: trace normal program flow, especially external
119
+ interactions.
120
+ * ``WARNING``: an abnormal condition was detected that might need
121
+ attention.
122
+ * ``ERROR``: an error was detected but execution continued.
123
+ * ``CRITICAL``: an error was detected and execution was halted.
124
+
125
+ :param level: a string containing the desired logging level.
126
+
127
+ .. versionadded:: 0.1.0
128
+ """
129
+ if not self.disabled:
130
+ self.setLevel(levelNames[level])
131
+
132
+
133
+ logging.setLoggerClass(ControlableLogger)
134
+ logger = cast(ControlableLogger, logging.getLogger(__name__.split('.')[0]))
@@ -0,0 +1,96 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Please refer to AUTHORS.rst for a complete list of Copyright holders.
4
+ # Copyright (C) 2022-2026, Agoras Developers.
5
+
6
+ # This program is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU General Public License for more details.
15
+
16
+ # You should have received a copy of the GNU General Public License
17
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
18
+
19
+ """
20
+
21
+ agoras.common.utils
22
+ ===================
23
+
24
+ This module contains common and low level functions to all modules in agoras.
25
+
26
+ """
27
+
28
+
29
+ from urllib.parse import parse_qs, urlencode, urlparse
30
+
31
+ import requests
32
+ from bs4 import BeautifulSoup
33
+
34
+
35
+ def add_url_timestamp(url, timestamp):
36
+ parsed = urlparse(url)
37
+ query = dict(parse_qs(str(parsed.query)))
38
+ query['t'] = timestamp
39
+ parsed = parsed._replace(query=urlencode(query))
40
+ return parsed.geturl()
41
+
42
+
43
+ def metatag(tag):
44
+ return tag.name == "meta" \
45
+ and tag.has_attr("content") \
46
+ and (tag.has_attr("property") or tag.has_attr("name"))
47
+
48
+
49
+ def find_metatags(url, search):
50
+ found = {}
51
+
52
+ response = requests.get(url, timeout=20)
53
+
54
+ if response.status_code != 200:
55
+ return found
56
+
57
+ soup = BeautifulSoup(response.content, 'html.parser')
58
+
59
+ for target in search:
60
+ found_meta_tag = soup.find_all(metatag)
61
+
62
+ if not found_meta_tag:
63
+ continue
64
+
65
+ for meta_tag in found_meta_tag:
66
+
67
+ prop = meta_tag.get("property", "")
68
+ name = meta_tag.get("name", "")
69
+
70
+ if prop == target or name == target:
71
+ found[target] = meta_tag.get("content", "")
72
+
73
+ return found
74
+
75
+
76
+ def parse_metatags(url):
77
+
78
+ KNOWN_TAGS = [
79
+ "og:title",
80
+ "og:image",
81
+ "og:description",
82
+ "twitter:title",
83
+ "twitter:image",
84
+ "twitter:description",
85
+ ]
86
+
87
+ try:
88
+ data = find_metatags(url, KNOWN_TAGS)
89
+ except Exception:
90
+ data = {}
91
+
92
+ return {
93
+ "title": data.get("og:title", data.get("twitter:title", "")),
94
+ "image": data.get("og:image", data.get("twitter:image", "")),
95
+ "description": data.get("og:description", data.get("twitter:description", "")),
96
+ }
@@ -0,0 +1,36 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Please refer to AUTHORS.rst for a complete list of Copyright holders.
4
+ # Copyright (C) 2022-2026, Agoras Developers.
5
+
6
+ # This program is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU General Public License for more details.
15
+
16
+ # You should have received a copy of the GNU General Public License
17
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
18
+ """
19
+ agoras.common.version
20
+ =====================
21
+
22
+ Version and metadata information for Agoras.
23
+
24
+ This module serves as the single source of truth for version info
25
+ across all Agoras packages.
26
+ """
27
+
28
+ __author__ = 'Luis Alejandro Martínez Faneyth'
29
+ __email__ = 'luis@luisalejandro.org'
30
+ __version__ = '2.0.0'
31
+ __url__ = 'https://github.com/LuisAlejandro/agoras'
32
+ __description__ = (
33
+ 'A command line python utility to manage your social'
34
+ ' networks (Twitter, Facebook, Instagram, LinkedIn, Discord, '
35
+ 'YouTube, TikTok, Telegram, Threads, WhatsApp, and X)'
36
+ )
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: agoras-common
3
+ Version: 2.0.0
4
+ Summary: Common utilities and logging for Agoras
5
+ Home-page: https://github.com/LuisAlejandro/agoras
6
+ Author: Luis Alejandro Martínez Faneyth
7
+ Author-email: luis@luisalejandro.org
8
+ Keywords: utilities,logging,social networks
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
12
+ Classifier: Natural Language :: English
13
+ Classifier: Programming Language :: Python :: 3
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: Programming Language :: Python :: 3.14
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/x-rst
21
+ Requires-Dist: requests==2.33.1
22
+ Requires-Dist: beautifulsoup4==4.14.3
23
+ Dynamic: author
24
+ Dynamic: author-email
25
+ Dynamic: classifier
26
+ Dynamic: description
27
+ Dynamic: description-content-type
28
+ Dynamic: home-page
29
+ Dynamic: keywords
30
+ Dynamic: requires-dist
31
+ Dynamic: requires-python
32
+ Dynamic: summary
33
+
34
+ agoras-common
35
+ =============
36
+
37
+ Low-level utilities, logging, and shared constants for the Agoras ecosystem.
38
+
39
+ Installation
40
+ ------------
41
+
42
+ .. code-block:: bash
43
+
44
+ pip install agoras-common
45
+
46
+ Contents
47
+ --------
48
+
49
+ - **Version Info**: ``__version__``, ``__author__``, ``__email__``, ``__url__``, ``__description__``
50
+ - **Utilities**: Helper functions for URL manipulation, metadata parsing
51
+ - **Logger**: Centralized logging configuration
52
+
53
+ Usage
54
+ -----
55
+
56
+ .. code-block:: python
57
+
58
+ from agoras.common import __version__, logger, add_url_timestamp, parse_metatags
59
+
60
+ # Version info
61
+ print(f"Agoras version: {__version__}")
62
+
63
+ # Logger
64
+ logger.start()
65
+ logger.loglevel('INFO')
66
+ logger.info("Hello from Agoras!")
67
+
68
+ # URL utilities
69
+ timestamped_url = add_url_timestamp('https://example.com', '20260110')
70
+ print(timestamped_url) # https://example.com?t=20260110
71
+
72
+ # Metatag parsing
73
+ metatags = parse_metatags('https://example.com')
74
+ print(metatags['title'], metatags['image'])
75
+
76
+ Dependencies
77
+ ------------
78
+
79
+ None (pure Python utilities)
@@ -0,0 +1,15 @@
1
+ README.rst
2
+ setup.py
3
+ src/agoras/__init__.py
4
+ src/agoras/common/__init__.py
5
+ src/agoras/common/logger.py
6
+ src/agoras/common/utils.py
7
+ src/agoras/common/version.py
8
+ src/agoras_common.egg-info/PKG-INFO
9
+ src/agoras_common.egg-info/SOURCES.txt
10
+ src/agoras_common.egg-info/dependency_links.txt
11
+ src/agoras_common.egg-info/not-zip-safe
12
+ src/agoras_common.egg-info/requires.txt
13
+ src/agoras_common.egg-info/top_level.txt
14
+ tests/test_logger.py
15
+ tests/test_utils.py
@@ -0,0 +1,2 @@
1
+ requests==2.33.1
2
+ beautifulsoup4==4.14.3
@@ -0,0 +1,243 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Please refer to AUTHORS.rst for a complete list of Copyright holders.
4
+ # Copyright (C) 2022-2026, Agoras Developers.
5
+
6
+ # This program is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU General Public License for more details.
15
+
16
+ # You should have received a copy of the GNU General Public License
17
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
18
+
19
+ import doctest
20
+ import io
21
+ import logging
22
+ import os
23
+ import sys
24
+ import tempfile
25
+ import unittest
26
+
27
+ from agoras.common.logger import levelNames, logger
28
+
29
+
30
+ class TestLogger(unittest.TestCase):
31
+
32
+ def setUp(self):
33
+ # Ensure logger is stopped before each test
34
+ if not logger.disabled:
35
+ logger.stop()
36
+
37
+ def tearDown(self):
38
+ # Clean up after each test
39
+ if not logger.disabled:
40
+ logger.stop()
41
+
42
+ def test_01_default_level(self):
43
+ """Test logger default state."""
44
+ # Logger should be disabled initially
45
+ self.assertTrue(logger.disabled)
46
+
47
+ def test_start_without_filename(self):
48
+ """Test starting logger without filename parameter."""
49
+ logger.start()
50
+
51
+ # Logger should be enabled
52
+ self.assertFalse(logger.disabled)
53
+
54
+ # Should have exactly one handler (StreamHandler)
55
+ self.assertEqual(len(logger.handlers), 1)
56
+ self.assertIsInstance(logger.handlers[0], logging.StreamHandler)
57
+
58
+ def test_start_with_filename(self):
59
+ """Test starting logger with filename parameter."""
60
+ with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
61
+ temp_filename = f.name
62
+
63
+ try:
64
+ logger.start(filename=temp_filename)
65
+
66
+ # Logger should be enabled
67
+ self.assertFalse(logger.disabled)
68
+
69
+ # Should have two handlers (StreamHandler + FileHandler)
70
+ self.assertEqual(len(logger.handlers), 2)
71
+
72
+ # Check handler types
73
+ handler_types = [type(h).__name__ for h in logger.handlers]
74
+ self.assertIn('StreamHandler', handler_types)
75
+ self.assertIn('FileHandler', handler_types)
76
+
77
+ logger.stop()
78
+ finally:
79
+ # Clean up temp file
80
+ if os.path.exists(temp_filename):
81
+ os.remove(temp_filename)
82
+
83
+ def test_start_when_already_started(self):
84
+ """Test that starting an already started logger doesn't add duplicate handlers."""
85
+ logger.start()
86
+ initial_handler_count = len(logger.handlers)
87
+
88
+ # Try to start again
89
+ logger.start()
90
+
91
+ # Handler count should not increase
92
+ self.assertEqual(len(logger.handlers), initial_handler_count)
93
+
94
+ def test_stop_after_start(self):
95
+ """Test stopping logger after starting."""
96
+ logger.start()
97
+ self.assertFalse(logger.disabled)
98
+ self.assertGreater(len(logger.handlers), 0)
99
+
100
+ logger.stop()
101
+
102
+ # Logger should be disabled
103
+ self.assertTrue(logger.disabled)
104
+
105
+ # All handlers should be removed
106
+ self.assertEqual(len(logger.handlers), 0)
107
+
108
+ def test_stop_when_already_stopped(self):
109
+ """Test stopping an already stopped logger."""
110
+ # Ensure logger is stopped
111
+ if not logger.disabled:
112
+ logger.stop()
113
+
114
+ # Should not raise error
115
+ logger.stop()
116
+
117
+ # Should still be disabled
118
+ self.assertTrue(logger.disabled)
119
+
120
+ def test_stop_clears_all_handlers(self):
121
+ """Test that stop clears all handlers including file handler."""
122
+ with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
123
+ temp_filename = f.name
124
+
125
+ try:
126
+ logger.start(filename=temp_filename)
127
+ self.assertEqual(len(logger.handlers), 2)
128
+
129
+ logger.stop()
130
+
131
+ # All handlers should be cleared
132
+ self.assertEqual(len(logger.handlers), 0)
133
+ finally:
134
+ if os.path.exists(temp_filename):
135
+ os.remove(temp_filename)
136
+
137
+ def test_loglevel_sets_debug(self):
138
+ """Test setting log level to DEBUG."""
139
+ logger.start()
140
+ logger.loglevel('DEBUG')
141
+
142
+ self.assertEqual(logger.level, 10)
143
+
144
+ logger.stop()
145
+
146
+ def test_loglevel_sets_info(self):
147
+ """Test setting log level to INFO."""
148
+ logger.start()
149
+ logger.loglevel('INFO')
150
+
151
+ self.assertEqual(logger.level, 20)
152
+
153
+ logger.stop()
154
+
155
+ def test_loglevel_sets_warning(self):
156
+ """Test setting log level to WARNING."""
157
+ logger.start()
158
+ logger.loglevel('WARNING')
159
+
160
+ self.assertEqual(logger.level, 30)
161
+
162
+ logger.stop()
163
+
164
+ def test_loglevel_sets_error(self):
165
+ """Test setting log level to ERROR."""
166
+ logger.start()
167
+ logger.loglevel('ERROR')
168
+
169
+ self.assertEqual(logger.level, 40)
170
+
171
+ logger.stop()
172
+
173
+ def test_loglevel_sets_critical(self):
174
+ """Test setting log level to CRITICAL."""
175
+ logger.start()
176
+ logger.loglevel('CRITICAL')
177
+
178
+ self.assertEqual(logger.level, 50)
179
+
180
+ logger.stop()
181
+
182
+ def test_loglevel_sets_notset(self):
183
+ """Test setting log level to NOTSET."""
184
+ logger.start()
185
+ logger.loglevel('NOTSET')
186
+
187
+ self.assertEqual(logger.level, 0)
188
+
189
+ logger.stop()
190
+
191
+ def test_loglevel_on_disabled_logger(self):
192
+ """Test that loglevel has no effect on disabled logger."""
193
+ # Ensure logger is disabled
194
+ if not logger.disabled:
195
+ logger.stop()
196
+
197
+ initial_level = logger.level
198
+
199
+ # Try to set level on disabled logger
200
+ logger.loglevel('DEBUG')
201
+
202
+ # Level should not change
203
+ self.assertEqual(logger.level, initial_level)
204
+
205
+ def test_logger_format_string(self):
206
+ """Test that logger uses correct format string."""
207
+ self.assertEqual(logger.formatstring, '[%(levelname)s] %(message)s')
208
+
209
+ def test_logger_actually_logs_messages(self):
210
+ """Test that logger actually outputs log messages."""
211
+ # Create a string buffer to capture output
212
+ string_buffer = io.StringIO()
213
+
214
+ # Create a handler that writes to our buffer
215
+ handler = logging.StreamHandler(string_buffer)
216
+ handler.setFormatter(logging.Formatter(logger.formatstring))
217
+
218
+ # Manually add handler (simulating start behavior)
219
+ logger.addHandler(handler)
220
+ logger.disabled = False
221
+ logger.setLevel(logging.INFO)
222
+
223
+ # Log a message
224
+ logger.info('Test message')
225
+
226
+ # Get output
227
+ output = string_buffer.getvalue()
228
+
229
+ # Verify message appears with correct format
230
+ self.assertIn('[INFO] Test message', output)
231
+
232
+ # Clean up
233
+ logger.removeHandler(handler)
234
+ logger.disabled = True
235
+
236
+
237
+ def load_tests(loader, tests, pattern):
238
+ tests.addTests(doctest.DocTestSuite('agoras.common.logger'))
239
+ return tests
240
+
241
+
242
+ if __name__ == '__main__':
243
+ sys.exit(unittest.main())
@@ -0,0 +1,259 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Please refer to AUTHORS.rst for a complete list of Copyright holders.
4
+ # Copyright (C) 2022-2026, Agoras Developers.
5
+
6
+ # This program is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU General Public License for more details.
15
+
16
+ # You should have received a copy of the GNU General Public License
17
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
18
+
19
+ import doctest
20
+ import sys
21
+ import unittest
22
+ from unittest.mock import Mock, patch
23
+
24
+ from bs4 import BeautifulSoup
25
+
26
+ from agoras.common.utils import add_url_timestamp, find_metatags, metatag, parse_metatags
27
+
28
+
29
+ class TestAddUrlTimestamp(unittest.TestCase):
30
+ """Tests for add_url_timestamp function."""
31
+
32
+ def test_add_timestamp_to_url_without_query(self):
33
+ """Test adding timestamp to URL without query parameters."""
34
+ url = "https://example.com"
35
+ timestamp = 12345
36
+ result = add_url_timestamp(url, timestamp)
37
+ self.assertIn("t=12345", result)
38
+ self.assertTrue(result.startswith("https://example.com?"))
39
+
40
+ def test_add_timestamp_to_url_with_existing_query(self):
41
+ """Test adding timestamp to URL with existing query parameters."""
42
+ url = "https://example.com?foo=bar"
43
+ timestamp = 67890
44
+ result = add_url_timestamp(url, timestamp)
45
+ # parse_qs returns lists, so foo will be encoded as ['bar']
46
+ self.assertIn("foo=", result)
47
+ self.assertIn("t=67890", result)
48
+ self.assertTrue(result.startswith("https://example.com?"))
49
+
50
+ def test_replace_existing_timestamp(self):
51
+ """Test replacing existing timestamp parameter."""
52
+ url = "https://example.com?t=111&foo=bar"
53
+ timestamp = 999
54
+ result = add_url_timestamp(url, timestamp)
55
+ self.assertIn("t=999", result)
56
+ self.assertIn("foo=", result)
57
+ self.assertNotIn("t=111", result)
58
+
59
+ def test_url_with_fragment(self):
60
+ """Test URL with fragment."""
61
+ url = "https://example.com#section"
62
+ timestamp = 55555
63
+ result = add_url_timestamp(url, timestamp)
64
+ self.assertIn("t=55555", result)
65
+ self.assertIn("#section", result)
66
+
67
+
68
+ class TestMetatag(unittest.TestCase):
69
+ """Tests for metatag function."""
70
+
71
+ def test_valid_meta_tag_with_property(self):
72
+ """Test with valid meta tag that has property attribute."""
73
+ html = '<meta property="og:title" content="Test">'
74
+ soup = BeautifulSoup(html, 'html.parser')
75
+ tag = soup.find('meta')
76
+ result = metatag(tag)
77
+ self.assertTrue(result)
78
+
79
+ def test_valid_meta_tag_with_name(self):
80
+ """Test with valid meta tag that has name attribute."""
81
+ html = '<meta name="description" content="Test">'
82
+ soup = BeautifulSoup(html, 'html.parser')
83
+ tag = soup.find('meta')
84
+ result = metatag(tag)
85
+ self.assertTrue(result)
86
+
87
+ def test_invalid_tag_not_meta(self):
88
+ """Test with invalid tag (not meta)."""
89
+ html = '<div content="Test">Text</div>'
90
+ soup = BeautifulSoup(html, 'html.parser')
91
+ tag = soup.find('div')
92
+ result = metatag(tag)
93
+ self.assertFalse(result)
94
+
95
+ def test_meta_tag_without_content(self):
96
+ """Test with meta tag without content attribute."""
97
+ html = '<meta property="og:title">'
98
+ soup = BeautifulSoup(html, 'html.parser')
99
+ tag = soup.find('meta')
100
+ result = metatag(tag)
101
+ self.assertFalse(result)
102
+
103
+ def test_meta_tag_without_property_or_name(self):
104
+ """Test with meta tag without property or name attribute."""
105
+ html = '<meta content="Test">'
106
+ soup = BeautifulSoup(html, 'html.parser')
107
+ tag = soup.find('meta')
108
+ result = metatag(tag)
109
+ self.assertFalse(result)
110
+
111
+
112
+ class TestFindMetatags(unittest.TestCase):
113
+ """Tests for find_metatags function."""
114
+
115
+ @patch('agoras.common.utils.requests.get')
116
+ def test_successful_meta_tag_extraction(self, mock_get):
117
+ """Test successful meta tag extraction."""
118
+ mock_response = Mock()
119
+ mock_response.status_code = 200
120
+ mock_response.content = b'<html><meta property="og:title" content="My Title"></html>'
121
+ mock_get.return_value = mock_response
122
+
123
+ result = find_metatags('https://example.com', ['og:title'])
124
+ self.assertEqual(result, {'og:title': 'My Title'})
125
+
126
+ @patch('agoras.common.utils.requests.get')
127
+ def test_multiple_meta_tags(self, mock_get):
128
+ """Test extraction of multiple meta tags."""
129
+ html = '''<html>
130
+ <meta property="og:title" content="Title">
131
+ <meta property="og:image" content="image.jpg">
132
+ <meta name="twitter:description" content="Description">
133
+ </html>'''
134
+ mock_response = Mock()
135
+ mock_response.status_code = 200
136
+ mock_response.content = html.encode()
137
+ mock_get.return_value = mock_response
138
+
139
+ result = find_metatags('https://example.com',
140
+ ['og:title', 'og:image', 'twitter:description'])
141
+ self.assertEqual(result['og:title'], 'Title')
142
+ self.assertEqual(result['og:image'], 'image.jpg')
143
+ self.assertEqual(result['twitter:description'], 'Description')
144
+
145
+ @patch('agoras.common.utils.requests.get')
146
+ def test_non_200_status_code(self, mock_get):
147
+ """Test with non-200 status code."""
148
+ mock_response = Mock()
149
+ mock_response.status_code = 404
150
+ mock_get.return_value = mock_response
151
+
152
+ result = find_metatags('https://example.com', ['og:title'])
153
+ self.assertEqual(result, {})
154
+
155
+ @patch('agoras.common.utils.requests.get')
156
+ def test_html_without_matching_meta_tags(self, mock_get):
157
+ """Test HTML without matching meta tags."""
158
+ mock_response = Mock()
159
+ mock_response.status_code = 200
160
+ mock_response.content = b'<html><p>No meta tags here</p></html>'
161
+ mock_get.return_value = mock_response
162
+
163
+ result = find_metatags('https://example.com', ['og:title'])
164
+ self.assertEqual(result, {})
165
+
166
+ @patch('agoras.common.utils.requests.get')
167
+ def test_meta_tags_with_name_attribute(self, mock_get):
168
+ """Test meta tags with name attribute."""
169
+ html = '<html><meta name="twitter:title" content="Twitter Title"></html>'
170
+ mock_response = Mock()
171
+ mock_response.status_code = 200
172
+ mock_response.content = html.encode()
173
+ mock_get.return_value = mock_response
174
+
175
+ result = find_metatags('https://example.com', ['twitter:title'])
176
+ self.assertEqual(result, {'twitter:title': 'Twitter Title'})
177
+
178
+
179
+ class TestParseMetatags(unittest.TestCase):
180
+ """Tests for parse_metatags function."""
181
+
182
+ @patch('agoras.common.utils.find_metatags')
183
+ def test_with_open_graph_tags(self, mock_find):
184
+ """Test with Open Graph tags."""
185
+ mock_find.return_value = {
186
+ 'og:title': 'OG Title',
187
+ 'og:image': 'og.jpg',
188
+ 'og:description': 'OG Desc'
189
+ }
190
+
191
+ result = parse_metatags('https://example.com')
192
+ self.assertEqual(result, {
193
+ 'title': 'OG Title',
194
+ 'image': 'og.jpg',
195
+ 'description': 'OG Desc'
196
+ })
197
+
198
+ @patch('agoras.common.utils.find_metatags')
199
+ def test_with_twitter_tags_fallback(self, mock_find):
200
+ """Test with Twitter tags as fallback."""
201
+ mock_find.return_value = {
202
+ 'twitter:title': 'Twitter Title',
203
+ 'twitter:image': 'twitter.jpg',
204
+ 'twitter:description': 'Twitter Desc'
205
+ }
206
+
207
+ result = parse_metatags('https://example.com')
208
+ self.assertEqual(result, {
209
+ 'title': 'Twitter Title',
210
+ 'image': 'twitter.jpg',
211
+ 'description': 'Twitter Desc'
212
+ })
213
+
214
+ @patch('agoras.common.utils.find_metatags')
215
+ def test_with_mixed_tags(self, mock_find):
216
+ """Test with mixed OG and Twitter tags."""
217
+ mock_find.return_value = {
218
+ 'og:title': 'OG Title',
219
+ 'twitter:image': 'twitter.jpg',
220
+ 'og:description': 'OG Desc'
221
+ }
222
+
223
+ result = parse_metatags('https://example.com')
224
+ self.assertEqual(result['title'], 'OG Title')
225
+ self.assertEqual(result['image'], 'twitter.jpg')
226
+ self.assertEqual(result['description'], 'OG Desc')
227
+
228
+ @patch('agoras.common.utils.find_metatags')
229
+ def test_with_no_tags_found(self, mock_find):
230
+ """Test with no tags found."""
231
+ mock_find.return_value = {}
232
+
233
+ result = parse_metatags('https://example.com')
234
+ self.assertEqual(result, {
235
+ 'title': '',
236
+ 'image': '',
237
+ 'description': ''
238
+ })
239
+
240
+ @patch('agoras.common.utils.find_metatags')
241
+ def test_exception_handling(self, mock_find):
242
+ """Test exception handling."""
243
+ mock_find.side_effect = Exception('Network error')
244
+
245
+ result = parse_metatags('https://example.com')
246
+ self.assertEqual(result, {
247
+ 'title': '',
248
+ 'image': '',
249
+ 'description': ''
250
+ })
251
+
252
+
253
+ def load_tests(loader, tests, pattern):
254
+ tests.addTests(doctest.DocTestSuite('agoras.common.utils'))
255
+ return tests
256
+
257
+
258
+ if __name__ == '__main__':
259
+ sys.exit(unittest.main())