Repository: joke2k/faker

Issue:
`fake.pystr()` returns unexpected length when using `max_chars` with `prefix` | `suffix`
* Faker version: 18.10.1
* OS: ubuntu 22.04

Love this library, thanks for the hard work!

I came across a situation today where strings returned by `fake.pystr(...)` are not what I would have expected.  [This line](https://github.com/joke2k/faker/blob/d193bba77917f28504a036d1c11e0c517cc61373/faker/providers/python/__init__.py#L126) adds the prefix and/or suffix to a string with length already established according to `max_chars`/`min_chars`.  Because of this, the resulting string is longer than expected when `max_chars` is used in conjunction with either `prefix` or `suffix`.

If you're open to a PR, I'd be willing to submit one to update this.  Just lemme know...thanks again!

### Steps to reproduce

```python
from faker import Faker

fake = Faker()
Faker.seed()

fake.pystr(max_chars=3, prefix="a")
fake.pystr(max_chars=3, suffix="z")
fake.pystr(max_chars=3, prefix="a", suffix="z")
```

### Expected behavior

```
aRu
TTz
aFz
```

### Actual behavior

```
aRuB
VTTz
aFmDz
```


Discussion:
Thank you for the report! Yes, a Pull Request would be much appreciated!
I am not sure if this should be considered a documentation issue or bug. The prefix and suffix implementation has been added by me and follows the same convention regarding the length as `factory_boy.fuzzy.FuzzyText` (which is deprecated and I needed a replacement for). https://github.com/FactoryBoy/factory_boy/blob/53b34d504fad05dd1887a7eb0056ce9516fd4646/factory/fuzzy.py#L59 just has better documentation on where the length limit actually apply to.

The tests actually ensure that the prefix and suffix indeed do not count towards the specified limits: https://github.com/joke2k/faker/blob/d193bba77917f28504a036d1c11e0c517cc61373/tests/providers/test_python.py#L474

In my opinion, updating the documentation here to point out the the length limits are only for the random part should be easier, as we would have additionally ensure the prefix and suffix actually allow for the requested length otherwise.
Thanks for the feedback and for the reference in the unit tests!

You are correct that changing this would require an additional check to ensure that `max_chars >= len(prefix) + len(suffix)`.  There is already a check for relationship between `max_chars` and `min_chars`:

https://github.com/joke2k/faker/blob/d193bba77917f28504a036d1c11e0c517cc61373/faker/providers/python/__init__.py#L119

so I considered adding this check to be trivial.

However, I didn't realize there was a precedent set for this.  I find it counterintuitive that adding prefix and/or suffix will cause the returned string to be longer than `max_chars`, but maybe it's just me, I'd be curious to know what others think.  I just workaround it right now by specifying a `max_chars` that takes into account the length of the `prefix` and/or `suffix`.  I recognize updating it would be a breaking change.

I can submit a PR for either situation (docs or update to logic).  Any thoughts on this @fcurella ?
I can see how one would expect prefix/suffixes to be included in the count,
but in this case I’d rather keep it consistent with other tools in the
ecosystem. I’d accept a PR on the docs clarifying the behavior :)

On Fri, Jun 16, 2023 at 8:33 AM Matt Morris ***@***.***>
wrote:

> Thanks for the feedback and for the reference in the unit tests!
>
> You are correct that changing this would require an additional check to
> ensure that max_chars >= len(prefix) + len(suffix). There is already a
> check for relationship between max_chars and min_chars:
>
>
> https://github.com/joke2k/faker/blob/d193bba77917f28504a036d1c11e0c517cc61373/faker/providers/python/__init__.py#L119
>
> so I considered adding this check to be trivial.
>
> However, I didn't realize there was a precedent set for this. I find it
> counterintuitive that adding prefix and/or suffix will cause the returned
> string to be longer than max_chars, but maybe it's just me, I'd be
> curious to know what others think. I just workaround it right now by
> specifying a max_chars that takes into account the length of the prefix
> and/or suffix. I recognize updating it would be a breaking change.
>
> I can submit a PR either way. Any thoughts on this @fcurella
> <https://github.com/fcurella> ?
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/joke2k/faker/issues/1877#issuecomment-1594685535>, or
> unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AAAV4B5UZ3XNCLUPUPMHVNTXLROEBANCNFSM6AAAAAAZIMXP54>
> .
> You are receiving this because you were mentioned.Message ID:
> ***@***.***>
>


Resolving patch:
diff --git a/faker/providers/date_time/__init__.py b/faker/providers/date_time/__init__.py
--- a/faker/providers/date_time/__init__.py
+++ b/faker/providers/date_time/__init__.py
@@ -1809,6 +1809,7 @@ def unix_time(
         """
         Get a timestamp between January 1, 1970 and now, unless passed
         explicit start_datetime or end_datetime values.
+
         :example: 1061306726
         """
         start_datetime = self._parse_start_datetime(start_datetime)
@@ -1833,6 +1834,7 @@ def date_time(
     ) -> datetime:
         """
         Get a datetime object for a date between January 1, 1970 and now
+
         :param tzinfo: timezone, instance of datetime.tzinfo subclass
         :example: datetime('2005-08-16 20:39:21')
         :return: datetime
@@ -1850,6 +1852,7 @@ def date_time_ad(
     ) -> datetime:
         """
         Get a datetime object for a date between January 1, 001 and now
+
         :param tzinfo: timezone, instance of datetime.tzinfo subclass
         :example: datetime('1265-03-22 21:15:52')
         :return: datetime
@@ -1883,6 +1886,7 @@ def iso8601(
     ) -> str:
         """
         Get a timestamp in ISO 8601 format (or one of its profiles).
+
         :param tzinfo: timezone, instance of datetime.tzinfo subclass
         :param sep: separator between date and time, defaults to 'T'
         :param timespec: format specifier for the time part, defaults to 'auto' - see datetime.isoformat() documentation
@@ -1903,6 +1907,7 @@ def date(self, pattern: str = "%Y-%m-%d", end_datetime: Optional[DateParseType]
     def date_object(self, end_datetime: Optional[datetime] = None) -> dtdate:
         """
         Get a date object between January 1, 1970 and now
+
         :example: datetime.date(2016, 9, 20)
         """
         return self.date_time(end_datetime=end_datetime).date()
@@ -1910,6 +1915,7 @@ def date_object(self, end_datetime: Optional[datetime] = None) -> dtdate:
     def time(self, pattern: str = "%H:%M:%S", end_datetime: Optional[DateParseType] = None) -> str:
         """
         Get a time string (24h format by default)
+
         :param pattern: format
         :example: '15:02:34'
         """
@@ -1918,6 +1924,7 @@ def time(self, pattern: str = "%H:%M:%S", end_datetime: Optional[DateParseType]
     def time_object(self, end_datetime: Optional[DateParseType] = None) -> dttime:
         """
         Get a time object
+
         :example: datetime.time(15, 56, 56, 772876)
         """
         return self.date_time(end_datetime=end_datetime).time()
diff --git a/faker/providers/person/__init__.py b/faker/providers/person/__init__.py
--- a/faker/providers/person/__init__.py
+++ b/faker/providers/person/__init__.py
@@ -200,7 +200,7 @@ class Provider(BaseProvider):
 
     def name(self) -> str:
         """
-        :example 'John Doe'
+        :example: 'John Doe'
         """
         pattern: str = self.random_element(self.formats)
         return self.generator.parse(pattern)
diff --git a/faker/providers/python/__init__.py b/faker/providers/python/__init__.py
--- a/faker/providers/python/__init__.py
+++ b/faker/providers/python/__init__.py
@@ -111,6 +111,11 @@ def pystr(
     ) -> str:
         """
         Generates a random string of upper and lowercase letters.
+
+        :param min_chars: minimum length of the random part.
+        :param max_chars: maximum length of the random part.
+        :param prefix: an optional prefix to prepend to the random string.
+        :param suffix: an optional suffix to append to the random string.
         :return: Random of random length between min and max characters.
         """
         if min_chars is None:


Test patch: