Repository: python-pillow/Pillow

Issue:
No documented way to programmatically request single iteration of gif animation
<!--
Thank you for reporting an issue.

Follow these guidelines to ensure your issue is handled properly.

If you have a ...

1.  General question: consider asking the question on Stack Overflow
    with the python-imaging-library tag:

    * https://stackoverflow.com/questions/tagged/python-imaging-library

    Do not ask a question in both places.

    If you think you have found a bug or have an unexplained exception
    then file a bug report here.

2.  Bug report: include a self-contained, copy-pastable example that
    generates the issue if possible. Be concise with code posted.
    Guidelines on how to provide a good bug report:

    * https://stackoverflow.com/help/mcve

    Bug reports which follow these guidelines are easier to diagnose,
    and are often handled much more quickly.

3.  Feature request: do a quick search of existing issues
    to make sure this has not been asked before.

We know asking good questions takes effort, and we appreciate your time.
Thank you.
-->

### What did you do?

Generate a .gif file using `loop=1`. 

### What did you expect to happen?

I expected this to cause the .gif animation to play through once and then not repeat. 

### What actually happened?

On some gif viewers the gif played once, on others it played twice. 

### What are your OS, Python and Pillow versions?

* OS: Windows 11
* Python: 3.9
* Pillow: 9.1.1

### Discussion

See this issue which spells out the problem in more detail: https://github.com/python-pillow/Pillow/issues/4575

I was able to resolve my issue by omitting the `loop` argument from my gif `save` call altogether. In this case 1 iteration of the animation was reliably played on all gif players I tried. But how can I access the omitted argument behavior while still including the argument? Suppose I have code like:

```python
def save_images_to_gif(images, gif_path, tot_duration, loop):
    duration = tot_duration / len(images)
    images[0].save(gif_path, save_all=True, append_iamges=images[1:], duration=duration, loop=loop)
```

What argument can a user of this function pass in for `loop` to get the same behavior as if no `loop` argument was supplied to `save`? 0 doesn't work, 1 doesn't work, `None` doesn't work. I don't know of any argument that works and as far as I can tell there is no such argument in the documentation.



Discussion:
There isn't one. The code just checks whether or not `loop` has been set at all.

https://github.com/python-pillow/Pillow/blob/c98a7994da83a416bfd0efc068bc7ebf5fc1d133/src/PIL/GifImagePlugin.py#L940-L950
Ok, thanks for the info and clarifying there is no way to do this. Would it be reasonable to request a way to be able to do this? For example using `None`, or some other sentinel, to realize this behavior?
I think `None` currently raises an exception, right? If so, it could probably check that `loop` isn't `None` without breaking anyone's code.
Yes, if I pass `loop=None` I get `error: required argument is not an integer`.

Resolving patch:
diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py
--- a/src/PIL/GifImagePlugin.py
+++ b/src/PIL/GifImagePlugin.py
@@ -912,7 +912,7 @@ def _get_global_header(im, info):
         info
         and (
             "transparency" in info
-            or "loop" in info
+            or info.get("loop") is not None
             or info.get("duration")
             or info.get("comment")
         )
@@ -937,7 +937,7 @@ def _get_global_header(im, info):
         # Global Color Table
         _get_header_palette(palette_bytes),
     ]
-    if "loop" in info:
+    if info.get("loop") is not None:
         header.append(
             b"!"
             + o8(255)  # extension intro


Test patch:
diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py
--- a/Tests/test_file_gif.py
+++ b/Tests/test_file_gif.py
@@ -875,6 +875,14 @@ def test_identical_frames_to_single_frame(duration, tmp_path):
         assert reread.info["duration"] == 8500
 
 
+def test_loop_none(tmp_path):
+    out = str(tmp_path / "temp.gif")
+    im = Image.new("L", (100, 100), "#000")
+    im.save(out, loop=None)
+    with Image.open(out) as reread:
+        assert "loop" not in reread.info
+
+
 def test_number_of_loops(tmp_path):
     number_of_loops = 2