Repository: facelessuser/pymdown-extensions

Issue:
Allow snippets to only show certain user defined lines from the included file
### Description

When using `literalinclude` in a rst file you can specify the lines from the included file that are shown, e.g.:

```rst
.. literalinclude:: example.py
   :lines: 1,3,5-10,20-
```

Would it be possible to include something similar for snippets, using, e.g.:

```md
-->8-- "example.py" lines: 1,3,5-10,20-
```

### Benefits

This would allow users to only show required parts of a file, e.g., just show one function from a larger Python file.

### Solution Idea

My regular expression knowlegde isn't good enough to know how to add this into `RE_ALL_SNIPPETS`, but if the line numbers can be read and converted into a list of lines to include (in, say, a variable called `includelines`), then I assume:

```python
with codecs.open(snippet, 'r', encoding=self.encoding) as f:
    new_lines.extend(
        [space + l2 for l2 in self.parse_snippets([l.rstrip('\r\n') for l in f], snippet)]
    )
```

could be changed to:

```python
with codecs.open(snippet, 'r', encoding=self.encoding) as f:
    snippetlines = f.readlines()
    new_lines.extend(
        [space + l2 for l2 in self.parse_snippets([snippetlines[i].rstrip('\r\n') for i in includelines], snippet)]
    )
```

Discussion:
facelessuser: @gir-bot lgtm

Patch:
diff --git a/docs/src/markdown/about/changelog.md b/docs/src/markdown/about/changelog.md
index 0bd3ec0c..cf06c843 100644
--- a/docs/src/markdown/about/changelog.md
+++ b/docs/src/markdown/about/changelog.md
@@ -3,6 +3,7 @@
 ## 9.6
 
 - **NEW**: Add options for generating tab IDs from tab titles.
+- **NEW**: Add support for specifying specific lines for Snippets.
 
 ## 9.5
 
diff --git a/docs/src/markdown/extensions/snippets.md b/docs/src/markdown/extensions/snippets.md
index 3b0f73e0..03adb5b3 100644
--- a/docs/src/markdown/extensions/snippets.md
+++ b/docs/src/markdown/extensions/snippets.md
@@ -100,6 +100,16 @@ include.md
 ; skip.md
 --8&lt;--</code></pre>
 
+## Snippet Lines
+
+When specifying a snippet, you can specify which lines of the Snippet file that you wish to include. To specify line
+numbers, simply append the start and/or end to the end of the file name with each number separated with `:`.
+
+- To specify extraction of content to start at a specific line number, simply use `file.md:3`.
+- To extract all content up to a specific line, use `file.md::3`. This will extract lines 1 - 3.
+- To extract all content starting at a specific line up to another line, use `file.md:4:6`. This will extract lines
+  4 - 6.
+
 ## Formatting Snippets
 
 When inserting your snippet, it is important to remember that some snippets may need whitespace around them.
diff --git a/pymdownx/snippets.py b/pymdownx/snippets.py
index 7460d0f5..c6702536 100644
--- a/pymdownx/snippets.py
+++ b/pymdownx/snippets.py
@@ -61,6 +61,8 @@ class SnippetPreprocessor(Preprocessor):
         '''
     )
 
+    RE_SNIPPET_FILE = re.compile(r'(.*?)(:[0-9]*)?(:[0-9]*)?$')
+
     def __init__(self, config, md):
         """Initialize."""
 
@@ -112,7 +114,7 @@ class SnippetPreprocessor(Preprocessor):
             # Fail if status is not OK
             status = response.status if util.PY39 else response.code
             if status != 200:
-                raise SnippetMissingError('Cannot download snippet {}'.format(url))
+                raise SnippetMissingError("Cannot download snippet '{}'".format(url))
 
             # We provide some basic protection against absurdly large files.
             # 32MB is chosen as an arbitrary upper limit. This can be raised if desired.
@@ -187,6 +189,24 @@ class SnippetPreprocessor(Preprocessor):
                 if path.startswith('; '):
                     continue
 
+                # Get line numbers (if specified)
+                end = None
+                start = None
+                m = self.RE_SNIPPET_FILE.match(path)
+                path = m.group(1).strip()
+                # Looks like we have an empty file and only lines specified
+                if not path:
+                    if self.check_paths:
+                        raise SnippetMissingError("Snippet at path '{}' could not be found".format(path))
+                    else:
+                        continue
+                ending = m.group(3)
+                if ending and len(ending) > 1:
+                    end = int(ending[1:])
+                starting = m.group(2)
+                if starting and len(starting) > 1:
+                    start = max(1, int(starting[1:]) - 1)
+
                 # Ignore path links if we are in external, downloaded content
                 is_link = path.lower().startswith(('https://', 'http://'))
                 if is_url and not is_link:
@@ -207,10 +227,16 @@ class SnippetPreprocessor(Preprocessor):
                         # Read file content
                         with codecs.open(snippet, 'r', encoding=self.encoding) as f:
                             s_lines = [l for l in f]
+                            if start is not None or end is not None:
+                                s = slice(start, end)
+                                s_lines = s_lines[s]
                     else:
                         # Read URL content
                         try:
                             s_lines = self.download(snippet)
+                            if start is not None or end is not None:
+                                s = slice(start, end)
+                                s_lines = s_lines[s]
                         except SnippetMissingError:
                             if self.check_paths:
                                 raise
@@ -228,7 +254,7 @@ class SnippetPreprocessor(Preprocessor):
                     )
 
                 elif self.check_paths:
-                    raise SnippetMissingError("Snippet at path %s could not be found" % path)
+                    raise SnippetMissingError("Snippet at path '{}' could not be found".format(path))
 
         # Pop the current file name out of the cache
         if file_name:


Test patch:
diff --git a/tests/test_extensions/_snippets/lines.txt b/tests/test_extensions/_snippets/lines.txt
new file mode 100644
index 00000000..a6b1847e
--- /dev/null
+++ b/tests/test_extensions/_snippets/lines.txt
@@ -0,0 +1,9 @@
+This is a multi-line
+snippet.
+
+Content resides on various lines.
+If we use line specifiers,
+we can select any number of lines we want.
+
+This is the end of the file.
+There is no more.
diff --git a/tests/test_extensions/test_snippets.py b/tests/test_extensions/test_snippets.py
index 01bbbf96..da2dd31c 100644
--- a/tests/test_extensions/test_snippets.py
+++ b/tests/test_extensions/test_snippets.py
@@ -119,6 +119,110 @@ class TestSnippets(util.MdCase):
             True
         )
 
+    def test_start_line_inline(self):
+        """Test starting line with inline syntax."""
+
+        self.check_markdown(
+            R'''
+            ---8<--- "lines.txt:4"
+            ''',
+            '''
+            <p>Content resides on various lines.
+            If we use line specifiers,
+            we can select any number of lines we want.</p>
+            <p>This is the end of the file.
+            There is no more.</p>
+            ''',
+            True
+        )
+
+    def test_end_line_inline(self):
+        """Test ending line with inline syntax."""
+
+        self.check_markdown(
+            R'''
+            ---8<--- "lines.txt::6"
+            ''',
+            '''
+            <p>This is a multi-line
+            snippet.</p>
+            <p>Content resides on various lines.
+            If we use line specifiers,
+            we can select any number of lines we want.</p>
+            ''',
+            True
+        )
+
+    def test_start_end_line_inline(self):
+        """Test starting and ending line with inline syntax."""
+
+        self.check_markdown(
+            R'''
+            ---8<--- "lines.txt:4:6"
+            ''',
+            '''
+            <p>Content resides on various lines.
+            If we use line specifiers,
+            we can select any number of lines we want.</p>
+            ''',
+            True
+        )
+
+    def test_start_line_block(self):
+        """Test starting line with block syntax."""
+
+        self.check_markdown(
+            R'''
+            --8<--
+            lines.txt:4
+            --8<--
+            ''',
+            '''
+            <p>Content resides on various lines.
+            If we use line specifiers,
+            we can select any number of lines we want.</p>
+            <p>This is the end of the file.
+            There is no more.</p>
+            ''',
+            True
+        )
+
+    def test_end_line_block(self):
+        """Test ending line with block syntax."""
+
+        self.check_markdown(
+            R'''
+            --8<--
+            lines.txt::6
+            --8<--
+            ''',
+            '''
+            <p>This is a multi-line
+            snippet.</p>
+            <p>Content resides on various lines.
+            If we use line specifiers,
+            we can select any number of lines we want.</p>
+            ''',
+            True
+        )
+
+    def test_start_end_line_block(self):
+        """Test starting and ending line with block syntax."""
+
+        self.check_markdown(
+            R'''
+            --8<--
+            lines.txt:4:6
+            --8<--
+            ''',
+            '''
+            <p>Content resides on various lines.
+            If we use line specifiers,
+            we can select any number of lines we want.</p>
+            ''',
+            True
+        )
+
 
 class TestSnippetsFile(util.MdCase):
     """Test snippet file case."""
@@ -229,6 +333,19 @@ class TestSnippetsMissing(util.MdCase):
                 True
             )
 
+    def test_missing_lines(self):
+        """Test missing file with line numbers."""
+
+        with self.assertRaises(SnippetMissingError):
+            self.check_markdown(
+                R'''
+                --8<-- ":3:4"
+                ''',
+                '''
+                ''',
+                True
+            )
+
 
 class TestSnippetsGracefulMissing(util.MdCase):
     """Test snippet file case."""
@@ -267,6 +384,18 @@ class TestSnippetsGracefulMissing(util.MdCase):
             True
         )
 
+    def test_missing_lines(self):
+        """Test missing file with lines."""
+
+        self.check_markdown(
+            R'''
+            --8<-- ":3:4"
+            ''',
+            '''
+            ''',
+            True
+        )
+
 
 class TestURLSnippets(util.MdCase):
     """Test snippet URL cases."""
@@ -371,6 +500,37 @@ class TestURLSnippets(util.MdCase):
             True
         )
 
+    @patch('urllib.request.urlopen')
+    def test_url_lines(self, mock_urlopen):
+        """Test nested file in URL."""
+
+        content = []
+        length = 0
+        with open('tests/test_extensions/_snippets/lines.txt', 'rb') as f:
+            for l in f:
+                length += len(l)
+                content.append(l)
+
+        cm = MagicMock()
+        cm.status = 200
+        cm.code = 200
+        cm.readlines.return_value = content
+        cm.headers = {'content-length': '183'}
+        cm.__enter__.return_value = cm
+        mock_urlopen.return_value = cm
+
+        self.check_markdown(
+            R'''
+            --8<-- "https://test.com/myfile.md:4:6"
+            ''',
+            '''
+            <p>Content resides on various lines.
+            If we use line specifiers,
+            we can select any number of lines we want.</p>
+            ''',
+            True
+        )
+
     @patch('urllib.request.urlopen')
     def test_missing(self, mock_urlopen):
         """Test missing URL."""