audioarxiv 0.1.2rc62.post1__tar.gz → 0.1.2rc68.post1__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.
Files changed (60) hide show
  1. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/PKG-INFO +1 -1
  2. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/docs/installation.rst +2 -12
  3. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/src/audioarxiv/__init__.py +6 -6
  4. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/src/audioarxiv/tools/main.py +31 -33
  5. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/tests/audio/test_base.py +74 -2
  6. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/tests/resources/test_paper.py +64 -0
  7. audioarxiv-0.1.2rc68.post1/tests/test_logging.py +86 -0
  8. audioarxiv-0.1.2rc68.post1/tests/tools/test_main.py +358 -0
  9. audioarxiv-0.1.2rc62.post1/tests/tools/test_main.py +0 -101
  10. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/.devcontainer/Dockerfile +0 -0
  11. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/.devcontainer/devcontainer.json +0 -0
  12. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/.github/dependabot.yml +0 -0
  13. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/.github/template-sync.yml +0 -0
  14. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/.github/workflows/CI.yml +0 -0
  15. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/.github/workflows/publish.yml +0 -0
  16. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/.github/workflows/schedule-update-actions.yml +0 -0
  17. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/.github/workflows/semantic-pr-check.yml +0 -0
  18. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/.github/workflows/sphinx.yml +0 -0
  19. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/.github/workflows/template-sync.yml +0 -0
  20. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/.gitignore +0 -0
  21. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/.pre-commit-config.yaml +0 -0
  22. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/.pypirc +0 -0
  23. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/.vscode/launch.json +0 -0
  24. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/.vscode/settings.json +0 -0
  25. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/CODE_OF_CONDUCT.md +0 -0
  26. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/CONTRIBUTING.md +0 -0
  27. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/LICENSE +0 -0
  28. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/README.md +0 -0
  29. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/SECURITY.md +0 -0
  30. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/SUPPORT.md +0 -0
  31. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/codecov.yml +0 -0
  32. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/cspell.json +0 -0
  33. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/docs/.gitignore +0 -0
  34. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/docs/Makefile +0 -0
  35. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/docs/conf.py +0 -0
  36. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/docs/devcontainer.md +0 -0
  37. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/docs/developer.md +0 -0
  38. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/docs/index.rst +0 -0
  39. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/docs/make.bat +0 -0
  40. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/docs/pre-commit-config.md +0 -0
  41. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/docs/pylint.md +0 -0
  42. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/docs/pyproject.md +0 -0
  43. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/docs/release_notes.rst +0 -0
  44. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/docs/requirements.txt +0 -0
  45. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/docs/templates/custom-class-template.rst +0 -0
  46. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/docs/templates/custom-module-template.rst +0 -0
  47. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/docs/user_guide.rst +0 -0
  48. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/docs/vscode.md +0 -0
  49. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/docs/workflows.md +0 -0
  50. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/pyproject.toml +0 -0
  51. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/src/audioarxiv/audio/__init__.py +0 -0
  52. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/src/audioarxiv/audio/base.py +0 -0
  53. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/src/audioarxiv/preprocess/__init__.py +0 -0
  54. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/src/audioarxiv/preprocess/article.py +0 -0
  55. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/src/audioarxiv/preprocess/math_equation.py +0 -0
  56. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/src/audioarxiv/resources/__init__.py +0 -0
  57. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/src/audioarxiv/resources/paper.py +0 -0
  58. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/src/audioarxiv/tools/__init__.py +0 -0
  59. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/tests/preprocess/test_article.py +0 -0
  60. {audioarxiv-0.1.2rc62.post1 → audioarxiv-0.1.2rc68.post1}/tests/preprocess/test_math_equation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: audioarxiv
3
- Version: 0.1.2rc62.post1
3
+ Version: 0.1.2rc68.post1
4
4
  Summary: Turn arXiv papers into audio. audioarxiv lets you fetch the research papers from arXiv and read them aloud.
5
5
  Author-email: "Isaac C. F. Wong" <isaac.cf.wong@gmail.com>
6
6
  Requires-Python: >=3.9
@@ -3,19 +3,9 @@ Installation
3
3
 
4
4
  To install audioarxiv, you need Python 3.9 or higher. You can install the package using pip:
5
5
 
6
- .. tabs::
7
-
8
- .. tab:: Conda
9
-
10
- .. code-block:: console
11
-
12
- $ conda install -c conda-forge audioarxiv
13
-
14
- .. tab:: Pip
15
-
16
- .. code-block:: console
6
+ .. code-block:: console
17
7
 
18
- $ pip install audioarxiv
8
+ $ pip install audioarxiv
19
9
 
20
10
  Requirements
21
11
  ------------
@@ -24,7 +24,7 @@ from pandas import DataFrame
24
24
 
25
25
  from . import audio, preprocess, resources
26
26
 
27
- __version__ = "0.1.2-rc62-post1"
27
+ __version__ = "0.1.2-rc68-post1"
28
28
 
29
29
 
30
30
  def get_version_information() -> str:
@@ -111,11 +111,11 @@ def env_package_list(as_dataframe: bool = False) -> list | DataFrame:
111
111
 
112
112
  Returns:
113
113
  Union[list, DataFrame]:
114
- If ``as_dataframe=False`` is given, the output is a `list` of `dict`,
115
- one for each package, at least with ``'name'`` and ``'version'`` keys
116
- (more if `conda` is used).
117
- If ``as_dataframe=True`` is given, the output is a `DataFrame`
118
- created from the `list` of `dicts`.
114
+ If ``as_dataframe=False`` is given, the output is a `list` of `dict`,
115
+ one for each package, at least with ``'name'`` and ``'version'`` keys
116
+ (more if `conda` is used).
117
+ If ``as_dataframe=True`` is given, the output is a `DataFrame`
118
+ created from the `list` of `dicts`.
119
119
  """
120
120
  prefix = sys.prefix
121
121
  pkgs = []
@@ -57,7 +57,6 @@ def initialize_configuration(args: configargparse.Namespace) -> tuple:
57
57
  os.makedirs(config_dir, exist_ok=True)
58
58
  config_file = 'config.json'
59
59
  config_path = os.path.join(config_dir, config_file)
60
-
61
60
  # Default settings.
62
61
  settings = {
63
62
  'audio': {
@@ -159,36 +158,35 @@ def main():
159
158
  paper = Paper(**settings['paper'])
160
159
 
161
160
  # Search the paper.
162
- if args.id is not None:
163
- # Print the information
164
- logger.info('Configuration file: %s', config_path)
165
- logger.info('Audio settings')
166
- for key, value in settings['audio'].items():
167
- logger.info('%s: %s', key, value)
168
-
169
- logger.info('Paper settings')
170
- for key, value in settings['paper'].items():
171
- logger.info('%s: %s', key, value)
172
-
173
- logger.info('Searching arxiv: %s...', args.id)
174
- paper.search_by_arxiv_id(arxiv_id=args.id)
175
- # Get the sections
176
- sections = paper.sections
177
- if args.output is None:
178
- for section in sections:
179
- audio.read_article(section['header'])
161
+ # Print the information
162
+ logger.info('Configuration file: %s', config_path)
163
+ logger.info('Audio settings')
164
+ for key, value in settings['audio'].items():
165
+ logger.info('%s: %s', key, value)
166
+
167
+ logger.info('Paper settings')
168
+ for key, value in settings['paper'].items():
169
+ logger.info('%s: %s', key, value)
170
+
171
+ logger.info('Searching arxiv: %s...', args.id)
172
+ paper.search_by_arxiv_id(arxiv_id=args.id)
173
+ # Get the sections
174
+ sections = paper.sections
175
+ if args.output is None:
176
+ for section in sections:
177
+ audio.read_article(section['header'])
178
+ time.sleep(1)
179
+ for content in section['content']:
180
+ audio.read_article(content)
180
181
  time.sleep(1)
181
- for content in section['content']:
182
- audio.read_article(content)
183
- time.sleep(1)
184
- else:
185
- article = []
186
- for section in sections:
187
- if section['header'] is not None:
188
- article.append(section['header'])
189
- if section['content'] is not None:
190
- article += section['content']
191
- article = " ".join(article)
192
- logger.info('Saving audio...')
193
- audio.save_article(filename=args.output, article=article)
194
- logger.info('Audio is saved to %s.', args.output)
182
+ else:
183
+ article = []
184
+ for section in sections:
185
+ if section['header'] is not None:
186
+ article.append(section['header'])
187
+ if section['content'] is not None:
188
+ article += section['content']
189
+ article = " ".join(article)
190
+ logger.info('Saving audio...')
191
+ audio.save_article(filename=args.output, article=article)
192
+ logger.info('Audio is saved to %s.', args.output)
@@ -186,10 +186,10 @@ def test_read_article_with_non_string_input(mock_init, caplog):
186
186
  assert "is not str. Skipping." in caplog.text
187
187
 
188
188
 
189
- @patch('audioarxiv.audio.base.pyttsx3.init') # control TTS engine
189
+ @patch("audioarxiv.audio.base.pyttsx3.init")
190
190
  def test_audio_stop(mock_init):
191
191
  mock_engine = MagicMock()
192
- mock_init.return_value = mock_engine # Ensure the mock engine is returned
192
+ mock_init.return_value = mock_engine
193
193
 
194
194
  # Create the Audio instance, which should use the mocked engine
195
195
  audio = Audio()
@@ -199,3 +199,75 @@ def test_audio_stop(mock_init):
199
199
 
200
200
  # Verify that the stop method was called on the mocked engine
201
201
  mock_engine.stop.assert_called_once()
202
+
203
+
204
+ @patch("audioarxiv.audio.base.pyttsx3.init")
205
+ def test_validate_arguments_enabled(mock_init):
206
+ mock_engine = MagicMock()
207
+ mock_init.return_value = mock_engine
208
+
209
+ # Arrange
210
+ with patch("audioarxiv.audio.base.validate_audio_arguments") as mock_validate:
211
+ mock_validate.return_value = {
212
+ "rate": 150,
213
+ "volume": 0.8,
214
+ "voice": "voice_id",
215
+ "pause_seconds": 0.2
216
+ }
217
+
218
+ # Act
219
+ audio = Audio(rate=150, # noqa: F841 # pylint: disable=unused-variable
220
+ volume=0.8,
221
+ voice="voice_id",
222
+ pause_seconds=0.2,
223
+ validate_arguments=True)
224
+
225
+ # Assert
226
+ mock_validate.assert_called_once()
227
+ mock_engine.setProperty.assert_any_call('rate', 150)
228
+ mock_engine.setProperty.assert_any_call('volume', 0.8)
229
+ mock_engine.setProperty.assert_any_call('voice', 'voice_id')
230
+
231
+
232
+ @patch("audioarxiv.audio.base.pyttsx3.init")
233
+ def test_validate_arguments_disabled(mock_init):
234
+ mock_engine = MagicMock()
235
+ mock_init.return_value = mock_engine
236
+ # Should not call `validate_audio_arguments`
237
+ with patch("audioarxiv.audio.base.validate_audio_arguments") as mock_validate:
238
+ audio = Audio(rate=150, # noqa: F841 # pylint: disable=unused-variable
239
+ volume=0.8,
240
+ voice="voice_id",
241
+ pause_seconds=0.2,
242
+ validate_arguments=False)
243
+
244
+ mock_validate.assert_not_called()
245
+ mock_engine.setProperty.assert_any_call('rate', 150)
246
+ mock_engine.setProperty.assert_any_call('volume', 0.8)
247
+ mock_engine.setProperty.assert_any_call('voice', 'voice_id')
248
+
249
+
250
+ @pytest.mark.parametrize("rate", [100, None])
251
+ @patch("audioarxiv.audio.base.pyttsx3.init")
252
+ def test_rate_handling(mock_init, rate):
253
+ mock_engine = MagicMock()
254
+ mock_init.return_value = mock_engine
255
+ audio = Audio(rate=rate, volume=0.8, validate_arguments=False) # noqa: F841 # pylint: disable=unused-variable
256
+ if rate is not None:
257
+ mock_engine.setProperty.assert_any_call('rate', rate)
258
+ else:
259
+ for call in mock_engine.setProperty.call_args_list:
260
+ assert call[0][0] != 'rate'
261
+
262
+
263
+ @pytest.mark.parametrize("volume", [0.5, None])
264
+ @patch("audioarxiv.audio.base.pyttsx3.init")
265
+ def test_volume_handling(mock_init, volume):
266
+ mock_engine = MagicMock()
267
+ mock_init.return_value = mock_engine
268
+ audio = Audio(rate=140, volume=volume, validate_arguments=False) # noqa: F841 # pylint: disable=unused-variable
269
+ if volume is not None:
270
+ mock_engine.setProperty.assert_any_call('volume', volume)
271
+ else:
272
+ for call in mock_engine.setProperty.call_args_list:
273
+ assert call[0][0] != 'volume'
@@ -181,3 +181,67 @@ def test_sections_when_no_paper(mock_client_class, caplog):
181
181
  # Assertions
182
182
  assert 'Paper is None. Cannot download PDF.' in caplog.text
183
183
  assert len(sections) == 0 # No sections should be found since paper is None
184
+
185
+
186
+ @patch("fitz.open")
187
+ @patch.object(Paper, "download_pdf")
188
+ def test_sections_extraction_logic(download_pdf_mock, fitz_open_mock):
189
+ # Setup fake PDF text blocks
190
+ mock_page = MagicMock()
191
+ mock_page.get_text.return_value = [
192
+ (0, 0, 100, 100, "1 Introduction", 0, 0, 0),
193
+ (0, 0, 100, 100, "This is the first paragraph.", 0, 0, 0),
194
+ (0, 0, 100, 100, "2 Related Work", 0, 0, 0),
195
+ (0, 0, 100, 100, "Some related work goes here.", 0, 0, 0),
196
+ ]
197
+
198
+ mock_doc = [mock_page]
199
+ fitz_open_mock.return_value = mock_doc
200
+
201
+ paper = Paper()
202
+ paper.paper = MagicMock()
203
+ download_pdf_mock.return_value = "mock_path"
204
+
205
+ sections = paper.sections
206
+
207
+ assert len(sections) == 2 # ✅ tests `if len(self._sections) == 0`
208
+ assert sections[0]["header"] == "1 Introduction"
209
+ assert sections[0]["content"] == ["This is the first paragraph."]
210
+ assert sections[1]["header"] == "2 Related Work"
211
+ assert sections[1]["content"] == ["Some related work goes here."]
212
+
213
+
214
+ @patch("fitz.open")
215
+ @patch.object(Paper, "download_pdf")
216
+ def test_sections_only_appends_nonempty_sections(download_pdf_mock, fitz_open_mock):
217
+ # Only content, no section header detected
218
+ mock_page = MagicMock()
219
+ mock_page.get_text.return_value = [
220
+ (0, 0, 100, 100, "Just some content without a header", 0, 0, 0)
221
+ ]
222
+
223
+ mock_doc = [mock_page]
224
+ fitz_open_mock.return_value = mock_doc
225
+
226
+ paper = Paper()
227
+ paper.paper = MagicMock()
228
+ download_pdf_mock.return_value = "mock_path"
229
+
230
+ sections = paper.sections
231
+
232
+ # Tests `if current_section["header"] or current_section["content"]`
233
+ assert len(sections) == 1
234
+ assert sections[0]["header"] is None
235
+ assert sections[0]["content"] == ["Just some content without a header"]
236
+
237
+
238
+ @patch("fitz.open")
239
+ @patch.object(Paper, "download_pdf")
240
+ def test_sections_empty_when_no_paper(download_pdf_mock, fitz_open_mock):
241
+ paper = Paper()
242
+ paper.paper = None # simulate not setting a paper
243
+
244
+ sections = paper.sections
245
+
246
+ # Triggers early return due to missing paper
247
+ assert sections == []
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import tempfile
6
+ from pathlib import Path
7
+ from unittest.mock import patch
8
+
9
+ from audioarxiv import (env_package_list, get_version_information,
10
+ loaded_modules_dict, setup_logger)
11
+
12
+
13
+ def test_get_version_information():
14
+ version = get_version_information()
15
+ assert isinstance(version, str)
16
+ assert len(version) > 0
17
+
18
+
19
+ def test_setup_logger_creates_handlers():
20
+ logger = logging.getLogger("test_logger")
21
+ logger.handlers = [] # Clear any existing handlers
22
+
23
+ with tempfile.TemporaryDirectory() as tmpdir:
24
+ setup_logger(logger, outdir=tmpdir, label="test", log_level="INFO", print_version=True)
25
+
26
+ # Verify handlers
27
+ assert any(isinstance(h, logging.StreamHandler) for h in logger.handlers)
28
+ assert any(isinstance(h, logging.FileHandler) for h in logger.handlers)
29
+
30
+ # Verify log file content
31
+ log_path = Path(tmpdir) / "test.log"
32
+ assert log_path.exists()
33
+ with open(log_path) as f:
34
+ content = f.read()
35
+ assert "audioarxiv version" in content.lower()
36
+
37
+
38
+ def test_loaded_modules_dict_structure():
39
+ modules = loaded_modules_dict()
40
+ assert isinstance(modules, dict)
41
+ for k, v in modules.items():
42
+ assert isinstance(k, str)
43
+ assert isinstance(v, str)
44
+
45
+
46
+ @patch("audioarxiv.subprocess.check_output")
47
+ def test_env_package_list_conda(mock_sub_proc):
48
+ mock_pkgs = [
49
+ {"name": "numpy", "version": "1.24.0"},
50
+ {"name": "pandas", "version": "2.0.0"},
51
+ ]
52
+ mock_sub_proc.return_value = json.dumps(mock_pkgs).encode("utf-8")
53
+
54
+ with patch("pathlib.Path.is_dir", return_value=True): # Simulate conda-meta directory
55
+ result = env_package_list()
56
+ assert isinstance(result, list)
57
+ assert all("name" in pkg and "version" in pkg for pkg in result)
58
+
59
+
60
+ @patch("audioarxiv.subprocess.check_output")
61
+ def test_env_package_list_pip(mock_sub_proc):
62
+ mock_pkgs = [
63
+ {"name": "requests", "version": "2.31.0"},
64
+ {"name": "flask", "version": "3.0.0"},
65
+ ]
66
+ mock_sub_proc.return_value = json.dumps(mock_pkgs).encode("utf-8")
67
+
68
+ with patch("pathlib.Path.is_dir", return_value=False): # Simulate no conda-meta
69
+ result = env_package_list()
70
+ assert isinstance(result, list)
71
+ assert all("name" in pkg and "version" in pkg for pkg in result)
72
+
73
+
74
+ @patch("audioarxiv.subprocess.check_output")
75
+ def test_env_package_list_as_dataframe(mock_sub_proc):
76
+ mock_pkgs = [
77
+ {"name": "torch", "version": "2.1.0"},
78
+ {"name": "scipy", "version": "1.11.0"},
79
+ ]
80
+ mock_sub_proc.return_value = json.dumps(mock_pkgs).encode("utf-8")
81
+
82
+ with patch("pathlib.Path.is_dir", return_value=False):
83
+ df = env_package_list(as_dataframe=True)
84
+ assert df.shape[0] == 2 # type: ignore[attr-defined]
85
+ assert "name" in df.columns # type: ignore[attr-defined]
86
+ assert "version" in df.columns # type: ignore[attr-defined]
@@ -0,0 +1,358 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import signal
7
+ import tempfile
8
+ from datetime import datetime
9
+ from unittest import mock
10
+ from unittest.mock import MagicMock, patch
11
+
12
+ import pytest
13
+ import pyttsx3
14
+
15
+ from audioarxiv.tools.main import (handle_exit, initialize_configuration, main,
16
+ save_settings)
17
+
18
+
19
+ @pytest.fixture
20
+ def mock_pyttsx3_init(monkeypatch):
21
+ mock_engine = MagicMock()
22
+ monkeypatch.setattr(pyttsx3, "init", lambda: mock_engine)
23
+ return mock_engine
24
+
25
+
26
+ @pytest.fixture
27
+ def mock_paper_object():
28
+ mock_paper = MagicMock()
29
+ mock_paper.title = "Test Title"
30
+ mock_paper.summary = "This is a test abstract."
31
+
32
+ author1 = MagicMock()
33
+ author1.name = "Alice"
34
+ author2 = MagicMock()
35
+ author2.name = "Bob"
36
+ mock_paper.authors = [author1, author2]
37
+
38
+ mock_paper.published = datetime(2022, 1, 1)
39
+ mock_paper.updated = datetime(2022, 1, 2)
40
+ return mock_paper
41
+
42
+
43
+ # Patch where classes/functions are used, not defined
44
+ @pytest.mark.integration
45
+ @patch("audioarxiv.tools.main.Audio")
46
+ @patch("audioarxiv.tools.main.Paper")
47
+ @patch("audioarxiv.tools.main.configargparse.ArgParser.parse_args")
48
+ def test_main_with_id_and_output(mock_parse_args, mock_Paper, mock_Audio):
49
+ mock_args = MagicMock()
50
+ mock_args.id = "1234.5678"
51
+ mock_args.output = "output.mp3"
52
+ mock_args.list_voices = False
53
+ mock_args.rate = None
54
+ mock_args.volume = None
55
+ mock_args.voice = None
56
+ mock_args.pause_seconds = None
57
+ mock_args.page_size = None
58
+ mock_args.delay_seconds = None
59
+ mock_args.num_retries = None
60
+ mock_parse_args.return_value = mock_args
61
+
62
+ mock_audio = mock_Audio.return_value
63
+ mock_paper = mock_Paper.return_value
64
+ mock_paper.sections = [
65
+ {'header': "Introduction", 'content': ["This is content."]},
66
+ {'header': None, 'content': ["More content."]}
67
+ ]
68
+
69
+ with patch("audioarxiv.tools.main.initialize_configuration") as mock_init_config:
70
+ mock_init_config.return_value = ({"audio": {}, "paper": {}}, "mock/config/path")
71
+
72
+ main()
73
+
74
+ mock_audio.save_article.assert_called_once()
75
+ assert mock_audio.save_article.call_args[1]["filename"] == "output.mp3"
76
+
77
+
78
+ @pytest.mark.integration
79
+ @patch("audioarxiv.tools.main.Audio")
80
+ @patch("audioarxiv.tools.main.configargparse.ArgParser.parse_args")
81
+ def test_main_list_voices(mock_parse_args, mock_Audio):
82
+ mock_args = MagicMock()
83
+ mock_args.list_voices = True
84
+ mock_parse_args.return_value = mock_args
85
+
86
+ mock_audio = mock_Audio.return_value
87
+
88
+ main()
89
+
90
+ mock_audio.list_voices.assert_called_once()
91
+
92
+
93
+ @pytest.mark.integration
94
+ @patch("audioarxiv.tools.main.validate_audio_arguments")
95
+ @patch("audioarxiv.tools.main.validate_paper_arguments")
96
+ def test_initialize_configuration_defaults(mock_validate_paper, mock_validate_audio):
97
+ mock_validate_audio.return_value = {'rate': 140, 'volume': 0.9, 'voice': None, 'pause_seconds': 0.1}
98
+ mock_validate_paper.return_value = {'page_size': 100, 'delay_seconds': 3.0, 'num_retries': 3}
99
+
100
+ dummy_args = MagicMock()
101
+ for attr in ['rate', 'volume', 'voice', 'pause_seconds', 'page_size', 'delay_seconds', 'num_retries']:
102
+ setattr(dummy_args, attr, None)
103
+
104
+ with tempfile.TemporaryDirectory() as tmp_dir_name:
105
+ config_path = os.path.join(tmp_dir_name, 'config.json') # noqa: F841 # pylint: disable=unused-variable
106
+
107
+ with patch("audioarxiv.tools.main.user_config_dir", return_value=tmp_dir_name):
108
+ settings, path = initialize_configuration(dummy_args)
109
+ assert settings['audio']['rate'] == 140
110
+ assert os.path.exists(path)
111
+
112
+
113
+ @pytest.mark.integration
114
+ @patch("builtins.open", new_callable=mock.mock_open)
115
+ def test_save_settings(mock_open_func):
116
+ settings = {"audio": {"rate": 150}, "paper": {"page_size": 50}}
117
+ save_settings("config.json", settings)
118
+ mock_open_func.assert_called_once_with("config.json", 'w', encoding='utf-8')
119
+ handle = mock_open_func()
120
+ handle.write.assert_called()
121
+
122
+
123
+ @pytest.mark.integration
124
+ @patch("audioarxiv.tools.main.sys.exit")
125
+ def test_handle_exit(mock_exit):
126
+ with patch("audioarxiv.tools.main.logger.info") as mock_logger:
127
+ handle_exit(signal.SIGINT, None)
128
+ mock_logger.assert_called_once()
129
+ mock_exit.assert_called_once_with(0)
130
+
131
+
132
+ @patch('builtins.open', side_effect=IOError('Mocked IOError during file open'))
133
+ def test_save_settings_throws_exception(mock_open, caplog):
134
+ config_path = "test_config.json"
135
+ settings = {
136
+ 'audio': {
137
+ 'rate': 140,
138
+ 'volume': 0.9,
139
+ 'voice': None,
140
+ 'pause_seconds': 0.1
141
+ },
142
+ 'paper': {
143
+ 'page_size': 100,
144
+ 'delay_seconds': 3.0,
145
+ 'num_retries': 3
146
+ }
147
+ }
148
+
149
+ logger = logging.getLogger('audioarxiv')
150
+ logger.setLevel(logging.ERROR)
151
+ logger.propagate = True
152
+
153
+ with caplog.at_level(logging.ERROR, logger='audioarxiv'):
154
+ # Call save_settings, which should raise an exception
155
+ save_settings(config_path, settings)
156
+
157
+ # Assert that the open function was called (even though it will raise an exception)
158
+ mock_open.assert_called_once_with(config_path, 'w', encoding="utf-8")
159
+
160
+ assert 'Error saving settings: Mocked IOError during file open' in caplog.text
161
+
162
+
163
+ @patch('audioarxiv.tools.main.user_config_dir', return_value='.') # Mock user_config_dir
164
+ @patch('os.path.exists')
165
+ @patch('builtins.open', new_callable=MagicMock) # Mock the open function to read the file
166
+ @patch('json.load') # Mock json.load to simulate loading settings
167
+ @patch('audioarxiv.audio.base.validate_audio_arguments') # Mock the validate_audio_arguments function
168
+ @patch('audioarxiv.resources.paper.validate_paper_arguments') # Mock the validate_paper_arguments function
169
+ @patch("audioarxiv.resources.paper.arxiv.Client")
170
+ @patch("configargparse.ArgParser.parse_args")
171
+ def test_load_settings(mock_parse_args,
172
+ mock_client_class,
173
+ mock_validate_paper,
174
+ mock_validate_audio,
175
+ mock_json_load,
176
+ mock_open,
177
+ mock_exists,
178
+ mock_user_config_dir,
179
+ mock_pyttsx3_init,
180
+ mock_paper_object,
181
+ caplog):
182
+ # Set up mock arguments
183
+ mock_args = MagicMock()
184
+ mock_args.id = "1234.5678"
185
+ mock_args.output = None
186
+ mock_args.list_voices = False
187
+ mock_args.rate = None
188
+ mock_args.volume = None
189
+ mock_args.voice = None
190
+ mock_args.pause_seconds = None
191
+ mock_args.page_size = None
192
+ mock_args.delay_seconds = None
193
+ mock_args.num_retries = None
194
+ mock_parse_args.return_value = mock_args
195
+
196
+ mock_engine = mock_pyttsx3_init() # noqa: F841
197
+
198
+ mock_client = MagicMock()
199
+ mock_client.results.return_value = iter([mock_paper_object])
200
+ mock_client_class.return_value = mock_client
201
+
202
+ mock_paper_object.download_pdf.return_value = "path/to/pdf"
203
+
204
+ # Set up the logger
205
+ logger = logging.getLogger('audioarxiv')
206
+ logger.setLevel(logging.DEBUG)
207
+ logger.propagate = True
208
+
209
+ # Sample settings to be loaded
210
+ config_dir = mock_user_config_dir('audioarxiv') # Assuming this function is defined correctly
211
+ config_file = 'config.json'
212
+ config_path = os.path.join(config_dir, config_file)
213
+ settings_from_file = {
214
+ 'audio': {
215
+ 'rate': 150,
216
+ 'volume': 1.0,
217
+ 'voice': None,
218
+ 'pause_seconds': 0.2
219
+ },
220
+ 'paper': {
221
+ 'page_size': 50,
222
+ 'delay_seconds': 2.0,
223
+ 'num_retries': 5
224
+ }
225
+ }
226
+
227
+ # Mock file reading
228
+ mock_file = MagicMock()
229
+ mock_file.read.return_value = b'mocked file content'
230
+ mock_open.return_value.__enter__.return_value = mock_file
231
+ mock_json_load.return_value = settings_from_file
232
+
233
+ # Mock validation functions
234
+ mock_validate_audio.return_value = settings_from_file['audio']
235
+ mock_validate_paper.return_value = settings_from_file['paper']
236
+
237
+ # Mock file existence
238
+ mock_exists.return_value = True
239
+
240
+ with caplog.at_level(logging.ERROR, logger='audioarxiv'):
241
+ # Mock the `download_pdf` and `fitz.open` methods
242
+ with patch('fitz.open') as mock_fitz_open:
243
+ mock_page = MagicMock()
244
+ mock_page.get_text.return_value = [[None, None, None, None, "SECTION HEADER\n"],
245
+ [None, None, None, None, "Section Content"]]
246
+ mock_fitz_open.return_value = [mock_page]
247
+ main()
248
+
249
+ # Verify config_path was checked
250
+ mock_exists.assert_any_call(config_path)
251
+ mock_json_load.assert_called_once_with(mock_file)
252
+
253
+
254
+ @patch('audioarxiv.tools.main.user_config_dir', return_value='.') # Mock user_config_dir
255
+ @patch('os.path.exists')
256
+ @patch('builtins.open', new_callable=MagicMock) # Mock the open function to read the file
257
+ @patch('json.load') # Mock json.load to simulate loading settings
258
+ @patch('audioarxiv.audio.base.validate_audio_arguments') # Mock the validate_audio_arguments function
259
+ @patch('audioarxiv.resources.paper.validate_paper_arguments') # Mock the validate_paper_arguments function
260
+ @patch("audioarxiv.resources.paper.arxiv.Client")
261
+ @patch("configargparse.ArgParser.parse_args")
262
+ def test_load_settings_error(mock_parse_args,
263
+ mock_client_class,
264
+ mock_validate_paper,
265
+ mock_validate_audio,
266
+ mock_json_load,
267
+ mock_open,
268
+ mock_exists,
269
+ mock_user_config_dir,
270
+ mock_pyttsx3_init,
271
+ mock_paper_object,
272
+ caplog):
273
+ # Set up mock arguments
274
+ mock_args = MagicMock()
275
+ mock_args.id = "1234.5678"
276
+ mock_args.output = None
277
+ mock_args.list_voices = False
278
+ mock_args.rate = None
279
+ mock_args.volume = None
280
+ mock_args.voice = None
281
+ mock_args.pause_seconds = None
282
+ mock_args.page_size = None
283
+ mock_args.delay_seconds = None
284
+ mock_args.num_retries = None
285
+ mock_parse_args.return_value = mock_args
286
+
287
+ mock_engine = mock_pyttsx3_init() # noqa: F841
288
+
289
+ mock_client = MagicMock()
290
+ mock_client.results.return_value = iter([mock_paper_object])
291
+ mock_client_class.return_value = mock_client
292
+
293
+ mock_paper_object.download_pdf.return_value = "path/to/pdf"
294
+
295
+ # Set up the logger
296
+ logger = logging.getLogger('audioarxiv')
297
+ logger.setLevel(logging.DEBUG)
298
+ logger.propagate = True
299
+
300
+ # Sample settings to be loaded
301
+ config_dir = mock_user_config_dir('audioarxiv') # Assuming this function is defined correctly
302
+ config_file = 'config.json'
303
+ config_path = os.path.join(config_dir, config_file)
304
+
305
+ settings_from_file = {
306
+ 'audio': {
307
+ 'rate': 150,
308
+ 'volume': 1.0,
309
+ 'voice': None,
310
+ 'pause_seconds': 0.2
311
+ },
312
+ 'paper': {
313
+ 'page_size': 50,
314
+ 'delay_seconds': 2.0,
315
+ 'num_retries': 5
316
+ }
317
+ }
318
+
319
+ # Mock validation functions
320
+ mock_validate_audio.return_value = settings_from_file['audio']
321
+ mock_validate_paper.return_value = settings_from_file['paper']
322
+
323
+ # Set up the mock to simulate file reading failure (File not found error)
324
+ mock_exists.return_value = False # Simulate that the file doesn't exist
325
+ with caplog.at_level(logging.INFO, logger='audioarxiv'):
326
+ with patch('fitz.open') as mock_fitz_open:
327
+ mock_page = MagicMock()
328
+ mock_page.get_text.return_value = [[None, None, None, None, "SECTION HEADER\n"],
329
+ [None, None, None, None, "Section Content"]]
330
+ mock_fitz_open.return_value = [mock_page]
331
+ main()
332
+
333
+ # Ensure the file existence check was called and failed
334
+ mock_exists.assert_any_call(config_path)
335
+ # Check for the error in logs
336
+ assert 'Saving default settings to ./config.json...' in caplog.text
337
+
338
+ # Now simulate an invalid JSON error
339
+ mock_exists.return_value = True
340
+ mock_file = MagicMock()
341
+ mock_file.read.return_value = b'mocked file content'
342
+ mock_open.return_value.__enter__.return_value = mock_file
343
+ mock_json_load.side_effect = json.JSONDecodeError("Expecting value", "doc", 0) # Simulate JSONDecodeError
344
+ mock_client.results.return_value = iter([mock_paper_object])
345
+
346
+ caplog.clear()
347
+ with caplog.at_level(logging.ERROR, logger='audioarxiv'):
348
+ with patch('fitz.open') as mock_fitz_open:
349
+ mock_page = MagicMock()
350
+ mock_page.get_text.return_value = [[None, None, None, None, "SECTION HEADER\n"],
351
+ [None, None, None, None, "Section Content"]]
352
+ mock_fitz_open.return_value = [mock_page]
353
+ main()
354
+
355
+ # Ensure json.load was called
356
+ mock_json_load.assert_called_once_with(mock_file)
357
+ # Check for the JSON error in logs
358
+ assert 'Error loading settings: Expecting value' in caplog.text
@@ -1,101 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import os
4
- import signal
5
- import tempfile
6
- from unittest import mock
7
- from unittest.mock import MagicMock, patch
8
-
9
- import pytest
10
-
11
- from audioarxiv.tools.main import (handle_exit, initialize_configuration, main,
12
- save_settings)
13
-
14
-
15
- # Patch where classes/functions are used, not defined
16
- @pytest.mark.integration
17
- @patch("audioarxiv.tools.main.Audio")
18
- @patch("audioarxiv.tools.main.Paper")
19
- @patch("audioarxiv.tools.main.configargparse.ArgParser.parse_args")
20
- def test_main_with_id_and_output(mock_parse_args, mock_Paper, mock_Audio):
21
- mock_args = MagicMock()
22
- mock_args.id = "1234.5678"
23
- mock_args.output = "output.mp3"
24
- mock_args.list_voices = False
25
- mock_args.rate = None
26
- mock_args.volume = None
27
- mock_args.voice = None
28
- mock_args.pause_seconds = None
29
- mock_args.page_size = None
30
- mock_args.delay_seconds = None
31
- mock_args.num_retries = None
32
- mock_parse_args.return_value = mock_args
33
-
34
- mock_audio = mock_Audio.return_value
35
- mock_paper = mock_Paper.return_value
36
- mock_paper.sections = [
37
- {'header': "Introduction", 'content': ["This is content."]},
38
- {'header': None, 'content': ["More content."]}
39
- ]
40
-
41
- with patch("audioarxiv.tools.main.initialize_configuration") as mock_init_config:
42
- mock_init_config.return_value = ({"audio": {}, "paper": {}}, "mock/config/path")
43
-
44
- main()
45
-
46
- mock_audio.save_article.assert_called_once()
47
- assert mock_audio.save_article.call_args[1]["filename"] == "output.mp3"
48
-
49
-
50
- @pytest.mark.integration
51
- @patch("audioarxiv.tools.main.Audio")
52
- @patch("audioarxiv.tools.main.configargparse.ArgParser.parse_args")
53
- def test_main_list_voices(mock_parse_args, mock_Audio):
54
- mock_args = MagicMock()
55
- mock_args.list_voices = True
56
- mock_parse_args.return_value = mock_args
57
-
58
- mock_audio = mock_Audio.return_value
59
-
60
- main()
61
-
62
- mock_audio.list_voices.assert_called_once()
63
-
64
-
65
- @pytest.mark.integration
66
- @patch("audioarxiv.tools.main.validate_audio_arguments")
67
- @patch("audioarxiv.tools.main.validate_paper_arguments")
68
- def test_initialize_configuration_defaults(mock_validate_paper, mock_validate_audio):
69
- mock_validate_audio.return_value = {'rate': 140, 'volume': 0.9, 'voice': None, 'pause_seconds': 0.1}
70
- mock_validate_paper.return_value = {'page_size': 100, 'delay_seconds': 3.0, 'num_retries': 3}
71
-
72
- dummy_args = MagicMock()
73
- for attr in ['rate', 'volume', 'voice', 'pause_seconds', 'page_size', 'delay_seconds', 'num_retries']:
74
- setattr(dummy_args, attr, None)
75
-
76
- with tempfile.TemporaryDirectory() as tmp_dir_name:
77
- config_path = os.path.join(tmp_dir_name, 'config.json') # noqa: F841 # pylint: disable=unused-variable
78
-
79
- with patch("audioarxiv.tools.main.user_config_dir", return_value=tmp_dir_name):
80
- settings, path = initialize_configuration(dummy_args)
81
- assert settings['audio']['rate'] == 140
82
- assert os.path.exists(path)
83
-
84
-
85
- @pytest.mark.integration
86
- @patch("builtins.open", new_callable=mock.mock_open)
87
- def test_save_settings(mock_open_func):
88
- settings = {"audio": {"rate": 150}, "paper": {"page_size": 50}}
89
- save_settings("config.json", settings)
90
- mock_open_func.assert_called_once_with("config.json", 'w', encoding='utf-8')
91
- handle = mock_open_func()
92
- handle.write.assert_called()
93
-
94
-
95
- @pytest.mark.integration
96
- @patch("audioarxiv.tools.main.sys.exit")
97
- def test_handle_exit(mock_exit):
98
- with patch("audioarxiv.tools.main.logger.info") as mock_logger:
99
- handle_exit(signal.SIGINT, None)
100
- mock_logger.assert_called_once()
101
- mock_exit.assert_called_once_with(0)