Metadata-Version: 2.4
Name: loopinfo
Version: 0.1.0
Summary: Know where you are in a for-loop: first, last, index, count, previous and next, like Jinja's loop object.
Author-email: Khalid Sulaiman Al-Mulaify <khalidpythonist@gmail.com>
License-Expression: MIT
Keywords: loop,iteration,first,last,enumerate,beginners
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Education
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# loopinfo

loopinfo tells your `for` loop where it is: whether this is the first item, the last item, which number it is, and what comes before and after it. You write `for item, loop in loopinfo(items):` and then simply ask `loop.first`, `loop.last` or `loop.index`, with no counting and no index arithmetic.

## The itch

A plain Python `for` loop hands you one item at a time, and that is all it tells you. It does not know whether you are at the beginning, in the middle or at the very end. Most of the time that is fine. But sooner or later you meet one of these small, everyday problems.

### 1. The trailing comma

You want to print a list of fruits separated by commas.

```python
fruits = ["apple", "banana", "cherry"]

for fruit in fruits:
    print(fruit, end=", ")
```

```text
apple, banana, cherry, 
```

Almost. There is an extra comma and space after the last fruit, because the loop had no way of knowing that "cherry" was the last one.

### 2. Putting "and" before the last item

You want to write a friendly sentence.

```python
friends = ["Sara", "Omar", "Lina"]

sentence = "I invited "
for friend in friends:
    sentence += friend + ", "
print(sentence)
```

```text
I invited Sara, Omar, Lina, 
```

A person would write "Sara, Omar, and Lina." To do that, the loop has to treat the last name differently. The usual workaround looks like this:

```python
friends = ["Sara", "Omar", "Lina"]

sentence = "I invited "
for i, friend in enumerate(friends):
    if i == len(friends) - 1:
        sentence += "and " + friend + "."
    else:
        sentence += friend + ", "
print(sentence)
```

```text
I invited Sara, Omar, and Lina.
```

It works, but look at how much you had to know to write that one line, `if i == len(friends) - 1`. You had to know that `enumerate` exists and gives you a counter. You had to know that the counter starts at 0, not 1. You had to know that `len()` counts from 1, so the last position is one less than the length. And you had to know that `len()` only works on things like lists. It does not work on a generator or a file, so this trick quietly breaks the day your data comes from somewhere else.

### 3. "Task 2 of 5"

You want to number your tasks the way a person would.

```python
tasks = ["wake up", "make tea", "write code", "take a walk", "sleep"]

number = 0
for task in tasks:
    number += 1
    print("Task", number, "of", len(tasks), ":", task)
```

```text
Task 1 of 5 : wake up
Task 2 of 5 : make tea
Task 3 of 5 : write code
Task 4 of 5 : take a walk
Task 5 of 5 : sleep
```

It does the job, but you are now keeping your own counter by hand, remembering to add 1 at the right moment, and asking for the length separately.

None of these problems is hard. They are just small itches that every Python learner scratches over and over, each time a little differently.

## The scratch

Here are the same three problems with loopinfo. The only change to the loop itself is that you get a second name, `loop`, and you can ask it questions.

### 1. No trailing comma

```python
from loopinfo import loopinfo

fruits = ["apple", "banana", "cherry"]

for fruit, loop in loopinfo(fruits):
    if loop.last:
        print(fruit)
    else:
        print(fruit, end=", ")
```

```text
apple, banana, cherry
```

### 2. "and" before the last item

```python
from loopinfo import loopinfo

friends = ["Sara", "Omar", "Lina"]

sentence = "I invited "
for friend, loop in loopinfo(friends):
    if loop.last:
        sentence += "and " + friend + "."
    else:
        sentence += friend + ", "
print(sentence)
```

```text
I invited Sara, Omar, and Lina.
```

### 3. "Task 2 of 5"

```python
from loopinfo import loopinfo

tasks = ["wake up", "make tea", "write code", "take a walk", "sleep"]

for task, loop in loopinfo(tasks):
    print(f"Task {loop.index} of {loop.count}: {task}")
```

```text
Task 1 of 5: wake up
Task 2 of 5: make tea
Task 3 of 5: write code
Task 4 of 5: take a walk
Task 5 of 5: sleep
```

Notice what you did not have to think about: where counting starts, subtracting one, or keeping your own counter. You asked the question you actually had in mind, "is this the last one?", and got an answer.

## Install

```text
pip install loopinfo
```

loopinfo is written in plain Python, has no dependencies, and works on Python 3.9 and newer.

## Everything loop knows

| Attribute | What it tells you |
| --- | --- |
| `loop.first` | `True` on the first item, `False` on all the others. |
| `loop.last` | `True` on the last item, `False` on all the others. |
| `loop.index` | The position of this item, counting from 1, the way people count. |
| `loop.index0` | The position of this item, counting from 0, the way Python indexes lists. |
| `loop.count` | The total number of items, or `None` if it cannot be known yet. |
| `loop.remaining` | How many items are still to come after this one, or `None` if that cannot be known yet. |
| `loop.even` | `True` on the 2nd, 4th, 6th item and so on. |
| `loop.odd` | `True` on the 1st, 3rd, 5th item and so on, so the first item is odd. |
| `loop.previous` | The item that came just before this one, or `None` on the first item. |
| `loop.next` | The item that comes just after this one, or `None` on the last item. |
| `loop.cycle(a, b, ...)` | Picks one of the values you give it in turn: `a` for the first item, `b` for the second, and round again. |

The `loop` object is read-only. It describes the loop, so if you try to change it, for example `loop.first = False`, Python raises an `AttributeError` that explains why.

Here is a program that prints every one of these for each item in a three-item list:

```python
from loopinfo import loopinfo

colors = ["red", "green", "blue"]

for color, loop in loopinfo(colors):
    print(f"--- {color} ---")
    print("first:    ", loop.first)
    print("last:     ", loop.last)
    print("index:    ", loop.index)
    print("index0:   ", loop.index0)
    print("count:    ", loop.count)
    print("remaining:", loop.remaining)
    print("even:     ", loop.even)
    print("odd:      ", loop.odd)
    print("previous: ", loop.previous)
    print("next:     ", loop.next)
    print("cycle:    ", loop.cycle("light", "dark"))
```

```text
--- red ---
first:     True
last:      False
index:     1
index0:    0
count:     3
remaining: 2
even:      False
odd:       True
previous:  None
next:      green
cycle:     light
--- green ---
first:     False
last:      False
index:     2
index0:    1
count:     3
remaining: 1
even:      True
odd:       False
previous:  red
next:      blue
cycle:     dark
--- blue ---
first:     False
last:      True
index:     3
index0:    2
count:     3
remaining: 0
even:      False
odd:       True
previous:  green
next:      None
cycle:     light
```

Take a moment to read down one column, say `index`, and then across one item, say "green". Everything the loop knows about "green" is right there.

## It works on anything you can loop over

A list knows its own length before you start. Many things you loop over do not. A generator makes its items one at a time, and a file is read line by line, so nobody knows how many there will be until the end arrives. loopinfo still knows when you have reached the last item.

Here is a small generator that counts down:

```python
from loopinfo import loopinfo


def countdown(n):
    while n > 0:
        yield n
        n -= 1


for number, loop in loopinfo(countdown(3)):
    print(number, "| last:", loop.last, "| count:", loop.count)
```

```text
3 | last: False | count: None
2 | last: False | count: None
1 | last: True | count: 3
```

And here is a file. Suppose `shopping.txt` contains four lines:

```text
eggs
bread
milk
rice
```

```python
from loopinfo import loopinfo

with open("shopping.txt") as file:
    for line, loop in loopinfo(file):
        item = line.strip()
        if loop.last:
            print(f"and finally {item}. That makes {loop.count} things.")
        else:
            print(f"{item} (count so far: {loop.count})")
```

```text
eggs (count so far: None)
bread (count so far: None)
milk (count so far: None)
and finally rice. That makes 4 things.
```

In both examples, `loop.last` is correct the whole way through. `loop.count`, and with it `loop.remaining`, is `None` while the total is still unknown, and on the last item it becomes the real total. That is honest: until the end, there is no way to know.

How can loopinfo know an item is the last one when even the generator does not know? It looks one item ahead: before handing you an item, loopinfo quietly fetches the next one, and if there is no next one, the current item must be the last. This only matters if fetching an item has a side effect you can notice, such as printing a message or reading from a network connection, because that side effect will happen one step earlier than you might expect.

## Alternating rows with cycle

`loop.cycle()` takes a few values and gives you them in turn, one per item, starting over when it runs out. It is handy for striped tables, taking turns, or sorting people into teams.

```python
from loopinfo import loopinfo

students = ["Amal", "Badr", "Huda", "Yusuf", "Mona"]

for name, loop in loopinfo(students):
    shade = loop.cycle("light", "dark ")
    team = loop.cycle("Red", "Blue", "Green")
    print(f"[{shade}] {name:<6} plays for {team}")
```

```text
[light] Amal   plays for Red
[dark ] Badr   plays for Blue
[light] Huda   plays for Green
[dark ] Yusuf  plays for Red
[light] Mona   plays for Blue
```

With two values you get a simple back-and-forth. With three, everyone takes turns in a circle.

## Previous and next

Sometimes you need to compare an item with its neighbours. A common case is a sorted list where you want to notice the moment the value changes. Here we print a heading each time the first letter changes, and a blank line just before it is about to change:

```python
from loopinfo import loopinfo

words = ["apple", "avocado", "banana", "blueberry", "cherry"]

for word, loop in loopinfo(words):
    letter = word[0]
    if loop.first or loop.previous[0] != letter:
        print(letter.upper() + ":")
    print("   ", word)
    if not loop.last and loop.next[0] != letter:
        print()
```

```text
A:
    apple
    avocado

B:
    banana
    blueberry

C:
    cherry
```

Notice the order of the checks. On the first item there is no previous word, so `loop.previous` is `None`. By asking `loop.first` before touching `loop.previous`, we never try to look inside `None`. The same goes for `loop.last` and `loop.next` at the other end. And if your list can itself contain `None` as a real value, use `loop.first` and `loop.last` to find the edges, because `None` from `previous` or `next` could then mean either thing.

## When you do not need loopinfo

Being honest: if all you want is to put commas between the items of a list of strings, Python already has a tidy one-liner for that.

```python
fruits = ["apple", "banana", "cherry"]

print(", ".join(fruits))
```

```text
apple, banana, cherry
```

That is simpler than any loop, and you should use it. loopinfo earns its place when the body of your loop does more than join text: when the first or last item needs different handling, when you want "3 of 10", when you compare neighbours, or when your data comes from a generator or a file and you cannot call `len()` on it.

## How it works

When you call `loopinfo(items)`, it first asks whether `items` has a length (lists, tuples, strings, dictionaries and ranges do) and remembers it if so. Then, as you loop, it always holds on to exactly two things: the item it is about to give you and the one after it. Each step, it builds a small read-only `loop` object describing the current position, hands you the item together with that object, and moves one step forward. When it tries to fetch the next item and finds there is none, it knows the current item is the last, and at that moment the total count becomes known as well. It never keeps a copy of the whole collection, so it is just as happy with a file of a million lines as with a list of three.

## API

- `loopinfo(iterable)` takes anything you can loop over and yields pairs of `(item, loop)`, one for each item, and yields nothing at all for an empty iterable.
- `LoopInfo` is the class of the read-only `loop` object, with the attributes `first`, `last`, `index`, `index0`, `count`, `remaining`, `even`, `odd`, `previous`, `next` and the method `cycle(*values)`; it is fully type hinted and generic over the item type, so `loop.previous` and `loop.next` have the same type as your items.

## Author

Khalid Sulaiman Al-Mulaify

Email: khalidpythonist@gmail.com

X: [@Python__Task](https://x.com/Python__Task)

## License

MIT
