From 416a04008eaa65d03aaade2b821f1323d0a6c790 Mon Sep 17 00:00:00 2001 From: Sourab Mangrulkar <13534540+pacman100@users.noreply.github.com> Date: Wed, 18 Jan 2023 19:43:29 +0530 Subject: [PATCH] addressing comments and bug fixes --- Makefile | 4 +- README.md | 19 ++ setup.py | 3 +- src/peft/__init__.py | 15 + src/peft/mapping.py | 15 + src/peft/peft_model.py | 72 ++-- src/peft/tuners/__init__.py | 15 + src/peft/tuners/lora.py | 21 +- src/peft/tuners/p_tuning.py | 27 +- src/peft/tuners/prefix_tuning.py | 21 +- src/peft/tuners/prompt_tuning.py | 15 + src/peft/utils/__init__.py | 15 + src/peft/utils/config.py | 15 + src/peft/utils/other.py | 15 + src/peft/utils/save_and_load.py | 15 + utils/style_doc.py | 556 ------------------------------- 16 files changed, 241 insertions(+), 602 deletions(-) delete mode 100644 utils/style_doc.py diff --git a/Makefile b/Makefile index 888056d..532a640 100644 --- a/Makefile +++ b/Makefile @@ -9,11 +9,11 @@ quality: black --check $(check_dirs) isort --check-only $(check_dirs) flake8 $(check_dirs) - python utils/style_doc.py src --max_len 119 --check_only + doc-builder style src --max_len 119 --check_only # Format source code automatically and check is there are any problems left that need manual fixing style: black $(check_dirs) isort $(check_dirs) - python utils/style_doc.py src --max_len 119 + doc-builder style src --max_len 119 \ No newline at end of file diff --git a/README.md b/README.md index 2efe67c..6a03cd6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,19 @@ + +

🤗 PEFT

State-of-the-art Parameter-Efficient Fine-Tuning (PEFT) methods

@@ -104,6 +120,9 @@ accelerate launch train_dreambooth.py \ --max_train_steps=800 ``` +Try out the 🤗 Gradio Space which should run seamlessly on a T4 instance: +[smangrul/peft-lora-sd-dreambooth](https://huggingface.co/spaces/smangrul/peft-lora-sd-dreambooth). + ### Parameter Efficient Tuning of LLMs for RLHF components such as Ranker and Policy [ToDo] ### Save compute and storage even for medium and small models diff --git a/setup.py b/setup.py index 2583f08..08188f0 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,8 @@ from setuptools import find_packages extras = {} extras["quality"] = ["black ~= 22.0", "isort >= 5.5.4", "flake8 >= 3.8.3"] -extras["dev"] = extras["quality"] +extras["docs_specific"] = ["hf-doc-builder"] +extras["dev"] = extras["quality"] + extras["docs_specific"] setup( name="peft", diff --git a/src/peft/__init__.py b/src/peft/__init__.py index c1882b3..b76dad8 100644 --- a/src/peft/__init__.py +++ b/src/peft/__init__.py @@ -2,6 +2,21 @@ # There's no way to ignore "F401 '...' imported but unused" warnings in this # module, but to preserve other warnings. So, don't check this module at all. +# coding=utf-8 +# Copyright 2023-present the HuggingFace Inc. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + __version__ = "0.1.0.dev0" from .mapping import MODEL_TYPE_TO_PEFT_MODEL_MAPPING, PEFT_TYPE_TO_CONFIG_MAPPING, get_peft_config, get_peft_model diff --git a/src/peft/mapping.py b/src/peft/mapping.py index 9ec027f..b91c7cf 100644 --- a/src/peft/mapping.py +++ b/src/peft/mapping.py @@ -1,3 +1,18 @@ +# coding=utf-8 +# Copyright 2023-present the HuggingFace Inc. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + from .peft_model import ( PeftModelForCausalLM, PeftModelForSeq2SeqLM, diff --git a/src/peft/peft_model.py b/src/peft/peft_model.py index 2ba74a8..7bf9e15 100644 --- a/src/peft/peft_model.py +++ b/src/peft/peft_model.py @@ -1,3 +1,18 @@ +# coding=utf-8 +# Copyright 2023-present the HuggingFace Inc. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import inspect import warnings @@ -20,12 +35,12 @@ class PeftModel(torch.nn.Module): Attributes: - base_model (`PreTrainedModel`): The base transformer model used for Peft. peft_config (`PeftConfig`): - The configuration of the Peft model. modules_to_save (`list` of `str`): The list of sub-module names - to save when saving the model. prompt_encoder (`PromptEncoder`): The prompt encoder used for Peft if - `peft_config.peft_type != PeftType.LORA`. prompt_tokens (`torch.Tensor`): The virtual prompt tokens used for - Peft if `peft_config.peft_type != PeftType.LORA`. transformer_backbone_name (`str`): The name of the - transformer backbone in the base model + base_model (`PreTrainedModel`): The base transformer model used for Peft. peft_config (`PeftConfig`): The + configuration of the Peft model. modules_to_save (`list` of `str`): The list of sub-module names to save when + saving the model. prompt_encoder (`PromptEncoder`): The prompt encoder used for Peft if `peft_config.peft_type + != PeftType.LORA`. prompt_tokens (`torch.Tensor`): The virtual prompt tokens used for Peft if + `peft_config.peft_type != PeftType.LORA`. transformer_backbone_name (`str`): The name of the transformer + backbone in the base model if `peft_config.peft_type != PeftType.LORA`. word_embeddings (`torch.nn.Embedding`): The word embeddings of the transformer backbone in the base model if `peft_config.peft_type != PeftType.LORA`. @@ -151,20 +166,20 @@ class PeftModelForSequenceClassification(PeftModel): peft_config (`PeftConfig`): Peft config. Attributes: - config (`PretrainedConfig`): The configuration object of the base model. cls_layer_name (`str`): The - name of the classification layer. + config (`PretrainedConfig`): The configuration object of the base model. cls_layer_name (`str`): The name of + the classification layer. Example:: >>> from transformers import AutoModelForSequenceClassification >>> from peft import PeftModelForSequenceClassification, get_peft_config >>> config = { - 'peft_type': 'PREFIX_TUNING', 'task_type': 'SEQ_CLS', 'inference_mode': False, 'num_virtual_tokens': 20, - 'token_dim': 768, 'num_transformer_submodules': 1, 'num_attention_heads': 12, 'num_layers': 12, + 'peft_type': 'PREFIX_TUNING', 'task_type': 'SEQ_CLS', 'inference_mode': False, 'num_virtual_tokens': + 20, 'token_dim': 768, 'num_transformer_submodules': 1, 'num_attention_heads': 12, 'num_layers': 12, 'encoder_hidden_size': 768, 'prefix_projection': False, 'postprocess_past_key_value_function': None } - >>> peft_config = get_peft_config(config) - >>> model = AutoModelForSequenceClassification.from_pretrained("bert-base-cased") - >>> peft_model = PeftModelForSequenceClassification(model, peft_config) >>> peft_model.print_trainable_parameters() trainable + >>> peft_config = get_peft_config(config) >>> model = + AutoModelForSequenceClassification.from_pretrained("bert-base-cased") >>> peft_model = + PeftModelForSequenceClassification(model, peft_config) >>> peft_model.print_trainable_parameters() trainable params: 370178 || all params: 108680450 || trainable%: 0.3406113979101117 """ @@ -323,17 +338,15 @@ class PeftModelForCausalLM(PeftModel): Example:: - >>> from transformers import AutoModelForCausalLM >>> from peft import PeftModelForCausalLM, get_peft_config >>> - config = { + >>> from transformers import AutoModelForCausalLM >>> from peft import PeftModelForCausalLM, get_peft_config + >>> config = { 'peft_type': 'PREFIX_TUNING', 'task_type': 'CAUSAL_LM', 'inference_mode': False, 'num_virtual_tokens': 20, 'token_dim': 1280, 'num_transformer_submodules': 1, 'num_attention_heads': 20, 'num_layers': 36, 'encoder_hidden_size': 1280, 'prefix_projection': False, 'postprocess_past_key_value_function': None } - >>> peft_config = get_peft_config(config) - >>> model = AutoModelForCausalLM.from_pretrained("gpt2-large") - >>> peft_model = PeftModelForCausalLM(model, peft_config) - >>> peft_model.print_trainable_parameters() trainable params: - 1843200 || all params: 775873280 || trainable%: 0.23756456724479544 + >>> peft_config = get_peft_config(config) >>> model = AutoModelForCausalLM.from_pretrained("gpt2-large") >>> + peft_model = PeftModelForCausalLM(model, peft_config) >>> peft_model.print_trainable_parameters() trainable + params: 1843200 || all params: 775873280 || trainable%: 0.23756456724479544 """ def __init__(self, model, peft_config: PeftConfig): @@ -447,16 +460,14 @@ class PeftModelForSeq2SeqLM(PeftModel): Example:: - >>> from transformers import AutoModelForSeq2SeqLM >>> from peft import PeftModelForSeq2SeqLM, get_peft_config >>> - config = { + >>> from transformers import AutoModelForSeq2SeqLM >>> from peft import PeftModelForSeq2SeqLM, get_peft_config + >>> config = { 'peft_type': 'LORA', 'task_type': 'SEQ_2_SEQ_LM', 'inference_mode': False, 'r': 8, 'target_modules': ['q', 'v'], 'lora_alpha': 32, 'lora_dropout': 0.1, 'merge_weights': False, 'fan_in_fan_out': False, 'enable_lora': None, 'bias': 'none' } - >>> peft_config = get_peft_config(config) - >>> model = AutoModelForSeq2SeqLM.from_pretrained("t5-base") - >>> peft_model = PeftModelForSeq2SeqLM(model, peft_config) - >>> peft_model.print_trainable_parameters() trainable + >>> peft_config = get_peft_config(config) >>> model = AutoModelForSeq2SeqLM.from_pretrained("t5-base") >>> + peft_model = PeftModelForSeq2SeqLM(model, peft_config) >>> peft_model.print_trainable_parameters() trainable params: 884736 || all params: 223843584 || trainable%: 0.3952474242013566 """ @@ -599,8 +610,8 @@ class PeftModelForTokenClassification(PeftModel): peft_config (`PeftConfig`): Peft config. Attributes: - config (`PretrainedConfig`): The configuration object of the base model. cls_layer_name (`str`): The - name of the classification layer. + config (`PretrainedConfig`): The configuration object of the base model. cls_layer_name (`str`): The name of + the classification layer. Example:: @@ -611,9 +622,8 @@ class PeftModelForTokenClassification(PeftModel): 'encoder_hidden_size': 768, 'prefix_projection': False, 'postprocess_past_key_value_function': None } >>> peft_config = get_peft_config(config) >>> model = - AutoModelForTokenClassification.from_pretrained("bert-base-cased") - >>> peft_model = PeftModelForTokenClassification(model, peft_config) - >>> peft_model.print_trainable_parameters() trainable + AutoModelForTokenClassification.from_pretrained("bert-base-cased") >>> peft_model = + PeftModelForTokenClassification(model, peft_config) >>> peft_model.print_trainable_parameters() trainable params: 370178 || all params: 108680450 || trainable%: 0.3406113979101117 """ diff --git a/src/peft/tuners/__init__.py b/src/peft/tuners/__init__.py index 54e554c..38b7926 100644 --- a/src/peft/tuners/__init__.py +++ b/src/peft/tuners/__init__.py @@ -2,6 +2,21 @@ # There's no way to ignore "F401 '...' imported but unused" warnings in this # module, but to preserve other warnings. So, don't check this module at all +# coding=utf-8 +# Copyright 2023-present the HuggingFace Inc. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + from .lora import LoraConfig, LoraModel from .p_tuning import PromptEncoder, PromptEncoderConfig, PromptEncoderReparameterizationType from .prefix_tuning import PrefixEncoder, PrefixTuningConfig diff --git a/src/peft/tuners/lora.py b/src/peft/tuners/lora.py index 9697152..e3ded08 100644 --- a/src/peft/tuners/lora.py +++ b/src/peft/tuners/lora.py @@ -1,3 +1,18 @@ +# coding=utf-8 +# Copyright 2023-present the HuggingFace Inc. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import math import warnings from dataclasses import asdict, dataclass, field @@ -70,8 +85,8 @@ class LoraModel(torch.nn.Module): >>> model = AutoModelForSeq2SeqLM.from_pretrained("t5-base") >>> lora_model = LoraModel(config, model) Attributes: - model (`transformers.PreTrainedModel`): The model to be adapted. config (`LoraConfig`): The - configuration of the Lora model. + model (`transformers.PreTrainedModel`): The model to be adapted. config (`LoraConfig`): The configuration of + the Lora model. """ def __init__(self, config, model): @@ -237,7 +252,7 @@ class Linear(nn.Linear, LoraLayer): result += self.lora_B(self.lora_A(self.lora_dropout(x))) * self.scaling return result else: - return F.linear(x, T(self.weight), bias=self.bias) + return F.linear(x, transpose(self.weight, self.fan_in_fan_out), bias=self.bias) class MergedLinear(nn.Linear, LoraLayer): diff --git a/src/peft/tuners/p_tuning.py b/src/peft/tuners/p_tuning.py index d5cca27..98f6537 100644 --- a/src/peft/tuners/p_tuning.py +++ b/src/peft/tuners/p_tuning.py @@ -1,3 +1,18 @@ +# coding=utf-8 +# Copyright 2023-present the HuggingFace Inc. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import enum from dataclasses import dataclass, field from typing import Union @@ -19,8 +34,7 @@ class PromptEncoderConfig(PromptLearningConfig): Args: encoder_reparameterization_type - (Union[:class:`PromptEncoderReparameterizationType`, `str`]): The type of reparameterization to - use. + (Union[:class:`PromptEncoderReparameterizationType`, `str`]): The type of reparameterization to use. encoder_hidden_size (`int`): The hidden size of the prompt encoder. encoder_num_layers (`int`): The number of layers of the prompt encoder. encoder_dropout (`float`): The dropout probability of the prompt encoder. @@ -70,11 +84,10 @@ class PromptEncoder(torch.nn.Module): (:class:`~torch.nn.Sequential`): The MLP head of the prompt encoder if `inference_mode=False`. lstm_head (:class:`~torch.nn.LSTM`): The LSTM head of the prompt encoder if `inference_mode=False` and `encoder_reparameterization_type="LSTM"`. - token_dim (`int`): The hidden embedding dimension of the base transformer model. input_size (`int`): - The input size of the prompt encoder. output_size (`int`): The output size of the prompt encoder. - hidden_size (`int`): The hidden size of the prompt encoder. total_virtual_tokens (`int`): The total - number of virtual tokens of the prompt encoder. encoder_type - (Union[:class:`PromptEncoderReparameterizationType`, `str`]): + token_dim (`int`): The hidden embedding dimension of the base transformer model. input_size (`int`): The input + size of the prompt encoder. output_size (`int`): The output size of the prompt encoder. hidden_size (`int`): + The hidden size of the prompt encoder. total_virtual_tokens (`int`): The total number of virtual tokens of the + prompt encoder. encoder_type (Union[:class:`PromptEncoderReparameterizationType`, `str`]): The encoder type of the prompt encoder. diff --git a/src/peft/tuners/prefix_tuning.py b/src/peft/tuners/prefix_tuning.py index e20a49c..48ae168 100644 --- a/src/peft/tuners/prefix_tuning.py +++ b/src/peft/tuners/prefix_tuning.py @@ -1,3 +1,19 @@ +# coding=utf-8 +# Copyright 2023-present the HuggingFace Inc. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + from dataclasses import dataclass, field from typing import Callable, Optional @@ -54,8 +70,9 @@ class PrefixEncoder(torch.nn.Module): Attributes: - embedding (`torch.nn.Embedding`): The embedding layer of the prefix encoder. trans - (`torch.nn.Sequential`): The two-layer MLP to transform the prefix embeddings + embedding (`torch.nn.Embedding`): + The embedding layer of the prefix encoder. trans (`torch.nn.Sequential`): The + two-layer MLP to transform the prefix embeddings if `prefix_projection` is `True`. prefix_projection (`bool`): Whether to project the prefix embeddings. diff --git a/src/peft/tuners/prompt_tuning.py b/src/peft/tuners/prompt_tuning.py index 3717964..04e2ef2 100644 --- a/src/peft/tuners/prompt_tuning.py +++ b/src/peft/tuners/prompt_tuning.py @@ -1,3 +1,18 @@ +# coding=utf-8 +# Copyright 2023-present the HuggingFace Inc. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import enum import math from dataclasses import dataclass, field diff --git a/src/peft/utils/__init__.py b/src/peft/utils/__init__.py index 54b1ef0..45b0cd5 100644 --- a/src/peft/utils/__init__.py +++ b/src/peft/utils/__init__.py @@ -2,6 +2,21 @@ # There's no way to ignore "F401 '...' imported but unused" warnings in this # module, but to preserve other warnings. So, don't check this module at all +# coding=utf-8 +# Copyright 2023-present the HuggingFace Inc. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + from .config import PeftConfig, PeftType, PromptLearningConfig, TaskType from .other import _set_trainable, bloom_model_postprocess_past_key_value, shift_tokens_right, transpose from .save_and_load import get_peft_model_state_dict, peft_model_load_and_dispatch, set_peft_model_state_dict diff --git a/src/peft/utils/config.py b/src/peft/utils/config.py index 6c041e7..8e485eb 100644 --- a/src/peft/utils/config.py +++ b/src/peft/utils/config.py @@ -1,3 +1,18 @@ +# coding=utf-8 +# Copyright 2023-present the HuggingFace Inc. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import enum from dataclasses import dataclass, field from typing import Optional, Union diff --git a/src/peft/utils/other.py b/src/peft/utils/other.py index 4063e59..14ab90e 100644 --- a/src/peft/utils/other.py +++ b/src/peft/utils/other.py @@ -1,3 +1,18 @@ +# coding=utf-8 +# Copyright 2023-present the HuggingFace Inc. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import torch diff --git a/src/peft/utils/save_and_load.py b/src/peft/utils/save_and_load.py index 36867fd..f4062af 100644 --- a/src/peft/utils/save_and_load.py +++ b/src/peft/utils/save_and_load.py @@ -1,3 +1,18 @@ +# coding=utf-8 +# Copyright 2023-present the HuggingFace Inc. team. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + from .config import PeftType diff --git a/utils/style_doc.py b/utils/style_doc.py deleted file mode 100644 index 0422ebe..0000000 --- a/utils/style_doc.py +++ /dev/null @@ -1,556 +0,0 @@ -# coding=utf-8 -# Copyright 2020 The HuggingFace Inc. team. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -"""Style utils for the .rst and the docstrings.""" - -import argparse -import os -import re -import warnings - -import black - - -BLACK_AVOID_PATTERNS = {} - - -# Regexes -# Re pattern that catches list introduction (with potential indent) -_re_list = re.compile(r"^(\s*-\s+|\s*\*\s+|\s*\d+\.\s+)") -# Re pattern that catches code block introduction (with potential indent) -_re_code = re.compile(r"^(\s*)```(.*)$") -# Re pattern that catches rst args blocks of the form `Parameters:`. -_re_args = re.compile("^\s*(Args?|Arguments?|Params?|Parameters?):\s*$") -# Re pattern that catches return blocks of the form `Return:`. -_re_returns = re.compile("^\s*Returns?:\s*$") -# Matches the special tag to ignore some paragraphs. -_re_doc_ignore = re.compile(r"(\.\.|#)\s*docstyle-ignore") -# Re pattern that matches , and blocks. -_re_tip = re.compile("^\s*|\s+warning={true}>)\s*$") - -DOCTEST_PROMPTS = [">>>", "..."] - - -def is_empty_line(line): - return len(line) == 0 or line.isspace() - - -def find_indent(line): - """ - Returns the number of spaces that start a line indent. - """ - search = re.search("^(\s*)(?:\S|$)", line) - if search is None: - return 0 - return len(search.groups()[0]) - - -def parse_code_example(code_lines): - """ - Parses a code example - - Args: - code_lines (`List[str]`): The code lines to parse. - max_len (`int`): The maximum length per line. - - Returns: - (List[`str`], List[`str`]): The list of code samples and the list of outputs. - """ - has_doctest = code_lines[0][:3] in DOCTEST_PROMPTS - - code_samples = [] - outputs = [] - in_code = True - current_bit = [] - - for line in code_lines: - if in_code and has_doctest and not is_empty_line(line) and line[:3] not in DOCTEST_PROMPTS: - code_sample = "\n".join(current_bit) - code_samples.append(code_sample.strip()) - in_code = False - current_bit = [] - elif not in_code and line[:3] in DOCTEST_PROMPTS: - output = "\n".join(current_bit) - outputs.append(output.strip()) - in_code = True - current_bit = [] - - # Add the line without doctest prompt - if line[:3] in DOCTEST_PROMPTS: - line = line[4:] - current_bit.append(line) - - # Add last sample - if in_code: - code_sample = "\n".join(current_bit) - code_samples.append(code_sample.strip()) - else: - output = "\n".join(current_bit) - outputs.append(output.strip()) - - return code_samples, outputs - - -def format_code_example(code: str, max_len: int, in_docstring: bool = False): - """ - Format a code example using black. Will take into account the doctest syntax as well as any initial indentation in - the code provided. - - Args: - code (`str`): The code example to format. - max_len (`int`): The maximum length per line. - in_docstring (`bool`, *optional*, defaults to `False`): Whether or not the code example is inside a docstring. - - Returns: - `str`: The formatted code. - """ - code_lines = code.split("\n") - - # Find initial indent - idx = 0 - while idx < len(code_lines) and is_empty_line(code_lines[idx]): - idx += 1 - if idx >= len(code_lines): - return "", "" - indent = find_indent(code_lines[idx]) - - # Remove the initial indent for now, we will had it back after styling. - # Note that l[indent:] works for empty lines - code_lines = [l[indent:] for l in code_lines[idx:]] - has_doctest = code_lines[0][:3] in DOCTEST_PROMPTS - - code_samples, outputs = parse_code_example(code_lines) - - # Let's blackify the code! We put everything in one big text to go faster. - delimiter = "\n\n### New code sample ###\n" - full_code = delimiter.join(code_samples) - line_length = max_len - indent - if has_doctest: - line_length -= 4 - - for k, v in BLACK_AVOID_PATTERNS.items(): - full_code = full_code.replace(k, v) - try: - mode = black.Mode(target_versions={black.TargetVersion.PY37}, line_length=line_length) - formatted_code = black.format_str(full_code, mode=mode) - error = "" - except Exception as e: - formatted_code = full_code - error = f"Code sample:\n{full_code}\n\nError message:\n{e}" - - # Let's get back the formatted code samples - for k, v in BLACK_AVOID_PATTERNS.items(): - formatted_code = formatted_code.replace(v, k) - # Triple quotes will mess docstrings. - if in_docstring: - formatted_code = formatted_code.replace('"""', "'''") - - code_samples = formatted_code.split(delimiter) - # We can have one output less than code samples - if len(outputs) == len(code_samples) - 1: - outputs.append("") - - formatted_lines = [] - for code_sample, output in zip(code_samples, outputs): - # black may have added some new lines, we remove them - code_sample = code_sample.strip() - in_triple_quotes = False - in_decorator = False - for line in code_sample.strip().split("\n"): - if has_doctest and not is_empty_line(line): - prefix = ( - "... " - if line.startswith(" ") or line in [")", "]", "}"] or in_triple_quotes or in_decorator - else ">>> " - ) - else: - prefix = "" - indent_str = "" if is_empty_line(line) else (" " * indent) - formatted_lines.append(indent_str + prefix + line) - - if '"""' in line: - in_triple_quotes = not in_triple_quotes - if line.startswith(" "): - in_decorator = False - if line.startswith("@"): - in_decorator = True - - formatted_lines.extend([" " * indent + line for line in output.split("\n")]) - if not output.endswith("===PT-TF-SPLIT==="): - formatted_lines.append("") - - result = "\n".join(formatted_lines) - return result.rstrip(), error - - -def format_text(text, max_len, prefix="", min_indent=None): - """ - Format a text in the biggest lines possible with the constraint of a maximum length and an indentation. - - Args: - text (`str`): The text to format - max_len (`int`): The maximum length per line to use - prefix (`str`, *optional*, defaults to `""`): A prefix that will be added to the text. - The prefix doesn't count toward the indent (like a - introducing a list). - min_indent (`int`, *optional*): The minimum indent of the text. - If not set, will default to the length of the `prefix`. - - Returns: - `str`: The formatted text. - """ - text = re.sub(r"\s+", " ", text) - if min_indent is not None: - if len(prefix) < min_indent: - prefix = " " * (min_indent - len(prefix)) + prefix - - indent = " " * len(prefix) - new_lines = [] - words = text.split(" ") - current_line = f"{prefix}{words[0]}" - for word in words[1:]: - try_line = f"{current_line} {word}" - if len(try_line) > max_len: - new_lines.append(current_line) - current_line = f"{indent}{word}" - else: - current_line = try_line - new_lines.append(current_line) - return "\n".join(new_lines) - - -def split_line_on_first_colon(line): - splits = line.split(":") - return splits[0], ":".join(splits[1:]) - - -def style_docstring(docstring, max_len): - """ - Style a docstring by making sure there is no useless whitespace and the maximum horizontal space is used. - - Args: - docstring (`str`): The docstring to style. - max_len (`int`): The maximum length of each line. - - Returns: - `str`: The styled docstring - """ - lines = docstring.split("\n") - new_lines = [] - - # Initialization - current_paragraph = None - current_indent = -1 - in_code = False - param_indent = -1 - prefix = "" - black_errors = [] - - # Special case for docstrings that begin with continuation of Args with no Args block. - idx = 0 - while idx < len(lines) and is_empty_line(lines[idx]): - idx += 1 - if ( - len(lines[idx]) > 1 - and lines[idx].rstrip().endswith(":") - and find_indent(lines[idx + 1]) > find_indent(lines[idx]) - ): - param_indent = find_indent(lines[idx]) - - for idx, line in enumerate(lines): - # Doing all re searches once for the one we need to repeat. - list_search = _re_list.search(line) - code_search = _re_code.search(line) - - # Are we starting a new paragraph? - # New indentation or new line: - new_paragraph = find_indent(line) != current_indent or is_empty_line(line) - # List item - new_paragraph = new_paragraph or list_search is not None - # Code block beginning - new_paragraph = new_paragraph or code_search is not None - # Beginning/end of tip - new_paragraph = new_paragraph or _re_tip.search(line) - - # In this case, we treat the current paragraph - if not in_code and new_paragraph and current_paragraph is not None and len(current_paragraph) > 0: - paragraph = " ".join(current_paragraph) - new_lines.append(format_text(paragraph, max_len, prefix=prefix, min_indent=current_indent)) - current_paragraph = None - - if code_search is not None: - if not in_code: - current_paragraph = [] - current_indent = len(code_search.groups()[0]) - current_code = code_search.groups()[1] - prefix = "" - if current_indent < param_indent: - param_indent = -1 - else: - current_indent = -1 - code = "\n".join(current_paragraph) - if current_code in ["py", "python"]: - formatted_code, error = format_code_example(code, max_len, in_docstring=True) - new_lines.append(formatted_code) - if len(error) > 0: - black_errors.append(error) - else: - new_lines.append(code) - current_paragraph = None - new_lines.append(line) - in_code = not in_code - - elif in_code: - current_paragraph.append(line) - elif is_empty_line(line): - current_paragraph = None - current_indent = -1 - prefix = "" - new_lines.append(line) - elif list_search is not None: - prefix = list_search.groups()[0] - current_indent = len(prefix) - current_paragraph = [line[current_indent:]] - elif _re_args.search(line): - new_lines.append(line) - param_indent = find_indent(lines[idx + 1]) - elif _re_tip.search(line): - # Add a new line before if not present - if not is_empty_line(new_lines[-1]): - new_lines.append("") - new_lines.append(line) - # Add a new line after if not present - if idx < len(lines) - 1 and not is_empty_line(lines[idx + 1]): - new_lines.append("") - elif current_paragraph is None or find_indent(line) != current_indent: - indent = find_indent(line) - # Special behavior for parameters intros. - if indent == param_indent: - # Special rules for some docstring where the Returns blocks has the same indent as the parameters. - if _re_returns.search(line) is not None: - param_indent = -1 - new_lines.append(line) - elif len(line) < max_len: - new_lines.append(line) - else: - intro, description = split_line_on_first_colon(line) - new_lines.append(intro + ":") - if len(description) != 0: - if find_indent(lines[idx + 1]) > indent: - current_indent = find_indent(lines[idx + 1]) - else: - current_indent = indent + 4 - current_paragraph = [description.strip()] - prefix = "" - else: - # Check if we have exited the parameter block - if indent < param_indent: - param_indent = -1 - - current_paragraph = [line.strip()] - current_indent = find_indent(line) - prefix = "" - elif current_paragraph is not None: - current_paragraph.append(line.lstrip()) - - if current_paragraph is not None and len(current_paragraph) > 0: - paragraph = " ".join(current_paragraph) - new_lines.append(format_text(paragraph, max_len, prefix=prefix, min_indent=current_indent)) - - return "\n".join(new_lines), "\n\n".join(black_errors) - - -def style_docstrings_in_code(code, max_len=119): - """ - Style all docstrings in some code. - - Args: - code (`str`): The code in which we want to style the docstrings. - max_len (`int`): The maximum number of characters per line. - - Returns: - `Tuple[str, str]`: A tuple with the clean code and the black errors (if any) - """ - # fmt: off - splits = code.split('\"\"\"') - splits = [ - (s if i % 2 == 0 or _re_doc_ignore.search(splits[i - 1]) is not None else style_docstring(s, max_len=max_len)) - for i, s in enumerate(splits) - ] - black_errors = "\n\n".join([s[1] for s in splits if isinstance(s, tuple) and len(s[1]) > 0]) - splits = [s[0] if isinstance(s, tuple) else s for s in splits] - clean_code = '\"\"\"'.join(splits) - # fmt: on - - return clean_code, black_errors - - -def style_file_docstrings(code_file, max_len=119, check_only=False): - """ - Style all docstrings in a given file. - - Args: - code_file (`str` or `os.PathLike`): The file in which we want to style the docstring. - max_len (`int`): The maximum number of characters per line. - check_only (`bool`, *optional*, defaults to `False`): - Whether to restyle file or just check if they should be restyled. - - Returns: - `bool`: Whether or not the file was or should be restyled. - """ - with open(code_file, "r", encoding="utf-8", newline="\n") as f: - code = f.read() - - clean_code, black_errors = style_docstrings_in_code(code, max_len=max_len) - - diff = clean_code != code - if not check_only and diff: - print(f"Overwriting content of {code_file}.") - with open(code_file, "w", encoding="utf-8", newline="\n") as f: - f.write(clean_code) - - return diff, black_errors - - -def style_mdx_file(mdx_file, max_len=119, check_only=False): - """ - Style a MDX file by formatting all Python code samples. - - Args: - mdx_file (`str` or `os.PathLike`): The file in which we want to style the examples. - max_len (`int`): The maximum number of characters per line. - check_only (`bool`, *optional*, defaults to `False`): - Whether to restyle file or just check if they should be restyled. - - Returns: - `bool`: Whether or not the file was or should be restyled. - """ - with open(mdx_file, "r", encoding="utf-8", newline="\n") as f: - content = f.read() - - lines = content.split("\n") - current_code = [] - current_language = "" - in_code = False - new_lines = [] - black_errors = [] - - for line in lines: - if _re_code.search(line) is not None: - in_code = not in_code - if in_code: - current_language = _re_code.search(line).groups()[1] - current_code = [] - else: - code = "\n".join(current_code) - if current_language in ["py", "python"]: - code, error = format_code_example(code, max_len) - if len(error) > 0: - black_errors.append(error) - new_lines.append(code) - - new_lines.append(line) - elif in_code: - current_code.append(line) - else: - new_lines.append(line) - - if in_code: - raise ValueError(f"There was a problem when styling {mdx_file}. A code block is opened without being closed.") - - clean_content = "\n".join(new_lines) - diff = clean_content != content - if not check_only and diff: - print(f"Overwriting content of {mdx_file}.") - with open(mdx_file, "w", encoding="utf-8", newline="\n") as f: - f.write(clean_content) - - return diff, "\n\n".join(black_errors) - - -def style_doc_files(*files, max_len=119, check_only=False): - """ - Applies doc styling or checks everything is correct in a list of files. - - Args: - files (several `str` or `os.PathLike`): The files to treat. - max_len (`int`): The maximum number of characters per line. - check_only (`bool`, *optional*, defaults to `False`): - Whether to restyle file or just check if they should be restyled. - - Returns: - List[`str`]: The list of files changed or that should be restyled. - """ - changed = [] - black_errors = [] - for file in files: - # Treat folders - if os.path.isdir(file): - files = [os.path.join(file, f) for f in os.listdir(file)] - files = [f for f in files if os.path.isdir(f) or f.endswith(".mdx") or f.endswith(".py")] - changed += style_doc_files(*files, max_len=max_len, check_only=check_only) - # Treat mdx - elif file.endswith(".mdx"): - try: - diff, black_error = style_mdx_file(file, max_len=max_len, check_only=check_only) - if diff: - changed.append(file) - if len(black_error) > 0: - black_errors.append( - f"There was a problem while formatting an example in {file} with black:\m{black_error}" - ) - except Exception: - print(f"There is a problem in {file}.") - raise - # Treat python files - elif file.endswith(".py"): - try: - diff, black_error = style_file_docstrings(file, max_len=max_len, check_only=check_only) - if diff: - changed.append(file) - if len(black_error) > 0: - black_errors.append( - f"There was a problem while formatting an example in {file} with black:\m{black_error}" - ) - except Exception: - print(f"There is a problem in {file}.") - raise - else: - warnings.warn(f"Ignoring {file} because it's not a py or an mdx file or a folder.") - if len(black_errors) > 0: - black_message = "\n\n".join(black_errors) - raise ValueError( - "Some code examples can't be interpreted by black, which means they aren't regular python:\n\n" - + black_message - + "\n\nMake sure to fix the corresponding docstring or doc file, or remove the py/python after ``` if it " - + "was not supposed to be a Python code sample." - ) - return changed - - -def main(*files, max_len=119, check_only=False): - changed = style_doc_files(*files, max_len=max_len, check_only=check_only) - if check_only and len(changed) > 0: - raise ValueError(f"{len(changed)} files should be restyled!") - elif len(changed) > 0: - print(f"Cleaned {len(changed)} files!") - - -if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument("files", nargs="+", help="The file(s) or folder(s) to restyle.") - parser.add_argument("--max_len", type=int, help="The maximum length of lines.") - parser.add_argument("--check_only", action="store_true", help="Whether to only check and not fix styling issues.") - args = parser.parse_args() - - main(*args.files, max_len=args.max_len, check_only=args.check_only)