##// END OF EJS Templates
authentication: add some more logging when extracting users.
marcink -
r1509:b11eecf9 default
parent child Browse files
Show More
@@ -1,647 +1,649 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 Authentication modules
23 23 """
24 24
25 25 import colander
26 26 import logging
27 27 import time
28 28 import traceback
29 29 import warnings
30 30
31 31 from pyramid.threadlocal import get_current_registry
32 32 from sqlalchemy.ext.hybrid import hybrid_property
33 33
34 34 from rhodecode.authentication.interface import IAuthnPluginRegistry
35 35 from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
36 36 from rhodecode.lib import caches
37 37 from rhodecode.lib.auth import PasswordGenerator, _RhodeCodeCryptoBCrypt
38 38 from rhodecode.lib.utils2 import md5_safe, safe_int
39 39 from rhodecode.lib.utils2 import safe_str
40 40 from rhodecode.model.db import User
41 41 from rhodecode.model.meta import Session
42 42 from rhodecode.model.settings import SettingsModel
43 43 from rhodecode.model.user import UserModel
44 44 from rhodecode.model.user_group import UserGroupModel
45 45
46 46
47 47 log = logging.getLogger(__name__)
48 48
49 49 # auth types that authenticate() function can receive
50 50 VCS_TYPE = 'vcs'
51 51 HTTP_TYPE = 'http'
52 52
53 53
54 54 class LazyFormencode(object):
55 55 def __init__(self, formencode_obj, *args, **kwargs):
56 56 self.formencode_obj = formencode_obj
57 57 self.args = args
58 58 self.kwargs = kwargs
59 59
60 60 def __call__(self, *args, **kwargs):
61 61 from inspect import isfunction
62 62 formencode_obj = self.formencode_obj
63 63 if isfunction(formencode_obj):
64 64 # case we wrap validators into functions
65 65 formencode_obj = self.formencode_obj(*args, **kwargs)
66 66 return formencode_obj(*self.args, **self.kwargs)
67 67
68 68
69 69 class RhodeCodeAuthPluginBase(object):
70 70 # cache the authentication request for N amount of seconds. Some kind
71 71 # of authentication methods are very heavy and it's very efficient to cache
72 72 # the result of a call. If it's set to None (default) cache is off
73 73 AUTH_CACHE_TTL = None
74 74 AUTH_CACHE = {}
75 75
76 76 auth_func_attrs = {
77 77 "username": "unique username",
78 78 "firstname": "first name",
79 79 "lastname": "last name",
80 80 "email": "email address",
81 81 "groups": '["list", "of", "groups"]',
82 82 "extern_name": "name in external source of record",
83 83 "extern_type": "type of external source of record",
84 84 "admin": 'True|False defines if user should be RhodeCode super admin',
85 85 "active":
86 86 'True|False defines active state of user internally for RhodeCode',
87 87 "active_from_extern":
88 88 "True|False\None, active state from the external auth, "
89 89 "None means use definition from RhodeCode extern_type active value"
90 90 }
91 91 # set on authenticate() method and via set_auth_type func.
92 92 auth_type = None
93 93
94 94 # List of setting names to store encrypted. Plugins may override this list
95 95 # to store settings encrypted.
96 96 _settings_encrypted = []
97 97
98 98 # Mapping of python to DB settings model types. Plugins may override or
99 99 # extend this mapping.
100 100 _settings_type_map = {
101 101 colander.String: 'unicode',
102 102 colander.Integer: 'int',
103 103 colander.Boolean: 'bool',
104 104 colander.List: 'list',
105 105 }
106 106
107 107 def __init__(self, plugin_id):
108 108 self._plugin_id = plugin_id
109 109
110 110 def __str__(self):
111 111 return self.get_id()
112 112
113 113 def _get_setting_full_name(self, name):
114 114 """
115 115 Return the full setting name used for storing values in the database.
116 116 """
117 117 # TODO: johbo: Using the name here is problematic. It would be good to
118 118 # introduce either new models in the database to hold Plugin and
119 119 # PluginSetting or to use the plugin id here.
120 120 return 'auth_{}_{}'.format(self.name, name)
121 121
122 122 def _get_setting_type(self, name):
123 123 """
124 124 Return the type of a setting. This type is defined by the SettingsModel
125 125 and determines how the setting is stored in DB. Optionally the suffix
126 126 `.encrypted` is appended to instruct SettingsModel to store it
127 127 encrypted.
128 128 """
129 129 schema_node = self.get_settings_schema().get(name)
130 130 db_type = self._settings_type_map.get(
131 131 type(schema_node.typ), 'unicode')
132 132 if name in self._settings_encrypted:
133 133 db_type = '{}.encrypted'.format(db_type)
134 134 return db_type
135 135
136 136 def is_enabled(self):
137 137 """
138 138 Returns true if this plugin is enabled. An enabled plugin can be
139 139 configured in the admin interface but it is not consulted during
140 140 authentication.
141 141 """
142 142 auth_plugins = SettingsModel().get_auth_plugins()
143 143 return self.get_id() in auth_plugins
144 144
145 145 def is_active(self):
146 146 """
147 147 Returns true if the plugin is activated. An activated plugin is
148 148 consulted during authentication, assumed it is also enabled.
149 149 """
150 150 return self.get_setting_by_name('enabled')
151 151
152 152 def get_id(self):
153 153 """
154 154 Returns the plugin id.
155 155 """
156 156 return self._plugin_id
157 157
158 158 def get_display_name(self):
159 159 """
160 160 Returns a translation string for displaying purposes.
161 161 """
162 162 raise NotImplementedError('Not implemented in base class')
163 163
164 164 def get_settings_schema(self):
165 165 """
166 166 Returns a colander schema, representing the plugin settings.
167 167 """
168 168 return AuthnPluginSettingsSchemaBase()
169 169
170 170 def get_setting_by_name(self, name, default=None):
171 171 """
172 172 Returns a plugin setting by name.
173 173 """
174 174 full_name = self._get_setting_full_name(name)
175 175 db_setting = SettingsModel().get_setting_by_name(full_name)
176 176 return db_setting.app_settings_value if db_setting else default
177 177
178 178 def create_or_update_setting(self, name, value):
179 179 """
180 180 Create or update a setting for this plugin in the persistent storage.
181 181 """
182 182 full_name = self._get_setting_full_name(name)
183 183 type_ = self._get_setting_type(name)
184 184 db_setting = SettingsModel().create_or_update_setting(
185 185 full_name, value, type_)
186 186 return db_setting.app_settings_value
187 187
188 188 def get_settings(self):
189 189 """
190 190 Returns the plugin settings as dictionary.
191 191 """
192 192 settings = {}
193 193 for node in self.get_settings_schema():
194 194 settings[node.name] = self.get_setting_by_name(node.name)
195 195 return settings
196 196
197 197 @property
198 198 def validators(self):
199 199 """
200 200 Exposes RhodeCode validators modules
201 201 """
202 202 # this is a hack to overcome issues with pylons threadlocals and
203 203 # translator object _() not beein registered properly.
204 204 class LazyCaller(object):
205 205 def __init__(self, name):
206 206 self.validator_name = name
207 207
208 208 def __call__(self, *args, **kwargs):
209 209 from rhodecode.model import validators as v
210 210 obj = getattr(v, self.validator_name)
211 211 # log.debug('Initializing lazy formencode object: %s', obj)
212 212 return LazyFormencode(obj, *args, **kwargs)
213 213
214 214 class ProxyGet(object):
215 215 def __getattribute__(self, name):
216 216 return LazyCaller(name)
217 217
218 218 return ProxyGet()
219 219
220 220 @hybrid_property
221 221 def name(self):
222 222 """
223 223 Returns the name of this authentication plugin.
224 224
225 225 :returns: string
226 226 """
227 227 raise NotImplementedError("Not implemented in base class")
228 228
229 229 def get_url_slug(self):
230 230 """
231 231 Returns a slug which should be used when constructing URLs which refer
232 232 to this plugin. By default it returns the plugin name. If the name is
233 233 not suitable for using it in an URL the plugin should override this
234 234 method.
235 235 """
236 236 return self.name
237 237
238 238 @property
239 239 def is_headers_auth(self):
240 240 """
241 241 Returns True if this authentication plugin uses HTTP headers as
242 242 authentication method.
243 243 """
244 244 return False
245 245
246 246 @hybrid_property
247 247 def is_container_auth(self):
248 248 """
249 249 Deprecated method that indicates if this authentication plugin uses
250 250 HTTP headers as authentication method.
251 251 """
252 252 warnings.warn(
253 253 'Use is_headers_auth instead.', category=DeprecationWarning)
254 254 return self.is_headers_auth
255 255
256 256 @hybrid_property
257 257 def allows_creating_users(self):
258 258 """
259 259 Defines if Plugin allows users to be created on-the-fly when
260 260 authentication is called. Controls how external plugins should behave
261 261 in terms if they are allowed to create new users, or not. Base plugins
262 262 should not be allowed to, but External ones should be !
263 263
264 264 :return: bool
265 265 """
266 266 return False
267 267
268 268 def set_auth_type(self, auth_type):
269 269 self.auth_type = auth_type
270 270
271 271 def allows_authentication_from(
272 272 self, user, allows_non_existing_user=True,
273 273 allowed_auth_plugins=None, allowed_auth_sources=None):
274 274 """
275 275 Checks if this authentication module should accept a request for
276 276 the current user.
277 277
278 278 :param user: user object fetched using plugin's get_user() method.
279 279 :param allows_non_existing_user: if True, don't allow the
280 280 user to be empty, meaning not existing in our database
281 281 :param allowed_auth_plugins: if provided, users extern_type will be
282 282 checked against a list of provided extern types, which are plugin
283 283 auth_names in the end
284 284 :param allowed_auth_sources: authentication type allowed,
285 285 `http` or `vcs` default is both.
286 286 defines if plugin will accept only http authentication vcs
287 287 authentication(git/hg) or both
288 288 :returns: boolean
289 289 """
290 290 if not user and not allows_non_existing_user:
291 291 log.debug('User is empty but plugin does not allow empty users,'
292 292 'not allowed to authenticate')
293 293 return False
294 294
295 295 expected_auth_plugins = allowed_auth_plugins or [self.name]
296 296 if user and (user.extern_type and
297 297 user.extern_type not in expected_auth_plugins):
298 298 log.debug(
299 299 'User `%s` is bound to `%s` auth type. Plugin allows only '
300 300 '%s, skipping', user, user.extern_type, expected_auth_plugins)
301 301
302 302 return False
303 303
304 304 # by default accept both
305 305 expected_auth_from = allowed_auth_sources or [HTTP_TYPE, VCS_TYPE]
306 306 if self.auth_type not in expected_auth_from:
307 307 log.debug('Current auth source is %s but plugin only allows %s',
308 308 self.auth_type, expected_auth_from)
309 309 return False
310 310
311 311 return True
312 312
313 313 def get_user(self, username=None, **kwargs):
314 314 """
315 315 Helper method for user fetching in plugins, by default it's using
316 316 simple fetch by username, but this method can be custimized in plugins
317 317 eg. headers auth plugin to fetch user by environ params
318 318
319 319 :param username: username if given to fetch from database
320 320 :param kwargs: extra arguments needed for user fetching.
321 321 """
322 322 user = None
323 323 log.debug(
324 324 'Trying to fetch user `%s` from RhodeCode database', username)
325 325 if username:
326 326 user = User.get_by_username(username)
327 327 if not user:
328 328 log.debug('User not found, fallback to fetch user in '
329 329 'case insensitive mode')
330 330 user = User.get_by_username(username, case_insensitive=True)
331 331 else:
332 332 log.debug('provided username:`%s` is empty skipping...', username)
333 333 if not user:
334 334 log.debug('User `%s` not found in database', username)
335 else:
336 log.debug('Got DB user:%s', user)
335 337 return user
336 338
337 339 def user_activation_state(self):
338 340 """
339 341 Defines user activation state when creating new users
340 342
341 343 :returns: boolean
342 344 """
343 345 raise NotImplementedError("Not implemented in base class")
344 346
345 347 def auth(self, userobj, username, passwd, settings, **kwargs):
346 348 """
347 349 Given a user object (which may be null), username, a plaintext
348 350 password, and a settings object (containing all the keys needed as
349 351 listed in settings()), authenticate this user's login attempt.
350 352
351 353 Return None on failure. On success, return a dictionary of the form:
352 354
353 355 see: RhodeCodeAuthPluginBase.auth_func_attrs
354 356 This is later validated for correctness
355 357 """
356 358 raise NotImplementedError("not implemented in base class")
357 359
358 360 def _authenticate(self, userobj, username, passwd, settings, **kwargs):
359 361 """
360 362 Wrapper to call self.auth() that validates call on it
361 363
362 364 :param userobj: userobj
363 365 :param username: username
364 366 :param passwd: plaintext password
365 367 :param settings: plugin settings
366 368 """
367 369 auth = self.auth(userobj, username, passwd, settings, **kwargs)
368 370 if auth:
369 371 # check if hash should be migrated ?
370 372 new_hash = auth.get('_hash_migrate')
371 373 if new_hash:
372 374 self._migrate_hash_to_bcrypt(username, passwd, new_hash)
373 375 return self._validate_auth_return(auth)
374 376 return auth
375 377
376 378 def _migrate_hash_to_bcrypt(self, username, password, new_hash):
377 379 new_hash_cypher = _RhodeCodeCryptoBCrypt()
378 380 # extra checks, so make sure new hash is correct.
379 381 password_encoded = safe_str(password)
380 382 if new_hash and new_hash_cypher.hash_check(
381 383 password_encoded, new_hash):
382 384 cur_user = User.get_by_username(username)
383 385 cur_user.password = new_hash
384 386 Session().add(cur_user)
385 387 Session().flush()
386 388 log.info('Migrated user %s hash to bcrypt', cur_user)
387 389
388 390 def _validate_auth_return(self, ret):
389 391 if not isinstance(ret, dict):
390 392 raise Exception('returned value from auth must be a dict')
391 393 for k in self.auth_func_attrs:
392 394 if k not in ret:
393 395 raise Exception('Missing %s attribute from returned data' % k)
394 396 return ret
395 397
396 398
397 399 class RhodeCodeExternalAuthPlugin(RhodeCodeAuthPluginBase):
398 400
399 401 @hybrid_property
400 402 def allows_creating_users(self):
401 403 return True
402 404
403 405 def use_fake_password(self):
404 406 """
405 407 Return a boolean that indicates whether or not we should set the user's
406 408 password to a random value when it is authenticated by this plugin.
407 409 If your plugin provides authentication, then you will generally
408 410 want this.
409 411
410 412 :returns: boolean
411 413 """
412 414 raise NotImplementedError("Not implemented in base class")
413 415
414 416 def _authenticate(self, userobj, username, passwd, settings, **kwargs):
415 417 # at this point _authenticate calls plugin's `auth()` function
416 418 auth = super(RhodeCodeExternalAuthPlugin, self)._authenticate(
417 419 userobj, username, passwd, settings, **kwargs)
418 420 if auth:
419 421 # maybe plugin will clean the username ?
420 422 # we should use the return value
421 423 username = auth['username']
422 424
423 425 # if external source tells us that user is not active, we should
424 426 # skip rest of the process. This can prevent from creating users in
425 427 # RhodeCode when using external authentication, but if it's
426 428 # inactive user we shouldn't create that user anyway
427 429 if auth['active_from_extern'] is False:
428 430 log.warning(
429 431 "User %s authenticated against %s, but is inactive",
430 432 username, self.__module__)
431 433 return None
432 434
433 435 cur_user = User.get_by_username(username, case_insensitive=True)
434 436 is_user_existing = cur_user is not None
435 437
436 438 if is_user_existing:
437 439 log.debug('Syncing user `%s` from '
438 440 '`%s` plugin', username, self.name)
439 441 else:
440 442 log.debug('Creating non existing user `%s` from '
441 443 '`%s` plugin', username, self.name)
442 444
443 445 if self.allows_creating_users:
444 446 log.debug('Plugin `%s` allows to '
445 447 'create new users', self.name)
446 448 else:
447 449 log.debug('Plugin `%s` does not allow to '
448 450 'create new users', self.name)
449 451
450 452 user_parameters = {
451 453 'username': username,
452 454 'email': auth["email"],
453 455 'firstname': auth["firstname"],
454 456 'lastname': auth["lastname"],
455 457 'active': auth["active"],
456 458 'admin': auth["admin"],
457 459 'extern_name': auth["extern_name"],
458 460 'extern_type': self.name,
459 461 'plugin': self,
460 462 'allow_to_create_user': self.allows_creating_users,
461 463 }
462 464
463 465 if not is_user_existing:
464 466 if self.use_fake_password():
465 467 # Randomize the PW because we don't need it, but don't want
466 468 # them blank either
467 469 passwd = PasswordGenerator().gen_password(length=16)
468 470 user_parameters['password'] = passwd
469 471 else:
470 472 # Since the password is required by create_or_update method of
471 473 # UserModel, we need to set it explicitly.
472 474 # The create_or_update method is smart and recognises the
473 475 # password hashes as well.
474 476 user_parameters['password'] = cur_user.password
475 477
476 478 # we either create or update users, we also pass the flag
477 479 # that controls if this method can actually do that.
478 480 # raises NotAllowedToCreateUserError if it cannot, and we try to.
479 481 user = UserModel().create_or_update(**user_parameters)
480 482 Session().flush()
481 483 # enforce user is just in given groups, all of them has to be ones
482 484 # created from plugins. We store this info in _group_data JSON
483 485 # field
484 486 try:
485 487 groups = auth['groups'] or []
486 488 UserGroupModel().enforce_groups(user, groups, self.name)
487 489 except Exception:
488 490 # for any reason group syncing fails, we should
489 491 # proceed with login
490 492 log.error(traceback.format_exc())
491 493 Session().commit()
492 494 return auth
493 495
494 496
495 497 def loadplugin(plugin_id):
496 498 """
497 499 Loads and returns an instantiated authentication plugin.
498 500 Returns the RhodeCodeAuthPluginBase subclass on success,
499 501 or None on failure.
500 502 """
501 503 # TODO: Disusing pyramids thread locals to retrieve the registry.
502 504 authn_registry = get_authn_registry()
503 505 plugin = authn_registry.get_plugin(plugin_id)
504 506 if plugin is None:
505 507 log.error('Authentication plugin not found: "%s"', plugin_id)
506 508 return plugin
507 509
508 510
509 511 def get_authn_registry(registry=None):
510 512 registry = registry or get_current_registry()
511 513 authn_registry = registry.getUtility(IAuthnPluginRegistry)
512 514 return authn_registry
513 515
514 516
515 517 def get_auth_cache_manager(custom_ttl=None):
516 518 return caches.get_cache_manager(
517 519 'auth_plugins', 'rhodecode.authentication', custom_ttl)
518 520
519 521
520 522 def authenticate(username, password, environ=None, auth_type=None,
521 523 skip_missing=False, registry=None):
522 524 """
523 525 Authentication function used for access control,
524 526 It tries to authenticate based on enabled authentication modules.
525 527
526 528 :param username: username can be empty for headers auth
527 529 :param password: password can be empty for headers auth
528 530 :param environ: environ headers passed for headers auth
529 531 :param auth_type: type of authentication, either `HTTP_TYPE` or `VCS_TYPE`
530 532 :param skip_missing: ignores plugins that are in db but not in environment
531 533 :returns: None if auth failed, plugin_user dict if auth is correct
532 534 """
533 535 if not auth_type or auth_type not in [HTTP_TYPE, VCS_TYPE]:
534 536 raise ValueError('auth type must be on of http, vcs got "%s" instead'
535 537 % auth_type)
536 538 headers_only = environ and not (username and password)
537 539
538 540 authn_registry = get_authn_registry(registry)
539 541 for plugin in authn_registry.get_plugins_for_authentication():
540 542 plugin.set_auth_type(auth_type)
541 543 user = plugin.get_user(username)
542 544 display_user = user.username if user else username
543 545
544 546 if headers_only and not plugin.is_headers_auth:
545 547 log.debug('Auth type is for headers only and plugin `%s` is not '
546 548 'headers plugin, skipping...', plugin.get_id())
547 549 continue
548 550
549 551 # load plugin settings from RhodeCode database
550 552 plugin_settings = plugin.get_settings()
551 553 log.debug('Plugin settings:%s', plugin_settings)
552 554
553 555 log.debug('Trying authentication using ** %s **', plugin.get_id())
554 556 # use plugin's method of user extraction.
555 557 user = plugin.get_user(username, environ=environ,
556 558 settings=plugin_settings)
557 559 display_user = user.username if user else username
558 560 log.debug(
559 561 'Plugin %s extracted user is `%s`', plugin.get_id(), display_user)
560 562
561 563 if not plugin.allows_authentication_from(user):
562 564 log.debug('Plugin %s does not accept user `%s` for authentication',
563 565 plugin.get_id(), display_user)
564 566 continue
565 567 else:
566 568 log.debug('Plugin %s accepted user `%s` for authentication',
567 569 plugin.get_id(), display_user)
568 570
569 571 log.info('Authenticating user `%s` using %s plugin',
570 572 display_user, plugin.get_id())
571 573
572 574 _cache_ttl = 0
573 575
574 576 if isinstance(plugin.AUTH_CACHE_TTL, (int, long)):
575 577 # plugin cache set inside is more important than the settings value
576 578 _cache_ttl = plugin.AUTH_CACHE_TTL
577 579 elif plugin_settings.get('cache_ttl'):
578 580 _cache_ttl = safe_int(plugin_settings.get('cache_ttl'), 0)
579 581
580 582 plugin_cache_active = bool(_cache_ttl and _cache_ttl > 0)
581 583
582 584 # get instance of cache manager configured for a namespace
583 585 cache_manager = get_auth_cache_manager(custom_ttl=_cache_ttl)
584 586
585 587 log.debug('AUTH_CACHE_TTL for plugin `%s` active: %s (TTL: %s)',
586 588 plugin.get_id(), plugin_cache_active, _cache_ttl)
587 589
588 590 # for environ based password can be empty, but then the validation is
589 591 # on the server that fills in the env data needed for authentication
590 592 _password_hash = md5_safe(plugin.name + username + (password or ''))
591 593
592 594 # _authenticate is a wrapper for .auth() method of plugin.
593 595 # it checks if .auth() sends proper data.
594 596 # For RhodeCodeExternalAuthPlugin it also maps users to
595 597 # Database and maps the attributes returned from .auth()
596 598 # to RhodeCode database. If this function returns data
597 599 # then auth is correct.
598 600 start = time.time()
599 601 log.debug('Running plugin `%s` _authenticate method', plugin.get_id())
600 602
601 603 def auth_func():
602 604 """
603 605 This function is used internally in Cache of Beaker to calculate
604 606 Results
605 607 """
606 608 return plugin._authenticate(
607 609 user, username, password, plugin_settings,
608 610 environ=environ or {})
609 611
610 612 if plugin_cache_active:
611 613 plugin_user = cache_manager.get(
612 614 _password_hash, createfunc=auth_func)
613 615 else:
614 616 plugin_user = auth_func()
615 617
616 618 auth_time = time.time() - start
617 619 log.debug('Authentication for plugin `%s` completed in %.3fs, '
618 620 'expiration time of fetched cache %.1fs.',
619 621 plugin.get_id(), auth_time, _cache_ttl)
620 622
621 623 log.debug('PLUGIN USER DATA: %s', plugin_user)
622 624
623 625 if plugin_user:
624 626 log.debug('Plugin returned proper authentication data')
625 627 return plugin_user
626 628 # we failed to Auth because .auth() method didn't return proper user
627 629 log.debug("User `%s` failed to authenticate against %s",
628 630 display_user, plugin.get_id())
629 631 return None
630 632
631 633
632 634 def chop_at(s, sub, inclusive=False):
633 635 """Truncate string ``s`` at the first occurrence of ``sub``.
634 636
635 637 If ``inclusive`` is true, truncate just after ``sub`` rather than at it.
636 638
637 639 >>> chop_at("plutocratic brats", "rat")
638 640 'plutoc'
639 641 >>> chop_at("plutocratic brats", "rat", True)
640 642 'plutocrat'
641 643 """
642 644 pos = s.find(sub)
643 645 if pos == -1:
644 646 return s
645 647 if inclusive:
646 648 return s[:pos+len(sub)]
647 649 return s[:pos]
General Comments 0
You need to be logged in to leave comments. Login now