Skip to content

Commit 13f33e6

Browse files
committed
feat: Add automatic Address1, Address2, and FullAddress properties to Address model
- Add Address1 property that builds street address from components and handles PO Box addresses - Add Address2 property that builds unit/apartment line from subaddress components - Add FullAddress property that combines all components into formatted string - Properties are computed on-demand and always available after parsing - Add comprehensive test suite with 16 new tests covering various address types - All code passes ruff and mypy checks with 100% backward compatibility
1 parent 4850090 commit 13f33e6

3 files changed

Lines changed: 321 additions & 0 deletions

File tree

LICENSE

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,8 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121
SOFTWARE.
2222

23+
24+
25+
26+
27+

src/ryandata_address_utils/models.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,126 @@ def to_dict(self) -> dict[str, Optional[str]]:
139139
"""Convert address to dictionary."""
140140
return self.model_dump()
141141

142+
@property
143+
def Address1(self) -> Optional[str]:
144+
"""Build the street address line (Address1) from street components.
145+
146+
Includes all street-related components in proper order:
147+
AddressNumberPrefix, AddressNumber, AddressNumberSuffix,
148+
StreetNamePreModifier, StreetNamePreDirectional, StreetNamePreType,
149+
StreetName, StreetNamePostType, StreetNamePostDirectional
150+
151+
Also handles PO Box addresses (USPSBoxType + USPSBoxID).
152+
153+
Returns:
154+
Formatted street address line, or None if no street components present.
155+
"""
156+
parts: list[str] = []
157+
158+
# Check if this is a PO Box address
159+
if self.USPSBoxType and self.USPSBoxID:
160+
parts.append(self.USPSBoxType)
161+
parts.append(self.USPSBoxID)
162+
return " ".join(parts)
163+
164+
# Build street address
165+
if self.AddressNumberPrefix:
166+
parts.append(self.AddressNumberPrefix)
167+
if self.AddressNumber:
168+
parts.append(self.AddressNumber)
169+
if self.AddressNumberSuffix:
170+
parts.append(self.AddressNumberSuffix)
171+
if self.StreetNamePreModifier:
172+
parts.append(self.StreetNamePreModifier)
173+
if self.StreetNamePreDirectional:
174+
parts.append(self.StreetNamePreDirectional)
175+
if self.StreetNamePreType:
176+
parts.append(self.StreetNamePreType)
177+
if self.StreetName:
178+
parts.append(self.StreetName)
179+
if self.StreetNamePostType:
180+
parts.append(self.StreetNamePostType)
181+
if self.StreetNamePostDirectional:
182+
parts.append(self.StreetNamePostDirectional)
183+
184+
return " ".join(parts) if parts else None
185+
186+
@property
187+
def Address2(self) -> Optional[str]:
188+
"""Build the unit/apartment line (Address2) from subaddress components.
189+
190+
Includes subaddress and occupancy information:
191+
SubaddressType + SubaddressIdentifier,
192+
BuildingName,
193+
OccupancyType + OccupancyIdentifier
194+
195+
Returns:
196+
Formatted unit/apartment line, or None if no subaddress components present.
197+
"""
198+
parts: list[str] = []
199+
200+
# Subaddress (Apt, Suite, Unit, etc.)
201+
if self.SubaddressType and self.SubaddressIdentifier:
202+
parts.append(f"{self.SubaddressType} {self.SubaddressIdentifier}")
203+
elif self.SubaddressType:
204+
parts.append(self.SubaddressType)
205+
elif self.SubaddressIdentifier:
206+
parts.append(self.SubaddressIdentifier)
207+
208+
# Building name
209+
if self.BuildingName:
210+
parts.append(self.BuildingName)
211+
212+
# Occupancy (Dept, Room, etc.)
213+
if self.OccupancyType and self.OccupancyIdentifier:
214+
parts.append(f"{self.OccupancyType} {self.OccupancyIdentifier}")
215+
elif self.OccupancyType:
216+
parts.append(self.OccupancyType)
217+
elif self.OccupancyIdentifier:
218+
parts.append(self.OccupancyIdentifier)
219+
220+
return ", ".join(parts) if parts else None
221+
222+
@property
223+
def FullAddress(self) -> str:
224+
"""Build the complete formatted address string.
225+
226+
Combines Address1, Address2, City, State, and Zip into a single
227+
comma-separated line: "Address1, Address2, City, State Zip"
228+
229+
Missing components are omitted gracefully. If all components are None,
230+
returns an empty string.
231+
232+
Returns:
233+
Formatted full address string.
234+
"""
235+
parts: list[str] = []
236+
237+
# Add Address1 (street address)
238+
if self.Address1:
239+
parts.append(self.Address1)
240+
241+
# Add Address2 (unit/apartment)
242+
if self.Address2:
243+
parts.append(self.Address2)
244+
245+
# Add City, State Zip line
246+
city_state_zip_parts: list[str] = []
247+
if self.PlaceName:
248+
city_state_zip_parts.append(self.PlaceName)
249+
250+
if self.StateName and self.ZipCode:
251+
city_state_zip_parts.append(f"{self.StateName} {self.ZipCode}")
252+
elif self.StateName:
253+
city_state_zip_parts.append(self.StateName)
254+
elif self.ZipCode:
255+
city_state_zip_parts.append(self.ZipCode)
256+
257+
if city_state_zip_parts:
258+
parts.append(", ".join(city_state_zip_parts))
259+
260+
return ", ".join(parts)
261+
142262

143263
@dataclass
144264
class ZipInfo:

tests/test_address_parser.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,3 +676,199 @@ def test_service_parse_to_dict(self) -> None:
676676
result = service.parse_to_dict("123 Main St, Austin TX 78749")
677677
assert result["AddressNumber"] == "123"
678678
assert result["ZipCode"] == "78749"
679+
680+
681+
# =============================================================================
682+
# Address Formatting Tests (Address1, Address2, FullAddress Properties)
683+
# =============================================================================
684+
685+
686+
class TestAddressFormatting:
687+
"""Test the Address formatting properties (Address1, Address2, FullAddress)."""
688+
689+
def test_simple_street_address_to_address1(self) -> None:
690+
"""Simple street address should format to Address1 correctly."""
691+
result = parse("123 Main St, Austin TX 78749", validate=False)
692+
assert result.address is not None
693+
address1 = result.address.Address1
694+
assert address1 is not None
695+
assert "123" in address1
696+
assert "Main" in address1
697+
assert "St" in address1
698+
699+
def test_address1_with_directionals(self) -> None:
700+
"""Address1 should include directionals."""
701+
result = parse("100 N Main St S, Austin TX 78749", validate=False)
702+
assert result.address is not None
703+
address1 = result.address.Address1
704+
assert address1 is not None
705+
# Should contain both pre and post directionals
706+
assert "100" in address1
707+
assert "Main" in address1
708+
assert "St" in address1
709+
710+
def test_address1_po_box(self) -> None:
711+
"""PO Box addresses should format to Address1."""
712+
result = parse("PO Box 1234, Austin TX 78749", validate=False)
713+
assert result.address is not None
714+
address1 = result.address.Address1
715+
assert address1 is not None
716+
assert "PO Box" in address1 or "P.O." in address1
717+
assert "1234" in address1
718+
719+
def test_address1_none_when_no_components(self) -> None:
720+
"""Address1 should be None when no street components present."""
721+
from ryandata_address_utils.models import AddressBuilder
722+
723+
address = AddressBuilder().with_city("Austin").with_state("TX").with_zip("78749").build()
724+
assert address.Address1 is None
725+
726+
def test_address2_with_apartment(self) -> None:
727+
"""Address2 should include apartment/unit information."""
728+
result = parse("400 Main St Apt 5, Austin TX 78749", validate=False)
729+
assert result.address is not None
730+
address2 = result.address.Address2
731+
# Address2 may or may not be populated depending on parser
732+
if address2:
733+
assert "Apt" in address2 or "5" in address2
734+
735+
def test_address2_none_when_no_subaddress(self) -> None:
736+
"""Address2 should be None when no subaddress components present."""
737+
result = parse("123 Main St, Austin TX 78749", validate=False)
738+
assert result.address is not None
739+
assert result.address.Address2 is None
740+
741+
def test_address2_with_suite(self) -> None:
742+
"""Address2 should include suite information."""
743+
result = parse("500 Main St Suite 100, Austin TX 78749", validate=False)
744+
assert result.address is not None
745+
address2 = result.address.Address2
746+
if address2:
747+
# May contain suite info depending on parser
748+
assert len(address2) > 0
749+
750+
def test_full_address_basic(self) -> None:
751+
"""FullAddress should format correctly for basic address."""
752+
result = parse("123 Main St, Austin TX 78749", validate=False)
753+
assert result.address is not None
754+
full_address = result.address.FullAddress
755+
assert "123" in full_address
756+
assert "Main" in full_address
757+
assert "Austin" in full_address
758+
assert "TX" in full_address
759+
assert "78749" in full_address
760+
761+
def test_full_address_with_po_box(self) -> None:
762+
"""FullAddress should format correctly for PO Box."""
763+
result = parse("PO Box 1234, Austin TX 78749", validate=False)
764+
assert result.address is not None
765+
full_address = result.address.FullAddress
766+
assert "1234" in full_address
767+
assert "Austin" in full_address
768+
assert "TX" in full_address
769+
assert "78749" in full_address
770+
771+
def test_full_address_only_city_state_zip(self) -> None:
772+
"""FullAddress should work with only city, state, zip."""
773+
from ryandata_address_utils.models import AddressBuilder
774+
775+
address = (
776+
AddressBuilder().with_city("Austin").with_state("TX").with_zip("78749").build()
777+
)
778+
full_address = address.FullAddress
779+
assert "Austin" in full_address
780+
assert "TX" in full_address
781+
assert "78749" in full_address
782+
783+
def test_full_address_no_components(self) -> None:
784+
"""FullAddress should return empty string when all components None."""
785+
from ryandata_address_utils.models import Address
786+
787+
address = Address()
788+
assert address.FullAddress == ""
789+
790+
def test_full_address_only_address1(self) -> None:
791+
"""FullAddress should work with only Address1."""
792+
from ryandata_address_utils.models import AddressBuilder
793+
794+
address = (
795+
AddressBuilder()
796+
.with_street_number("123")
797+
.with_street_name("Main")
798+
.with_street_type("St")
799+
.build()
800+
)
801+
full_address = address.FullAddress
802+
assert "123" in full_address
803+
assert "Main" in full_address
804+
assert "St" in full_address
805+
806+
def test_address1_with_number_suffix(self) -> None:
807+
"""Address1 should include address number suffix."""
808+
from ryandata_address_utils.models import AddressBuilder
809+
810+
address = (
811+
AddressBuilder()
812+
.with_street_number("123")
813+
.with_address_number_suffix("1/2")
814+
.with_street_name("Main")
815+
.with_street_type("St")
816+
.build()
817+
)
818+
address1 = address.Address1
819+
assert address1 is not None
820+
assert "123" in address1
821+
assert "1/2" in address1
822+
823+
def test_address2_building_name(self) -> None:
824+
"""Address2 should include building name."""
825+
from ryandata_address_utils.models import AddressBuilder
826+
827+
address = (
828+
AddressBuilder()
829+
.with_street_number("456")
830+
.with_street_name("Oak")
831+
.with_street_type("Ave")
832+
.with_building_name("The Towers")
833+
.build()
834+
)
835+
address1 = address.Address1
836+
address2 = address.Address2
837+
assert address1 is not None
838+
assert "456" in address1
839+
assert address2 is not None
840+
assert "The Towers" in address2
841+
842+
def test_address_formatting_after_parsing(self) -> None:
843+
"""Address properties should be available after parsing."""
844+
result = parse("123 Main St, Austin TX 78749")
845+
assert result.address is not None
846+
# All three properties should be available regardless of validation result
847+
assert result.address.Address1 is not None
848+
assert result.address.Address2 is None # No unit info
849+
assert result.address.FullAddress is not None
850+
851+
def test_full_address_format_consistency(self) -> None:
852+
"""FullAddress format should be consistent across different address types."""
853+
from ryandata_address_utils.models import AddressBuilder
854+
855+
# Address without Address2
856+
addr1 = (
857+
AddressBuilder()
858+
.with_street_number("100")
859+
.with_street_name("First")
860+
.with_street_type("Ave")
861+
.with_city("Dallas")
862+
.with_state("TX")
863+
.with_zip("75201")
864+
.build()
865+
)
866+
full = addr1.FullAddress
867+
# Should be: "100 First Ave, Dallas, TX 75201"
868+
assert "100" in full
869+
assert "First" in full
870+
assert "Dallas" in full
871+
assert "TX" in full
872+
assert "75201" in full
873+
# Should contain exactly 3 commas (addr1, city/state/zip)
874+
assert full.count(",") >= 1

0 commit comments

Comments
 (0)