🌐 Add Ukrainian translation for `docs/uk/docs/tutorial/body-nested-models.md`

@@ -0,0 +1,245 @@
+# Тіло запиту - Вкладені моделі
+
+З **FastAPI** Ви можете визначати, перевіряти, документувати та використовувати моделі, які можуть бути вкладені на будь-яку глибину (завдяки Pydantic).
+
+## Поля списку
+
+Ви можете визначити атрибут як підтип. Наприклад, Python-список (`list`):
+
+{* ../../docs_src/body_nested_models/tutorial001_py310.py hl[12] *}
+
+Це зробить `tags` списком, хоча не визначається тип елементів списку.
+
+## Поля списку з параметром типу
+
+Але Python має специфічний спосіб оголошення списків з внутрішніми типами або "параметрами типу":
+### Імпортуємо `List` з модуля typing
+
+У Python 3.9 і вище можна використовувати стандартний `list` для оголошення таких типів, як ми побачимо нижче. 💡
+
+Але в Python версії до 3.9 (від 3.6 і вище) спочатку потрібно імпортувати `List` з модуля стандартної бібліотеки Python `typing`:
+
+{* ../../docs_src/body_nested_models/tutorial002.py hl[1] *}
+
+### Оголошення `list` з параметром типу
+
+Щоб оголосити типи з параметрами типу (внутрішніми типами), такими як `list`, `dict`, `tuple`:
+
+* Якщо Ви використовуєте версію Python до 3.9, імпортуйте їх відповідну версію з модуля `typing`.
+* Передайте внутрішні типи як "параметри типу", використовуючи квадратні дужки: `[` and `]`.
+
+У Python 3.9 це буде виглядати так:
+
+```Python
+my_list: list[str]
+```
+
+У версіях Python до 3.9 це виглядає так:
+
+```Python
+from typing import List
+
+my_list: List[str]
+```
+
+Це стандартний синтаксис Python для оголошення типів.
+
+Використовуйте той самий стандартний синтаксис для атрибутів моделей з внутрішніми типами.
+
+Отже, у нашому прикладі, ми можемо зробити `tags` саме "списком рядків":
+
+{* ../../docs_src/body_nested_models/tutorial002_py310.py hl[12] *}
+
+## Типи множин
+
+Але потім ми подумали, що теги не повинні повторюватися, вони, ймовірно, повинні бути унікальними рядками.
+
+І Python має спеціальний тип даних для множин унікальних елементів — це `set`.
+
+Тому ми можемо оголосити `tags` як множину рядків:
+
+{* ../../docs_src/body_nested_models/tutorial003_py310.py hl[12] *}
+
+Навіть якщо Ви отримаєте запит з дубльованими даними, він буде перетворений у множину унікальних елементів.
+
+І коли Ви будете виводити ці дані, навіть якщо джерело містить дублікати, вони будуть виведені як множина унікальних елементів.
+
+І це буде анотовано/документовано відповідно.
+
+## Вкладені моделі
+
+Кожен атрибут моделі Pydantic має тип.
+
+Але цей тип сам може бути іншою моделлю Pydantic.
+
+Отже, Ви можете оголосити глибоко вкладені JSON "об'єкти" з конкретними іменами атрибутів, типами та перевірками.
+
+Усе це, вкладене без обмежень.
+
+### Визначення підмоделі
+
+Наприклад, ми можемо визначити модель `Image`:
+
+{* ../../docs_src/body_nested_models/tutorial004_py310.py hl[7:9] *}
+
+### Використання підмоделі як типу
+
+А потім ми можемо використовувати її як тип атрибута:
+
+{* ../../docs_src/body_nested_models/tutorial004_py310.py hl[18] *}
+
+Це означатиме, що  **FastAPI**  очікуватиме тіло запиту такого вигляду:
+
+```JSON
+{
+    "name": "Foo",
+    "description": "The pretender",
+    "price": 42.0,
+    "tax": 3.2,
+    "tags": ["rock", "metal", "bar"],
+    "image": {
+        "url": "http://example.com/baz.jpg",
+        "name": "The Foo live"
+    }
+}
+```
+
+Завдяки такій декларації у **FastAPI** Ви отримуєте:
+
+* Підтримку в редакторі (автозавершення тощо), навіть для вкладених моделей
+* Конвертацію даних
+* Валідацію даних
+* Автоматичну документацію
+
+## Спеціальні типи та валідація
+
+Окрім звичайних типів, таких як `str`, `int`, `float`, та ін. Ви можете використовувати складніші типи, які наслідують `str`.
+
+Щоб побачити всі доступні варіанти, ознайомтеся з оглядом типів у Pydantic. Деякі приклади будуть у наступних розділах.
+
+Наприклад, у моделі `Image` є поле `url`, тому ми можемо оголосити його як `HttpUrl` від Pydantic замість `str`:
+
+{* ../../docs_src/body_nested_models/tutorial005_py310.py hl[2,8] *}
+
+Рядок буде перевірено як дійсну URL-адресу і задокументовано в JSON Schema / OpenAPI як URL.
+
+## Атрибути зі списками підмоделей
+
+У Pydantic Ви можете використовувати моделі як підтипи для `list`, `set` тощо:
+
+{* ../../docs_src/body_nested_models/tutorial006_py310.py hl[18] *}
+
+Це означає, що **FastAPI** буде очікувати (конвертувати, валідувати, документувати тощо) JSON тіло запиту у вигляді:
+
+```JSON hl_lines="11"
+{
+    "name": "Foo",
+    "description": "The pretender",
+    "price": 42.0,
+    "tax": 3.2,
+    "tags": [
+        "rock",
+        "metal",
+        "bar"
+    ],
+    "images": [
+        {
+            "url": "http://example.com/baz.jpg",
+            "name": "The Foo live"
+        },
+        {
+            "url": "http://example.com/dave.jpg",
+            "name": "The Baz"
+        }
+    ]
+}
+```
+
+/// info | Інформація
+
+Зверніть увагу, що тепер ключ `images` містить список об'єктів зображень.
+
+///
+
+## Глибоко вкладені моделі
+
+Ви можете визначати вкладені моделі довільної глибини:
+
+{* ../../docs_src/body_nested_models/tutorial007_py310.py hl[7,12,18,21,25] *}
+
+/// info | Інформація
+
+Зверніть увагу, що в моделі `Offer` є список `Item`ів, які, своєю чергою, можуть мати необов'язковий список `Image`ів.
+
+///
+
+## Тіла запитів, що складаються зі списків
+
+Якщо верхній рівень JSON тіла, яке Ви очікуєте, є JSON `масивом` (у Python — `list`), Ви можете оголосити тип у параметрі функції, як і в моделях Pydantic:
+
+```Python
+images: List[Image]
+```
+або в Python 3.9 і вище:
+
+```Python
+images: list[Image]
+```
+
+наприклад:
+
+{* ../../docs_src/body_nested_models/tutorial008_py39.py hl[13] *}
+
+## Підтримка в редакторі всюди
+
+Ви отримаєте підтримку в редакторі всюди.
+
+Навіть для елементів у списках:
+
+
+
+Ви не змогли б отримати таку підтримку в редакторі, якби працювали напряму зі  `dict`, а не з моделями Pydantic.
+
+Але Вам не потрібно турбуватися про це: вхідні dict'и автоматично конвертуються, а вихідні дані автоматично перетворюються в JSON.
+
+## Тіла з довільними `dict`
+
+Ви також можете оголосити тіло як `dict` з ключами одного типу та значеннями іншого типу.
+
+Це корисно, якщо Ви не знаєте наперед, які імена полів будуть дійсними (як у випадку з моделями Pydantic).
+
+Це буде корисно, якщо Ви хочете приймати ключі, які заздалегідь невідомі.
+
+---
+
+Це також зручно, якщо Ви хочете мати ключі іншого типу (наприклад, `int`).
+
+Ось що ми розглянемо далі.
+
+У цьому випадку Ви можете приймати будь-який `dict`, якщо його ключі — це `int`, а значення — `float`:
+
+{* ../../docs_src/body_nested_models/tutorial009_py39.py hl[7] *}
+
+/// tip | Порада
+
+Майте на увазі, що в JSON тілі ключі можуть бути лише рядками (`str`).
+
+Але Pydantic автоматично конвертує дані.
+
+Це означає, що навіть якщо клієнти вашого API надсилатимуть ключі у вигляді рядків, якщо вони містять цілі числа, Pydantic конвертує їх і проведе валідацію.
+
+Тобто `dict`, який Ви отримаєте як `weights`, матиме ключі типу `int` та значення типу `float`.
+
+///
+
+## Підсумок
+
+З **FastAPI** Ви маєте максимальну гнучкість завдяки моделям Pydantic, зберігаючи при цьому код простим, коротким та елегантним.
+
+А також отримуєте всі переваги:
+
+* Підтримка в редакторі (автодоповнення всюди!)
+* Конвертація даних (парсинг/сериалізація)
+* Валідація даних
+* Документація схем
+* Автоматичне створення документації

I did a quick sanity check to verify the structure of the document and compare the sections with the English version, and this looks good from a structural point of view.

Thank you [USER_REDACTED]! :cake:

And thanks all for the reviews :coffee:

```suggestion

```
Remove additional empty line


🌐 Add Ukrainian translation for `docs/uk/docs/tutorial/body-multiple-params.md`

@@ -0,0 +1,170 @@
+# Тіло запиту - Декілька параметрів
+
+Тепер, коли ми розглянули використання `Path` та `Query`, розгляньмо більш просунуті способи оголошення тіла запиту в **FastAPI**.
+
+## Змішування `Path`, `Query` та параметрів тіла запиту
+
+По-перше, звісно, Ви можете вільно змішувати оголошення параметрів `Path`, `Query` та тіла запиту, і **FastAPI** правильно їх обробить.
+
+Також Ви можете оголосити параметри тіла як необов’язкові, встановивши для них значення за замовчуванням `None`:
+
+{* ../../docs_src/body_multiple_params/tutorial001_an_py310.py hl[18:20] *}
+
+/// note | Примітка
+
+Зверніть увагу, що в цьому випадку параметр `item`, який береться з тіла запиту, є необов'язковим, оскільки має значення за замовчуванням `None`.
+
+///
+
+## Декілька параметрів тіла запиту
+
+У попередньому прикладі *операція шляху* очікувала JSON з атрибутами `Item`, наприклад:
+
+```JSON
+{
+    "name": "Foo",
+    "description": "The pretender",
+    "price": 42.0,
+    "tax": 3.2
+}
+```
+Але Ви також можете оголосити декілька параметрів тіла, наприклад `item` та `user`:
+
+{* ../../docs_src/body_multiple_params/tutorial002_py310.py hl[20] *}
+
+У цьому випадку **FastAPI** розпізнає, що є кілька параметрів тіла (два параметри є моделями Pydantic).
+
+Тому він використає назви параметрів як ключі (назви полів) у тілі запиту, очікуючи:
+
+```JSON
+{
+    "item": {
+        "name": "Foo",
+        "description": "The pretender",
+        "price": 42.0,
+        "tax": 3.2
+    },
+    "user": {
+        "username": "dave",
+        "full_name": "[NAME_REDACTED]"
+    }
+}
+```
+
+/// note | Примітка
+
+Зверніть увагу, що хоча `item` оголошено, так само як і раніше, тепер він очікується в тілі під ключем  `item`.
+
+///
+
+**FastAPI**  автоматично конвертує дані із запиту таким чином, щоб параметр `item` отримав свій вміст, і те ж саме стосується `user`.
+
+Він виконає валідацію складених даних і задокументує їх відповідним чином у схемі OpenAPI та в автоматичній документації.
+
+## Одиничні значення в тілі запиту
+
+Так само як є `Query` і `Path` для визначення додаткових даних для параметрів запиту та шляху, **FastAPI** надає еквівалентний `Body`.
+
+Наприклад, розширюючи попередню модель, Ви можете вирішити додати ще один ключ `importance` в те ж саме тіло запиту разом із `item` і `user`.
+
+Якщо Ви оголосите його як є, то, оскільки це одиничне значення,  **FastAPI**  припускатиме, що це параметр запиту (query parameter).
+
+Але Ви можете вказати **FastAPI** обробляти його як інший ключ тіла (body key), використовуючи `Body`:
+
+{* ../../docs_src/body_multiple_params/tutorial003_an_py310.py hl[23] *}
+
+У цьому випадку **FastAPI** очікуватиме тіло запиту у такому вигляді:
+
+```JSON
+{
+    "item": {
+        "name": "Foo",
+        "description": "The pretender",
+        "price": 42.0,
+        "tax": 3.2
+    },
+    "user": {
+        "username": "dave",
+        "full_name": "[NAME_REDACTED]"
+    },
+    "importance": 5
+}
+```
+Знову ж таки, **FastAPI** конвертуватиме типи даних, перевірятиме їх, створюватиме документацію тощо.
+
+## Декілька body та query параметрів
+
+Звісно, Ви можете оголошувати додаткові query параметри запиту, коли це необхідно, на додаток до будь-яких параметрів тіла запиту.
+
+Оскільки за замовчуванням окремі значення інтерпретуються як параметри запиту, Вам не потрібно явно додавати `Query`, можна просто використати:
+
+```Python
+q: Union[str, None] = None
+```
+
+Або в Python 3.10 та вище:
+
+```Python
+q: str | None = None
+```
+
+Наприклад:
+
+{* ../../docs_src/body_multiple_params/tutorial004_an_py310.py hl[28] *}
+
+
+/// info | Інформація
+
+`Body` також має ті самі додаткові параметри валідації та метаданих, що й `Query`, `Path` та інші, які Ви побачите пізніше.
+
+///
+
+## Вкладений поодинокий параметр тіла запиту
+
+Припустимо, у вас є лише один параметр тіла запиту `item` з моделі Pydantic `Item`.
+
+За замовчуванням **FastAPI** очікуватиме, що тіло запиту міститиме вміст безпосередньо.
+
+Але якщо Ви хочете, щоб він очікував JSON з ключем `item`, а всередині — вміст моделі (так, як це відбувається при оголошенні додаткових параметрів тіла), Ви можете використати спеціальний параметр `Body` — `embed`:
+
+```Python
+item: Item = Body(embed=True)
+```
+
+як у прикладі:
+
+{* ../../docs_src/body_multiple_params/tutorial005_an_py310.py hl[17] *}
+
+У цьому випадку **FastAPI** очікуватиме тіло запиту такого вигляду:
+
+```JSON hl_lines="2"
+{
+    "item": {
+        "name": "Foo",
+        "description": "The pretender",
+        "price": 42.0,
+        "tax": 3.2
+    }
+}
+```
+
+замість:
+
+```JSON
+{
+    "name": "Foo",
+    "description": "The pretender",
+    "price": 42.0,
+    "tax": 3.2
+}
+```
+
+## Підсумок
+
+Ви можете додавати кілька параметрів тіла до Вашої *функції операції шляху* (*path operation function*), навіть якщо запит може мати лише одне тіло.
+
+Але **FastAPI** обробить це, надасть Вам потрібні дані у функції, перевірить їх та задокументує коректну схему в *операції шляху*.
+
+Також Ви можете оголошувати окремі значення, які будуть отримані як частина тіла запиту.
+
+Крім того, Ви можете вказати **FastAPI**  вбудовувати тіло в ключ, навіть якщо оголошено лише один параметр.

Apart from a few very nitpicky comments, this all looks good from a structural point of view 👍

Thanks [USER_REDACTED]! :cake:

And thanks all for the reviews :coffee:

Here, it looks like a header is missing?

```
## Multiple body parameters
```

```suggestion
Наприклад, розширюючи попередню модель, Ви можете вирішити додати ще один ключ `importance` в те ж саме тіло запиту разом із `item` і `user`.
```

```suggestion
Але Ви можете вказати **FastAPI** обробляти його як інший ключ тіла (body key), використовуючи `Body`:
```

no, it's here. Just a bit below :)

https://github.com/fastapi/fastapi/pull/13408/files#diff-aeab1bb090125e2209dac6208e8f8d2f0e8af15877ceef4757081232a7278200R19

Oh! Actually the English version has the title twice - we should probably correct it over there 😁

```
## Multiple body parameters

/// note

Notice that, in this case, the `item` that would be taken from the body is optional. As it has a `None` default value.

///

## Multiple body parameters

In the previous example, ...
```

So yea no change is necessary here!

I guess you are looking at the old `en` version. I pulled the latest master updates and see that header only repeats once in a file

Right you are (again), this was fixed recently and I was looking at a local outdated `master`: https://github.com/fastapi/fastapi/pull/13345


🌐 Add Vietnamese translation for `docs/vi/docs/deployment/cloud.md`

@@ -0,0 +1,17 @@
+# Triển khai FastAPI trên các Dịch vụ Cloud
+
+Bạn có thể sử dụng **bất kỳ nhà cung cấp dịch vụ cloud** nào để triển khai ứng dụng FastAPI của mình.
+
+Trong hầu hết các trường hợp, các nhà cung cấp dịch vụ cloud lớn đều có hướng dẫn triển khai FastAPI với họ.
+
+## Nhà cung cấp dịch vụ Cloud - Nhà tài trợ
+Một vài nhà cung cấp dịch vụ cloud ✨ [**tài trợ cho FastAPI**](../help-fastapi.md#sponsor-the-author){.internal-link target=_blank} ✨, điều này giúp đảm bảo sự phát triển liên tục và khỏe mạnh của FastAPI và hệ sinh thái của nó.
+
+Thêm nữa, điều này cũng thể hiện cam kết thực sự của họ đối với FastAPI và **cộng đồng người dùng** (bạn), vì họ không chỉ muốn cung cấp cho bạn một **dịch vụ tốt** mà còn muốn đảm bảo rằng bạn có một **framework tốt và bền vững**, đó chính là FastAPI. 🙇
+
+Bạn có thể thử các dịch vụ của họ và làm theo hướng dẫn của họ:
+
+* Platform.sh
+* Porter
+* Coherence
+* Render

I think it will be better with this

LGTM!

Thank you [USER_REDACTED]! :cake:

Thanks all for the reviews! :coffee:

```suggestion
# Triển khai FastAPI trên các Dịch vụ Cloud
```
I assume the second line is the correct translation for the original English line, which should be deleted?

Thank you, that's it

```suggestion
Một vài nhà cung cấp dịch vụ cloud ✨ [**tài trợ cho FastAPI**](../help-fastapi.md#sponsor-the-author){.internal-link target=_blank} ✨, điều này giúp đảm bảo sự phát triển liên tục và khỏe mạnh của FastAPI và hệ sinh thái của nó.
```

```suggestion
Trong hầu hết các trường hợp, các nhà cung cấp dịch vụ cloud lớn đều có hướng dẫn triển khai FastAPI với họ.
```

```suggestion
Bạn có thể thử các dịch vụ của họ và làm theo hướng dẫn của họ:
```

```suggestion
Thêm nữa, điều này cũng thể hiện cam kết thực sự của họ đối với FastAPI và **cộng đồng người dùng** (bạn), vì họ không chỉ muốn cung cấp cho bạn một **dịch vụ tốt** mà còn muốn đảm bảo rằng bạn có một **framework tốt và bền vững**, đó chính là FastAPI. 🙇
```


🌐 Add Ukrainian translation for `docs/uk/docs/tutorial/request-form-models.md`

@@ -0,0 +1,78 @@
+# Моделі форм ([NAME_REDACTED])
+
+У FastAPI Ви можете використовувати **Pydantic-моделі** для оголошення **полів форми**.
+
+/// info | Інформація
+
+Щоб використовувати форми, спочатку встановіть python-multipart.
+
+Переконайтеся, що Ви створили [віртуальне середовище](../virtual-environments.md){.internal-link target=_blank}, активували його, а потім встановили бібліотеку, наприклад:
+
+```console
+$ pip install python-multipart
+```
+
+///
+
+/// note | Підказка
+
+Ця функція підтримується, починаючи з FastAPI версії `0.113.0`. 🤓
+
+///
+
+## Використання Pydantic-моделей для форм
+
+Вам просто потрібно оголосити **Pydantic-модель** з полями, які Ви хочете отримати як **поля форми**, а потім оголосити параметр як `Form`:
+
+{* ../../docs_src/request_form_models/tutorial001_an_py39.py hl[9:11,15] *}
+
+**FastAPI**  **витягне** дані для **кожного поля** з **формових даних** у запиті та надасть вам Pydantic-модель, яку Ви визначили.
+
+## Перевірка документації
+
+Ви можете перевірити це в UI документації за `/docs`:
+
+

+
+

+
+## Заборона додаткових полів форми
+
+У деяких особливих випадках (ймовірно, рідко) Ви можете **обмежити** форму лише тими полями, які були оголошені в Pydantic-моделі, і **заборонити** будь-які **додаткові** поля.
+
+/// note | Підказка
+
+Ця функція підтримується, починаючи з FastAPI версії `0.114.0`. 🤓
+
+///
+
+Ви можете використати конфігурацію Pydantic-моделі, щоб заборонити `forbid` будь-які додаткові `extra` поля:
+
+{* ../../docs_src/request_form_models/tutorial002_an_py39.py hl[12] *}
+
+Якщо клієнт спробує надіслати додаткові дані, він отримає **відповідь з помилкою**.
+
+Наприклад, якщо клієнт спробує надіслати наступні поля форми:
+
+* `username`: `Rick`
+* `password`: `[NAME_REDACTED]`
+* `extra`: `Mr. Poopybutthole`
+
+Він отримає відповідь із помилкою, яка повідомляє, що поле `extra` не дозволено:
+
+```json
+{
+    "detail": [
+        {
+            "type": "extra_forbidden",
+            "loc": ["body", "extra"],
+            "msg": "Extra inputs are not permitted",
+            "input": "Mr. Poopybutthole"
+        }
+    ]
+}
+```
+
+## Підсумок
+
+Ви можете використовувати Pydantic-моделі для оголошення полів форми у FastAPI. 😎

Good job! Please update a couple of places (we usually use `Ви` with the proper case in translations)

```suggestion
Вам просто потрібно оголосити **Pydantic-модель** з полями, які Ви хочете отримати як **поля форми**, а потім оголосити параметр як `Form`:
```

```suggestion
**FastAPI**  **витягне** дані для **кожного поля** з **формових даних** у запиті та надасть вам Pydantic-модель, яку Ви визначили.
```

```suggestion
У деяких особливих випадках (ймовірно, рідко) Ви можете **обмежити** форму лише тими полями, які були оголошені в Pydantic-моделі, і **заборонити** будь-які **додаткові** поля.
```

```suggestion
У FastAPI Ви можете використовувати **Pydantic-моделі** для оголошення **полів форми**.
```

```suggestion
Переконайтеся, що Ви створили [віртуальне середовище](../virtual-environments.md){.internal-link target=_blank}, активували його, а потім встановили бібліотеку, наприклад:
```


🌐 Add Ukrainian translation for `docs/uk/docs/tutorial/request-forms.md`

@@ -0,0 +1,73 @@
+# Дані форми
+
+Якщо Вам потрібно отримувати поля форми замість JSON, Ви можете використовувати `Form`.
+
+/// info | Інформація
+
+Щоб використовувати форми, спочатку встановіть `python-multipart`.
+
+Переконайтеся, що Ви створили [віртуальне середовище](../virtual-environments.md){.internal-link target=_blank}, активували його, і потім встановили бібліотеку, наприклад:
+
+```console
+$ pip install python-multipart
+```
+
+///
+
+## Імпорт `Form`
+
+Імпортуйте `Form` з `fastapi`:
+
+{* ../../docs_src/request_forms/tutorial001_an_py39.py hl[3] *}
+
+## Оголошення параметрів `Form`
+
+Створюйте параметри форми так само як Ви б створювали `Body` або `Query`:
+
+{* ../../docs_src/request_forms/tutorial001_an_py39.py hl[9] *}
+
+Наприклад, один зі способів використання специфікації OAuth2 (так званий "password flow") вимагає надсилати `username` та `password` як поля форми.
+
+spec вимагає, щоб ці поля мали точні назви `username` і `password` та надсилалися у вигляді полів форми, а не JSON.
+
+З `Form` Ви можете оголошувати ті ж конфігурації, що і з `Body` (та `Query`, `Path`, `Cookie`), включаючи валідацію, приклади, псевдоніми (наприклад, `user-name` замість `username`) тощо.
+
+/// info | Інформація
+
+`Form` — це клас, який безпосередньо наслідується від `Body`.
+
+///
+
+/// tip | Порада
+
+Щоб оголосити тіло форми, потрібно явно використовувати `Form`, оскільки без нього параметри будуть інтерпретуватися як параметри запиту або тіла (JSON).
+
+///
+
+## Про "поля форми"
+
+HTML-форми (`<form></form>`) надсилають дані на сервер у "спеціальному" кодуванні, яке відрізняється від JSON.
+
+**FastAPI** подбає про те, щоб зчитати ці дані з правильного місця, а не з JSON.
+
+/// note | Технічні деталі
+
+Дані з форм зазвичай кодуються за допомогою "типу медіа" `application/x-www-form-urlencoded`.
+
+Але якщо форма містить файли, вона кодується як `multipart/form-data`. Ви дізнаєтеся про обробку файлів у наступному розділі.
+
+Якщо Ви хочете дізнатися більше про ці кодування та поля форм, зверніться до MDN вебдокументації для POST.
+
+///
+
+/// warning | Попередження
+
+Ви можете оголосити кілька параметрів `Form`  в *операції шляху*, але не можете одночасно оголосити поля `Body`, які Ви очікуєте отримати у форматі JSON, оскільки тіло запиту буде закодовано у форматі `application/x-www-form-urlencoded`, а не `application/json`.
+
+Це не обмеження **FastAPI**, а частина HTTP-протоколу.
+
+///
+
+## Підсумок
+
+Використовуйте `Form` для оголошення вхідних параметрів у вигляді даних форми.

Great job! could you pls make a little update as I requested in comments

Looking good from a purely structural point of view! 🥳

Thanks [USER_REDACTED]! :cake:

And thanks for the reviews [USER_REDACTED], [USER_REDACTED], [USER_REDACTED] :coffee:

```suggestion
Якщо Вам потрібно отримувати поля форми замість JSON, Ви можете використовувати `Form`.
```

```suggestion
Переконайтеся, що Ви створили [віртуальне середовище](../virtual-environments.md){.internal-link target=_blank}, активували його, і потім встановили бібліотеку, наприклад:
```

```suggestion
Створюйте параметри форми так само як Ви б створювали `Body` або `Query`:
```

```suggestion
З `Form` Ви можете оголошувати ті ж конфігурації, що і з `Body` (та `Query`, `Path`, `Cookie`), включаючи валідацію, приклади, псевдоніми (наприклад, `user-name` замість `username`) тощо.
```

```suggestion
Якщо Ви хочете дізнатися більше про ці кодування та поля форм, зверніться до <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST" class="external-link" target="_blank"><abbr title="[NAME_REDACTED]">MDN</abbr> вебдокументації для <code>POST</code></a>.
```

```suggestion
Ви можете оголосити кілька параметрів `Form`  в *операції шляху*, але не можете одночасно оголосити поля `Body`, які Ви очікуєте отримати у форматі JSON, оскільки тіло запиту буде закодовано у форматі `application/x-www-form-urlencoded`, а не `application/json`.
```


🌐 Add Ukrainian translation for `docs/uk/docs/tutorial/header-params.md`

@@ -0,0 +1,91 @@
+# Header-параметри
+
+Ви можете визначати параметри заголовків, так само як визначаєте `Query`, `Path` і `Cookie` параметри.
+
+## Імпорт `Header`
+
+Спочатку імпортуйте `Header`:
+
+{* ../../docs_src/header_params/tutorial001_an_py310.py hl[3] *}
+
+## Оголошення параметрів `Header`
+
+Потім оголосіть параметри заголовків, використовуючи ту ж структуру, що й для `Path`, `Query` та `Cookie`.
+
+Ви можете визначити значення за замовчуванням, а також усі додаткові параметри валідації або анотації:
+
+{* ../../docs_src/header_params/tutorial001_an_py310.py hl[9] *}
+
+/// note | Технічні деталі
+
+`Header`є "сестринським" класом для `Path`, `Query` і `Cookie`. Він також успадковується від загального класу `Param`.
+
+Але пам’ятайте, що при імпорті `Query`, `Path`, `Header` та інших із `fastapi`, то насправді вони є функціями, які повертають спеціальні класи.
+
+///
+
+/// info | Інформація
+
+Щоб оголосити заголовки, потрібно використовувати `Header`, інакше параметри будуть інтерпретуватися як параметри запиту.
+
+///
+
+## Автоматичне перетворення
+
+`Header` має додатковий функціонал порівняно з `Path`, `Query` та `Cookie`.
+
+Більшість стандартних заголовків розділяються символом «дефіс», також відомим як «мінус» (`-`).
+
+Але змінна, така як `user-agent`, є недійсною в Python.
+
+Тому, за замовчуванням, `Header` автоматично перетворює символи підкреслення (`_`) на дефіси (`-`) для отримання та документування заголовків.
+
+Оскільки заголовки HTTP не чутливі до регістру, Ви можете використовувати стандартний стиль Python ("snake_case").
+
+Тому Ви можете використовувати `user_agent`, як зазвичай у коді Python, замість того щоб писати з великої літери, як `User_Agent` або щось подібне.
+
+Якщо Вам потрібно вимкнути автоматичне перетворення підкреслень у дефіси, встановіть `convert_underscores` в `Header` значення `False`:
+
+{* ../../docs_src/header_params/tutorial002_an_py310.py hl[10] *}
+
+/// warning | Увага
+
+Перед тим як встановити значення `False` для `convert_underscores` пам’ятайте, що деякі HTTP-проксі та сервери не підтримують заголовки з підкресленнями.
+
+///
+
+## Дубльовані заголовки
+
+Можливо отримати дубльовані заголовки, тобто той самий заголовок із кількома значеннями.
+
+Це можна визначити, використовуючи список у типізації параметра.
+
+Ви отримаєте всі значення дубльованого заголовка у вигляді `list` у Python.
+
+Наприклад, щоб оголосити заголовок `X-Token`, який може з’являтися більше ніж один раз:
+
+{* ../../docs_src/header_params/tutorial003_an_py310.py hl[9] *}
+
+Якщо Ви взаємодієте з цією операцією шляху, надсилаючи два HTTP-заголовки, наприклад:
+
+```
+X-Token: foo
+X-Token: bar
+```
+
+Відповідь буде така:
+
+```JSON
+{
+    "X-Token values": [
+        "bar",
+        "foo"
+    ]
+}
+```
+
+## Підсумок
+
+Оголошуйте заголовки за допомогою `Header`, використовуючи той самий підхід, що й для `Query`, `Path` та `Cookie`.
+
+Не хвилюйтеся про підкреслення у змінних — **FastAPI** автоматично конвертує їх.

```suggestion
Оскільки заголовки HTTP не чутливі до регістру, Ви можете використовувати стандартний стиль Python ("snake_case").
```

```suggestion
Тому Ви можете використовувати `user_agent`, як зазвичай у коді Python, замість того щоб писати з великої літери, як `User_Agent` або щось подібне.
```

```suggestion
Якщо Вам потрібно вимкнути автоматичне перетворення підкреслень у дефіси, встановіть `convert_underscores` в `Header` значення `False`:
```

```suggestion
Якщо Ви взаємодієте з цією операцією шляху, надсилаючи два HTTP-заголовки, наприклад:
```


BUG: fix regression in DataFrame construction from list of dicts with missing value as key

- [x] closes #63889
- [ ] [Tests added and passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#writing-tests)
- [x] Added an entry in the latest `doc/source/whatsnew/vX.X.X.rst` file if fixing a bug or adding a new feature.

Note that this does _not_ fix the corner-case of having more than one missing value indicator in the dictionary (eg entries with both None and pd.NA as key)

@@ -14,6 +14,7 @@ including other versions of pandas.
 Fixed regressions
 ~~~~~~~~~~~~~~~~~
 - Fixed regression in :meth:`DataFrame.groupby` and :meth:`Series.groupby` when grouping on categorical data with NA values, ``observed=False``, and ``dropna=True`` (:issue:`52445`)
+- Fixed regression in the :class:`DataFrame` and :meth:`DataFrame.from_records` constructor with a list of dicts with a missing value indicator as key (:issue:`63889`)
 - Fixed regression when calling ``numpy.random``'s ``permutation()`` on a (pyarrow-backed) string :class:`Series` (:issue:`63935`)

 .. ---------------------------------------------------------------------------

@@ -58,6 +58,7 @@
 from pandas.core.indexes.api import (
     DatetimeIndex,
     Index,
+    MultiIndex,
     TimedeltaIndex,
     default_index,
     ensure_index,
@@ -580,7 +581,17 @@ def _homogenize(
                 else:
                     # see test_constructor_subclass_dict
                     val = dict(val)
-                val = lib.fast_multiget(val, oindex._values, default=np.nan)
+
+                if not isinstance(index, MultiIndex) and index.hasnans:
+                    # GH#63889 Check if dict has missing value keys that need special
+                    # handling (i.e. None/np.nan/pd.NA might no longer be matched
+                    # when using fast_multiget with processed object index values)
+                    from pandas import Series
+
+                    val = Series(val).reindex(index)._values
+                else:
+                    # Fast path: use lib.fast_multiget for dicts without missing keys
+                    val = lib.fast_multiget(val, oindex._values, default=np.nan)

             val = sanitize_array(val, index, dtype=dtype, copy=False)
             com.require_length_match(val, index)
@@ -867,17 +878,22 @@ def _list_of_dict_to_arrays(
     content : np.ndarray[object, ndim=2]
     columns : Index
     """
+    # assure that they are of the base dict class and not of derived
+    # classes
+    data = [d if type(d) is dict else dict(d) for d in data]
+
     if columns is None:
         gen = (list(x.keys()) for x in data)
         sort = not any(isinstance(d, dict) for d in data)
         pre_cols = lib.fast_unique_multiple_list_gen(gen, sort=sort)
         columns = ensure_index(pre_cols)

-    # assure that they are of the base dict class and not of derived
-    # classes
-    data = [d if type(d) is dict else dict(d) for d in data]
+        # use pre_cols to preserve exact values that were present as dict keys
+        # (e.g. otherwise missing values might be coerced to the canonical repr)
+        content = lib.dicts_to_array(data, pre_cols)
+    else:
+        content = lib.dicts_to_array(data, list(columns))

-    content = lib.dicts_to_array(data, list(columns))
     return content, columns

@@ -12,6 +12,7 @@

 from pandas.compat import is_platform_little_endian

+import pandas as pd
 from pandas import (
     CategoricalIndex,
     DataFrame,
@@ -501,3 +502,28 @@ def test_from_records_empty_iterator_with_preserve_columns(self):
         )
         expected = DataFrame([], index=[0, 1], columns=["col_1", "Col_2"])
         tm.assert_frame_equal(result, expected)
+
+    [USER_REDACTED].mark.parametrize("missing_value", [None, np.nan, pd.NA])
+    def test_from_records_missing_value_key(self, missing_value, using_infer_string):
+        # https://github.com/[USER_REDACTED]ndas-dev/pandas/issues/63889
+        # preserve values when None key is converted to NaN column name
+        dict_data = [
+            {"colA": 1, missing_value: 2},
+            {"colA": 3, missing_value: 4},
+        ]
+        result = DataFrame.from_records(dict_data)
+        expected = DataFrame(
+            [[1, 2], [3, 4]],
+            columns=["colA", np.nan if using_infer_string else missing_value],
+        )
+        tm.assert_frame_equal(result, expected)
+
+    [USER_REDACTED].mark.parametrize("missing_value", [None, np.nan, pd.NA])
+    def test_from_records_missing_value_key_only(self, missing_value):
+        dict_data = [
+            {missing_value: 1},
+            {missing_value: 2},
+        ]
+        result = DataFrame.from_records(dict_data)
+        expected = DataFrame([[1], [2]], columns=Index([missing_value]))
+        tm.assert_frame_equal(result, expected)

@@ -641,6 +641,38 @@ def test_constructor_dict_nan_key_and_columns(self):
         expected = DataFrame([[1, 2], [2, 3]], columns=[np.nan, 2])
         tm.assert_frame_equal(result, expected)

+    [USER_REDACTED].mark.parametrize("missing_value", [None, np.nan, pd.NA])
+    def test_constructor_list_of_dict_with_str_na_key(
+        self, missing_value, using_infer_string
+    ):
+        # https://github.com/[USER_REDACTED]ndas-dev/pandas/issues/63889
+        # preserve values when None key is converted to NaN column name
+        dict_data = [
+            {"colA": 1, missing_value: 2},
+            {"colA": 3, missing_value: 4},
+        ]
+        result = DataFrame(dict_data)
+        expected = DataFrame(
+            [[1, 2], [3, 4]],
+            columns=["colA", np.nan if using_infer_string else missing_value],
+        )
+        tm.assert_frame_equal(result, expected)
+
+    [USER_REDACTED].mark.parametrize("missing_value", [None, np.nan, pd.NA])
+    def test_constructor_dict_of_dict_with_str_na_key(
+        self, missing_value, using_infer_string
+    ):
+        # https://github.com/[USER_REDACTED]ndas-dev/pandas/issues/63889
+        dict_data = {"col": {"row1": 1, missing_value: 2, "row3": 3}}
+        result = DataFrame(dict_data)
+        expected = DataFrame(
+            {"col": [1, 2, 3]},
+            index=Index(
+                ["row1", np.nan if using_infer_string else missing_value, "row3"]
+            ),
+        )
+        tm.assert_frame_equal(result, expected)
+
     def test_constructor_multi_index(self):
         # GH 4078
         # construction error with mi and all-nan frame

Keeping the fast path here to avoid a performance regression in the general case where there are no NA keys (we previously had a regression when trying to remove the usage of `fast_multiget` with the Series constructor, see https://github.com/[USER_REDACTED]ndas-dev/pandas/pull/42338)


CI: Add dedicated Python 3.14 jobs

Allows us to tests all our optional dependencies with (hopefully) released wheels/conda packages

@@ -27,7 +27,7 @@ jobs:
     strategy:
       matrix:
         platform: [ubuntu-24.04, ubuntu-24.04-arm]
-        env_file: [actions-311.yaml, actions-312.yaml, actions-313.yaml]
+        env_file: [actions-311.yaml, actions-312.yaml, actions-313.yaml, actions-314.yaml]
         # Prevent the include jobs from overriding other jobs
         pattern: [""]
         pandas_future_infer_string: ["1"]
@@ -190,7 +190,7 @@ jobs:
     strategy:
       matrix:
         os: [macos-15-intel, macos-15, windows-2025]
-        env_file: [actions-311.yaml, actions-312.yaml, actions-313.yaml]
+        env_file: [actions-311.yaml, actions-312.yaml, actions-313.yaml, actions-314.yaml]
       fail-fast: false
     runs-on: ${{ matrix.os }}
     name: ${{ format('{0} {1}', matrix.os, matrix.env_file) }}
@@ -318,7 +318,7 @@ jobs:
     #    To freeze this file, uncomment out the ``if: false`` condition, and migrate the jobs
     #    to the corresponding posix/windows-macos/sdist etc. workflows.
     # Feel free to modify this comment as necessary.
-    # if: false
+    if: false
     defaults:
       run:
         shell: bash -eou pipefail {0}
@@ -349,7 +349,7 @@ jobs:
       - name: Set up [NAME_REDACTED]
         uses: actions/setup-python@v6
         with:
-          python-version: '3.14-dev'
+          python-version: '3.15-dev'

       - name: [NAME_REDACTED]
         run: |

@@ -0,0 +1,63 @@
+name: pandas-dev-314
+channels:
+  - conda-forge
+dependencies:
+  - python=3.14
+
+  # build dependencies
+  - versioneer
+  - cython<4.0.0a0
+  - meson=1.10.0
+  - meson-python=0.18.0
+
+  # test dependencies
+  - pytest>=8.3.4
+  - pytest-cov
+  - pytest-xdist>=3.6.1
+  - pytest-localserver>=0.9.0
+  - pytest-qt>=4.4.0
+  - boto3=1.37.3
+
+  # required dependencies
+  - python-dateutil
+  - numpy
+
+  # optional dependencies
+  - adbc-driver-postgresql>=1.2.0
+  - adbc-driver-sqlite>=1.2.0
+  - beautifulsoup4>=4.12.3
+  - blosc>=1.21.3
+  - bottleneck>=1.4.2
+  - fastparquet>=2024.11.0
+  - fsspec>=2024.10.0
+  - html5lib>=1.1
+  - hypothesis>=6.116.0
+  - gcsfs>=2024.10.0
+  - jinja2>=3.1.5
+  - lxml>=5.3.0
+  - matplotlib>=3.9.3
+  - numba>=0.60.0
+  - numexpr>=2.10.2
+  - odfpy>=1.4.1
+  - qtpy>=2.4.2
+  - pyqt>=5.15.9
+  - openpyxl>=3.1.5
+  - psycopg2>=2.9.10
+  - pyarrow>=13.0.0
+  - pymysql>=1.1.1
+  - pyreadstat>=1.2.8
+  - pytables>=3.10.1
+  - python-calamine>=0.3.0
+  - pytz>=2024.2
+  - pyxlsb>=1.0.10
+  - s3fs>=2024.10.0
+  - scipy>=1.14.1
+  - sqlalchemy>=2.0.36
+  - tabulate>=0.9.0
+  - xarray>=2024.10.0
+  - xlrd>=2.0.1
+  - xlsxwriter>=3.2.0
+  - zstandard>=0.23.0
+
+  - pip:
+    - tzdata>=2023.3

@@ -7,7 +7,10 @@
 import numpy as np
 import pytest

-from pandas.compat import PY312
+from pandas.compat import (
+    PY312,
+    PY314,
+)
 from pandas.compat._optional import import_optional_dependency
 from pandas.errors import (
     NumExprClobberingError,
@@ -179,7 +182,7 @@ def test_simple_cmp_ops(self, cmp_op, lhs, rhs, engine, parser):
                 r"only list-like( or dict-like)? objects are allowed to be "
                 r"passed to (DataFrame\.)?isin\(\), you passed a "
                 r"(`|')bool(`|')",
-                "argument of type 'bool' is not iterable",
+                "argument of type 'bool' is not .*",
             ]
         )
         if cmp_op in ("in", "not in") and not is_list_like(rhs):
@@ -224,7 +227,7 @@ def test_compound_invert_op(self, op, lhs, rhs, request, engine, parser):
                 r"only list-like( or dict-like)? objects are allowed to be "
                 r"passed to (DataFrame\.)?isin\(\), you passed a "
                 r"(`|')float(`|')",
-                "argument of type 'float' is not iterable",
+                "argument of type 'float' is not .*",
             ]
         )
         if is_scalar(rhs) and op in skip_these:
@@ -568,7 +571,9 @@ def test_series_pos(self, lhs, engine, parser):
     def test_scalar_unary(self, engine, parser):
         msg = "bad operand type for unary ~: 'float'"
         warn = None
-        if PY312 and not (engine == "numexpr" and parser == "pandas"):
+        if (PY314 and engine == "numexpr" and parser == "pandas") or (
+            PY312 and not (engine == "numexpr" and parser == "pandas")
+        ):
             warn = DeprecationWarning
         with pytest.raises(TypeError, match=msg):
             pd.eval("~1.0", engine=engine, parser=parser)
@@ -1110,7 +1115,7 @@ def test_simple_arith_ops(self, engine, parser):
             ex3 = f"1 {op} (x + 1)"

             if op in ("in", "not in"):
-                msg = "argument of type 'int' is not iterable"
+                msg = "argument of type 'int' is not .*"
                 with pytest.raises(TypeError, match=msg):
                     pd.eval(ex, engine=engine, parser=parser)
             else:

@@ -387,8 +387,16 @@ def test_bad_url_protocol(self, httpserver, flavor_read_html):
     [USER_REDACTED].mark.single_cpu
     def test_invalid_url(self, httpserver, flavor_read_html):
         httpserver.serve_content("Name or service not known", code=404)
-        with pytest.raises((URLError, ValueError), match="HTTP Error 404: NOT FOUND"):
-            flavor_read_html(httpserver.url, match=".*Water.*")
+        try:
+            with pytest.raises(
+                (URLError, ValueError), match="HTTP Error 404: NOT FOUND"
+            ) as err:
+                flavor_read_html(httpserver.url, match=".*Water.*")
+        finally:
+            if isinstance(err.value, URLError):
+                # Has a file-like handle that we can close
+                # https://docs.python.org/3/library/urllib.error.html#urllib.error.HTTPError
+                err.value.close()

     [USER_REDACTED].mark.slow
     def test_file_url(self, banklist_data, flavor_read_html):

@@ -513,8 +513,14 @@ def test_url(httpserver, xml_file):
 [USER_REDACTED].mark.single_cpu
 def test_wrong_url(parser, httpserver):
     httpserver.serve_content("NOT FOUND", code=404)
-    with pytest.raises(HTTPError, match=("HTTP Error 404: NOT FOUND")):
-        read_xml(httpserver.url, xpath=".//book[count(*)=4]", parser=parser)
+    try:
+        with pytest.raises(HTTPError, match=("HTTP Error 404: NOT FOUND")) as err:
+            read_xml(httpserver.url, xpath=".//book[count(*)=4]", parser=parser)
+    finally:
+        if isinstance(err.value, HTTPError):
+            # Has a file-like handle that we can close
+            # https://docs.python.org/3/library/urllib.error.html#urllib.error.HTTPError
+            err.value.close()

 # CONTENT

What's the reason for this change? (a comment for the future reader might be useful)

The test above also has a `with pytest.raises(URLError, ..`, do you know why that does not need an update?

OK, I see that HTTPError is actually also a file-like object (https://docs.python.org/3/library/urllib.error.html#urllib.error.HTTPError)

Hmm I'm not immediately sure. Inspecting the error from the test above it doesn't seem to even have a `close` method, maybe because the `filename`s are `None`?

```python
(Pdb) dir(e)
['__cause__', '__class__', '__context__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__setstate__', '__sizeof__', '__str__', '__subclasshook__', '__suppress_context__', '__traceback__', '__weakref__', 'add_note', 'args', 'characters_written', 'errno', 'filename', 'filename2', 'reason', 'strerror', 'with_traceback']
(Pdb) !e.filename
(Pdb) !e.filename2
(Pdb)
```

My hypothesis as that the above test doesn't even get to the point of receiving a http response (since it's checking an invalid url) so there's no data to close.

Correct, added a comment with this link


BUG: Fix `read_excel` infinite loop on callable `skiprows` all True

- [x] [Tests added and passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#writing-tests) if fixing a bug or adding a new feature
- [x] All [code checks passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#pre-commit).
- [x] Added [type annotations](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#type-hints) to new arguments/methods/functions.
- [ ] Added an entry in the latest `doc/source/whatsnew/vX.X.X.rst` file if fixing a bug or adding a new feature.
- [x] I have reviewed and followed all the [contribution guidelines](https://pandas.pydata.org/docs/dev/development/contributing.html)
- [x] If I used AI to develop this pull request, I prompted it to follow `AGENTS.md`.

Fixed infinite loop of `pd.read_excel("test1" + read_ext, skiprows=lambda _: True, nrows=1)`. In general, there was an infinite loop each time the number of non-skipped rows was less than `nrows`.

@@ -166,7 +166,7 @@ MultiIndex

 I/O
 ^^^
--
+- Fixed bug in :func:`read_excel` where usage of ``skiprows`` could lead to an infinite loop (:issue:`64027`)
 -

 Period

@@ -84,6 +84,8 @@
         WriteExcelBuffer,
     )

+EXCEL_ROWS_MAX = 1_048_576
+

 [USER_REDACTED]
 def read_excel(
@@ -608,7 +610,7 @@ def _check_skiprows_func(
         self,
         skiprows: Callable,
         rows_to_use: int,
-    ) -> int:
+    ) -> int | None:
         """
         Determine how many file rows are required to obtain `nrows` data
         rows when `skiprows` is a function.
@@ -631,6 +633,8 @@ def _check_skiprows_func(
             if not skiprows(i):
                 rows_used_so_far += 1
             i += 1
+            if i >= EXCEL_ROWS_MAX:
+                return None
         return i

     def _calc_rows(

@@ -343,6 +343,17 @@ def test_read_with_bad_dimension(
     tm.assert_frame_equal(result, expected)

+[USER_REDACTED].mark.parametrize(
+    "filename", ["dimension_missing", "dimension_small", "dimension_large"]
+)
+def test_read_with_bad_dimension_skiprows_callable_all(datapath, ext, filename):
+    # GH 64027
+    path = datapath("io", "data", "excel", f"{filename}{ext}")
+    result = pd.read_excel(path, skiprows=lambda _: True, nrows=1)
+    expected = DataFrame()
+    tm.assert_frame_equal(result, expected)
+
+
 def test_append_mode_file(tmp_excel):
     # GH 39576
     df = DataFrame()

@@ -1321,6 +1321,12 @@ def test_read_excel_skiprows_callable_not_in(self, request, engine, read_ext):
         expected["c"] = expected["c"].astype(f"M8[{unit}]")
         tm.assert_frame_equal(actual, expected)

+    def test_read_excel_skiprows_callable_all(self, read_ext):
+        # GH 64027
+        actual = pd.read_excel("test1" + read_ext, skiprows=lambda _: True, nrows=1)
+        expected = DataFrame()
+        tm.assert_frame_equal(actual, expected)
+
     def test_read_excel_nrows(self, read_ext):
         # GH 16645
         num_rows_to_pull = 5

Thanks for the PR! Will need an entry in the 3.1.0 whatsnew under I/O bugfixes.

Can you add a note in the whatsnew for 3.1.0 under the `I/O` section. I'd suggest something like

    - Fixed bug in :func:`read_excel` where usage of ``skiprows`` could lead to an infinite loop (:issue:`64027`)

But feel free to modify.

lgtm

Instead of implementing this here, can you add an abstract method and implement it on reader classes directly. Then you can use the corresponding engine and not have to resort to `getattr` calls.

```suggestion
        # GH 64027
        actual = pd.read_excel("test1" + read_ext, skiprows=lambda _: True, nrows=1)
```


DOC: Fix PANDAS_VERSION in docs build

- [x] closes #63990 (Replace xxxx with the GitHub issue number)
- [ ] [Tests added and passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#writing-tests) if fixing a bug or adding a new feature
- [ ] All [code checks passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#pre-commit).
- [ ] Added [type annotations](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#type-hints) to new arguments/methods/functions.
- [ ] Added an entry in the latest `doc/source/whatsnew/vX.X.X.rst` file if fixing a bug or adding a new feature.
- [ ] I have reviewed and followed all the [contribution guidelines](https://pandas.pydata.org/docs/dev/development/contributing.html)
- [ ] If I used AI to develop this pull request, I prompted it to follow `AGENTS.md`.

We should only set the pandas version when the ref matches a valid SemVer string.

@@ -47,10 +47,33 @@ jobs:
     steps:
     - name: Set pandas version
       run: |
-        if [ -n "${{ github.event.inputs.version }}" ]; then
-          echo "PANDAS_VERSION=${{ github.event.inputs.version }}" >> "$GITHUB_ENV"
+        # tags include a `v` prefix.
+        tag_version_pat="^v\d+\.\d+\.\d+(rc\d+)?$"
+        version_pat="^\d+\.\d+\.\d+(rc\d+)?$"
+
+        if [[ -n "${{ github.event.inputs.version }}" ]]; then
+          PANDAS_VERSION="${{ github.event.inputs.version }}"
+        elif [[ "${{ github.event_name }}" == "push" && "$GITHUB_REF_NAME" =~ $tag_version_pat ]]; then
+          PANDAS_VERSION="${GITHUB_REF_NAME:1}"
         else
-          echo "PANDAS_VERSION=${GITHUB_REF_NAME:1}" >> "$GITHUB_ENV"
+          PANDAS_VERSION=""
+        fi
+        echo "PANDAS_VERSION=$PANDAS_VERSION" >> "$GITHUB_ENV"
+
+        if [[ "${{ github.event_name }}" == "push" && -n "$PANDAS_VERSION" ]]; then
+          PUBLISH_PROD="true"
+        elif [[ "${{ github.event.inputs.publish_prod }}" == "true" ]]; then
+          PUBLISH_PROD="true"
+        else
+          PUBLISH_PROD="false"
+        fi
+        echo "PUBLISH_PROD=$PUBLISH_PROD" >> "$GITHUB_ENV"
+
+        if [[ "$PUBLISH_PROD" == "true" ]] &&
+           [[ ! "$PANDAS_VERSION" =~ $version_pat ]]
+        then
+          echo "Invalid version $PANDAS_VERSION for publishing to prod."
+          exit 1
         fi

     - name: Checkout
@@ -105,7 +128,7 @@ jobs:

     - name: Upload prod docs
       run: rsync -az --delete doc/build/html/ web@${{ secrets.server_ip }}:/var/www/html/pandas-docs/version/${{ env.PANDAS_VERSION }}
-      if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) || (inputs.publish_prod == 'true') }}
+      if: ${{ env.PUBLISH_PROD == 'true' }}

     - name: Move docs into site directory
       run: mv doc/build/html web/build/docs

Thanks for the updates, looks good now, let's give this a try

Will that allow rc versions? (those get currently uploaded)

i.e. anytime that the `if` check passes in the "Upload prod docs" step, we should have a value set for `PANDAS_VERSION` (not fully sure if it would otherwise error, or fill in an empty string and upload the docs to the wrong directory)

Ah, thanks. It's not clear to me if we should match on e.g. `3.1.0.dev0`, the latest commit currently does.  The regex will now match all tags of the repo except the following.

<details>

```
0.3.0
debian/0.4.0-1
debian/0.4.1-1
debian/0.4.3-1
debian/0.5.0+git7-gcf32be2-1
debian/0.6.1-1
debian/0.7.0-1
debian/0.7.1+git1-ga2e86c2-1
debian/0.7.3-1
debian/0.8.0-1
debian/0.8.0-2
debian/0.8.0_b2+git68-g7240b87-1
debian/0.8.0_b2-1
debian/0.8.0_rc2+git26-g76c6351-1
debian/0.8.1-1
v0.8.0b1
v0.8.0b2
v0.10.0b1
v0.13.0_ahl1
v0.13.0_ahl2
v0.15.2pre
v0.15pre
v0.21.0.dev
```

</details>

I've also added a step that checks the tag more stringently when publishing to prod that does not allow this to happen on e.g. `dev0`.

Yeah, in theory we don't have to upload the docs for the dev0 tags. And probably we can also remove those existing docs on the server to save some space.

Here you did not add "dev", so this workflow will then fail for that tag? (because above it will set PUBLISH_PROD to true I think for such tags)

(I think it would also be fine to not upload any docs for dev tags)

Thanks, fixed.

> As an aside - why do we publish the dev tag?

Do you mean "why do we publish it on the website", then I think that was just a consequence of how the pushing on any tag was implemented, which you improved here.

If you are asking why we are creating this tag in the first place, it is to let the git-tag-based version number of pandas for the development version have a "correct" number, that sorts after the released versions

Otherwise we get something like

```
>>> from packaging.version import Version
>>> Version("3.0.0+1200") > Version("3.0.1")
False
```


BUG: Missing explicit libm dependency in meson.build causes undefined symbol errors #63640

… symbol errors #63640

- [x] closes #63640 (Replace xxxx with the GitHub issue number)
- [x] All [code checks passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#pre-commit).
- [ ] Added [type annotations](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#type-hints) to new arguments/methods/functions.
- [ ] Added an entry in the latest `doc/source/whatsnew/vX.X.X.rst` file if fixing a bug or adding a new feature.
- [x] I have reviewed and followed all the [contribution guidelines](https://pandas.pydata.org/docs/dev/development/contributing.html)
- [x] If I used AI to develop this pull request, I prompted it to follow `AGENTS.md`.

@@ -50,6 +50,8 @@ _khash_primitive_helper_dep = declare_dependency(
     sources: _khash_primitive_helper,
 )

+m_dep = cc.find_library('m', required: false)
+
 cdata = configuration_data()
 if cy.version().version_compare('>=3.1.0')
     cdata.set('freethreading_compatible', '1')
@@ -70,10 +72,10 @@ libs_sources = {
     # numpy include dir is implicitly included
     'algos': {
         'sources': ['algos.pyx', _algos_common_helper, _algos_take_helper],
-        'deps': _khash_primitive_helper_dep,
+        'deps': [_khash_primitive_helper_dep, m_dep],
     },
     'arrays': {'sources': ['arrays.pyx']},
-    'groupby': {'sources': ['groupby.pyx']},
+    'groupby': {'sources': ['groupby.pyx'], 'deps': [m_dep]},
     'hashing': {'sources': ['hashing.pyx']},
     'hashtable': {
         'sources': [
@@ -126,14 +128,15 @@ libs_sources = {
             'src/vendored/ujson/lib/ultrajsonenc.c',
             'src/vendored/ujson/lib/ultrajsondec.c',
         ],
+        'deps': [m_dep],
     },
     'ops': {'sources': ['ops.pyx']},
     'ops_dispatch': {'sources': ['ops_dispatch.pyx']},
     'properties': {'sources': ['properties.pyx']},
     'reshape': {'sources': ['reshape.pyx']},
     'sas': {'sources': ['sas.pyx']},
     'byteswap': {'sources': ['byteswap.pyx']},
-    'sparse': {'sources': ['sparse.pyx', _sparse_op_helper]},
+    'sparse': {'sources': ['sparse.pyx', _sparse_op_helper], 'deps': [m_dep]},
     'tslib': {'sources': ['tslib.pyx']},
     'testing': {'sources': ['testing.pyx']},
     'writers': {'sources': ['writers.pyx']},

@@ -4,7 +4,7 @@ tslibs_sources = {
     'base': {'sources': ['base.pyx']},
     'ccalendar': {'sources': ['ccalendar.pyx']},
     'dtypes': {'sources': ['dtypes.pyx']},
-    'conversion': {'sources': ['conversion.pyx']},
+    'conversion': {'sources': ['conversion.pyx'], 'deps': [m_dep]},
     'fields': {'sources': ['fields.pyx']},
     'nattype': {'sources': ['nattype.pyx']},
     'np_datetime': {'sources': ['np_datetime.pyx']},

LGTM

```console
$ fd --extension so --no-ignore --exec nm --undefined-only -A  | grep -e mod -e pow -e abs -e sqrt -e log
./pandas/_libs/json.cpython-313-x86_64-linux-gnu.so:                 U pow@GLIBC_2.29
./pandas/_libs/groupby.cpython-313-x86_64-linux-gnu.so:                 U fmod@GLIBC_2.38
./pandas/_libs/groupby.cpython-313-x86_64-linux-gnu.so:                 U pow@GLIBC_2.29
./pandas/_libs/groupby.cpython-313-x86_64-linux-gnu.so:                 U sqrtf@GLIBC_2.2.5
./pandas/_libs/groupby.cpython-313-x86_64-linux-gnu.so:                 U sqrt@GLIBC_2.2.5
./pandas/_libs/sparse.cpython-313-x86_64-linux-gnu.so:                 U fmod@GLIBC_2.38
./pandas/_libs/sparse.cpython-313-x86_64-linux-gnu.so:                 U pow@GLIBC_2.29
./pandas/_libs/algos.cpython-313-x86_64-linux-gnu.so:                 U sqrt@GLIBC_2.2.5
./pandas/_libs/window/aggregations.cpython-313-x86_64-linux-gnu.so:                 U log@GLIBC_2.29
./pandas/_libs/window/aggregations.cpython-313-x86_64-linux-gnu.so:                 U pow@GLIBC_2.29
./pandas/_libs/window/aggregations.cpython-313-x86_64-linux-gnu.so:                 U sqrt@GLIBC_2.2.5
./pandas/_libs/tslibs/offsets.cpython-313-x86_64-linux-gnu.so:                 U PyNumber_Divmod
./pandas/_libs/tslibs/conversion.cpython-313-x86_64-linux-gnu.so:                 U log10@GLIBC_2.2.5
```

The `cc` variable already exists from the project root.
```suggestion
```

[USER_REDACTED] Thank you for your review. I reflected it.


REGR: pivot_table with empty values inconsistency

- [x] closes #46475 (Replace xxxx with the GitHub issue number)
- [ ] [Tests added and passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#writing-tests) if fixing a bug or adding a new feature
- [ ] All [code checks passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#pre-commit).
- [ ] Added [type annotations](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#type-hints) to new arguments/methods/functions.
- [ ] Added an entry in the latest `doc/source/whatsnew/vX.X.X.rst` file if fixing a bug or adding a new feature.
- [ ] I have reviewed and followed all the [contribution guidelines](https://pandas.pydata.org/docs/dev/development/contributing.html)
- [ ] If I used AI to develop this pull request, I prompted it to follow `AGENTS.md`.

When there are no values to aggregate on, the behavior of `pivot_table` depends on the number of columns provided to `index` and `columns` combined.

```python
frame = pd.DataFrame({"a": [1, 1, 2]})
table = pd.pivot_table(frame, index=["a"], aggfunc=len)
print(table)
# [NAME_REDACTED]
# Columns: []
# Index: [1, 2]

frame = pd.DataFrame({"a": [1, 1, 2], "b": [3, 4, 5]})
table = pd.pivot_table(frame, index=["a", "b"], aggfunc=len)
print(table)
# a  b
# 1  3    1
#    4    1
# 2  5    1
# dtype: int64
```

This is due to inconsistencies in `DataFrameGroupBy.agg`. While I aim to address these in the near future, they would resolve the inconsistency in `pivot_table` by [NAME_REDACTED] result above, whereas the second seems more desirable.

It appears to me that we get naturally good behavior by [NAME_REDACTED]_table act on all NA data in these cases: sums give 0, products give 1, count gives 0, size and len give the expected values. It also closely agrees with current behavior.

@@ -186,7 +186,7 @@ Groupby/resample/rolling

 Reshaping
 ^^^^^^^^^
--
+- In :func:`pivot_table`, when ``values`` is empty, the aggregation will be computed on a Series of all NA values (:issue:`46475`)
 -

 Sparse

@@ -49,6 +49,10 @@
     )

     from pandas import DataFrame
+    from pandas.api.typing import (
+        DataFrameGroupBy,
+        SeriesGroupBy,
+    )

 [USER_REDACTED]_module("pandas")
@@ -147,6 +151,11 @@ def pivot_table(
     -----
     Reference :ref:`the user guide <reshaping.pivot>` for more examples.

+    .. versionchanged:: 3.1.0
+        When ``values`` is empty, which is the case when all columns of
+        ``data`` are either in ``index`` or ``columns`` arguments, aggregation
+        will act on a Series of all NA values.
+
     Examples
     --------
     >>> df = pd.DataFrame(
@@ -335,11 +344,16 @@ def __internal_pivot_table(
                 pass
         values = list(values)

-    grouped = data.groupby(keys, observed=observed, sort=sort, dropna=dropna)
+    grouped: SeriesGroupBy | DataFrameGroupBy = data.groupby(
+        keys, observed=observed, sort=sort, dropna=dropna
+    )
     if values_passed:
         # GH#57876 and GH#61292
-        # mypy is not aware `grouped[values]` will always be a DataFrameGroupBy
-        grouped = grouped[values]  # type: ignore[assignment]
+        grouped = grouped[values]
+    elif grouped._obj_with_exclusions.columns.empty:
+        grouped = Series(np.nan, index=data.index).groupby(
+            [data[key] for key in keys], observed=observed, sort=sort, dropna=dropna
+        )

     agged = grouped.agg(aggfunc, **kwargs)

@@ -450,9 +464,10 @@ def _add_margins(
     if not values and isinstance(table, ABCSeries):
         # If there are no values and the table is a series, then there is only
         # one column in the data. Compute grand margin and return it.
-        return table._append_internal(
-            table._constructor({key: grand_margin[margins_name]})
-        )
+        row = table._constructor({key: grand_margin[margins_name]})
+        if not isinstance(table.index, MultiIndex):
+            row.index.name = table.index.name
+        return table._append_internal(row)

     elif values:
         marginal_result_set = _generate_marginal_results(
@@ -480,7 +495,6 @@ def _add_margins(
             return marginal_result_set
         result, margin_keys, row_margin = marginal_result_set

-    row_margin = row_margin.reindex(result.columns, fill_value=fill_value)
     # populate grand margin
     for k in margin_keys:
         if isinstance(k, str):
@@ -490,11 +504,9 @@ def _add_margins(

     from pandas import DataFrame

+    row_margin = row_margin.reindex(result.columns, fill_value=fill_value)
     margin_dummy = DataFrame(row_margin, columns=Index([key])).T

-    row_names = result.index.names
-    # check the result column and leave floats
-
     for dtype in set(result.dtypes):
         if isinstance(dtype, ExtensionDtype):
             # Can hold NA already
@@ -504,6 +516,8 @@ def _add_margins(
         margin_dummy[cols] = margin_dummy[cols].apply(
             maybe_downcast_to_dtype, args=(dtype,)
         )
+
+    row_names = result.index.names
     result = concat([result, margin_dummy])
     result.index.names = row_names

@@ -530,7 +544,15 @@ def _compute_grand_margin(
                 pass
         return grand_margin
     else:
-        return {margins_name: aggfunc(data.index, **kwargs)}
+        from pandas import Categorical
+
+        # Use groupby for consistency with how values are aggregated.
+        gb = Series(np.nan, index=data.index).groupby(
+            Categorical(np.zeros(len(data.index)), categories=[0]), observed=False
+        )
+        agged = gb.agg(aggfunc, **kwargs)
+        assert isinstance(agged, Series) and len(agged) == 1
+        return {margins_name: agged.iloc[0]}

 def _generate_marginal_results(
@@ -651,16 +673,27 @@ def _all_key():
             return (margins_name,) + ("",) * (len(cols) - 1)

         if len(rows) > 0:
-            margin = data.groupby(rows, observed=observed, dropna=dropna)[rows].apply(
-                aggfunc, **kwargs
-            )
+            if len(data.index) > 0:
+                gb = Series(np.nan, index=data.index).groupby(
+                    [data[row] for row in rows], observed=observed, dropna=dropna
+                )
+                margin = gb.agg(aggfunc, **kwargs)
+            else:
+                from pandas import Categorical
+
+                gb = Series(np.nan, index=data.index).groupby(
+                    Categorical(np.zeros(0), categories=[0]), observed=False
+                )
+                agged = gb.agg(aggfunc, **kwargs)
+                assert isinstance(agged, Series) and len(agged) == 1
+                margin = agged.iloc[0]
             all_key = _all_key()
             table[all_key] = margin
             result = table
             margin_keys.append(all_key)

         else:
-            margin = data.groupby(level=0, observed=observed, dropna=dropna).apply(
+            margin = data.groupby(level=0, observed=observed, dropna=dropna).agg(
                 aggfunc, **kwargs
             )
             all_key = _all_key()
@@ -672,9 +705,11 @@ def _all_key():
         result = table
         margin_keys = table.columns

-    if len(cols):
-        row_margin = data.groupby(cols, observed=observed, dropna=dropna)[cols].apply(
-            aggfunc, **kwargs
+    if len(result.columns) > 0:
+        row_margin = (
+            Series(np.nan, index=data.index)
+            .groupby([data[col] for col in cols], observed=observed, dropna=dropna)
+            .agg(aggfunc, **kwargs)
         )
     else:
         row_margin = Series(np.nan, index=result.columns)

@@ -1196,6 +1196,60 @@ def test_pivot_complex_aggfunc(self, data):

         tm.assert_frame_equal(result, expected)

+    [USER_REDACTED].mark.parametrize("margins", [True, False])
+    [USER_REDACTED].mark.parametrize(
+        "aggfunc, value",
+        [
+            ("sum", [0.0, 0.0, 0.0]),
+            ("prod", [1.0, 1.0, 1.0]),
+            ("count", [0, 0, 0]),
+            ("size", [2, 1, 3]),
+            (len, [2, 1, 3]),
+        ],
+    )
+    def test_reducers_no_values_one_index(self, margins, aggfunc, value):
+        # https://github.com/[USER_REDACTED]ndas-dev/pandas/issues/46475
+        df = DataFrame({"a": [1, 1, 2]})
+        result = pivot_table(df, index=["a"], aggfunc=aggfunc, margins=margins)
+        if margins:
+            index = Index([1, 2, "All"], name="a")
+        else:
+            value = value[:-1]
+            index = Index([1, 2], name="a")
+        expected = Series(value, index=index)
+        tm.assert_series_equal(result, expected)
+
+    [USER_REDACTED].mark.parametrize("margins", [True, False])
+    [USER_REDACTED].mark.parametrize("index", [["a"], ["a", "b"]])
+    [USER_REDACTED].mark.parametrize("columns", [["c"], ["c", "d"]])
+    def test_no_values_no_data(self, margins, index, columns):
+        # https://github.com/[USER_REDACTED]ndas-dev/pandas/issues/46475
+        df = DataFrame(columns=index + columns)
+        result = pivot_table(
+            df, index=index, columns=columns, aggfunc=len, margins=margins
+        )
+
+        if margins:
+            level0, level1, codes, data = ["All"], [""], [[0], [0]], [0]
+        else:
+            level0, level1, codes, data = [], [], [[], []], []
+
+        if index == ["a"]:
+            expected_index = Index(level0, name="a")
+        else:
+            expected_index = MultiIndex(
+                levels=[level0, level1], codes=codes, names=index
+            )
+        if columns == ["c"]:
+            expected_columns = Index(level0, name="c")
+        else:
+            expected_columns = MultiIndex(
+                levels=[level0, level1], codes=codes, names=columns
+            )
+
+        expected = DataFrame(data, index=expected_index, columns=expected_columns)
+        tm.assert_frame_equal(result, expected)
+
     def test_margins_no_values_no_cols(self, data):
         # Regression test on pivot table: no values or cols passed.
         result = data[["A", "B"]].pivot_table(
@@ -2054,8 +2108,8 @@ def test_pivot_margins_name_unicode(self):
             frame, index=["foo"], aggfunc=len, margins=True, margins_name=greek
         )
         index = Index([1, 2, 3, greek], dtype="object", name="foo")
-        expected = DataFrame(index=index, columns=[])
-        tm.assert_frame_equal(table, expected)
+        expected = Series([1, 1, 1, 3], index=index)
+        tm.assert_series_equal(table, expected)

     def test_pivot_string_as_func(self):
         # GH #18713

Should this missing value (or input `dtype=` to Series) be dependent on the type of the original `grouped.obj`? e.g. If the original frame had a nullable type we'd want a nullable type in the output?

Thinking through what this might look like - we'd need to determine the "flavor" - numpy, nullable, or pyarrow (I recall some objections to "flavor" in the past, but don't know if there is an accepted term). This would make a useful function in `core.dtypes`. Then we could do something like this.

We'd need to decide what the rule is in case of multiple flavors. I'd suggest if there are any `pyarrow` then go with that; if no `pyarrow` and there are `nullable` then go with that, and finally only have `numpy` if all columns are `numpy`. I think we'd want to consider `str` dtype (whether backed by [NAME_REDACTED]) as being `numpy` for the purposes of this. This might be problematic when the data has only `str` columns, but I don't see a better alternative.

For this particular instance, only the `index + columns` will be relevant in this determination.

I see. Understandable that this is probably more complicated than the tools we have today to probably get this case fully correct. I'm fine crossing that bridge when we get to it.

I've opened https://github.com/[USER_REDACTED]ndas-dev/pandas/issues/64005


BUG: groupby.agg with no groups, multiple keys, and group_keys=False

- [x] closes #51445 (Replace xxxx with the GitHub issue number)
- [ ] [Tests added and passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#writing-tests) if fixing a bug or adding a new feature
- [ ] All [code checks passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#pre-commit).
- [ ] Added [type annotations](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#type-hints) to new arguments/methods/functions.
- [ ] Added an entry in the latest `doc/source/whatsnew/vX.X.X.rst` file if fixing a bug or adding a new feature.
- [ ] I have reviewed and followed all the [contribution guidelines](https://pandas.pydata.org/docs/dev/development/contributing.html)
- [ ] If I used AI to develop this pull request, I prompted it to follow `AGENTS.md`.

In cases of an empty result `_python_agg_general` will call `_python_apply_general`. This can give bad behavior, e.g. `group_keys` can impact the result (which should only be the case with `apply`). In the case of an empty result, the index/columns are clear: it should have `result_index` as the index (as all aggregations should) and the columns of `self._obj_with_exclusions` columns.

@@ -181,7 +181,7 @@ [NAME_REDACTED]/resample/rolling
 ^^^^^^^^^^^^^^^^^^^^^^^^
--
+- Bug in :meth:`.DataFrameGroupBy.agg` when there are no groups, multiple keys, and ``group_keys=False`` (:issue:`51445`)
 -

 Reshaping

@@ -2372,24 +2372,20 @@ def aggregate(self, func=None, *args, engine=None, engine_kwargs=None, **kwargs)
     def _python_agg_general(self, func, *args, **kwargs):
         f = lambda x: func(x, *args, **kwargs)

-        if self.ngroups == 0:
-            # e.g. test_evaluate_with_empty_groups different path gets different
-            #  result dtype in empty case.
-            return self._python_apply_general(f, self._selected_obj, is_agg=True)
-
         obj = self._obj_with_exclusions

-        if not len(obj.columns):
-            # e.g. test_margins_no_values_no_cols
-            return self._python_apply_general(f, self._selected_obj)
-
-        output: dict[int, ArrayLike] = {}
-        for idx, (name, ser) in enumerate(obj.items()):
-            result = self._grouper.agg_series(ser, f)
-            output[idx] = result
-
-        res = self.obj._constructor(output)
-        res.columns = obj.columns.copy(deep=False)
+        if self.ngroups == 0 or len(obj.columns) == 0:
+            res_index = self._grouper.result_index
+            res = self.obj._constructor(index=res_index, columns=obj.columns).astype(
+                obj.dtypes
+            )
+        else:
+            output: dict[int, ArrayLike] = {
+                idx: self._grouper.agg_series(ser, f)
+                for idx, (name, ser) in enumerate(obj.items())
+            }
+            res = self.obj._constructor(output)
+            res.columns = obj.columns.copy(deep=False)
         return self._wrap_aggregated_output(res)

     def _aggregate_frame(self, func, *args, **kwargs) -> DataFrame:

@@ -1658,7 +1658,6 @@ def _python_apply_general(
         data: DataFrame | Series,
         not_indexed_same: bool | None = None,
         is_transform: bool = False,
-        is_agg: bool = False,
     ) -> NDFrameT:
         """
         Apply function f in python space
@@ -1676,10 +1675,6 @@ def _python_apply_general(
         is_transform : bool, default False
             Indicator for whether the function is actually a transform
             and should not have group keys prepended.
-        is_agg : bool, default False
-            Indicator for whether the function is an aggregation. When the
-            result is empty, we don't want to warn for this case.
-            See _GroupBy._python_agg_general.

         Returns
         -------

@@ -1032,13 +1032,31 @@ def test_groupby_aggregate_empty_key_empty_return():
     tm.assert_frame_equal(result, expected)

-def test_groupby_aggregate_empty_with_multiindex_frame():
+def test_groupby_aggregate_empty_with_multiindex_frame_single(as_index):
+    # GH 39178, 51445
+    df = DataFrame(columns=["a", "b", "c"])
+    gb = df.groupby(["a", "b"], group_keys=False, as_index=as_index)
+    result = gb.agg(lambda x: x.sum())
+    expected = DataFrame(
+        columns=["c"], index=MultiIndex([[], []], [[], []], names=["a", "b"])
+    )
+    if not as_index:
+        expected = expected.reset_index()
+    tm.assert_frame_equal(result, expected)
+
+
+def test_groupby_aggregate_empty_with_multiindex_frame_multi(as_index):
     # GH 39178
     df = DataFrame(columns=["a", "b", "c"])
-    result = df.groupby(["a", "b"], group_keys=False).agg(d=("c", list))
+    result = df.groupby(["a", "b"], group_keys=False, as_index=as_index).agg(
+        d=("c", list)
+    )
     expected = DataFrame(
         columns=["d"], index=MultiIndex([[], []], [[], []], names=["a", "b"])
     )
+    if not as_index:
+        expected = expected.reset_index()
+
     tm.assert_frame_equal(result, expected)

@@ -1835,14 +1835,24 @@ def get_categorical_invalid_expected():
     tm.assert_equal(result, expected)

-def test_empty_groupby_apply_nonunique_columns():
-    # GH#44417
-    df = DataFrame(np.random.default_rng(2).standard_normal((0, 4)))
-    df[3] = df[3].astype(np.int64)
-    df.columns = [0, 1, 2, 0]
-    gb = df.groupby(df[1], group_keys=False)
-    res = gb.apply(lambda x: x)
-    assert (res.dtypes == df.drop(columns=1).dtypes).all()
+[USER_REDACTED].mark.parametrize("method_name", ["agg", "apply"])
+[USER_REDACTED].mark.parametrize("keys", [[1], [1, 2]])
+def test_empty_groupby_apply_nonunique_columns(method_name, keys):
+    # GH#44417 & GH#63975
+    df = DataFrame(np.random.default_rng(2).standard_normal((0, 5)))
+    df[4] = df[4].astype(np.int64)
+    df.columns = [0, 1, 2, 3, 0]
+    gb = df.groupby(keys, group_keys=False)
+    method = getattr(gb, method_name)
+    result = method(lambda x: x)
+    expected = DataFrame(dtype="float64", columns=range(5))
+    expected[4] = expected[4].astype("int64")
+    expected.columns = [0, 1, 2, 3, 0]
+    if method_name == "agg":
+        expected = expected.set_index(keys)
+    else:
+        expected = expected.drop(columns=keys)
+    tm.assert_frame_equal(result, expected)

 def test_tuple_as_grouping():

Could use a dict comprehension if you're inclined

Good call, done.

are these comments about tests hitting these paths still useful?

is this astype going to misbehave if obj.columns is non-unique?

I was guessing these were added (e.g. in #51447) for future work on simplifying these paths. I believe this PR accomplishes that. If there is another reason these might exist, I can restore and add a comment as to why.

No, when the indexes of the caller and callee match, astype does the correct thing. Added a test.

i suspect they were added as an easy way to tell "this path has test coverage".  so makes sense theyd no longer be relevant. thanks for taking a look


DOC: Fix spelling mistakes and incomplete sentences in Timestamp docstrings

This PR fixes a couple of clear spelling mistakes and a few incomplete or
awkwardly phrased sentences in Timestamp-related docstrings.
These are documentation-only changes and do not affect any behavior or logic.

@@ -716,7 +716,7 @@ cdef datetime dateutil_parse(
             # e.g. "1994 Jan 15 05:16 FOO" where FOO is not recognized
             # GH#18702, # GH 50235 enforced in 3.0
             raise ValueError(
-                f'Parsed string "{timestr}" included an un-recognized timezone '
+                f'Parsed string "{timestr}" included an unrecognized timezone '
                 f'"{res.tzname}".'
             )

@@ -3475,7 +3475,7 @@ default 'raise'

     def to_julian_date(self) -> np.float64:
         """
-        [NAME_REDACTED] to a [NAME_REDACTED].
+        [NAME_REDACTED] to a [NAME_REDACTED] method returns the number of days as a float since
         0 Julian date, which is noon January 1, 4713 BC.

@@ -2260,7 +2260,7 @@ def isocalendar(self) -> DataFrame:

     def to_julian_date(self) -> npt.NDArray[np.float64]:
         """
-        [NAME_REDACTED] to a [NAME_REDACTED].
+        [NAME_REDACTED] to a [NAME_REDACTED] method returns the number of days as a float since noon January 1, 4713 BC.

@@ -601,7 +601,7 @@ def to_period(self, freq=None) -> PeriodIndex:

     def to_julian_date(self) -> Index:
         """
-        [NAME_REDACTED] to a [NAME_REDACTED].
+        [NAME_REDACTED] to a [NAME_REDACTED] method returns the number of days as a float since noon January 1, 4713 BC.

@@ -3611,7 +3611,7 @@ def test_to_datetime_iso8601_utc_mixed_both_offsets():
 def test_unknown_tz_raises():
     # GH#18702, GH#51476
     dtstr = "2014 Jan 9 05:15 FAKE"
-    msg = '.*un-recognized timezone "FAKE".'
+    msg = '.*unrecognized timezone "FAKE".'
     with pytest.raises(ValueError, match=msg):
         Timestamp(dtstr)

@@ -43,7 +43,7 @@ def test_parsing_tzlocal_deprecated():
             r"Parsing 'EST' as tzlocal \(dependent on system timezone\) "
             r"is no longer supported\. "
             "Pass the 'tz' keyword or call tz_localize after construction instead",
-            ".*included an un-recognized timezone",
+            ".*included an unrecognized timezone",
         ]
     )
     dtstr = "Jan 15 2004 03:00 EST"

Thanks for the PR! The pr-commit build needs to be passing.

lgtm

lgtm

The existence or not of `for` seems to me to be a matter of style, can you revert.

Adding a period in the middle of these sentence is grammatically incorrect.

This seems fine as-is. Can you revert.


BUG: unary operators not supported with pd.col (issue#63939)

- [x] closes #63939 (Replace xxxx with the GitHub issue number)
- [x] [Tests added and passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#writing-tests) if fixing a bug or adding a new feature
- [x] All [code checks passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#pre-commit).
- [x] Added [type annotations](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#type-hints) to new arguments/methods/functions.
- [x] Added an entry in the latest `doc/source/whatsnew/vX.X.X.rst` file if fixing a bug or adding a new feature.
- [x] I have reviewed and followed all the [contribution guidelines](https://pandas.pydata.org/docs/dev/development/contributing.html)
- [x] If I used AI to develop this pull request, I prompted it to follow `AGENTS.md`.

### issue
pd.col() does not support unary operator.
``` python
-1 * pd.col("A") # OK
-pd.col("A") # error
+pd.col("A") # error
```

### implement
pd.col() support unary operator
``` python
-pd.col("A") # OK
+pd.col("A") # OK
```

@@ -16,6 +16,7 @@ Bug fixes
 - Fixed a bug in the :class:`DataFrame` constructor when passed a :class:`Series` or
   :class:`Index` correctly handling Copy-on-Write (:issue:`63899`)
 - Allow :class:`.ExtensionArray` to have dtypes involving :class:`numpy.void` (:issue:`54810`)
+- Fixed a bug in :func:`col` where unary operators (``-``, ``+``, ``abs``) were not supported (:issue:`63939`)

 .. ---------------------------------------------------------------------------
 .. _whatsnew_301.contributors:

@@ -230,6 +230,35 @@ def __invert__(self) -> Expression:
             needs_parenthese=True,
         )

+    def __neg__(self) -> Expression:
+        if self._needs_parentheses:
+            repr_str = f"-({self._repr_str})"
+        else:
+            repr_str = f"-{self._repr_str}"
+        return Expression(
+            lambda df: -self._eval_expression(df),
+            repr_str,
+            needs_parenthese=True,
+        )
+
+    def __pos__(self) -> Expression:
+        if self._needs_parentheses:
+            repr_str = f"+({self._repr_str})"
+        else:
+            repr_str = f"+{self._repr_str}"
+        return Expression(
+            lambda df: +self._eval_expression(df),
+            repr_str,
+            needs_parenthese=True,
+        )
+
+    def __abs__(self) -> Expression:
+        return Expression(
+            lambda df: abs(self._eval_expression(df)),
+            f"abs({self._repr_str})",
+            needs_parenthese=True,
+        )
+
     def __array_ufunc__(
         self, ufunc: Callable[..., Any], method: str, *inputs: Any, **kwargs: Any
     ) -> Expression:

Thank you for working on this issue. I have reviewed the code changes and they look good overall.

**[NAME_REDACTED]:**

The implementation correctly adds support for unary operators (`-` and `+`) to `pd.col()` by implementing `__neg__` and `__pos__` methods in the Expression class. The logic follows the existing patterns in the codebase.

**What I verified:**

1. ✓ Tests added for the new functionality covering various use cases
2. ✓ Documentation updated in the whatsnew file
3. ✓ Code follows the existing pattern for Expression class methods
4. ✓ Proper handling of parentheses based on `_needs_parentheses` flag
5. ✓ Lambda functions correctly apply the unary operations
6. ✓ AI tool usage properly disclosed in PR description following contribution guidelines

**CI/CD Status:**
I noticed there are test failures in the CI pipeline (16 jobs failed). These appear to be in various platform-specific test suites. Could you please investigate whether these failures are related to your changes or if they are pre-existing issues?

The implementation addresses issue #63939 correctly. The code is clean and well-structured.

**Note:** I used an AI coding assistant to help analyze this PR, and I have reviewed and agree with the assessment provided.

Could you add `__abs__` as well while we're adding these?

Thank you for your review. I will add `__abs__`.

[USER_REDACTED] I have reflected your review.


replaced all [USER_REDACTED] in core/arrays/arrow/array.py with inline docstrings

- [x] xref #62437 (partially)
- [x] [Tests added and passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#writing-tests) if fixing a bug or adding a new feature
- [x] All [code checks passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#pre-commit).
- [x] Added [type annotations](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#type-hints) to new arguments/methods/functions.
- [x] Added an entry in the latest `doc/source/whatsnew/vX.X.X.rst` file if fixing a bug or adding a new feature.
- [x] I have reviewed and followed all the [contribution guidelines](https://pandas.pydata.org/docs/dev/development/contributing.html)
- [x] If I used AI to develop this pull request, I prompted it to follow `AGENTS.md`.

[USER_REDACTED] replaced with direct copies of method docstring from core/arrays/base.py (to which the [USER_REDACTED] decorators referred). Modified the docstring of the fillna method to reflect that the return type is ArrowExtensionArray rather than generic ExtensionArray.

@@ -38,7 +38,6 @@
 )
 from pandas.errors import Pandas4Warning
 from pandas.util._decorators import (
-    doc,
     set_module,
 )
 from pandas.util._exceptions import find_stack_level
@@ -1372,13 +1371,50 @@ def _pad_or_backfill(
             method=method, limit=limit, limit_area=limit_area, copy=copy
         )

-    [USER_REDACTED](ExtensionArray.fillna)
     def fillna(
         self,
         value: object | ArrayLike,
         limit: int | None = None,
         copy: bool = True,
     ) -> Self:
+        """
+        Fill NA/NaN values using the specified method.
+
+        Parameters
+        ----------
+        value : scalar, array-like
+            If a scalar value is passed it is used to fill all missing values.
+            Alternatively, an array-like "value" can be given. It's expected
+            that the array-like have the same length as 'self'.
+        limit : int, default None
+            The maximum number of entries where NA values will be filled.
+        copy : bool, default True
+            Whether to make a copy of the data before filling. This parameter
+            is inherited from pandas.ExtensionArray and should be left at its
+            default value (Arrow arrays cannot be altered in place).
+
+        Returns
+        -------
+        ArrowExtensionArray
+            With NA/NaN filled.
+
+        See Also
+        --------
+        api.extensions.ExtensionArray.dropna : [NAME_REDACTED] without
+            NA values.
+        api.extensions.ExtensionArray.isna : A 1-D array indicating if
+            each value is missing.
+
+        Examples
+        --------
+        >>> arr = pd.array(
+        ...     [np.nan, np.nan, 2, 3, np.nan, np.nan], dtype="int64[pyarrow]"
+        ... )
+        >>> arr.fillna(0)
+        <ArrowExtensionArray>
+        [0, 0, 2, 3, 0, 0]
+        Length: 6, dtype: int64[pyarrow]
+        """
         if not self._hasna:
             return self.copy()

@@ -1440,11 +1476,55 @@ def _values_for_factorize(self) -> tuple[np.ndarray, Any]:
         values = self._pa_array.to_numpy()
         return values, self.dtype.na_value

-    [USER_REDACTED](ExtensionArray.factorize)
     def factorize(
         self,
         use_na_sentinel: bool = True,
     ) -> tuple[np.ndarray, ExtensionArray]:
+        """
+        Encode the arrow array as an enumerated type.
+
+        Parameters
+        ----------
+        use_na_sentinel : bool, default True
+            If True, the sentinel -1 will be used for NaN values. If False,
+            NaN values will be encoded as non-negative integers and will not drop the
+            NaN from the uniques of the values.
+
+        Returns
+        -------
+        codes : ndarray
+            An integer NumPy array that's an indexer into the original
+            ArrowArray.
+        uniques : ArrowArray
+            An ArrowArray containing the unique values of `self`.
+
+            .. note::
+
+               uniques will *not* contain an entry for the NA value of
+               the ArrowArray if there are any missing values present
+               in `self`.
+
+        See Also
+        --------
+        factorize : Top-level factorize method that dispatches here.
+
+        Notes
+        -----
+        :meth:`pandas.factorize` offers a `sort` keyword as well.
+
+        Examples
+        --------
+        >>> arr = pd.array(
+        ...     ["Ant", "Badger", "Cobra", "Cobra", "Deer", "Ant"], dtype=str
+        ... )
+        >>> arr_codes, arr_uniques = arr.factorize()
+        >>> arr_codes
+        array([0, 1, 2, 2, 3, 0])
+        >>> arr_uniques
+        <ArrowStringArray>
+        ['Ant', 'Badger', 'Cobra', 'Deer']
+        Length: 4, dtype: str
+        """
         null_encoding = "mask" if use_na_sentinel else "encode"

         data = self._pa_array
@@ -1506,13 +1586,56 @@ def round(self, decimals: int = 0, *args, **kwargs) -> Self:
         """
         return self._from_pyarrow_array(pc.round(self._pa_array, ndigits=decimals))

-    [USER_REDACTED](ExtensionArray.searchsorted)
     def searchsorted(
         self,
         value: NumpyValueArrayLike | ExtensionArray,
         side: Literal["left", "right"] = "left",
         sorter: NumpySorter | None = None,
     ) -> npt.NDArray[np.intp] | np.intp:
+        """
+        Find indices where elements should be inserted to maintain order.
+
+        Find the indices into a sorted array `self` (a) such that, if the
+        corresponding elements in `value` were inserted before the indices,
+        the order of `self` would be preserved.
+
+        Assuming that `self` is sorted:
+
+        ======  ================================
+        `side`  returned index `i` satisfies
+        ======  ================================
+        left    ``self[i-1] < value <= self[i]``
+        right   ``self[i-1] <= value < self[i]``
+        ======  ================================
+
+        Parameters
+        ----------
+        value : array-like, list or scalar
+            Value(s) to insert into `self`.
+        side : {'left', 'right'}, optional
+            If 'left', the index of the first suitable location found is given.
+            If 'right', return the last such index.  If there is no suitable
+            index, return either 0 or N (where N is the length of `self`).
+        sorter : 1-D array-like, optional
+            Optional array of integer indices that sort array a into ascending
+            order. They are typically the result of argsort.
+
+        Returns
+        -------
+        array of ints or int
+            If value is array-like, array of insertion points.
+            If value is scalar, a single integer.
+
+        See Also
+        --------
+        numpy.searchsorted : Similar method from NumPy.
+
+        Examples
+        --------
+        >>> arr = pd.array([1, 2, 3, 5], dtype="int64[pyarrow]")
+        >>> arr.searchsorted([4])
+        array([3])
+        """
         if self._hasna:
             raise ValueError(
                 "searchsorted requires array to be sorted, which is impossible "
@@ -1664,13 +1787,35 @@ def _values_for_json(self) -> np.ndarray:
             return np.asarray(self, dtype=object)
         return super()._values_for_json()

-    [USER_REDACTED](ExtensionArray.to_numpy)
     def to_numpy(
         self,
         dtype: npt.DTypeLike | None = None,
         copy: bool = False,
         na_value: object = lib.no_default,
     ) -> np.ndarray:
+        """
+        Convert to a NumPy ndarray.
+
+        This is similar to :meth:`numpy.asarray`, but may provide additional control
+        over how the conversion is done.
+
+        Parameters
+        ----------
+        dtype : str or numpy.dtype, optional
+            The dtype to pass to :meth:`numpy.asarray`.
+        copy : bool, default False
+            Whether to ensure that the returned value is a not a view on
+            another array. Note that ``copy=False`` does not *ensure* that
+            ``to_numpy()`` is no-copy. Rather, ``copy=True`` ensure that
+            a copy is made, even if not strictly necessary.
+        na_value : Any, optional
+            The value to use for missing values. The default value depends
+            on `dtype` and the type of the array.
+
+        Returns
+        -------
+        numpy.ndarray
+        """
         original_na_value = na_value
         dtype, na_value = to_numpy_dtype_inference(self, dtype, na_value, self._hasna)
         pa_type = self._pa_array.type
@@ -1736,10 +1881,37 @@ def map(self, mapper, na_action: Literal["ignore"] | None = None):
             #  to None.
             return super().map(mapper, na_action)

-    [USER_REDACTED](ExtensionArray.duplicated)
     def duplicated(
         self, keep: Literal["first", "last", False] = "first"
     ) -> npt.NDArray[np.bool_]:
+        """
+        Return boolean ndarray denoting duplicate values.
+
+        Parameters
+        ----------
+        keep : {'first', 'last', False}, default 'first'
+            - ``first`` : Mark duplicates as ``True`` except for the first occurrence.
+            - ``last`` : Mark duplicates as ``True`` except for the last occurrence.
+            - False : Mark all duplicates as ``True``.
+
+        Returns
+        -------
+        ndarray[bool]
+            With true in indices where elements are duplicated and false otherwise.
+
+        See Also
+        --------
+        DataFrame.duplicated : Return boolean Series denoting
+            duplicate rows.
+        Series.duplicated : Indicate duplicate Series values.
+        api.extensions.ExtensionArray.unique : Compute the ExtensionArray
+            of unique values.
+
+        Examples
+        --------
+        >>> pd.array([1, 1, 2, 3, 3], dtype="int64[pyarrow]").duplicated()
+        array([False,  True, False, False,  True])
+        """
         pa_type = self._pa_array.type
         if pa.types.is_floating(pa_type) or pa.types.is_integer(pa_type):
             values = self.to_numpy(na_value=0)

Similar comment as your other PR, could you ensure these examples show that the array is an `ArrowExtensionArray`?

Oh, yes. I will start working on that.

Question: for the ArrowArray.fillna() method, the docstring from ExtensionArray says that copy defaults to true, but that some ExtensionArray subclasses may not be able to do false. [NAME_REDACTED] fillna in place?

> [NAME_REDACTED] fillna in place?

I believe not, no. Pyarrow arrays are immutable so I doubt pyarrow will add support for inplace filling

A reminder to update all examples changed here to use `ArrowExtentionArray`

Clarifying question: the docstring for the factorize method (copied from ExtensionArray) uses a PeriodIndex object in its example, not an ExtensionArray. Do we still want me to replace it with an ArrowExtensionArray in the example, or was there some other reason for using a PeriodIndex instead?

> Do we still want me to replace it with an ArrowExtensionArray in the example, or was there some other reason for using a PeriodIndex instead?

Yes, all the examples in this file should use `ArrowExtensionArray`.

All the examples should now be updated to use ArrowExtensionArrays (for the factorize method I used an ArrowStringArray to more clearly demonstrate the difference between the codes and uniques arrays that are returned)


DOC Replace [USER_REDACTED] decorator with inline docstring in core/arrays/sparse/array.py

- [x] xref #62437
- [x] [Tests added and passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#writing-tests) if fixing a bug or adding a new feature
- [x] All [code checks passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#pre-commit).
- [x] Added [type annotations](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#type-hints) to new arguments/methods/functions.
- [x] Added an entry in the latest `doc/source/whatsnew/vX.X.X.rst` file if fixing a bug or adding a new feature.
- [ x] I have reviewed and followed all the [contribution guidelines](https://pandas.pydata.org/docs/dev/development/contributing.html)
- [x] If I used AI to develop this pull request, I prompted it to follow `AGENTS.md`.

@@ -32,7 +32,6 @@
 from pandas.compat.numpy import function as nv
 from pandas.errors import PerformanceWarning
 from pandas.util._decorators import (
-    doc,
     set_module,
 )
 from pandas.util._exceptions import find_stack_level
@@ -885,10 +884,38 @@ def _first_fill_value_loc(self):
         diff = np.r_[np.diff(indices), 2]
         return indices[(diff > 1).argmax()] + 1

-    [USER_REDACTED](ExtensionArray.duplicated)
     def duplicated(
         self, keep: Literal["first", "last", False] = "first"
     ) -> npt.NDArray[np.bool_]:
+        """
+        Return boolean ndarray denoting duplicate values.
+
+        Parameters
+        ----------
+        keep : {'first', 'last', False}, default 'first'
+            - ``first`` : Mark duplicates as ``True`` except for the first occurrence.
+            - ``last`` : Mark duplicates as ``True`` except for the last occurrence.
+            - False : Mark all duplicates as ``True``.
+
+        Returns
+        -------
+        ndarray[bool]
+            With true in indices where elements are duplicated and false otherwise.
+
+        See Also
+        --------
+        DataFrame.duplicated : Return boolean Series denoting
+            duplicate rows.
+        Series.duplicated : Indicate duplicate Series values.
+        api.extensions.ExtensionArray.unique : Compute the ExtensionArray
+            of unique values.
+
+        Examples
+        --------
+        >>> pd.array([1, 1, 2, 3, 3], dtype=pd.SparseDtype(np.int64)).duplicated()
+        array([False,  True, False, False,  True])
+        """
+
         values = np.asarray(self)
         mask = np.asarray(self.isna())
         return algos.duplicated(values, keep=keep, mask=mask)

Can you modify this example to use a `SparseDtype(np.int64)` to match the parent class?

Will do

Done

The docstring check script threw an error after I made the changes, but I'm not sure why. I ran the example code from the docstring and it executes normally. I pushed the commit to this branch so you can see; do you know what is making it fail the test?